Compare commits

..

No commits in common. "adf85792d5cd1b584be8e6eb81109fcf5f5a7ab0" and "779e22a84e111d2df65ce037a6084f91b86fe256" have entirely different histories.

404 changed files with 60079 additions and 67424 deletions

View file

@ -1,4 +1,6 @@
{ {
"recommendations": [], "recommendations": [],
"unwantedRecommendations": ["ms-python.pylint"] "unwantedRecommendations": [
"ms-python.pylint"
]
} }

View file

@ -1,169 +1,174 @@
{ {
"name": "jpawlowski/hass.tibber_prices", "name": "jpawlowski/hass.tibber_prices",
"image": "mcr.microsoft.com/devcontainers/python:3.14", "image": "mcr.microsoft.com/devcontainers/python:3.14",
"postCreateCommand": "bash .devcontainer/setup-git.sh && scripts/setup/setup", "postCreateCommand": "bash .devcontainer/setup-git.sh && scripts/setup/setup",
"postStartCommand": "scripts/motd", "postStartCommand": "scripts/motd",
"containerEnv": { "containerEnv": {
"PYTHONASYNCIODEBUG": "1", "PYTHONASYNCIODEBUG": "1",
"TIBBER_PRICES_DEV": "1" "TIBBER_PRICES_DEV": "1"
},
"forwardPorts": [8123, 3000, 3001],
"portsAttributes": {
"8123": {
"label": "Home Assistant",
"onAutoForward": "notify"
}, },
"3000": { "forwardPorts": [
"label": "Docusaurus User Docs", 8123,
"onAutoForward": "notify" 3000,
3001
],
"portsAttributes": {
"8123": {
"label": "Home Assistant",
"onAutoForward": "notify"
},
"3000": {
"label": "Docusaurus User Docs",
"onAutoForward": "notify"
},
"3001": {
"label": "Docusaurus Developer Docs",
"onAutoForward": "notify"
}
}, },
"3001": { "customizations": {
"label": "Docusaurus Developer Docs", "vscode": {
"onAutoForward": "notify" "extensions": [
"charliermarsh.ruff",
"EditorConfig.EditorConfig",
"esbenp.prettier-vscode",
"github.copilot",
"github.vscode-pull-request-github",
"ms-python.python",
"ms-python.vscode-pylance",
"ms-vscode-remote.remote-containers",
"redhat.vscode-yaml",
"ryanluker.vscode-coverage-gutters"
],
"settings": {
"editor.tabSize": 4,
"editor.formatOnSave": true,
"editor.formatOnType": false,
"extensions.ignoreRecommendations": false,
"files.eol": "\n",
"files.trimTrailingWhitespace": true,
"python.analysis.typeCheckingMode": "basic",
"python.analysis.autoImportCompletions": true,
"python.analysis.diagnosticMode": "workspace",
"python.analysis.diagnosticSeverityOverrides": {
"reportUnusedImport": "none",
"reportUnusedVariable": "none",
"reportUnusedCoroutine": "none",
"reportMissingTypeStubs": "none"
},
"python.analysis.include": [
"custom_components/tibber_prices"
],
"python.analysis.exclude": [
"**/.venv/**",
"**/venv/**",
"**/__pycache__/**",
"**/.git/**",
"**/.github/**",
"**/docs/**",
"**/node_modules/**"
],
"python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python",
"python.analysis.extraPaths": [
"${workspaceFolder}/.venv/lib/python3.14/site-packages"
],
"python.terminal.activateEnvironment": true,
"python.terminal.activateEnvInCurrentTerminal": true,
"python.testing.pytestArgs": [
"--no-cov"
],
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.tabSize": 2
},
"[jsonc]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.tabSize": 2
},
"[python]": {
"editor.defaultFormatter": "charliermarsh.ruff",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.ruff": "explicit",
"source.organizeImports.ruff": "explicit"
}
},
"[markdown]": {
"editor.wordWrap": "on"
},
"yaml.customTags": [
"!secret scalar",
"!include scalar",
"!include_dir_list scalar",
"!include_dir_merge_list scalar",
"!include_dir_named scalar",
"!include_dir_merge_named scalar",
"!input scalar"
],
"markdown.validate.enabled": false,
"markdown.validate.fileLinks.enabled": "ignore",
"markdown.validate.fragmentLinks.enabled": "ignore",
"json.schemas": [
{
"fileMatch": [
"homeassistant/components/*/manifest.json"
],
"url": "${containerWorkspaceFolder}/schemas/json/manifest_schema.json"
},
{
"fileMatch": [
"homeassistant/components/*/translations/*.json"
],
"url": "${containerWorkspaceFolder}/schemas/json/translation_schema.json"
}
],
"git.useConfigOnly": false
}
}
},
"mounts": [
"source=${localEnv:HOME}${localEnv:USERPROFILE}/.gitconfig,target=/home/vscode/.gitconfig.host,type=bind,consistency=cached"
],
"remoteUser": "vscode",
"features": {
"ghcr.io/devcontainers/features/github-cli:1": {},
"ghcr.io/flexwie/devcontainer-features/op:1": {
"version": "latest"
},
"ghcr.io/devcontainers/features/node:1": {
"version": "24"
},
"ghcr.io/devcontainers/features/rust:1": {
"version": "latest",
"profile": "minimal"
},
"ghcr.io/devcontainer-community/devcontainer-features/yq:1": {
"version": "latest"
},
"ghcr.io/devcontainers-extra/features/apt-packages:1": {
"packages": [
"bat",
"eza",
"fd-find",
"ffmpeg",
"fzf",
"git-delta",
"httpie",
"hyperfine",
"ipython3",
"jo",
"jq",
"libpcap-dev",
"libturbojpeg0",
"miller",
"moreutils",
"ripgrep",
"shellcheck",
"shfmt",
"sqlite3",
"tree",
"yamllint"
]
}
} }
},
"customizations": {
"vscode": {
"extensions": [
"charliermarsh.ruff",
"EditorConfig.EditorConfig",
"esbenp.prettier-vscode",
"github.copilot",
"github.vscode-pull-request-github",
"ms-python.python",
"ms-python.vscode-pylance",
"ms-vscode-remote.remote-containers",
"redhat.vscode-yaml",
"ryanluker.vscode-coverage-gutters"
],
"settings": {
"editor.tabSize": 4,
"editor.formatOnSave": true,
"editor.formatOnType": false,
"extensions.ignoreRecommendations": false,
"files.eol": "\n",
"files.trimTrailingWhitespace": true,
"python.analysis.typeCheckingMode": "basic",
"python.analysis.autoImportCompletions": true,
"python.analysis.diagnosticMode": "workspace",
"python.analysis.diagnosticSeverityOverrides": {
"reportUnusedImport": "none",
"reportUnusedVariable": "none",
"reportUnusedCoroutine": "none",
"reportMissingTypeStubs": "none"
},
"python.analysis.include": ["custom_components/tibber_prices"],
"python.analysis.exclude": [
"**/.venv/**",
"**/venv/**",
"**/__pycache__/**",
"**/.git/**",
"**/.github/**",
"**/docs/**",
"**/node_modules/**"
],
"python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python",
"python.analysis.extraPaths": [
"${workspaceFolder}/.venv/lib/python3.14/site-packages"
],
"python.terminal.activateEnvironment": true,
"python.terminal.activateEnvInCurrentTerminal": true,
"python.testing.pytestArgs": ["--no-cov"],
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.tabSize": 2
},
"[jsonc]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.tabSize": 2
},
"[python]": {
"editor.defaultFormatter": "charliermarsh.ruff",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.ruff": "explicit",
"source.organizeImports.ruff": "explicit"
}
},
"[markdown]": {
"editor.wordWrap": "on"
},
"yaml.customTags": [
"!secret scalar",
"!include scalar",
"!include_dir_list scalar",
"!include_dir_merge_list scalar",
"!include_dir_named scalar",
"!include_dir_merge_named scalar",
"!input scalar"
],
"markdown.validate.enabled": false,
"markdown.validate.fileLinks.enabled": "ignore",
"markdown.validate.fragmentLinks.enabled": "ignore",
"json.schemas": [
{
"fileMatch": ["homeassistant/components/*/manifest.json"],
"url": "${containerWorkspaceFolder}/schemas/json/manifest_schema.json"
},
{
"fileMatch": ["homeassistant/components/*/translations/*.json"],
"url": "${containerWorkspaceFolder}/schemas/json/translation_schema.json"
}
],
"github.copilot.chat.commitMessageGeneration.instructions": [
{
"file": ".github/instructions/commit-messages.instructions.md"
}
],
"git.useConfigOnly": false
}
}
},
"mounts": [
"source=${localEnv:HOME}${localEnv:USERPROFILE}/.gitconfig,target=/home/vscode/.gitconfig.host,type=bind,consistency=cached"
],
"remoteUser": "vscode",
"features": {
"ghcr.io/devcontainers/features/github-cli:1": {},
"ghcr.io/flexwie/devcontainer-features/op:1": {
"version": "latest"
},
"ghcr.io/devcontainers/features/node:1": {
"version": "24"
},
"ghcr.io/devcontainers/features/rust:1": {
"version": "latest",
"profile": "minimal"
},
"ghcr.io/devcontainer-community/devcontainer-features/yq:1": {
"version": "latest"
},
"ghcr.io/devcontainers-extra/features/apt-packages:1": {
"packages": [
"bat",
"eza",
"fd-find",
"ffmpeg",
"fzf",
"git-delta",
"httpie",
"hyperfine",
"ipython3",
"jo",
"jq",
"libpcap-dev",
"libturbojpeg0",
"miller",
"moreutils",
"ripgrep",
"shellcheck",
"shfmt",
"sqlite3",
"tree",
"yamllint"
]
}
}
} }

View file

@ -51,15 +51,15 @@ if grep -q '^\[alias\]' ~/.gitconfig.host; then
# First, collect all aliases from host config # First, collect all aliases from host config
TEMP_ALIASES=$(mktemp) TEMP_ALIASES=$(mktemp)
sed -n '/^\[alias\]/,/^\[/p' ~/.gitconfig.host | sed -n '/^\[alias\]/,/^\[/p' ~/.gitconfig.host | \
grep -v '^\[' | grep -v '^\[' | \
grep -v '^$' | grep -v '^$' | \
while IFS= read -r line; do while IFS= read -r line; do
# Skip aliases with macOS-specific paths # Skip aliases with macOS-specific paths
if echo "$line" | grep -q -E '/(Applications|usr/local)'; then if echo "$line" | grep -q -E '/(Applications|usr/local)'; then
continue continue
fi fi
echo "$line" >>"$TEMP_ALIASES" echo "$line" >> "$TEMP_ALIASES"
done done
# Apply each alias (git config --global overwrites existing values = idempotent) # Apply each alias (git config --global overwrites existing values = idempotent)
@ -68,8 +68,8 @@ if grep -q '^\[alias\]' ~/.gitconfig.host; then
ALIAS_NAME=$(echo "$line" | awk '{print $1}') ALIAS_NAME=$(echo "$line" | awk '{print $1}')
ALIAS_VALUE=$(echo "$line" | sed "s/^$ALIAS_NAME = //") ALIAS_VALUE=$(echo "$line" | sed "s/^$ALIAS_NAME = //")
git config --global "alias.$ALIAS_NAME" "$ALIAS_VALUE" 2>/dev/null || true git config --global "alias.$ALIAS_NAME" "$ALIAS_VALUE" 2>/dev/null || true
done <"$TEMP_ALIASES" done < "$TEMP_ALIASES"
echo " Synced $(wc -l <"$TEMP_ALIASES") aliases" echo " Synced $(wc -l < "$TEMP_ALIASES") aliases"
fi fi
rm -f "$TEMP_ALIASES" rm -f "$TEMP_ALIASES"

View file

@ -1,95 +0,0 @@
---
description: "Use when writing or suggesting git commit messages, deciding commit type/scope, or preparing release-note-relevant commit trailers."
---
# Commit Message Rules (Release-Notes Aware)
Use these rules whenever you generate or suggest commit messages.
## Primary Goal
Write technically correct Conventional Commit messages while ensuring release notes only include user-relevant changes.
## Required Format
Use this structure:
<type>(<scope>): <short summary>
<body>
Impact: <user-facing outcome>
### Notes
- Keep summary imperative and concise.
- Keep body technical (what changed and why).
- Keep Impact user-facing (what users notice).
## Type Selection
- Use feat for new user-visible capability.
- Use fix only for user-visible bug fixes.
- Use perf for user-visible reliability/performance improvements.
- Use docs, test, refactor, chore, ci, build for non-user-facing work.
## Critical Rule: Internal/Unreleased Fixes
If a fix addresses code that was not released to users yet, DO NOT treat it as a user-facing fix.
In that case:
- Prefer chore(...) or refactor(...) instead of fix(...), and/or
- Add an explicit trailer in the commit body:
- Release-Notes: skip
- User-Impact: none
- Released-Bug: no
Any one of these trailers is enough.
## How To Decide Released vs Unreleased
When uncertain whether users were affected, check if the introducing commit was part of a release tag:
./scripts/release/check-if-released <commit-hash>
Interpretation:
- NOT RELEASED -> treat as internal/non-user-facing.
- ALREADY RELEASED -> user-facing fix is possible.
## Release Notes Alignment
This repository's release notes generator excludes commits with any of these trailers:
- Release-Notes: skip
- User-Impact: none
- Released-Bug: no
Therefore, add one of them whenever you intentionally want to exclude a commit from release notes.
## Examples
### User-facing fix
fix(config_flow): prevent setup failure on invalid home selection
Validate home selection before entry creation to avoid runtime errors when stale API data is returned.
Impact: Setup wizard no longer fails for users when home data changes during configuration.
### Internal-only fix for unreleased code
chore(periods): adjust extension guard for new geometric matcher
Tune guard conditions in the new matcher implementation to avoid edge-case misclassification during development.
User-Impact: none
### Alternative with explicit skip marker
fix(periods): correct follow-up edge case in unreleased geometric matcher
Adjust comparison threshold in iterative matcher pass.
Release-Notes: skip

View file

@ -1,25 +0,0 @@
---
name: Auto-assign
on:
issues:
types:
- opened
jobs:
auto-assign:
name: Assign to owner
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- name: Assign issue to owner
uses: actions/github-script@v7
with:
script: |
await github.rest.issues.addAssignees({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
assignees: [context.repo.owner],
});

View file

@ -1,9 +1,9 @@
{ {
"default": true, "default": true,
"MD013": false, "MD013": false,
"MD033": false, "MD033": false,
"MD041": false, "MD041": false,
"no-inline-html": false, "no-inline-html": false,
"line-length": false, "line-length": false,
"first-line-heading": false "first-line-heading": false
} }

1444
AGENTS.md

File diff suppressed because it is too large Load diff

View file

@ -1,11 +0,0 @@
# CODEOWNERS
#
# This file defines code owners for this repository.
# Code owners are automatically requested for review when a pull request
# modifies files they own.
#
# See: https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
#
# NOTE: This file is updated automatically by initialize.sh when using the blueprint.
* @jpawlowski

View file

@ -18,14 +18,14 @@ For detailed developer documentation, see [docs/development/](docs/development/)
1. **Fork the repository** on GitHub 1. **Fork the repository** on GitHub
2. **Clone your fork**: 2. **Clone your fork**:
```bash ```bash
git clone https://github.com/YOUR_USERNAME/hass.tibber_prices.git git clone https://github.com/YOUR_USERNAME/hass.tibber_prices.git
cd hass.tibber_prices cd hass.tibber_prices
``` ```
3. **Open in DevContainer** (recommended): 3. **Open in DevContainer** (recommended):
- Open in VS Code - Open in VS Code
- Click "Reopen in Container" when prompted - Click "Reopen in Container" when prompted
- Or manually: `Ctrl+Shift+P` → "Dev Containers: Reopen in Container" - Or manually: `Ctrl+Shift+P` → "Dev Containers: Reopen in Container"
See [Development Setup](docs/development/setup.md) for detailed instructions. See [Development Setup](docs/development/setup.md) for detailed instructions.
@ -72,18 +72,7 @@ Impact: <user-visible effects>
**Types:** `feat`, `fix`, `docs`, `refactor`, `chore`, `test` **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:** **Example:**
```bash ```bash
git commit -m "feat(sensors): add daily average price sensor git commit -m "feat(sensors): add daily average price sensor
@ -92,7 +81,7 @@ Added new sensor that calculates average price for the entire day.
Impact: Users can now track daily average prices for cost analysis." Impact: Users can now track daily average prices for cost analysis."
``` ```
See `.github/instructions/commit-messages.instructions.md` for detailed commit-message guidelines. See [`AGENTS.md`](AGENTS.md) section "Git Workflow Guidance" for detailed guidelines.
## Submitting Changes ## Submitting Changes
@ -100,9 +89,9 @@ See `.github/instructions/commit-messages.instructions.md` for detailed commit-m
1. **Push your branch** to your fork 1. **Push your branch** to your fork
2. **Create a Pull Request** on GitHub with: 2. **Create a Pull Request** on GitHub with:
- Clear title describing the change - Clear title describing the change
- Detailed description with context - Detailed description with context
- Reference related issues (`Fixes #123`) - Reference related issues (`Fixes #123`)
3. **Wait for review** and address feedback 3. **Wait for review** and address feedback
### PR Requirements ### PR Requirements
@ -122,7 +111,6 @@ See `.github/instructions/commit-messages.instructions.md` for detailed commit-m
- **Python version**: 3.13+ - **Python version**: 3.13+
Always run before committing: Always run before committing:
```bash ```bash
./scripts/lint ./scripts/lint
``` ```
@ -141,14 +129,13 @@ See [Coding Guidelines](docs/developer/docs/coding-guidelines.md) for complete d
Documentation is organized in two Docusaurus sites: Documentation is organized in two Docusaurus sites:
- **User docs** (`docs/user/`): Installation, configuration, usage guides - **User docs** (`docs/user/`): Installation, configuration, usage guides
- Markdown files in `docs/user/docs/*.md` - Markdown files in `docs/user/docs/*.md`
- Navigation via `docs/user/sidebars.ts` - Navigation via `docs/user/sidebars.ts`
- **Developer docs** (`docs/developer/`): Architecture, patterns, contribution guides - **Developer docs** (`docs/developer/`): Architecture, patterns, contribution guides
- Markdown files in `docs/developer/docs/*.md` - Markdown files in `docs/developer/docs/*.md`
- Navigation via `docs/developer/sidebars.ts` - Navigation via `docs/developer/sidebars.ts`
**When adding new documentation:** **When adding new documentation:**
1. Place file in appropriate `docs/*/docs/` directory 1. Place file in appropriate `docs/*/docs/` directory
2. Add to corresponding `sidebars.ts` for navigation 2. Add to corresponding `sidebars.ts` for navigation
3. Update translations when changing `translations/en.json` (update ALL language files) 3. Update translations when changing `translations/en.json` (update ALL language files)
@ -158,7 +145,6 @@ Documentation is organized in two Docusaurus sites:
Report bugs via [GitHub Issues](../../issues/new/choose). Report bugs via [GitHub Issues](../../issues/new/choose).
**Great bug reports include:** **Great bug reports include:**
- Quick summary and background - Quick summary and background
- Steps to reproduce (be specific!) - Steps to reproduce (be specific!)
- Expected vs. actual behavior - Expected vs. actual behavior

View file

@ -22,8 +22,8 @@
**[📚 Complete Documentation](https://jpawlowski.github.io/hass.tibber_prices/)** — Installation, guides, examples, and full sensor reference: **[📚 Complete Documentation](https://jpawlowski.github.io/hass.tibber_prices/)** — Installation, guides, examples, and full sensor reference:
- **[👤 User Documentation](https://jpawlowski.github.io/hass.tibber_prices/user/)** — Setup, sensors, automations, dashboards - **[👤 User Documentation](https://jpawlowski.github.io/hass.tibber_prices/user/)** — Setup, sensors, automations, dashboards
- **[🔧 Developer Documentation](https://jpawlowski.github.io/hass.tibber_prices/developer/)** — Architecture, contributing, development - **[🔧 Developer Documentation](https://jpawlowski.github.io/hass.tibber_prices/developer/)** — Architecture, contributing, development
**Quick Links:** **Quick Links:**
[Installation](https://jpawlowski.github.io/hass.tibber_prices/user/installation) · [Sensor Reference](https://jpawlowski.github.io/hass.tibber_prices/user/sensor-reference) · [Charts](https://jpawlowski.github.io/hass.tibber_prices/user/chart-examples) · [Automations](https://jpawlowski.github.io/hass.tibber_prices/user/automation-examples) · [FAQ](https://jpawlowski.github.io/hass.tibber_prices/user/faq) · [Changelog](https://github.com/jpawlowski/hass.tibber_prices/releases) [Installation](https://jpawlowski.github.io/hass.tibber_prices/user/installation) · [Sensor Reference](https://jpawlowski.github.io/hass.tibber_prices/user/sensor-reference) · [Charts](https://jpawlowski.github.io/hass.tibber_prices/user/chart-examples) · [Automations](https://jpawlowski.github.io/hass.tibber_prices/user/automation-examples) · [FAQ](https://jpawlowski.github.io/hass.tibber_prices/user/faq) · [Changelog](https://github.com/jpawlowski/hass.tibber_prices/releases)
@ -34,39 +34,39 @@ Most Tibber integrations give you a single price sensor. This one gives you a **
### 🔮 Know What's Coming ### 🔮 Know What's Coming
- **Quarter-hourly precision** — 15-minute interval prices, not just hourly averages - **Quarter-hourly precision** — 15-minute interval prices, not just hourly averages
- **Price forecasts** — See average prices for the next 1h, 2h, 3h, ... up to 12h ahead - **Price forecasts** — See average prices for the next 1h, 2h, 3h, ... up to 12h ahead
- **Trend analysis** — Know if prices are rising, falling, or stable — and when the next trend change happens - **Trend analysis** — Know if prices are rising, falling, or stable — and when the next trend change happens
- **Price trajectory** — Detect turning points before they happen (first-half vs second-half window comparison) - **Price trajectory** — Detect turning points before they happen (first-half vs second-half window comparison)
- **Price outlook** — Instantly see if the next hours will be cheaper or more expensive than now - **Price outlook** — Instantly see if the next hours will be cheaper or more expensive than now
### ⚡ Automate Smartly ### ⚡ Automate Smartly
- **Best Price & Peak Price Periods** — Intelligent binary sensors that detect the cheapest and most expensive periods of the day, with configurable flexibility, relaxation strategies, and gap tolerance ([how it works](https://jpawlowski.github.io/hass.tibber_prices/user/period-calculation)) - **Best Price & Peak Price Periods** — Intelligent binary sensors that detect the cheapest and most expensive periods of the day, with configurable flexibility, relaxation strategies, and gap tolerance ([how it works](https://jpawlowski.github.io/hass.tibber_prices/user/period-calculation))
- **Period timing sensors** — Duration, end time, remaining minutes, progress percentage, and countdown to next period — everything you need for advanced automations - **Period timing sensors** — Duration, end time, remaining minutes, progress percentage, and countdown to next period — everything you need for advanced automations
- **Runtime configuration** — Adjust period detection parameters on the fly via switches and number entities, without restarting — perfect for automations that adapt to your schedule - **Runtime configuration** — Adjust period detection parameters on the fly via switches and number entities, without restarting — perfect for automations that adapt to your schedule
- **5-level price classification** — VERY_CHEAP, CHEAP, NORMAL, EXPENSIVE, VERY_EXPENSIVE from Tibber's API - **5-level price classification** — VERY_CHEAP, CHEAP, NORMAL, EXPENSIVE, VERY_EXPENSIVE from Tibber's API
- **3-level price ratings** — LOW, NORMAL, HIGH based on 24h trailing average comparison - **3-level price ratings** — LOW, NORMAL, HIGH based on 24h trailing average comparison
### 📊 Visualize Beautifully ### 📊 Visualize Beautifully
- **Auto-generated ApexCharts** — One action call generates a complete chart configuration with dynamic Y-axis scaling and color-coded price levels ([see examples](https://jpawlowski.github.io/hass.tibber_prices/user/chart-examples)) - **Auto-generated ApexCharts** — One action call generates a complete chart configuration with dynamic Y-axis scaling and color-coded price levels ([see examples](https://jpawlowski.github.io/hass.tibber_prices/user/chart-examples))
- **Dynamic icons & colors** — Every sensor adapts its icon and color to the current price state — cheap prices glow green, expensive ones turn red ([icon guide](https://jpawlowski.github.io/hass.tibber_prices/user/dynamic-icons)) - **Dynamic icons & colors** — Every sensor adapts its icon and color to the current price state — cheap prices glow green, expensive ones turn red ([icon guide](https://jpawlowski.github.io/hass.tibber_prices/user/dynamic-icons))
- **Chart data export** — Flexible data API with filtering, resolution control, and multiple output formats for any visualization card - **Chart data export** — Flexible data API with filtering, resolution control, and multiple output formats for any visualization card
### 📈 Understand Your Market ### 📈 Understand Your Market
- **Volatility analysis** — Know if today's prices are stable or wild (low/moderate/high/very_high) - **Volatility analysis** — Know if today's prices are stable or wild (low/moderate/high/very_high)
- **Daily & rolling statistics** — Min, max, average, median for today, tomorrow, trailing 24h, and leading 24h - **Daily & rolling statistics** — Min, max, average, median for today, tomorrow, trailing 24h, and leading 24h
- **Energy & tax breakdown** — See spot price vs. tax components as sensor attributes - **Energy & tax breakdown** — See spot price vs. tax components as sensor attributes
- **Multi-currency support** — EUR, NOK, SEK, DKK, USD, GBP with configurable base/subunit display (€ vs ct, kr vs øre) - **Multi-currency support** — EUR, NOK, SEK, DKK, USD, GBP with configurable base/subunit display (€ vs ct, kr vs øre)
### 🛡️ Built for Reliability ### 🛡️ Built for Reliability
- **Intelligent caching** — Multi-layer caching minimizes API calls, survives HA restarts, auto-invalidates at midnight - **Intelligent caching** — Multi-layer caching minimizes API calls, survives HA restarts, auto-invalidates at midnight
- **High-performance interval pool** — O(1) timestamp lookups, gap detection, auto-fetching of missing data - **High-performance interval pool** — O(1) timestamp lookups, gap detection, auto-fetching of missing data
- **Quarter-hour precision updates** — Sensors refresh at :00/:15/:30/:45 boundaries, independent of API polling - **Quarter-hour precision updates** — Sensors refresh at :00/:15/:30/:45 boundaries, independent of API polling
- **Official API only** — Uses Tibber's [`priceInfo`](https://developer.tibber.com/docs/reference#priceinfo) and [`priceInfoRange`](https://developer.tibber.com/docs/reference#subscription) endpoints. All ratings and statistics are calculated locally. - **Official API only** — Uses Tibber's [`priceInfo`](https://developer.tibber.com/docs/reference#priceinfo) and [`priceInfoRange`](https://developer.tibber.com/docs/reference#subscription) endpoints. All ratings and statistics are calculated locally.
## 🚀 Quick Start ## 🚀 Quick Start
@ -91,9 +91,9 @@ Or manually: **Settings** → **Devices & Services** → **+ Add Integration**
### Step 3: Done! ### Step 3: Done!
- **100+ sensors** are now available (key sensors enabled by default, advanced ones ready to enable) - **100+ sensors** are now available (key sensors enabled by default, advanced ones ready to enable)
- Explore entities in **Settings****Devices & Services** → **Tibber Price Information & Ratings** - Explore entities in **Settings****Devices & Services** → **Tibber Price Information & Ratings**
- Start building automations, dashboards, and energy-saving workflows - Start building automations, dashboards, and energy-saving workflows
📖 **[Full Installation Guide →](https://jpawlowski.github.io/hass.tibber_prices/user/installation)** 📖 **[Full Installation Guide →](https://jpawlowski.github.io/hass.tibber_prices/user/installation)**
@ -103,18 +103,18 @@ 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"> <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 | | Category | Highlights | Count |
| ----------------------- | ----------------------------------------------------------------------------- | ----- | |----------|-----------|-------|
| **💰 Prices** | Current, next & previous interval price + rolling hour averages | 6+ | | **💰 Prices** | Current, next & previous interval price + rolling hour averages | 6+ |
| **📊 Statistics** | Daily min/max/avg for today & tomorrow, 24h trailing & leading windows | 12+ | | **📊 Statistics** | Daily min/max/avg for today & tomorrow, 24h trailing & leading windows | 12+ |
| **🔮 Forecasts** | Next 1h12h average prices, price outlook & trajectory sensors | 20+ | | **🔮 Forecasts** | Next 1h12h average prices, price outlook & trajectory sensors | 20+ |
| **📈 Trends** | Current trend direction, next trend change time & countdown | 3 | | **📈 Trends** | Current trend direction, next trend change time & countdown | 3 |
| **📉 Volatility** | Today, tomorrow, next 24h & combined volatility levels | 4 | | **📉 Volatility** | Today, tomorrow, next 24h & combined volatility levels | 4 |
| **🏷️ Levels & Ratings** | 5-level (API) and 3-level (computed) classification per interval, hour & day | 12+ | | **🏷️ Levels & Ratings** | 5-level (API) and 3-level (computed) classification per interval, hour & day | 12+ |
| **⏰ Period Timing** | Best/peak: end time, duration, remaining, progress, next start | 10+ | | **⏰ Period Timing** | Best/peak: end time, duration, remaining, progress, next start | 10+ |
| **🔌 Binary Sensors** | Best price period, peak price period, tomorrow data available, API connection | 4+ | | **🔌 Binary Sensors** | Best price period, peak price period, tomorrow data available, API connection | 4+ |
| **🎛️ Runtime Config** | Switches & numbers to adjust period detection live — no restart needed | 14 | | **🎛️ Runtime Config** | Switches & numbers to adjust period detection live — no restart needed | 14 |
| **🔧 Diagnostics** | Data lifecycle status, home metadata, grid info, subscription status | 15+ | | **🔧 Diagnostics** | Data lifecycle status, home metadata, grid info, subscription status | 15+ |
> **Every sensor includes rich attributes** — timestamps, detailed descriptions, and context data. Enable **Extended Descriptions** in the integration options to get `long_description` and `usage_tips` on every entity. > **Every sensor includes rich attributes** — timestamps, detailed descriptions, and context data. Enable **Extended Descriptions** in the integration options to get `long_description` and `usage_tips` on every entity.
@ -168,17 +168,17 @@ Generate beautiful price charts with a single action call — dynamic Y-axis, co
## ❓ Help & Support ## ❓ Help & Support
- 📖 **[FAQ](https://jpawlowski.github.io/hass.tibber_prices/user/faq)** — Common questions answered - 📖 **[FAQ](https://jpawlowski.github.io/hass.tibber_prices/user/faq)** — Common questions answered
- 🔧 **[Troubleshooting](https://jpawlowski.github.io/hass.tibber_prices/user/troubleshooting)** — Solving common issues - 🔧 **[Troubleshooting](https://jpawlowski.github.io/hass.tibber_prices/user/troubleshooting)** — Solving common issues
- 🐛 **[Report an Issue](https://github.com/jpawlowski/hass.tibber_prices/issues/new)** — Found a bug? Let us know - 🐛 **[Report an Issue](https://github.com/jpawlowski/hass.tibber_prices/issues/new)** — Found a bug? Let us know
## 🤝 Contributing ## 🤝 Contributing
Contributions are welcome! See the [Contributing Guidelines](CONTRIBUTING.md) and [Developer Documentation](https://jpawlowski.github.io/hass.tibber_prices/developer/) to get started. Contributions are welcome! See the [Contributing Guidelines](CONTRIBUTING.md) and [Developer Documentation](https://jpawlowski.github.io/hass.tibber_prices/developer/) to get started.
- **[Developer Setup](https://jpawlowski.github.io/hass.tibber_prices/developer/setup)** — DevContainer-based development environment - **[Developer Setup](https://jpawlowski.github.io/hass.tibber_prices/developer/setup)** — DevContainer-based development environment
- **[Architecture Guide](https://jpawlowski.github.io/hass.tibber_prices/developer/architecture)** — Understand the codebase - **[Architecture Guide](https://jpawlowski.github.io/hass.tibber_prices/developer/architecture)** — Understand the codebase
- **[Release Management](https://jpawlowski.github.io/hass.tibber_prices/developer/release-management)** — Release process and versioning - **[Release Management](https://jpawlowski.github.io/hass.tibber_prices/developer/release-management)** — Release process and versioning
## 🤖 Development Note ## 🤖 Development Note

View file

@ -5,19 +5,13 @@
# Template for the changelog body # Template for the changelog body
header = "" header = ""
body = """ body = """
{% for group, commits in commits | group_by(attribute="group") -%} {% for group, commits in commits | group_by(attribute="group") %}
### {{ group | striptags | trim | upper_first }} ### {{ group | striptags | trim | upper_first }}
{% for commit in commits -%} {% for commit in commits %}
{% set impact_text = "" -%} - {% if commit.scope %}**{{ commit.scope }}**: {% endif %}{{ commit.message | upper_first }}\
{% set footers = commit.footers | default(value=[]) -%} {% if commit.breaking %} [**BREAKING**]{% endif %} \
{% for footer in footers -%} ([{{ commit.id | truncate(length=7, end="") }}](https://github.com/jpawlowski/hass.tibber_prices/commit/{{ commit.id }}))
{% if footer.token == "Impact" -%} {% endfor %}
{% set impact_text = footer.value -%}
{% endif -%}
{% endfor -%}
- {% if impact_text %}{{ impact_text | trim | upper_first }}{% else %}{% if commit.scope %}**{{ commit.scope }}**: {% endif %}{{ commit.message | upper_first }}{% endif %}{% if commit.breaking %} [**BREAKING**]{% endif %} ([{{ commit.id | truncate(length=7, end="") }}](https://github.com/jpawlowski/hass.tibber_prices/commit/{{ commit.id }}))
{% endfor %}
{% endfor %} {% endfor %}
--- ---
@ -31,8 +25,7 @@ trim = true
[git] [git]
# Parse conventional commits # Parse conventional commits
conventional_commits = true conventional_commits = true
# Keep unconventional commits in parsing pipeline; parser rules decide what to skip. # Include all commits (even non-conventional)
# This avoids noisy parse-error warnings on older commit history.
filter_unconventional = false filter_unconventional = false
split_commits = false split_commits = false
@ -40,28 +33,22 @@ split_commits = false
commit_parsers = [ commit_parsers = [
# Skip manifest.json version bumps (release housekeeping) # Skip manifest.json version bumps (release housekeeping)
{ message = "^chore\\(release\\): bump version", skip = true }, { message = "^chore\\(release\\): bump version", skip = true },
# Skip explicit revert commits; final net state should drive release notes
{ message = "^revert", skip = true },
# Skip development environment changes (not user-relevant) # Skip development environment changes (not user-relevant)
{ message = "^(feat|fix|chore|refactor)\\((devcontainer|vscode|scripts|dev-env|environment)\\):", skip = true }, { message = "^(feat|fix|chore|refactor)\\((devcontainer|vscode|scripts|dev-env|environment)\\):", skip = true },
# Skip CI/CD infrastructure changes (not user-relevant) # Skip CI/CD infrastructure changes (not user-relevant)
{ message = "^(feat|fix|chore|ci)\\((ci|workflow|actions|github-actions)\\):", skip = true }, { message = "^(feat|fix|chore|ci)\\((ci|workflow|actions|github-actions)\\):", skip = true },
# Skip non-user-facing fix scopes # Keep dependency updates - these ARE relevant for users
{ message = "^fix\\((docs|lint|types|tests?|ci|workflow|scripts|devcontainer|vscode|build|release)\\):", skip = true }, { message = "^chore\\(deps\\):", group = "📦 Dependencies" },
# User-facing categories aligned with AI output style # Regular commit types
{ message = "^feat", group = "🎉 What's New" }, { message = "^feat", group = "🎉 New Features" },
{ message = "^fix", group = "🐛 Fixed" }, { message = "^fix", group = "🐛 Bug Fixes" },
{ message = "^perf", group = "⚡ More Reliable" }, { message = "^docs?", group = "📚 Documentation" },
{ message = "^chore\\(deps\\):", group = "📦 Updated Dependencies" }, { message = "^perf", group = "⚡ Performance" },
# Skip mostly developer-facing categories { message = "^refactor", group = "🔧 Maintenance & Refactoring" },
{ message = "^docs?", skip = true }, { message = "^style", group = "🎨 Styling" },
{ message = "^refactor", skip = true }, { message = "^test", group = "🧪 Testing" },
{ message = "^style", skip = true }, { message = "^chore", group = "🔧 Maintenance & Refactoring" },
{ message = "^test", skip = true }, { message = "^build", group = "📦 Build" },
{ message = "^build", skip = true },
{ message = "^chore", skip = true },
# Final fallback to avoid ungrouped commits
{ message = ".*", skip = true },
] ]
# Protect breaking changes # Protect breaking changes
@ -69,5 +56,5 @@ commit_preprocessors = [
{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](https://github.com/jpawlowski/hass.tibber_prices/issues/${2}))" }, { pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](https://github.com/jpawlowski/hass.tibber_prices/issues/${2}))" },
] ]
# Apply commit parser filtering rules # Filter out commits
filter_commits = true filter_commits = false

View file

@ -1,58 +1,23 @@
# yaml-language-server: $schema=../schemas/yaml/configuration_schema.yaml # Development-friendly config that excludes go2rtc which has compatibility issues
# Development-friendly Home Assistant configuration
#
# We don't use default_config to avoid HA OS-specific integrations like go2rtc
# that expect specific container environments. Instead, we explicitly load
# the integrations useful for custom component development.
# https://www.home-assistant.io/integrations/homeassistant/ # https://www.home-assistant.io/integrations/homeassistant/
homeassistant: homeassistant:
debug: true debug: true
# Debugging integration # Disable analytics, diagnostics and error reporting for development instance
# https://www.home-assistant.io/integrations/debugpy/
debugpy:
# Privacy & analytics settings
# https://www.home-assistant.io/integrations/analytics/ # https://www.home-assistant.io/integrations/analytics/
analytics: analytics:
# Analytics are disabled to prevent development instances from skewing # Disable usage analytics to prevent skewing production statistics
# production statistics at https://analytics.home-assistant.io # https://analytics.home-assistant.io should only reflect real user installations
# System monitoring
# https://www.home-assistant.io/integrations/system_health/ # https://www.home-assistant.io/integrations/system_health/
system_health: system_health:
# Provides system health information in Settings > System > Repairs
# Safe for development - only shows local system status
# Note: The diagnostics integration is always loaded and cannot be disabled. # https://www.home-assistant.io/integrations/diagnostics/
# With analytics disabled, diagnostic data stays local and isn't sent anywhere. # Note: Diagnostics integration cannot be disabled, but without analytics
# and with internal_url set, no data is sent externally
# Core integrations # Core integrations needed for development
http: http:
# Development server settings for Codespaces/DevContainer
server_host: "0.0.0.0"
# Disable IP banning for development to avoid lockouts
ip_ban_enabled: false
# Allow access from Codespaces reverse proxy
use_x_forwarded_for: true
trusted_proxies:
- 127.0.0.0/8
- ::1
- 192.168.0.0/16
- 172.16.0.0/12
- 10.0.0.0/8
# CORS for development
cors_allowed_origins:
- "*"
# Config UI integration - useful for development
config:
# Frontend - required for UI
frontend:
# Optional: Enable custom themes
# themes: !include_dir_merge_named themes
automation: automation:
@ -60,106 +25,13 @@ script:
scene: scene:
# Useful default_config integrations for development
# https://www.home-assistant.io/integrations/history/
history:
# https://www.home-assistant.io/integrations/logbook/
logbook:
# https://www.home-assistant.io/integrations/conversation/
# conversation:
# Note: Uncomment to enable voice assistant/conversation features
# Dependencies (hassil, home-assistant-intents) are pre-installed in bootstrap
# https://www.home-assistant.io/integrations/webhook/
webhook:
# https://www.home-assistant.io/integrations/my/
my:
# https://www.home-assistant.io/integrations/recorder/
recorder:
# Development-friendly database settings
# Reduce database size and improve performance
purge_keep_days: 2
commit_interval: 30
# Exclude entities you don't need history for
exclude:
domains:
# Sun position changes constantly, rarely needed in dev
- sun
# Backups don't need history
- backup
# Updates don't need full history tracking
- update
entity_globs:
# Time sensors change every second/minute
- sensor.time*
- sensor.date*
# Uptime sensors not interesting for development
- sensor.*uptime*
- sensor.*last_boot*
# Memory/CPU sensors create a lot of data
- sensor.*memory*
- sensor.*cpu*
event_types:
# Very frequent, rarely needed in development
- call_service
# System events create lots of noise
- system_log_event
# Component loading events
- component_loaded
energy: energy:
# https://www.home-assistant.io/integrations/logger/ # https://www.home-assistant.io/integrations/logger/
logger: logger:
default: info default: info
logs: logs:
# Reduce noise from chatty components # Main integration logger - applies to ALL sub-loggers by default
homeassistant.components.recorder: warning
homeassistant.components.recorder.util: warning
homeassistant.components.websocket_api: warning
homeassistant.components.http.ban: warning
homeassistant.components.zeroconf: warning
homeassistant.components.ssdp: warning
homeassistant.components.bluetooth: warning
# Conversation can be noisy with hassil
homeassistant.components.conversation: warning
# Analytics/metrics are not interesting during development
homeassistant.components.analytics: error
# Hide platform setup messages (scene, binary_sensor, sensor, etc.)
homeassistant.components.scene: warning
homeassistant.components.binary_sensor: warning
homeassistant.components.sensor: warning
homeassistant.components.event: warning
homeassistant.components.switch: warning
# HTTP/network
homeassistant.components.http: warning
# Keep loader at warning level to see real issues with our integration
homeassistant.loader: warning
# Hide the verbose "Setting up X" messages during startup
# but keep warnings/errors visible
homeassistant.bootstrap: warning
homeassistant.setup: warning
# Core system - keep visible for important messages
homeassistant.core: info
# IMPORTANT for custom integration development:
# Coordinator issues (API calls, update failures)
homeassistant.helpers.update_coordinator: info
# Entity registration problems
homeassistant.helpers.entity_registry: info
# Config flow debugging (setup, options)
homeassistant.config_entries: info
# Your integration debug logging - shows EVERYTHING from your integration
custom_components.tibber_prices: debug custom_components.tibber_prices: debug
# Reduce verbosity for details loggers (change to 'debug' for deep debugging) # Reduce verbosity for details loggers (change to 'debug' for deep debugging)

View file

@ -127,7 +127,7 @@ def get_price_intervals_attributes(
| { | {
"period_position": i, "period_position": i,
"period_count_total": total_filtered, "period_count_total": total_filtered,
"period_count_remaining": total_filtered - i, "periods_remaining": total_filtered - i,
} }
for i, period in enumerate(filtered_periods, 1) for i, period in enumerate(filtered_periods, 1)
] ]
@ -266,8 +266,8 @@ def add_detail_attributes(attributes: dict, current_period: dict) -> None:
attributes["period_position"] = current_period["period_position"] attributes["period_position"] = current_period["period_position"]
if "period_count_total" in current_period: if "period_count_total" in current_period:
attributes["period_count_total"] = current_period["period_count_total"] attributes["period_count_total"] = current_period["period_count_total"]
if "period_count_remaining" in current_period: if "periods_remaining" in current_period:
attributes["period_count_remaining"] = current_period["period_count_remaining"] attributes["periods_remaining"] = current_period["periods_remaining"]
def add_period_count_attributes( def add_period_count_attributes(
@ -421,7 +421,7 @@ def build_final_attributes_simple(
2. Core decision attributes (level, rating_level, rating_difference_%) 2. Core decision attributes (level, rating_level, rating_difference_%)
3. Price statistics (price_mean, price_median, price_min, price_max, price_spread, volatility) 3. Price statistics (price_mean, price_median, price_min, price_max, price_spread, volatility)
4. Price differences (period_price_diff_from_daily_min, period_price_diff_from_daily_min_%) 4. Price differences (period_price_diff_from_daily_min, period_price_diff_from_daily_min_%)
5. Detail information (period_interval_count, period_position, period_count_total, period_count_remaining) 5. Detail information (period_interval_count, period_position, period_count_total, periods_remaining)
6. Relaxation information (relaxation_active, relaxation_level, relaxation_threshold_original_%, 6. Relaxation information (relaxation_active, relaxation_level, relaxation_threshold_original_%,
relaxation_threshold_applied_%) - only if current period was relaxed relaxation_threshold_applied_%) - only if current period was relaxed
7. Calculation summary (min_periods_configured, flat_days_detected, 7. Calculation summary (min_periods_configured, flat_days_detected,

View file

@ -73,7 +73,7 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity, RestoreEn
"period_price_diff_from_daily_min", "period_price_diff_from_daily_min",
"period_price_diff_from_daily_min_%", "period_price_diff_from_daily_min_%",
"period_count_total", "period_count_total",
"period_count_remaining", "periods_remaining",
} }
) )

View file

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

View file

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

View file

@ -179,7 +179,7 @@ def build_period_summary_dict(
"period_interval_count": period_data.period_length, "period_interval_count": period_data.period_length,
"period_position": period_data.period_idx, "period_position": period_data.period_idx,
"period_count_total": period_data.total_periods, "period_count_total": period_data.total_periods,
"period_count_remaining": period_data.total_periods - period_data.period_idx, "periods_remaining": period_data.total_periods - period_data.period_idx,
} }
# Add period price difference attributes based on sensor type (step 4) # Add period price difference attributes based on sensor type (step 4)

View file

@ -12,7 +12,7 @@ if TYPE_CHECKING:
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
from custom_components.tibber_prices.utils.price import calculate_coefficient_of_variation, calculate_iqr_stats from custom_components.tibber_prices.utils.price import calculate_coefficient_of_variation
from .period_overlap import ( from .period_overlap import (
recalculate_period_metadata, recalculate_period_metadata,
@ -51,10 +51,7 @@ FLEX_WARNING_VSHAPE_RATIO = 0.5 # span/ref_price ratio below which a day is con
# On flat price days (low variation), it is unrealistic to require multiple distinct # On flat price days (low variation), it is unrealistic to require multiple distinct
# best/peak price periods. Requiring 2+ periods would force relaxation to create # best/peak price periods. Requiring 2+ periods would force relaxation to create
# artificial periods that don't represent genuine price structure. # artificial periods that don't represent genuine price structure.
LOW_CV_FLAT_DAY_THRESHOLD = 10.0 # %: fallback when IQR% not available (near-zero or negative median) LOW_CV_FLAT_DAY_THRESHOLD = 10.0 # %: days with CV ≤ this need only 1 period
# IQR% ≤ 15% ≈ CV ≤ 10% for clean data, but also catches "flat + isolated spike" days correctly:
# a single spike inflates CV to 15-25% while leaving IQR% near 0-5%.
LOW_IQR_PCT_FLAT_DAY_THRESHOLD = 15.0 # %: days with IQR% ≤ this need only 1 period
def _check_period_quality( def _check_period_quality(
@ -451,14 +448,11 @@ def _compute_day_effective_min(
""" """
Compute per-day effective min_periods with flat-day adaptation. Compute per-day effective min_periods with flat-day adaptation.
On days with very low price variation (IQR% LOW_IQR_PCT_FLAT_DAY_THRESHOLD), On days with very low price variation (CV LOW_CV_FLAT_DAY_THRESHOLD),
requiring multiple distinct cheapest/peak periods is unrealistic. Finding requiring multiple distinct cheapest/peak periods is unrealistic. Finding
ONE period is sufficient because there is no meaningful price structure that ONE period is sufficient because there is no meaningful price structure that
would create natural multiple periods. would create natural multiple periods.
Uses IQR% as primary metric (robust to isolated price spikes) with CV as
fallback when IQR% is undefined (near-zero or negative median prices).
This applies ONLY to BEST PRICE periods (reverse_sort=False). For PEAK PRICE This applies ONLY to BEST PRICE periods (reverse_sort=False). For PEAK PRICE
periods, full relaxation should run even on flat days because identifying the periods, full relaxation should run even on flat days because identifying the
genuinely most expensive window requires the complete filter evaluation. genuinely most expensive window requires the complete filter evaluation.
@ -477,6 +471,7 @@ def _compute_day_effective_min(
""" """
day_effective_min = {} day_effective_min = {}
flat_day_count = 0 flat_day_count = 0
min_prices_for_cv = 2 # Need at least 2 prices to calculate CV
for day, day_prices in prices_by_day.items(): for day, day_prices in prices_by_day.items():
if not enable_relaxation or min_periods <= 1 or reverse_sort: if not enable_relaxation or min_periods <= 1 or reverse_sort:
@ -486,46 +481,30 @@ def _compute_day_effective_min(
price_values = [float(p["total"]) for p in day_prices if p.get("total") is not None] price_values = [float(p["total"]) for p in day_prices if p.get("total") is not None]
if len(price_values) < 2: # noqa: PLR2004 - need at least 2 prices for any metric if len(price_values) < min_prices_for_cv:
day_effective_min[day] = min_periods day_effective_min[day] = min_periods
continue continue
# Primary flat-day metric: IQR% is robust to isolated price spikes. day_cv = calculate_coefficient_of_variation(price_values)
# A single spike inflates CV to 15-25% while leaving IQR% near 0-5%,
# so IQR correctly identifies "flat core + spike" days as flat.
iqr_stats = calculate_iqr_stats(price_values)
iqr_pct = iqr_stats["iqr_pct"] if iqr_stats else None
is_flat = False if day_cv is not None and day_cv <= LOW_CV_FLAT_DAY_THRESHOLD:
flat_metric = ""
if iqr_pct is not None:
is_flat = iqr_pct <= LOW_IQR_PCT_FLAT_DAY_THRESHOLD
flat_metric = f"IQR%={iqr_pct:.1f}% ≤ {LOW_IQR_PCT_FLAT_DAY_THRESHOLD:.0f}%"
else:
# IQR% undefined (near-zero or negative median): fall back to CV
day_cv = calculate_coefficient_of_variation(price_values)
if day_cv is not None:
is_flat = day_cv <= LOW_CV_FLAT_DAY_THRESHOLD
flat_metric = f"CV={day_cv:.1f}% ≤ {LOW_CV_FLAT_DAY_THRESHOLD:.0f}% (IQR% N/A)"
if is_flat:
day_effective_min[day] = 1 day_effective_min[day] = 1
flat_day_count += 1 flat_day_count += 1
_LOGGER_DETAILS.debug( _LOGGER_DETAILS.debug(
"%sDay %s: flat price profile (%s) → min_periods relaxed to 1", "%sDay %s: flat price profile (CV=%.1f%%%.1f%%) → min_periods relaxed to 1",
INDENT_L1, INDENT_L1,
day, day,
flat_metric, day_cv,
LOW_CV_FLAT_DAY_THRESHOLD,
) )
else: else:
day_effective_min[day] = min_periods day_effective_min[day] = min_periods
if flat_day_count > 0: if flat_day_count > 0:
_LOGGER.info( _LOGGER.info(
"Adaptive min_periods: %d flat day(s) (IQR%%%.0f%%) need only 1 period instead of %d", "Adaptive min_periods: %d flat day(s) (CV%.0f%%) need only 1 period instead of %d",
flat_day_count, flat_day_count,
LOW_IQR_PCT_FLAT_DAY_THRESHOLD, LOW_CV_FLAT_DAY_THRESHOLD,
min_periods, min_periods,
) )

View file

@ -1,17 +1,12 @@
""" """
Shape-based period extension: extend periods into adjacent cheap/expensive intervals. Shape-based period extension: extend periods into adjacent VERY_CHEAP/VERY_EXPENSIVE intervals.
After periods are identified by the core algorithm, this module optionally extends After periods are identified by the core algorithm, this module optionally extends
each period's boundaries to include any directly-adjacent intervals that carry a each period's boundaries to include any directly-adjacent intervals that carry the
favourable price level relevant to the period type: most extreme price level relevant to the period type:
- Best price periods extend into VERY_CHEAP neighbours; fall back to CHEAP - Best price periods extend into VERY_CHEAP neighbouring intervals
on each side where no VERY_CHEAP neighbour exists. - Peak price periods extend into VERY_EXPENSIVE neighbouring intervals
- Peak price periods extend into VERY_EXPENSIVE neighbours; fall back to
EXPENSIVE on each side where no VERY_EXPENSIVE exists.
The fallback is evaluated **per side independently**: one side may extend via
VERY_CHEAP while the other side falls back to CHEAP.
Extension is purely additive and opt-in (disabled by default). It does not affect Extension is purely additive and opt-in (disabled by default). It does not affect
the core period-finding logic; periods that would not normally be found are not the core period-finding logic; periods that would not normally be found are not
@ -25,8 +20,6 @@ from datetime import timedelta
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from custom_components.tibber_prices.const import ( from custom_components.tibber_prices.const import (
PRICE_LEVEL_CHEAP,
PRICE_LEVEL_EXPENSIVE,
PRICE_LEVEL_VERY_CHEAP, PRICE_LEVEL_VERY_CHEAP,
PRICE_LEVEL_VERY_EXPENSIVE, PRICE_LEVEL_VERY_EXPENSIVE,
) )
@ -62,17 +55,13 @@ def extend_periods_for_shape( # noqa: PLR0913 - Extension requires all context
time: TibberPricesTimeService, time: TibberPricesTimeService,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
""" """
Extend each period into adjacent cheap/expensive intervals. Extend each period into adjacent VERY_CHEAP or VERY_EXPENSIVE intervals.
For best price periods (reverse_sort=False): For best price periods (reverse_sort=False): extend into VERY_CHEAP neighbours.
Primary: extend into VERY_CHEAP neighbours. For peak price periods (reverse_sort=True): extend into VERY_EXPENSIVE neighbours.
Fallback: extend into CHEAP neighbours (per side, only if no VERY_CHEAP found).
For peak price periods (reverse_sort=True):
Primary: extend into VERY_EXPENSIVE neighbours.
Fallback: extend into EXPENSIVE neighbours (per side, only if no VERY_EXPENSIVE found).
Only intervals that are directly contiguous with the period and carry the Only intervals that are directly contiguous with the period and carry the
required level are added. At most *max_extension_intervals* are consumed on target level are added. At most *max_extension_intervals* are consumed on
each side independently. Period statistics are fully recalculated after each side independently. Period statistics are fully recalculated after
any extension. any extension.
@ -93,12 +82,7 @@ def extend_periods_for_shape( # noqa: PLR0913 - Extension requires all context
if not periods or max_extension_intervals <= 0: if not periods or max_extension_intervals <= 0:
return periods return periods
if reverse_sort: target_level = PRICE_LEVEL_VERY_EXPENSIVE if reverse_sort else PRICE_LEVEL_VERY_CHEAP
primary_level = PRICE_LEVEL_VERY_EXPENSIVE
fallback_level = PRICE_LEVEL_EXPENSIVE
else:
primary_level = PRICE_LEVEL_VERY_CHEAP
fallback_level = PRICE_LEVEL_CHEAP
# Build a lookup dict: local datetime → full interval dict # Build a lookup dict: local datetime → full interval dict
interval_index: dict[datetime, dict[str, Any]] = {} interval_index: dict[datetime, dict[str, Any]] = {}
@ -111,8 +95,7 @@ def extend_periods_for_shape( # noqa: PLR0913 - Extension requires all context
_extend_period_edges( _extend_period_edges(
period, period,
interval_index, interval_index,
primary_level=primary_level, target_level=target_level,
fallback_level=fallback_level,
max_intervals=max_extension_intervals, max_intervals=max_extension_intervals,
thresholds=thresholds, thresholds=thresholds,
price_context=price_context, price_context=price_context,
@ -124,72 +107,25 @@ def extend_periods_for_shape( # noqa: PLR0913 - Extension requires all context
# ── private helpers ──────────────────────────────────────────────────────────── # ── private helpers ────────────────────────────────────────────────────────────
def _walk_contiguous( def _extend_period_edges( # noqa: PLR0913, PLR0912, PLR0915 - Period edge extension requires many args, branches, and statements
interval_index: dict[datetime, dict[str, Any]],
start_cursor: datetime,
step: timedelta,
target_level: str,
max_intervals: int,
) -> list[dict[str, Any]]:
"""
Walk contiguously from *start_cursor* in direction *step*, collecting intervals.
Stops when the next interval is missing from the index, does not carry
*target_level*, or the *max_intervals* cap is reached.
Args:
interval_index: Lookup map of ``{starts_at_datetime: interval_dict}``.
start_cursor: First position to check (already offset from the period edge).
step: ``+_INTERVAL_DURATION`` for rightward, ``-_INTERVAL_DURATION`` for leftward.
target_level: Required ``level`` value (e.g. ``"VERY_CHEAP"``).
max_intervals: Maximum intervals to collect.
Returns:
Collected intervals in chronological order (reversed for leftward walks).
"""
additions: list[dict[str, Any]] = []
cursor = start_cursor
for _ in range(max_intervals):
iv = interval_index.get(cursor)
if iv is None or iv.get("level") != target_level:
break
additions.append(iv)
cursor += step
# For leftward walks the list was built newest-first; reverse to chronological
if step < timedelta(0):
additions.reverse()
return additions
def _extend_period_edges( # noqa: PLR0913 - Period edge extension requires many args
period: dict[str, Any], period: dict[str, Any],
interval_index: dict[datetime, dict[str, Any]], interval_index: dict[datetime, dict[str, Any]],
*, *,
primary_level: str, target_level: str,
fallback_level: str,
max_intervals: int, max_intervals: int,
thresholds: TibberPricesThresholdConfig, thresholds: TibberPricesThresholdConfig,
price_context: dict[str, Any], price_context: dict[str, Any],
) -> dict[str, Any]: ) -> dict[str, Any]:
""" """
Consume adjacent intervals on both edges of a period. Consume adjacent target-level intervals on both edges of a period.
Each side is evaluated independently:
1. Try extending into *primary_level* neighbours (VERY_CHEAP / VERY_EXPENSIVE).
2. If no primary-level neighbours were found on that side, fall back to
*fallback_level* neighbours (CHEAP / EXPENSIVE).
The original period dict is never mutated; a new dict is returned. The original period dict is never mutated; a new dict is returned.
If no extension is possible on either side, the original dict is returned. If no extension is possible, the original dict is returned unchanged.
Args: Args:
period: Period summary dict with ``start`` and ``end`` datetime keys. period: Period summary dict with ``start`` and ``end`` datetime keys.
interval_index: Lookup map of ``{starts_at_datetime: interval_dict}``. interval_index: Lookup map of ``{starts_at_datetime: interval_dict}``.
primary_level: Preferred level (``"VERY_CHEAP"`` or ``"VERY_EXPENSIVE"``). target_level: ``"VERY_CHEAP"`` or ``"VERY_EXPENSIVE"``.
fallback_level: Fallback level (``"CHEAP"`` or ``"EXPENSIVE"``).
max_intervals: Maximum intervals that may be added on each side. max_intervals: Maximum intervals that may be added on each side.
thresholds: Threshold config for aggregation helpers. thresholds: Threshold config for aggregation helpers.
price_context: Reference prices / averages per calendar day. price_context: Reference prices / averages per calendar day.
@ -203,21 +139,25 @@ def _extend_period_edges( # noqa: PLR0913 - Period edge extension requires many
# ``end`` is the exclusive boundary: the last included interval starts at # ``end`` is the exclusive boundary: the last included interval starts at
# ``end - _INTERVAL_DURATION``. # ``end - _INTERVAL_DURATION``.
backward_step = -_INTERVAL_DURATION
forward_step = _INTERVAL_DURATION
# ── walk LEFT (earlier than period start) ───────────────────────────────── # ── walk LEFT (earlier than period start) ─────────────────────────────────
left_cursor = start - _INTERVAL_DURATION left_additions: list[dict[str, Any]] = []
left_additions = _walk_contiguous(interval_index, left_cursor, backward_step, primary_level, max_intervals) cursor = start - _INTERVAL_DURATION
if not left_additions: for _ in range(max_intervals):
# Fallback: no primary-level neighbours on this side → try fallback level iv = interval_index.get(cursor)
left_additions = _walk_contiguous(interval_index, left_cursor, backward_step, fallback_level, max_intervals) if iv is None or iv.get("level") != target_level:
break
left_additions.insert(0, iv)
cursor -= _INTERVAL_DURATION
# ── walk RIGHT (later than period end) ──────────────────────────────────── # ── walk RIGHT (later than period end) ────────────────────────────────────
right_additions = _walk_contiguous(interval_index, end, forward_step, primary_level, max_intervals) right_additions: list[dict[str, Any]] = []
if not right_additions: cursor = end # first interval AFTER the period
# Fallback: no primary-level neighbours on this side → try fallback level for _ in range(max_intervals):
right_additions = _walk_contiguous(interval_index, end, forward_step, fallback_level, max_intervals) iv = interval_index.get(cursor)
if iv is None or iv.get("level") != target_level:
break
right_additions.append(iv)
cursor += _INTERVAL_DURATION
total_added = len(left_additions) + len(right_additions) total_added = len(left_additions) + len(right_additions)
if total_added == 0: if total_added == 0:
@ -259,7 +199,7 @@ def _extend_period_edges( # noqa: PLR0913 - Period edge extension requires many
cv_pct = round(statistics.stdev(prices_for_vol) / mean_p * 100, 1) cv_pct = round(statistics.stdev(prices_for_vol) / mean_p * 100, 1)
# ── assemble updated period dict (keep structural fields, update statistics) ─ # ── assemble updated period dict (keep structural fields, update statistics) ─
reverse_sort = primary_level == PRICE_LEVEL_VERY_EXPENSIVE reverse_sort = target_level == PRICE_LEVEL_VERY_EXPENSIVE
updated: dict[str, Any] = { updated: dict[str, Any] = {
**period, **period,
# Time fields # Time fields

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,79 +1,79 @@
{ {
"services": { "services": {
"get_price": { "get_price": {
"service": "mdi:table-search" "service": "mdi:table-search"
}, },
"get_chartdata": { "get_chartdata": {
"service": "mdi:chart-bar", "service": "mdi:chart-bar",
"sections": { "sections": {
"general": "mdi:identifier", "general": "mdi:identifier",
"selection": "mdi:calendar-range", "selection": "mdi:calendar-range",
"filters": "mdi:filter-variant", "filters": "mdi:filter-variant",
"transformation": "mdi:tune", "transformation": "mdi:tune",
"format": "mdi:file-table", "format": "mdi:file-table",
"arrays_of_objects": "mdi:code-json", "arrays_of_objects": "mdi:code-json",
"arrays_of_arrays": "mdi:code-brackets" "arrays_of_arrays": "mdi:code-brackets"
} }
}, },
"get_apexcharts_yaml": { "get_apexcharts_yaml": {
"service": "mdi:chart-line", "service": "mdi:chart-line",
"sections": { "sections": {
"entry_id": "mdi:identifier", "entry_id": "mdi:identifier",
"day": "mdi:calendar-range", "day": "mdi:calendar-range",
"level_type": "mdi:format-list-bulleted-type", "level_type": "mdi:format-list-bulleted-type",
"resolution": "mdi:timer-sand", "resolution": "mdi:timer-sand",
"highlight_best_price": "mdi:battery-charging-low", "highlight_best_price": "mdi:battery-charging-low",
"highlight_peak_price": "mdi:battery-alert" "highlight_peak_price": "mdi:battery-alert"
} }
}, },
"refresh_user_data": { "refresh_user_data": {
"service": "mdi:refresh" "service": "mdi:refresh"
}, },
"find_cheapest_block": { "find_cheapest_block": {
"service": "mdi:washing-machine", "service": "mdi:washing-machine",
"sections": { "sections": {
"search_range": "mdi:calendar-search", "search_range": "mdi:calendar-search",
"time_alternatives": "mdi:clock-time-eight-outline", "time_alternatives": "mdi:clock-time-eight-outline",
"price_filter": "mdi:filter-variant", "price_filter": "mdi:filter-variant",
"output": "mdi:tune-variant" "output": "mdi:tune-variant"
} }
}, },
"find_most_expensive_block": { "find_most_expensive_block": {
"service": "mdi:lightning-bolt-circle", "service": "mdi:lightning-bolt-circle",
"sections": { "sections": {
"search_range": "mdi:calendar-search", "search_range": "mdi:calendar-search",
"time_alternatives": "mdi:clock-time-eight-outline", "time_alternatives": "mdi:clock-time-eight-outline",
"price_filter": "mdi:filter-variant", "price_filter": "mdi:filter-variant",
"output": "mdi:tune-variant" "output": "mdi:tune-variant"
} }
}, },
"find_cheapest_hours": { "find_cheapest_hours": {
"service": "mdi:ev-station", "service": "mdi:ev-station",
"sections": { "sections": {
"search_range": "mdi:calendar-search", "search_range": "mdi:calendar-search",
"time_alternatives": "mdi:clock-time-eight-outline", "time_alternatives": "mdi:clock-time-eight-outline",
"price_filter": "mdi:filter-variant", "price_filter": "mdi:filter-variant",
"output": "mdi:tune-variant" "output": "mdi:tune-variant"
} }
}, },
"find_most_expensive_hours": { "find_most_expensive_hours": {
"service": "mdi:flash-alert", "service": "mdi:flash-alert",
"sections": { "sections": {
"search_range": "mdi:calendar-search", "search_range": "mdi:calendar-search",
"time_alternatives": "mdi:clock-time-eight-outline", "time_alternatives": "mdi:clock-time-eight-outline",
"price_filter": "mdi:filter-variant", "price_filter": "mdi:filter-variant",
"output": "mdi:tune-variant" "output": "mdi:tune-variant"
} }
}, },
"find_cheapest_schedule": { "find_cheapest_schedule": {
"service": "mdi:calendar-check", "service": "mdi:calendar-check",
"sections": { "sections": {
"scheduling_options": "mdi:format-list-numbered", "scheduling_options": "mdi:format-list-numbered",
"search_range": "mdi:calendar-search", "search_range": "mdi:calendar-search",
"time_alternatives": "mdi:clock-time-eight-outline", "time_alternatives": "mdi:clock-time-eight-outline",
"price_filter": "mdi:filter-variant", "price_filter": "mdi:filter-variant",
"output": "mdi:tune-variant" "output": "mdi:tune-variant"
} }
}
} }
}
} }

View file

@ -1,11 +1,15 @@
{ {
"domain": "tibber_prices", "domain": "tibber_prices",
"name": "Tibber Price Information & Ratings", "name": "Tibber Price Information & Ratings",
"codeowners": ["@jpawlowski"], "codeowners": [
"@jpawlowski"
],
"config_flow": true, "config_flow": true,
"documentation": "https://github.com/jpawlowski/hass.tibber_prices", "documentation": "https://github.com/jpawlowski/hass.tibber_prices",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"issue_tracker": "https://github.com/jpawlowski/hass.tibber_prices/issues", "issue_tracker": "https://github.com/jpawlowski/hass.tibber_prices/issues",
"requirements": ["aiofiles>=23.2.1"], "requirements": [
"aiofiles>=23.2.1"
],
"version": "0.30.0" "version": "0.30.0"
} }

View file

@ -47,7 +47,7 @@ from .lifecycle import build_lifecycle_attributes
from .metadata import get_day_pattern_attributes from .metadata import get_day_pattern_attributes
from .timing import _is_timing_or_volatility_sensor from .timing import _is_timing_or_volatility_sensor
from .trend import _add_cached_trend_attributes, _add_timing_or_volatility_attributes from .trend import _add_cached_trend_attributes, _add_timing_or_volatility_attributes
from .volatility import add_percentile_rank_attributes, add_volatility_type_attributes, get_prices_for_volatility from .volatility import add_volatility_type_attributes, get_prices_for_volatility
from .window_24h import add_average_price_attributes from .window_24h import add_average_price_attributes
__all__ = [ __all__ = [
@ -65,7 +65,6 @@ __all__ = [
"TrendAttributes", "TrendAttributes",
"VolatilityAttributes", "VolatilityAttributes",
"Window24hAttributes", "Window24hAttributes",
"add_percentile_rank_attributes",
"add_volatility_type_attributes", "add_volatility_type_attributes",
"build_extra_state_attributes", "build_extra_state_attributes",
"build_sensor_attributes", "build_sensor_attributes",
@ -191,9 +190,6 @@ def build_sensor_attributes( # noqa: PLR0912
elif _is_timing_or_volatility_sensor(key): elif _is_timing_or_volatility_sensor(key):
_add_timing_or_volatility_attributes(attributes, key, cached_data, native_value, time=time) _add_timing_or_volatility_attributes(attributes, key, cached_data, native_value, time=time)
elif "_price_rank_" in key:
add_percentile_rank_attributes(attributes, cached_data, time=time)
elif key in ("day_pattern_yesterday", "day_pattern_today", "day_pattern_tomorrow"): elif key in ("day_pattern_yesterday", "day_pattern_today", "day_pattern_tomorrow"):
day = key.removeprefix("day_pattern_") day = key.removeprefix("day_pattern_")
day_attrs = get_day_pattern_attributes(coordinator, day) day_attrs = get_day_pattern_attributes(coordinator, day)

View file

@ -164,54 +164,3 @@ def add_volatility_type_attributes(
# Add time window info # Add time window info
now = time.now() now = time.now()
volatility_attributes["timestamp"] = now volatility_attributes["timestamp"] = now
def add_percentile_rank_attributes(
attributes: dict,
cached_data: dict,
*,
time: TibberPricesTimeService,
) -> None:
"""
Add attributes for percentile rank sensors.
Sets the timestamp based on the percentile type stored in cached_data:
- "today" / "today_tomorrow": today's first interval start (midnight context)
- "tomorrow": tomorrow's first interval start
Args:
attributes: Dictionary to add attributes to
cached_data: Dictionary containing cached sensor data (percentile_rank_attributes,
percentile_rank_type, coordinator_data)
time: TibberPricesTimeService instance (required)
"""
from datetime import timedelta # noqa: PLC0415 - local import to avoid circular
rank_attrs = cached_data.get("percentile_rank_attributes")
if rank_attrs:
attributes.update(rank_attrs)
# Set timestamp based on period type
percentile_type = cached_data.get("percentile_rank_type", "today")
coordinator_data = cached_data.get("coordinator_data")
if coordinator_data:
from custom_components.tibber_prices.coordinator.helpers import ( # noqa: PLC0415
get_intervals_for_day_offsets,
)
all_intervals = get_intervals_for_day_offsets(coordinator_data, [-1, 0, 1])
now = time.now()
today_date = now.date()
tomorrow_date = (now + timedelta(days=1)).date()
if percentile_type == "tomorrow":
tomorrow_data = [p for p in all_intervals if p.get("startsAt") and p["startsAt"].date() == tomorrow_date]
if tomorrow_data:
attributes["timestamp"] = tomorrow_data[0].get("startsAt")
else:
# today / today_tomorrow → use today's midnight
today_data = [p for p in all_intervals if p.get("startsAt") and p["startsAt"].date() == today_date]
if today_data:
attributes["timestamp"] = today_data[0].get("startsAt")

View file

@ -2,7 +2,6 @@
from __future__ import annotations from __future__ import annotations
import bisect
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from custom_components.tibber_prices.const import ( from custom_components.tibber_prices.const import (
@ -14,18 +13,13 @@ from custom_components.tibber_prices.const import (
DEFAULT_VOLATILITY_THRESHOLD_VERY_HIGH, DEFAULT_VOLATILITY_THRESHOLD_VERY_HIGH,
get_display_unit_factor, get_display_unit_factor,
) )
from custom_components.tibber_prices.coordinator.helpers import get_intervals_for_day_offsets from custom_components.tibber_prices.entity_utils import add_icon_color_attribute
from custom_components.tibber_prices.entity_utils import add_icon_color_attribute, find_rolling_hour_center_index
from custom_components.tibber_prices.sensor.attributes import ( from custom_components.tibber_prices.sensor.attributes import (
add_volatility_type_attributes, add_volatility_type_attributes,
get_prices_for_volatility, get_prices_for_volatility,
) )
from custom_components.tibber_prices.utils.average import calculate_mean from custom_components.tibber_prices.utils.average import calculate_mean
from custom_components.tibber_prices.utils.price import ( from custom_components.tibber_prices.utils.price import calculate_volatility_with_cv
calculate_iqr_stats,
calculate_percentile_rank,
calculate_volatility_with_cv,
)
from .base import TibberPricesBaseCalculator from .base import TibberPricesBaseCalculator
@ -52,7 +46,6 @@ class TibberPricesVolatilityCalculator(TibberPricesBaseCalculator):
""" """
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self._last_volatility_attributes: dict[str, Any] = {} self._last_volatility_attributes: dict[str, Any] = {}
self._last_percentile_rank_attributes: dict[str, Any] = {}
def get_volatility_value(self, *, volatility_type: str) -> str | None: def get_volatility_value(self, *, volatility_type: str) -> str | None:
""" """
@ -108,33 +101,17 @@ class TibberPricesVolatilityCalculator(TibberPricesBaseCalculator):
# Calculate volatility level AND coefficient of variation # Calculate volatility level AND coefficient of variation
volatility, cv = calculate_volatility_with_cv(prices_to_analyze, **thresholds) volatility, cv = calculate_volatility_with_cv(prices_to_analyze, **thresholds)
# Calculate IQR statistics (robust to outliers)
iqr_stats = calculate_iqr_stats(prices_to_analyze)
# Store attributes for this sensor # Store attributes for this sensor
# Build attributes with all price_* together, interval_count last self._last_volatility_attributes = {
attrs: dict[str, Any] = {
"price_volatility": volatility.lower(),
"price_coefficient_variation_%": round(cv, 2) if cv is not None else None,
"price_spread": round(spread_display, 2), "price_spread": round(spread_display, 2),
"price_coefficient_variation_%": round(cv, 2) if cv is not None else None,
"price_volatility": volatility.lower(),
"price_min": round(price_min * factor, 2), "price_min": round(price_min * factor, 2),
"price_max": round(price_max * factor, 2), "price_max": round(price_max * factor, 2),
"price_mean": round(price_mean * factor, 2), "price_mean": round(price_mean * factor, 2),
"interval_count": len(prices_to_analyze),
} }
# Add IQR attributes when enough data is available (stay in price_* group)
if iqr_stats is not None:
attrs["price_median"] = round(iqr_stats["median"] * factor, 2)
attrs["price_q25"] = round(iqr_stats["q25"] * factor, 2)
attrs["price_q75"] = round(iqr_stats["q75"] * factor, 2)
attrs["price_typical_spread"] = round(iqr_stats["iqr"] * factor, 2)
if iqr_stats["iqr_pct"] is not None:
attrs["price_typical_spread_%"] = round(iqr_stats["iqr_pct"], 2)
attrs["price_spike_count"] = iqr_stats["outlier_count"]
attrs["interval_count"] = len(prices_to_analyze)
self._last_volatility_attributes = attrs
# Add icon_color for dynamic styling # Add icon_color for dynamic styling
add_icon_color_attribute(self._last_volatility_attributes, key="volatility", state_value=volatility) add_icon_color_attribute(self._last_volatility_attributes, key="volatility", state_value=volatility)
@ -159,146 +136,3 @@ class TibberPricesVolatilityCalculator(TibberPricesBaseCalculator):
""" """
return self._last_volatility_attributes return self._last_volatility_attributes
def get_percentile_rank_value(
self,
*,
percentile_type: str,
subject: str = "current_interval",
) -> float | None:
"""
Calculate the percentile rank of a subject price within a reference set.
The result is 0-100: percentage of reference prices strictly cheaper than
the subject price. 0% = cheapest, ~99% = most expensive.
Also stores detailed attributes in self._last_percentile_rank_attributes
for use in extra_state_attributes.
Args:
percentile_type: Reference window - one of "today", "tomorrow", "today_tomorrow".
subject: Price to rank - one of "current_interval" (default), "next_interval",
"previous_interval", "current_hour", "next_hour".
Returns:
Percentile rank (0.0-100.0) or None if unavailable.
"""
if not self.has_data():
return None
# Get the price of the subject to rank
subject_price = self._get_subject_price(subject)
if subject_price is None:
return None
# Get reference prices for this type (reuse volatility helper)
reference_prices = get_prices_for_volatility(
percentile_type,
self.coordinator.data,
time=self.coordinator.time,
)
if not reference_prices:
return None
# Calculate percentile rank
rank = calculate_percentile_rank(subject_price, reference_prices)
if rank is None:
return None
# Convert to display units for attribute storage
factor = get_display_unit_factor(self.config_entry)
price_attr_key = self._get_subject_price_attr_key(subject)
self._last_percentile_rank_attributes = {
price_attr_key: round(subject_price * factor, 2),
"prices_below_count": bisect.bisect_left(sorted(reference_prices), subject_price),
"interval_count": len(reference_prices),
"reference_min": round(min(reference_prices) * factor, 2),
"reference_max": round(max(reference_prices) * factor, 2),
"reference_mean": round(calculate_mean(reference_prices) * factor, 2),
}
return rank
def _get_subject_price(self, subject: str) -> float | None:
"""
Get the price of the subject to rank.
Args:
subject: One of "current_interval", "next_interval", "previous_interval",
"current_hour", "next_hour".
Returns:
Price as float or None if unavailable.
"""
if subject == "current_interval":
interval = self.find_interval_at_offset(0)
elif subject == "next_interval":
interval = self.find_interval_at_offset(1)
elif subject == "previous_interval":
interval = self.find_interval_at_offset(-1)
elif subject in ("current_hour", "next_hour"):
hour_offset = 0 if subject == "current_hour" else 1
return self._get_rolling_hour_avg_price(hour_offset)
else:
return None
if interval is None:
return None
raw = interval.get("total")
return float(raw) if raw is not None else None
def _get_subject_price_attr_key(self, subject: str) -> str:
"""Return the attribute key name for the subject's price."""
return {
"current_interval": "current_price",
"next_interval": "next_price",
"previous_interval": "previous_price",
"current_hour": "current_hour_avg_price",
"next_hour": "next_hour_avg_price",
}.get(subject, "ranked_price")
def _get_rolling_hour_avg_price(self, hour_offset: int) -> float | None:
"""
Get the rolling 1h average price for the given hour offset.
Uses the same 5-interval window as current_hour_average_price.
Args:
hour_offset: 0 for current hour, 1 for next hour.
Returns:
Average price as float or None if unavailable.
"""
all_prices = get_intervals_for_day_offsets(self.coordinator_data, [-1, 0, 1])
if not all_prices:
return None
time = self.coordinator.time
now = time.now()
center_idx = find_rolling_hour_center_index(all_prices, now, hour_offset, time=time)
if center_idx is None:
return None
window: list[float] = []
for offset in range(-2, 3):
idx = center_idx + offset
if 0 <= idx < len(all_prices):
raw = all_prices[idx].get("total")
if raw is not None:
window.append(float(raw))
return calculate_mean(window) if window else None
def get_percentile_rank_attributes(self) -> dict[str, Any]:
"""
Get stored percentile rank attributes from last calculation.
Returns:
Dictionary of percentile rank attributes, or empty dict if no calculation yet.
"""
return self._last_percentile_rank_attributes

View file

@ -100,22 +100,6 @@ MIN_HOURS_FOR_LATER_HALF = 3 # Minimum hours needed to calculate later half ave
_SENTINEL = object() _SENTINEL = object()
def _extract_percentile_rank_type(key: str) -> str | None:
"""
Extract the reference-window type from a price rank sensor key.
Returns "today_tomorrow", "tomorrow", or "today" based on the key suffix.
Returns None if the key is not a price rank sensor key.
"""
if "_rank_today_tomorrow" in key:
return "today_tomorrow"
if "_rank_tomorrow" in key:
return "tomorrow"
if "_rank_today" in key:
return "today"
return None
class TibberPricesSensor(TibberPricesEntity, RestoreSensor): class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
"""tibber_prices Sensor class with state restoration.""" """tibber_prices Sensor class with state restoration."""
@ -189,7 +173,7 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
"period_price_diff_from_daily_min", "period_price_diff_from_daily_min",
"period_price_diff_from_daily_min_%", "period_price_diff_from_daily_min_%",
"period_count_total", "period_count_total",
"period_count_remaining", "periods_remaining",
} }
) )
@ -1180,9 +1164,6 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
"current_trend_attributes": self._trend_calculator.get_current_trend_attributes(), "current_trend_attributes": self._trend_calculator.get_current_trend_attributes(),
"trend_change_attributes": self._trend_calculator.get_trend_change_attributes(), "trend_change_attributes": self._trend_calculator.get_trend_change_attributes(),
"volatility_attributes": self._volatility_calculator.get_volatility_attributes(), "volatility_attributes": self._volatility_calculator.get_volatility_attributes(),
"percentile_rank_attributes": self._volatility_calculator.get_percentile_rank_attributes(),
"percentile_rank_type": _extract_percentile_rank_type(key),
"coordinator_data": self.coordinator.data,
"last_extreme_interval": self._daily_stat_calculator.get_last_extreme_interval(), "last_extreme_interval": self._daily_stat_calculator.get_last_extreme_interval(),
"last_energy_tax_averages": self._daily_stat_calculator.get_last_energy_tax_averages(), "last_energy_tax_averages": self._daily_stat_calculator.get_last_energy_tax_averages(),
"last_price_level": self._interval_calculator.get_last_price_level(), "last_price_level": self._interval_calculator.get_last_price_level(),

View file

@ -736,139 +736,6 @@ VOLATILITY_SENSORS = (
), ),
) )
# ----------------------------------------------------------------------------
# 6b. PRICE PERCENTILE RANK SENSORS
# ----------------------------------------------------------------------------
# These sensors show where the current price ranks within a reference period.
# The state (0-100%) answers: "What percentage of reference prices are cheaper
# than the current price?"
#
# 0% = current price is the cheapest in the reference period
# 50% = half the prices are cheaper (current price at median level)
# ~99% = almost everything is cheaper (current price near the maximum)
#
# Reference periods:
# - today: 96 intervals of today (local calendar day)
# - tomorrow: 96 intervals of tomorrow (once data is available)
# - today_tomorrow: 192 combined intervals when tomorrow is available
#
# Use case: "Is now the right time to run a large appliance?"
# - current_interval_price_rank_today < 25 → bottom quartile, great time to use energy
# - current_interval_price_rank_today > 75 → top quartile, consider delaying consumption
PERCENTILE_RANK_SENSORS = (
# ----------------------------------------------------------------
# Current interval rank sensors
# ----------------------------------------------------------------
SensorEntityDescription(
key="current_interval_price_rank_today",
translation_key="current_interval_price_rank_today",
icon="mdi:percent",
native_unit_of_measurement=PERCENTAGE,
state_class=None, # Position metric: no statistics
suggested_display_precision=0,
),
SensorEntityDescription(
key="current_interval_price_rank_tomorrow",
translation_key="current_interval_price_rank_tomorrow",
icon="mdi:percent",
native_unit_of_measurement=PERCENTAGE,
state_class=None, # Position metric: no statistics
suggested_display_precision=0,
entity_registry_enabled_default=False, # Available once tomorrow's data arrives
),
SensorEntityDescription(
key="current_interval_price_rank_today_tomorrow",
translation_key="current_interval_price_rank_today_tomorrow",
icon="mdi:percent",
native_unit_of_measurement=PERCENTAGE,
state_class=None, # Position metric: no statistics
suggested_display_precision=0,
entity_registry_enabled_default=False, # Advanced overview use case
),
# ----------------------------------------------------------------
# Next interval rank sensors
# ----------------------------------------------------------------
SensorEntityDescription(
key="next_interval_price_rank_today",
translation_key="next_interval_price_rank_today",
icon="mdi:percent",
native_unit_of_measurement=PERCENTAGE,
state_class=None,
suggested_display_precision=0,
entity_registry_enabled_default=False,
),
SensorEntityDescription(
key="next_interval_price_rank_today_tomorrow",
translation_key="next_interval_price_rank_today_tomorrow",
icon="mdi:percent",
native_unit_of_measurement=PERCENTAGE,
state_class=None,
suggested_display_precision=0,
entity_registry_enabled_default=False,
),
# ----------------------------------------------------------------
# Previous interval rank sensors
# ----------------------------------------------------------------
SensorEntityDescription(
key="previous_interval_price_rank_today",
translation_key="previous_interval_price_rank_today",
icon="mdi:percent",
native_unit_of_measurement=PERCENTAGE,
state_class=None,
suggested_display_precision=0,
entity_registry_enabled_default=False,
),
SensorEntityDescription(
key="previous_interval_price_rank_today_tomorrow",
translation_key="previous_interval_price_rank_today_tomorrow",
icon="mdi:percent",
native_unit_of_measurement=PERCENTAGE,
state_class=None,
suggested_display_precision=0,
entity_registry_enabled_default=False,
),
# ----------------------------------------------------------------
# Rolling-hour rank sensors (rank of 1h rolling average)
# ----------------------------------------------------------------
SensorEntityDescription(
key="current_hour_price_rank_today",
translation_key="current_hour_price_rank_today",
icon="mdi:percent",
native_unit_of_measurement=PERCENTAGE,
state_class=None,
suggested_display_precision=0,
entity_registry_enabled_default=False,
),
SensorEntityDescription(
key="current_hour_price_rank_today_tomorrow",
translation_key="current_hour_price_rank_today_tomorrow",
icon="mdi:percent",
native_unit_of_measurement=PERCENTAGE,
state_class=None,
suggested_display_precision=0,
entity_registry_enabled_default=False,
),
SensorEntityDescription(
key="next_hour_price_rank_today",
translation_key="next_hour_price_rank_today",
icon="mdi:percent",
native_unit_of_measurement=PERCENTAGE,
state_class=None,
suggested_display_precision=0,
entity_registry_enabled_default=False,
),
SensorEntityDescription(
key="next_hour_price_rank_today_tomorrow",
translation_key="next_hour_price_rank_today_tomorrow",
icon="mdi:percent",
native_unit_of_measurement=PERCENTAGE,
state_class=None,
suggested_display_precision=0,
entity_registry_enabled_default=False,
),
)
# ---------------------------------------------------------------------------- # ----------------------------------------------------------------------------
# 7. BEST/PEAK PRICE TIMING SENSORS (period-based time tracking) # 7. BEST/PEAK PRICE TIMING SENSORS (period-based time tracking)
# ---------------------------------------------------------------------------- # ----------------------------------------------------------------------------
@ -1249,7 +1116,6 @@ ENTITY_DESCRIPTIONS = (
*FUTURE_TREND_SENSORS, *FUTURE_TREND_SENSORS,
*PRICE_TRAJECTORY_SENSORS, *PRICE_TRAJECTORY_SENSORS,
*VOLATILITY_SENSORS, *VOLATILITY_SENSORS,
*PERCENTILE_RANK_SENSORS,
*BEST_PRICE_TIMING_SENSORS, *BEST_PRICE_TIMING_SENSORS,
*PEAK_PRICE_TIMING_SENSORS, *PEAK_PRICE_TIMING_SENSORS,
*DAY_PATTERN_SENSORS, *DAY_PATTERN_SENSORS,

View file

@ -249,44 +249,6 @@ def get_value_getter_mapping( # noqa: PLR0913 - needs all calculators as parame
"today_tomorrow_volatility": lambda: volatility_calculator.get_volatility_value( "today_tomorrow_volatility": lambda: volatility_calculator.get_volatility_value(
volatility_type="today_tomorrow" volatility_type="today_tomorrow"
), ),
# Price rank sensors (via VolatilityCalculator - reuses same price extraction)
# Current interval rank
"current_interval_price_rank_today": lambda: volatility_calculator.get_percentile_rank_value(
subject="current_interval", percentile_type="today"
),
"current_interval_price_rank_tomorrow": lambda: volatility_calculator.get_percentile_rank_value(
subject="current_interval", percentile_type="tomorrow"
),
"current_interval_price_rank_today_tomorrow": lambda: volatility_calculator.get_percentile_rank_value(
subject="current_interval", percentile_type="today_tomorrow"
),
# Next interval rank
"next_interval_price_rank_today": lambda: volatility_calculator.get_percentile_rank_value(
subject="next_interval", percentile_type="today"
),
"next_interval_price_rank_today_tomorrow": lambda: volatility_calculator.get_percentile_rank_value(
subject="next_interval", percentile_type="today_tomorrow"
),
# Previous interval rank
"previous_interval_price_rank_today": lambda: volatility_calculator.get_percentile_rank_value(
subject="previous_interval", percentile_type="today"
),
"previous_interval_price_rank_today_tomorrow": lambda: volatility_calculator.get_percentile_rank_value(
subject="previous_interval", percentile_type="today_tomorrow"
),
# Rolling-hour rank (1h average)
"current_hour_price_rank_today": lambda: volatility_calculator.get_percentile_rank_value(
subject="current_hour", percentile_type="today"
),
"current_hour_price_rank_today_tomorrow": lambda: volatility_calculator.get_percentile_rank_value(
subject="current_hour", percentile_type="today_tomorrow"
),
"next_hour_price_rank_today": lambda: volatility_calculator.get_percentile_rank_value(
subject="next_hour", percentile_type="today"
),
"next_hour_price_rank_today_tomorrow": lambda: volatility_calculator.get_percentile_rank_value(
subject="next_hour", percentile_type="today_tomorrow"
),
# ================================================================ # ================================================================
# BEST/PEAK PRICE TIMING SENSORS - via TimingCalculator # BEST/PEAK PRICE TIMING SENSORS - via TimingCalculator
# ================================================================ # ================================================================

View file

@ -930,11 +930,6 @@ find_cheapest_schedule:
output: output:
collapsed: true collapsed: true
fields: fields:
include_comparison_details:
required: false
default: false
selector:
boolean:
use_base_unit: use_base_unit:
required: false required: false
default: false default: false

View file

@ -126,23 +126,6 @@ def _compute_price_comparison(
return result return result
def _determine_no_window_reason(
price_info: list[dict],
filtered_price_info: list[dict],
duration_intervals: int,
*,
level_filter_active: bool,
) -> str:
"""Classify why no block window could be found."""
if not price_info:
return "no_data_in_range"
if level_filter_active and not filtered_price_info:
return "no_intervals_matching_level_filter"
if len(filtered_price_info) < duration_intervals:
return "insufficient_intervals_after_filter"
return "insufficient_contiguous_window"
async def _handle_find_block( # noqa: PLR0915 async def _handle_find_block( # noqa: PLR0915
call: ServiceCall, call: ServiceCall,
*, *,
@ -163,7 +146,6 @@ async def _handle_find_block( # noqa: PLR0915
min_price_level: str | None = call.data.get("min_price_level") min_price_level: str | None = call.data.get("min_price_level")
include_comparison_details: bool = call.data.get("include_comparison_details", False) include_comparison_details: bool = call.data.get("include_comparison_details", False)
power_profile: list[int] | None = call.data.get("power_profile") power_profile: list[int] | None = call.data.get("power_profile")
level_filter_active = min_price_level is not None or max_price_level is not None
duration_minutes_requested = int(duration_td.total_seconds() / 60) duration_minutes_requested = int(duration_td.total_seconds() / 60)
# Round up to nearest quarter-hour interval # Round up to nearest quarter-hour interval
@ -235,16 +217,9 @@ async def _handle_find_block( # noqa: PLR0915
result = find_cheapest_contiguous_window(filtered_price_info, duration_intervals, reverse=reverse) result = find_cheapest_contiguous_window(filtered_price_info, duration_intervals, reverse=reverse)
if result is None: if result is None:
reason = _determine_no_window_reason(
price_info,
filtered_price_info,
duration_intervals,
level_filter_active=level_filter_active,
)
_LOGGER.info( _LOGGER.info(
"%s: no window found (reason=%s, need %d intervals, have %d after level filter)", "%s: no window found (need %d intervals, have %d after level filter)",
service_label, service_label,
reason,
duration_intervals, duration_intervals,
len(filtered_price_info), len(filtered_price_info),
) )
@ -257,7 +232,6 @@ async def _handle_find_block( # noqa: PLR0915
"currency": currency, "currency": currency,
"price_unit": price_unit, "price_unit": price_unit,
"window_found": False, "window_found": False,
"reason": reason,
"window": None, "window": None,
} }

View file

@ -84,23 +84,6 @@ _COMMON_HOURS_SCHEMA = {
FIND_CHEAPEST_HOURS_SERVICE_SCHEMA = vol.Schema(_COMMON_HOURS_SCHEMA) FIND_CHEAPEST_HOURS_SERVICE_SCHEMA = vol.Schema(_COMMON_HOURS_SCHEMA)
def _determine_no_intervals_reason(
price_info: list[dict],
filtered_price_info: list[dict],
total_intervals: int,
*,
level_filter_active: bool,
) -> str:
"""Classify why no interval selection could be found."""
if not price_info:
return "no_data_in_range"
if level_filter_active and not filtered_price_info:
return "no_intervals_matching_level_filter"
if len(filtered_price_info) < total_intervals:
return "insufficient_intervals_after_filter"
return "insufficient_intervals_for_constraints"
def _build_found_response( # noqa: PLR0913 def _build_found_response( # noqa: PLR0913
*, *,
result: dict, result: dict,
@ -224,7 +207,6 @@ async def _handle_find_hours(
min_price_level: str | None = call.data.get("min_price_level") min_price_level: str | None = call.data.get("min_price_level")
include_comparison_details: bool = call.data.get("include_comparison_details", False) include_comparison_details: bool = call.data.get("include_comparison_details", False)
power_profile: list[int] | None = call.data.get("power_profile") power_profile: list[int] | None = call.data.get("power_profile")
level_filter_active = min_price_level is not None or max_price_level is not None
total_minutes_requested = int(duration_td.total_seconds() / 60) total_minutes_requested = int(duration_td.total_seconds() / 60)
min_segment_minutes_requested = int(min_segment_td.total_seconds() / 60) if min_segment_td else INTERVAL_MINUTES min_segment_minutes_requested = int(min_segment_td.total_seconds() / 60) if min_segment_td else INTERVAL_MINUTES
@ -301,16 +283,9 @@ async def _handle_find_hours(
result = find_cheapest_n_intervals(filtered_price_info, total_intervals, min_segment_intervals, reverse=reverse) result = find_cheapest_n_intervals(filtered_price_info, total_intervals, min_segment_intervals, reverse=reverse)
if result is None: if result is None:
reason = _determine_no_intervals_reason(
price_info,
filtered_price_info,
total_intervals,
level_filter_active=level_filter_active,
)
_LOGGER.info( _LOGGER.info(
"%s: no interval selection found (reason=%s, need %d, have %d after level filter)", "%s: not enough intervals (need %d, have %d after level filter)",
service_label, service_label,
reason,
total_intervals, total_intervals,
len(filtered_price_info), len(filtered_price_info),
) )
@ -325,7 +300,6 @@ async def _handle_find_hours(
"currency": currency, "currency": currency,
"price_unit": price_unit, "price_unit": price_unit,
"intervals_found": False, "intervals_found": False,
"reason": reason,
"schedule": None, "schedule": None,
} }

View file

@ -22,7 +22,6 @@ from custom_components.tibber_prices.const import (
) )
from custom_components.tibber_prices.utils.price_window import ( from custom_components.tibber_prices.utils.price_window import (
calculate_window_statistics, calculate_window_statistics,
find_cheapest_contiguous_window,
) )
from homeassistant.exceptions import ServiceValidationError from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv from homeassistant.helpers import config_validation as cv
@ -84,77 +83,11 @@ FIND_CHEAPEST_SCHEDULE_SERVICE_SCHEMA = vol.Schema(
vol.Optional("search_scope"): vol.In(VALID_SEARCH_SCOPES), vol.Optional("search_scope"): vol.In(VALID_SEARCH_SCOPES),
vol.Optional("max_price_level"): vol.In([lvl.lower() for lvl in PRICE_LEVEL_ORDER]), vol.Optional("max_price_level"): vol.In([lvl.lower() for lvl in PRICE_LEVEL_ORDER]),
vol.Optional("min_price_level"): vol.In([lvl.lower() for lvl in PRICE_LEVEL_ORDER]), vol.Optional("min_price_level"): vol.In([lvl.lower() for lvl in PRICE_LEVEL_ORDER]),
vol.Optional("include_comparison_details", default=False): cv.boolean,
vol.Optional("use_base_unit", default=False): cv.boolean, vol.Optional("use_base_unit", default=False): cv.boolean,
} }
) )
def _compute_task_price_comparison(
task_intervals: list[dict[str, Any]],
full_price_info: list[dict[str, Any]],
unit_factor: int,
*,
include_details: bool,
) -> dict[str, float | str | None] | None:
"""Compute per-task comparison against most expensive window of same duration."""
duration_intervals = len(task_intervals)
comparison_result = find_cheapest_contiguous_window(full_price_info, duration_intervals, reverse=True)
if comparison_result is None:
return None
task_stats = calculate_window_statistics(task_intervals, unit_factor=unit_factor, round_decimals=4)
comparison_stats = calculate_window_statistics(
comparison_result["intervals"], unit_factor=unit_factor, round_decimals=4
)
task_mean = task_stats.get("price_mean")
comparison_mean = comparison_stats.get("price_mean")
if task_mean is None or comparison_mean is None:
return None
comparison_window_start = comparison_result["intervals"][0]["startsAt"]
if not isinstance(comparison_window_start, str):
comparison_window_start = comparison_window_start.isoformat()
result: dict[str, float | str | None] = {
"comparison_price_mean": comparison_mean,
"price_difference": abs(round(float(comparison_mean) - float(task_mean), 4)),
"comparison_window_start": comparison_window_start,
}
if include_details:
result["comparison_price_min"] = comparison_stats.get("price_min")
result["comparison_price_max"] = comparison_stats.get("price_max")
last_start = comparison_result["intervals"][-1]["startsAt"]
if not isinstance(last_start, str):
last_start = last_start.isoformat()
result["comparison_window_end"] = (
datetime.fromisoformat(last_start) + timedelta(minutes=INTERVAL_MINUTES)
).isoformat()
return result
def _determine_schedule_reason(
*,
all_tasks_scheduled: bool,
assignments_count: int,
price_info: list[dict[str, Any]],
filtered_price_info: list[dict[str, Any]],
level_filter_active: bool,
) -> str | None:
"""Classify schedule outcome reason for automation-friendly no-result handling."""
if all_tasks_scheduled:
return None
if not price_info:
return "no_data_in_range"
if level_filter_active and not filtered_price_info:
return "no_intervals_matching_level_filter"
if assignments_count == 0:
return "insufficient_contiguous_window"
return "insufficient_contiguous_window_for_some_tasks"
def _find_cheapest_window_in_pool( def _find_cheapest_window_in_pool(
pool: list[dict[str, Any]], pool: list[dict[str, Any]],
duration_intervals: int, duration_intervals: int,
@ -223,8 +156,6 @@ async def handle_find_cheapest_schedule(call: ServiceCall) -> ServiceResponse:
use_base_unit: bool = call.data.get("use_base_unit", False) use_base_unit: bool = call.data.get("use_base_unit", False)
max_price_level: str | None = call.data.get("max_price_level") max_price_level: str | None = call.data.get("max_price_level")
min_price_level: str | None = call.data.get("min_price_level") min_price_level: str | None = call.data.get("min_price_level")
include_comparison_details: bool = call.data.get("include_comparison_details", False)
level_filter_active = min_price_level is not None or max_price_level is not None
# Round gap up to nearest quarter interval # Round gap up to nearest quarter interval
gap_intervals = math.ceil(gap_minutes / INTERVAL_MINUTES) if gap_minutes > 0 else 0 gap_intervals = math.ceil(gap_minutes / INTERVAL_MINUTES) if gap_minutes > 0 else 0
@ -305,13 +236,6 @@ async def handle_find_cheapest_schedule(call: ServiceCall) -> ServiceResponse:
filtered_price_info = filter_intervals_by_price_level(price_info, min_price_level, max_price_level) filtered_price_info = filter_intervals_by_price_level(price_info, min_price_level, max_price_level)
if not filtered_price_info: if not filtered_price_info:
reason = _determine_schedule_reason(
all_tasks_scheduled=False,
assignments_count=0,
price_info=price_info,
filtered_price_info=filtered_price_info,
level_filter_active=level_filter_active,
)
return { return {
"home_id": home_id, "home_id": home_id,
"search_start": search_start.isoformat(), "search_start": search_start.isoformat(),
@ -319,7 +243,6 @@ async def handle_find_cheapest_schedule(call: ServiceCall) -> ServiceResponse:
"currency": currency, "currency": currency,
"price_unit": price_unit, "price_unit": price_unit,
"all_tasks_scheduled": False, "all_tasks_scheduled": False,
"reason": reason,
"tasks": [], "tasks": [],
"total_estimated_cost": None, "total_estimated_cost": None,
} }
@ -372,12 +295,6 @@ async def handle_find_cheapest_schedule(call: ServiceCall) -> ServiceResponse:
"duration_minutes": task["duration_minutes"], "duration_minutes": task["duration_minutes"],
**stats, **stats,
"intervals": task_response_intervals, "intervals": task_response_intervals,
"price_comparison": _compute_task_price_comparison(
task_intervals,
price_info,
unit_factor,
include_details=include_comparison_details,
),
} }
) )
@ -391,13 +308,6 @@ async def handle_find_cheapest_schedule(call: ServiceCall) -> ServiceResponse:
total_estimated_cost = round(sum(total_cost_values), 4) if total_cost_values else None total_estimated_cost = round(sum(total_cost_values), 4) if total_cost_values else None
all_scheduled = len(unscheduled) == 0 all_scheduled = len(unscheduled) == 0
reason = _determine_schedule_reason(
all_tasks_scheduled=all_scheduled,
assignments_count=len(assignments),
price_info=price_info,
filtered_price_info=filtered_price_info,
level_filter_active=level_filter_active,
)
_LOGGER.info( _LOGGER.info(
"%s: scheduled %d/%d tasks, total_cost=%s", "%s: scheduled %d/%d tasks, total_cost=%s",
@ -414,7 +324,6 @@ async def handle_find_cheapest_schedule(call: ServiceCall) -> ServiceResponse:
"currency": currency, "currency": currency,
"price_unit": price_unit, "price_unit": price_unit,
"all_tasks_scheduled": all_scheduled, "all_tasks_scheduled": all_scheduled,
"reason": reason,
"unscheduled_tasks": unscheduled or None, "unscheduled_tasks": unscheduled or None,
"tasks": assignments, "tasks": assignments,
"total_estimated_cost": total_estimated_cost, "total_estimated_cost": total_estimated_cost,

View file

@ -1027,39 +1027,6 @@
"ready": "Bereit", "ready": "Bereit",
"error": "Fehler" "error": "Fehler"
} }
},
"current_interval_price_rank_today": {
"name": "Aktueller Preisrang (heute)"
},
"current_interval_price_rank_tomorrow": {
"name": "Aktueller Preisrang (morgen)"
},
"current_interval_price_rank_today_tomorrow": {
"name": "Aktueller Preisrang (heute+morgen)"
},
"next_interval_price_rank_today": {
"name": "Nächster Preisrang (heute)"
},
"next_interval_price_rank_today_tomorrow": {
"name": "Nächster Preisrang (heute+morgen)"
},
"previous_interval_price_rank_today": {
"name": "Letzter Preisrang (heute)"
},
"previous_interval_price_rank_today_tomorrow": {
"name": "Letzter Preisrang (heute+morgen)"
},
"current_hour_price_rank_today": {
"name": "⌀ Stündlicher Preisrang Aktuell (heute)"
},
"current_hour_price_rank_today_tomorrow": {
"name": "⌀ Stündlicher Preisrang Aktuell (heute+morgen)"
},
"next_hour_price_rank_today": {
"name": "⌀ Stündlicher Preisrang Nächste (heute)"
},
"next_hour_price_rank_today_tomorrow": {
"name": "⌀ Stündlicher Preisrang Nächste (heute+morgen)"
} }
}, },
"binary_sensor": { "binary_sensor": {
@ -1880,10 +1847,6 @@
"name": "Minimale Preisstufe", "name": "Minimale Preisstufe",
"description": "Nur Intervalle ab dieser Tibber-Preisstufe beruecksichtigen. Nuetzlich fuer find_most_expensive, um wirklich teure Intervalle zu fokussieren." "description": "Nur Intervalle ab dieser Tibber-Preisstufe beruecksichtigen. Nuetzlich fuer find_most_expensive, um wirklich teure Intervalle zu fokussieren."
}, },
"include_comparison_details": {
"name": "Vergleichsdetails einbeziehen",
"description": "Fuegt pro Aufgabe zusaetzliche price_comparison-Details hinzu (comparison_price_min, comparison_price_max, comparison_window_end), um das gefundene Zeitfenster mit dem gegenteiligen Extremfenster gleicher Dauer zu vergleichen."
},
"use_base_unit": { "use_base_unit": {
"name": "Basiswährung verwenden", "name": "Basiswährung verwenden",
"description": "Preise in Basiswährung (EUR, NOK) statt der konfigurierten Anzeigeeinheit (ct, øre) erzwingen. Nützlich für Berechnungen." "description": "Preise in Basiswährung (EUR, NOK) statt der konfigurierten Anzeigeeinheit (ct, øre) erzwingen. Nützlich für Berechnungen."

View file

@ -1027,39 +1027,6 @@
"ready": "Ready", "ready": "Ready",
"error": "Error" "error": "Error"
} }
},
"current_interval_price_rank_today": {
"name": "Current Price Rank (Today)"
},
"current_interval_price_rank_tomorrow": {
"name": "Current Price Rank (Tomorrow)"
},
"current_interval_price_rank_today_tomorrow": {
"name": "Current Price Rank (Today+Tomorrow)"
},
"next_interval_price_rank_today": {
"name": "Next Price Rank (Today)"
},
"next_interval_price_rank_today_tomorrow": {
"name": "Next Price Rank (Today+Tomorrow)"
},
"previous_interval_price_rank_today": {
"name": "Last Price Rank (Today)"
},
"previous_interval_price_rank_today_tomorrow": {
"name": "Last Price Rank (Today+Tomorrow)"
},
"current_hour_price_rank_today": {
"name": "⌀ Hourly Price Current Rank (Today)"
},
"current_hour_price_rank_today_tomorrow": {
"name": "⌀ Hourly Price Current Rank (Today+Tomorrow)"
},
"next_hour_price_rank_today": {
"name": "⌀ Hourly Price Next Rank (Today)"
},
"next_hour_price_rank_today_tomorrow": {
"name": "⌀ Hourly Price Next Rank (Today+Tomorrow)"
} }
}, },
"binary_sensor": { "binary_sensor": {
@ -1420,7 +1387,7 @@
}, },
"find_cheapest_block": { "find_cheapest_block": {
"name": "Find Cheapest Block", "name": "Find Cheapest Block",
"description": "Finds the cheapest contiguous time window of a given duration. Designed for appliance scheduling: dishwasher, washing machine, dryer, etc. Returns the single cheapest window with start/end times and price statistics. If no window is found, the response includes a stable reason code in the reason field (for example: no_data_in_range, no_intervals_matching_level_filter, insufficient_intervals_after_filter, insufficient_contiguous_window).", "description": "Finds the cheapest contiguous time window of a given duration. Designed for appliance scheduling: dishwasher, washing machine, dryer, etc. Returns the single cheapest window with start/end times and price statistics.",
"sections": { "sections": {
"search_range": { "search_range": {
"name": "Search Range", "name": "Search Range",
@ -1604,7 +1571,7 @@
}, },
"find_cheapest_hours": { "find_cheapest_hours": {
"name": "Find Cheapest Hours", "name": "Find Cheapest Hours",
"description": "Finds the cheapest intervals totaling a given duration, not necessarily contiguous. Designed for flexible loads: battery charging, EV, water heater. Returns a schedule of intervals grouped into contiguous segments. If no schedule is found, the response includes a stable reason code in the reason field (for example: no_data_in_range, no_intervals_matching_level_filter, insufficient_intervals_after_filter, insufficient_intervals_for_constraints).", "description": "Finds the cheapest intervals totaling a given duration, not necessarily contiguous. Designed for flexible loads: battery charging, EV, water heater. Returns a schedule of intervals grouped into contiguous segments.",
"sections": { "sections": {
"search_range": { "search_range": {
"name": "Search Range", "name": "Search Range",
@ -1796,7 +1763,7 @@
}, },
"find_cheapest_schedule": { "find_cheapest_schedule": {
"name": "Find Cheapest Schedule", "name": "Find Cheapest Schedule",
"description": "Schedules multiple appliances optimally without time overlap. Each task gets the cheapest available contiguous window; tasks are placed greedily in ascending cost order. Returns a per-task schedule with start/end times and price stats. If scheduling is incomplete, the response includes a stable reason code in the reason field (for example: no_data_in_range, no_intervals_matching_level_filter, insufficient_contiguous_window, insufficient_contiguous_window_for_some_tasks).", "description": "Schedules multiple appliances optimally without time overlap. Each task gets the cheapest available contiguous window; tasks are placed greedily in ascending cost order. Returns a per-task schedule with start/end times and price stats.",
"sections": { "sections": {
"scheduling_options": { "scheduling_options": {
"name": "Scheduling Options", "name": "Scheduling Options",
@ -1880,10 +1847,6 @@
"name": "Minimum Price Level", "name": "Minimum Price Level",
"description": "Only consider intervals at or above this Tibber price level. Useful for find_most_expensive to focus on truly expensive intervals." "description": "Only consider intervals at or above this Tibber price level. Useful for find_most_expensive to focus on truly expensive intervals."
}, },
"include_comparison_details": {
"name": "Include Comparison Details",
"description": "Add per-task price_comparison details (comparison_price_min, comparison_price_max, comparison_window_end) to compare each selected task window against the opposite extreme window of the same duration."
},
"use_base_unit": { "use_base_unit": {
"name": "Use Base Currency Unit", "name": "Use Base Currency Unit",
"description": "Force prices in base currency (EUR, NOK) instead of the configured display unit (ct, øre). Useful for calculations." "description": "Force prices in base currency (EUR, NOK) instead of the configured display unit (ct, øre). Useful for calculations."

View file

@ -1027,39 +1027,6 @@
"ready": "Klar", "ready": "Klar",
"error": "Feil" "error": "Feil"
} }
},
"current_interval_price_rank_today": {
"name": "Aktuell prisrang (i dag)"
},
"current_interval_price_rank_tomorrow": {
"name": "Aktuell prisrang (i morgen)"
},
"current_interval_price_rank_today_tomorrow": {
"name": "Aktuell prisrang (i dag+i morgen)"
},
"next_interval_price_rank_today": {
"name": "Neste prisrang (i dag)"
},
"next_interval_price_rank_today_tomorrow": {
"name": "Neste prisrang (i dag+i morgen)"
},
"previous_interval_price_rank_today": {
"name": "Forrige prisrang (i dag)"
},
"previous_interval_price_rank_today_tomorrow": {
"name": "Forrige prisrang (i dag+i morgen)"
},
"current_hour_price_rank_today": {
"name": "⌀ Timesprisrang nå (i dag)"
},
"current_hour_price_rank_today_tomorrow": {
"name": "⌀ Timesprisrang nå (i dag+i morgen)"
},
"next_hour_price_rank_today": {
"name": "⌀ Timesprisrang neste (i dag)"
},
"next_hour_price_rank_today_tomorrow": {
"name": "⌀ Timesprisrang neste (i dag+i morgen)"
} }
}, },
"binary_sensor": { "binary_sensor": {
@ -1880,10 +1847,6 @@
"name": "Minimalt prisnivaae", "name": "Minimalt prisnivaae",
"description": "Ta bare med intervaller paa eller over dette Tibber-prisnivaeet. Nyttig for find_most_expensive for aa fokusere paa virkelig dyre intervaller." "description": "Ta bare med intervaller paa eller over dette Tibber-prisnivaeet. Nyttig for find_most_expensive for aa fokusere paa virkelig dyre intervaller."
}, },
"include_comparison_details": {
"name": "Inkluder sammenligningsdetaljer",
"description": "Legger til ekstra price_comparison-detaljer per oppgave (comparison_price_min, comparison_price_max, comparison_window_end) for aa sammenligne valgt vindu med motsatt ekstremvindu med samme varighet."
},
"use_base_unit": { "use_base_unit": {
"name": "Bruk basisvaluta", "name": "Bruk basisvaluta",
"description": "Tving priser i basisvaluta (EUR, NOK) i stedet for konfigurert visningsenhet (ct, øre). Nyttig for beregninger." "description": "Tving priser i basisvaluta (EUR, NOK) i stedet for konfigurert visningsenhet (ct, øre). Nyttig for beregninger."

View file

@ -1027,39 +1027,6 @@
"ready": "Gereed", "ready": "Gereed",
"error": "Fout" "error": "Fout"
} }
},
"current_interval_price_rank_today": {
"name": "Huidige prijsrang (vandaag)"
},
"current_interval_price_rank_tomorrow": {
"name": "Huidige prijsrang (morgen)"
},
"current_interval_price_rank_today_tomorrow": {
"name": "Huidige prijsrang (vandaag+morgen)"
},
"next_interval_price_rank_today": {
"name": "Volgende prijsrang (vandaag)"
},
"next_interval_price_rank_today_tomorrow": {
"name": "Volgende prijsrang (vandaag+morgen)"
},
"previous_interval_price_rank_today": {
"name": "Vorige prijsrang (vandaag)"
},
"previous_interval_price_rank_today_tomorrow": {
"name": "Vorige prijsrang (vandaag+morgen)"
},
"current_hour_price_rank_today": {
"name": "⌀ Uurlijkse prijsrang huidig (vandaag)"
},
"current_hour_price_rank_today_tomorrow": {
"name": "⌀ Uurlijkse prijsrang huidig (vandaag+morgen)"
},
"next_hour_price_rank_today": {
"name": "⌀ Uurlijkse prijsrang volgende (vandaag)"
},
"next_hour_price_rank_today_tomorrow": {
"name": "⌀ Uurlijkse prijsrang volgende (vandaag+morgen)"
} }
}, },
"binary_sensor": { "binary_sensor": {
@ -1880,10 +1847,6 @@
"name": "Minimaal prijsniveau", "name": "Minimaal prijsniveau",
"description": "Overweeg alleen intervallen op of boven dit Tibber-prijsniveau. Nuttig voor find_most_expensive om te focussen op echt dure intervallen." "description": "Overweeg alleen intervallen op of boven dit Tibber-prijsniveau. Nuttig voor find_most_expensive om te focussen op echt dure intervallen."
}, },
"include_comparison_details": {
"name": "Vergelijkingsdetails opnemen",
"description": "Voegt per taak extra price_comparison-details toe (comparison_price_min, comparison_price_max, comparison_window_end) om het gekozen venster te vergelijken met het tegenovergestelde extreme venster met dezelfde duur."
},
"use_base_unit": { "use_base_unit": {
"name": "Basisvaluta gebruiken", "name": "Basisvaluta gebruiken",
"description": "Forceer prijzen in basisvaluta (EUR, NOK) in plaats van de geconfigureerde weergave-eenheid (ct, øre). Handig voor berekeningen." "description": "Forceer prijzen in basisvaluta (EUR, NOK) in plaats van de geconfigureerde weergave-eenheid (ct, øre). Handig voor berekeningen."

View file

@ -1027,39 +1027,6 @@
"ready": "Redo", "ready": "Redo",
"error": "Fel" "error": "Fel"
} }
},
"current_interval_price_rank_today": {
"name": "Aktuellt prisrang (idag)"
},
"current_interval_price_rank_tomorrow": {
"name": "Aktuellt prisrang (imorgon)"
},
"current_interval_price_rank_today_tomorrow": {
"name": "Aktuellt prisrang (idag+imorgon)"
},
"next_interval_price_rank_today": {
"name": "Nästa prisrang (idag)"
},
"next_interval_price_rank_today_tomorrow": {
"name": "Nästa prisrang (idag+imorgon)"
},
"previous_interval_price_rank_today": {
"name": "Förra prisrang (idag)"
},
"previous_interval_price_rank_today_tomorrow": {
"name": "Förra prisrang (idag+imorgon)"
},
"current_hour_price_rank_today": {
"name": "⌀ Timprisrang aktuell (idag)"
},
"current_hour_price_rank_today_tomorrow": {
"name": "⌀ Timprisrang aktuell (idag+imorgon)"
},
"next_hour_price_rank_today": {
"name": "⌀ Timprisrang nästa (idag)"
},
"next_hour_price_rank_today_tomorrow": {
"name": "⌀ Timprisrang nästa (idag+imorgon)"
} }
}, },
"binary_sensor": { "binary_sensor": {
@ -1880,10 +1847,6 @@
"name": "Minimal prisnivaae", "name": "Minimal prisnivaae",
"description": "Ta bara med intervall paa eller oever denna Tibber-prisnivaae. Anvaendbart foer find_most_expensive foer att fokusera paa verkligt dyra intervall." "description": "Ta bara med intervall paa eller oever denna Tibber-prisnivaae. Anvaendbart foer find_most_expensive foer att fokusera paa verkligt dyra intervall."
}, },
"include_comparison_details": {
"name": "Inkludera jaemfoerelsedetaljer",
"description": "Laegger till extra price_comparison-detaljer per uppgift (comparison_price_min, comparison_price_max, comparison_window_end) foer att jaemfoera valt foenster med motsatt extremfoenster med samma laengd."
},
"use_base_unit": { "use_base_unit": {
"name": "Använd basvaluta", "name": "Använd basvaluta",
"description": "Tvinga priser i basvaluta (EUR, NOK) istället för konfigurerad visningsenhet (ct, öre). Användbart för beräkningar." "description": "Tvinga priser i basvaluta (EUR, NOK) istället för konfigurerad visningsenhet (ct, öre). Användbart för beräkningar."

View file

@ -2,7 +2,6 @@
from __future__ import annotations from __future__ import annotations
import bisect
import logging import logging
import statistics import statistics
from datetime import datetime, timedelta from datetime import datetime, timedelta
@ -177,104 +176,6 @@ def calculate_volatility_level(
return level return level
MIN_PRICES_FOR_IQR = 4 # Minimum price values needed for meaningful IQR calculation
def calculate_iqr_stats(prices: list[float]) -> dict[str, Any] | None:
"""
Calculate Interquartile Range (IQR) statistics from a price list.
IQR = Q75 - Q25, representing the spread of the central 50% of prices.
This is more robust to outliers than coefficient of variation because
extreme values (price spikes or negative prices) don't distort the result.
Args:
prices: List of price values (in any unit, e.g. EUR or NOK per kWh)
Returns:
Dict with keys:
- q25: 25th percentile (lower quartile)
- median: 50th percentile (median)
- q75: 75th percentile (upper quartile)
- iqr: Interquartile range (q75 - q25)
- iqr_pct: Relative IQR as percentage of median (None if median is 0)
- outlier_count: Intervals outside Tukey fences [Q25 - 1.5xIQR, Q75 + 1.5xIQR]
Returns None if fewer than MIN_PRICES_FOR_IQR prices are provided.
Examples:
- iqr_pct ~5%: Very tight price band, stability similar to CV
- iqr_pct ~20%: Moderate spread in the core price range
- iqr_pct ~50%: Wide core spread, significant optimization potential
- outlier_count > 0: Isolated price spikes/dips exist (CV-heavy days)
"""
if len(prices) < MIN_PRICES_FOR_IQR:
return None
quartiles = statistics.quantiles(prices, n=4) # Returns [Q25, Q50, Q75]
q25 = quartiles[0]
median = quartiles[1]
q75 = quartiles[2]
iqr = q75 - q25
# Relative IQR: normalized by median for cross-price-level comparison
iqr_pct = (iqr / abs(median) * 100) if median != 0 else None
# Tukey fence outlier detection (standard method)
lower_fence = q25 - 1.5 * iqr
upper_fence = q75 + 1.5 * iqr
outlier_count = sum(1 for p in prices if p < lower_fence or p > upper_fence)
return {
"q25": q25,
"median": median,
"q75": q75,
"iqr": iqr,
"iqr_pct": iqr_pct,
"outlier_count": outlier_count,
}
def calculate_percentile_rank(current_price: float, prices: list[float]) -> float | None:
"""
Calculate where the current price ranks among a reference price set.
Returns the percentage of prices in the reference set that are strictly
cheaper than current_price. A value of 0% means the current price is at
or below the cheapest reference price; ~99% means nearly everything is
cheaper (current price near the maximum).
The current interval's own price is included in today's reference set,
so the cheapest interval of the day always returns 0%.
Args:
current_price: The price to rank (any unit, must match prices unit)
prices: Reference price list to rank against
Returns:
Percentile rank as float 0.0-100.0 (1 decimal precision), or None if
reference list is empty.
Examples (8 intervals: [8, 10, 12, 15, 15, 18, 20, 22]):
- current=8: 0/8 x 100 = 0.0% (cheapest)
- current=15: 3/8 x 100 = 37.5% (above 3 cheaper intervals)
- current=22: 7/8 x 100 = 87.5% (most expensive)
Note:
Equal prices: All duplicate prices at the current level are counted as
"not below" the current price (bisect_left semantics). This matches
automation logic: "is now cheap?" returns False if current == minimum
is debatable but ensures 0% always means strictly cheapest.
"""
if not prices:
return None
sorted_prices = sorted(prices)
count_below = bisect.bisect_left(sorted_prices, current_price)
return round(count_below / len(sorted_prices) * 100, 1)
def calculate_trailing_average_for_interval( def calculate_trailing_average_for_interval(
interval_start: datetime, interval_start: datetime,
all_prices: list[dict[str, Any]], all_prices: list[dict[str, Any]],

View file

@ -22,30 +22,30 @@ Fetches home information and metadata:
```graphql ```graphql
query { query {
viewer { viewer {
homes { homes {
id id
appNickname appNickname
address { address {
address1 address1
postalCode postalCode
city city
country country
} }
timeZone timeZone
currentSubscription { currentSubscription {
priceInfo { priceInfo {
current { current {
currency currency
} }
}
}
meteringPointData {
consumptionEan
gridAreaCode
}
} }
}
meteringPointData {
consumptionEan
gridAreaCode
}
} }
}
} }
``` ```
@ -56,27 +56,26 @@ query {
Fetches quarter-hourly prices: Fetches quarter-hourly prices:
```graphql ```graphql
query ($homeId: ID!) { query($homeId: ID!) {
viewer { viewer {
home(id: $homeId) { home(id: $homeId) {
currentSubscription { currentSubscription {
priceInfo { priceInfo {
range(resolution: QUARTER_HOURLY, first: 384) { range(resolution: QUARTER_HOURLY, first: 384) {
nodes { nodes {
total total
startsAt startsAt
level level
}
}
}
} }
}
} }
}
} }
}
} }
``` ```
**Parameters:** **Parameters:**
- `homeId`: Tibber home identifier - `homeId`: Tibber home identifier
- `resolution`: Always `QUARTER_HOURLY` - `resolution`: Always `QUARTER_HOURLY`
- `first`: 384 intervals (4 days of data) - `first`: 384 intervals (4 days of data)
@ -86,12 +85,10 @@ query ($homeId: ID!) {
## Rate Limits ## Rate Limits
Tibber API rate limits (as of 2024): Tibber API rate limits (as of 2024):
- **5000 requests per hour** per token - **5000 requests per hour** per token
- **Burst limit:** 100 requests per minute - **Burst limit:** 100 requests per minute
Integration stays well below these limits: Integration stays well below these limits:
- Polls every 15 minutes = 96 requests/day - Polls every 15 minutes = 96 requests/day
- User data cached for 24h = 1 request/day - User data cached for 24h = 1 request/day
- **Total:** ~100 requests/day per home - **Total:** ~100 requests/day per home
@ -102,14 +99,13 @@ Integration stays well below these limits:
```json ```json
{ {
"total": 0.2456, "total": 0.2456,
"startsAt": "2024-12-06T14:00:00.000+01:00", "startsAt": "2024-12-06T14:00:00.000+01:00",
"level": "NORMAL" "level": "NORMAL"
} }
``` ```
**Fields:** **Fields:**
- `total`: Price including VAT and fees (currency's major unit, e.g., EUR) - `total`: Price including VAT and fees (currency's major unit, e.g., EUR)
- `startsAt`: ISO 8601 timestamp with timezone - `startsAt`: ISO 8601 timestamp with timezone
- `level`: Tibber's own classification (VERY_CHEAP, CHEAP, NORMAL, EXPENSIVE, VERY_EXPENSIVE) - `level`: Tibber's own classification (VERY_CHEAP, CHEAP, NORMAL, EXPENSIVE, VERY_EXPENSIVE)
@ -118,12 +114,11 @@ Integration stays well below these limits:
```json ```json
{ {
"currency": "EUR" "currency": "EUR"
} }
``` ```
Supported currencies: Supported currencies:
- `EUR` (Euro) - displayed as ct/kWh - `EUR` (Euro) - displayed as ct/kWh
- `NOK` (Norwegian Krone) - displayed as øre/kWh - `NOK` (Norwegian Krone) - displayed as øre/kWh
- `SEK` (Swedish Krona) - displayed as öre/kWh - `SEK` (Swedish Krona) - displayed as öre/kWh
@ -133,52 +128,42 @@ Supported currencies:
### Common Error Responses ### Common Error Responses
**Invalid Token:** **Invalid Token:**
```json ```json
{ {
"errors": [ "errors": [{
{ "message": "Unauthorized",
"message": "Unauthorized", "extensions": {
"extensions": { "code": "UNAUTHENTICATED"
"code": "UNAUTHENTICATED" }
} }]
}
]
} }
``` ```
**Rate Limit Exceeded:** **Rate Limit Exceeded:**
```json ```json
{ {
"errors": [ "errors": [{
{ "message": "Too Many Requests",
"message": "Too Many Requests", "extensions": {
"extensions": { "code": "RATE_LIMIT_EXCEEDED"
"code": "RATE_LIMIT_EXCEEDED" }
} }]
}
]
} }
``` ```
**Home Not Found:** **Home Not Found:**
```json ```json
{ {
"errors": [ "errors": [{
{ "message": "Home not found",
"message": "Home not found", "extensions": {
"extensions": { "code": "NOT_FOUND"
"code": "NOT_FOUND" }
} }]
}
]
} }
``` ```
Integration handles these with: Integration handles these with:
- Exponential backoff retry (3 attempts) - Exponential backoff retry (3 attempts)
- ConfigEntryAuthFailed for auth errors - ConfigEntryAuthFailed for auth errors
- ConfigEntryNotReady for temporary failures - ConfigEntryNotReady for temporary failures
@ -186,7 +171,6 @@ Integration handles these with:
## Data Transformation ## Data Transformation
Raw API data is enriched with: Raw API data is enriched with:
- **Trailing 24h average** - Calculated from previous intervals - **Trailing 24h average** - Calculated from previous intervals
- **Leading 24h average** - Calculated from future intervals - **Leading 24h average** - Calculated from future intervals
- **Price difference %** - Deviation from average - **Price difference %** - Deviation from average
@ -197,7 +181,6 @@ See `utils/price.py` for enrichment logic.
--- ---
💡 **External Resources:** 💡 **External Resources:**
- [Tibber API Documentation](https://developer.tibber.com/docs/overview) - [Tibber API Documentation](https://developer.tibber.com/docs/overview)
- [GraphQL Explorer](https://developer.tibber.com/explorer) - [GraphQL Explorer](https://developer.tibber.com/explorer)
- [Get API Token](https://developer.tibber.com/settings/access-token) - [Get API Token](https://developer.tibber.com/settings/access-token)

View file

@ -100,43 +100,43 @@ flowchart TB
### Flow Description ### Flow Description
1. **Setup** (`__init__.py`) 1. **Setup** (`__init__.py`)
- Integration loads, creates coordinator instance - Integration loads, creates coordinator instance
- Registers entity platforms (sensor, binary_sensor) - Registers entity platforms (sensor, binary_sensor)
- Sets up custom services - Sets up custom services
2. **Data Fetch** (every 15 minutes) 2. **Data Fetch** (every 15 minutes)
- Coordinator triggers update via `api.py` - Coordinator triggers update via `api.py`
- API client checks **persistent cache** first (`coordinator/cache.py`) - API client checks **persistent cache** first (`coordinator/cache.py`)
- If cache valid → return cached data - If cache valid → return cached data
- If cache stale → query Tibber GraphQL API - If cache stale → query Tibber GraphQL API
- Store fresh data in persistent cache (survives HA restart) - Store fresh data in persistent cache (survives HA restart)
3. **Price Enrichment** 3. **Price Enrichment**
- Coordinator passes raw prices to `DataTransformer` - Coordinator passes raw prices to `DataTransformer`
- Transformer checks **transformation cache** (memory) - Transformer checks **transformation cache** (memory)
- If cache valid → return enriched data - If cache valid → return enriched data
- If cache invalid → enrich via `price_utils.py` + `average_utils.py` - If cache invalid → enrich via `price_utils.py` + `average_utils.py`
- Calculate 24h trailing/leading averages - Calculate 24h trailing/leading averages
- Calculate price differences (% from average) - Calculate price differences (% from average)
- Assign rating levels (LOW/NORMAL/HIGH) - Assign rating levels (LOW/NORMAL/HIGH)
- Store enriched data in transformation cache - Store enriched data in transformation cache
4. **Period Calculation** 4. **Period Calculation**
- Coordinator passes enriched data to `PeriodCalculator` - Coordinator passes enriched data to `PeriodCalculator`
- Calculator computes **hash** from prices + config - Calculator computes **hash** from prices + config
- If hash matches cache → return cached periods - If hash matches cache → return cached periods
- If hash differs → recalculate best/peak price periods - If hash differs → recalculate best/peak price periods
- Store periods with new hash - Store periods with new hash
5. **Entity Updates** 5. **Entity Updates**
- Coordinator provides complete data (prices + periods) - Coordinator provides complete data (prices + periods)
- Sensors read values via unified handlers - Sensors read values via unified handlers
- Binary sensors evaluate period states - Binary sensors evaluate period states
- Entities update on quarter-hour boundaries (00/15/30/45) - Entities update on quarter-hour boundaries (00/15/30/45)
6. **Service Calls** 6. **Service Calls**
- Custom services access coordinator data directly - Custom services access coordinator data directly
- Return formatted responses (JSON, ApexCharts format) - Return formatted responses (JSON, ApexCharts format)
--- ---
@ -146,13 +146,13 @@ flowchart TB
The integration uses **5 independent caching layers** for optimal performance: The integration uses **5 independent caching layers** for optimal performance:
| Layer | Location | Lifetime | Invalidation | Memory | | Layer | Location | Lifetime | Invalidation | Memory |
| ------------------------ | ------------------------------------ | -------------------------------------- | ------------ | ------ | |-------|----------|----------|--------------|--------|
| **API Cache** | `coordinator/cache.py` | 24h (user)<br/>Until midnight (prices) | Automatic | 50KB | | **API Cache** | `coordinator/cache.py` | 24h (user)<br/>Until midnight (prices) | Automatic | 50KB |
| **Translation Cache** | `const.py` | Until HA restart | Never | 5KB | | **Translation Cache** | `const.py` | Until HA restart | Never | 5KB |
| **Config Cache** | `coordinator/*` | Until config change | Explicit | 1KB | | **Config Cache** | `coordinator/*` | Until config change | Explicit | 1KB |
| **Period Cache** | `coordinator/periods.py` | Until data/config change | Hash-based | 10KB | | **Period Cache** | `coordinator/periods.py` | Until data/config change | Hash-based | 10KB |
| **Transformation Cache** | `coordinator/data_transformation.py` | Until midnight/config | Automatic | 60KB | | **Transformation Cache** | `coordinator/data_transformation.py` | Until midnight/config | Automatic | 60KB |
**Total cache overhead:** ~126KB per coordinator instance (main entry + subentries) **Total cache overhead:** ~126KB per coordinator instance (main entry + subentries)
@ -195,31 +195,30 @@ For detailed cache behavior, see [Caching Strategy](./caching-strategy.md).
### Core Components ### Core Components
| Component | File | Responsibility | | Component | File | Responsibility |
| --------------------- | ------------------------------------ | ---------------------------------------------------------------------------------------- | |-----------|------|----------------|
| **API Client** | `api.py` | GraphQL queries to Tibber, retry logic, error handling | | **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 | | **Coordinator** | `coordinator.py` | Update orchestration, cache management, absolute-time scheduling with boundary tolerance |
| **Data Transformer** | `coordinator/data_transformation.py` | Price enrichment (averages, ratings, differences) | | **Data Transformer** | `coordinator/data_transformation.py` | Price enrichment (averages, ratings, differences) |
| **Period Calculator** | `coordinator/periods.py` | Best/peak price period calculation with relaxation | | **Period Calculator** | `coordinator/periods.py` | Best/peak price period calculation with relaxation |
| **Sensors** | `sensor/` | 80+ entities for prices, levels, ratings, statistics | | **Sensors** | `sensor/` | 80+ entities for prices, levels, ratings, statistics |
| **Binary Sensors** | `binary_sensor/` | Period indicators (best/peak price active) | | **Binary Sensors** | `binary_sensor/` | Period indicators (best/peak price active) |
| **Services** | `services/` | Custom service endpoints (get_chartdata, get_apexcharts_yaml, refresh_user_data) | | **Services** | `services/` | Custom service endpoints (get_chartdata, get_apexcharts_yaml, refresh_user_data) |
### Sensor Architecture (Calculator Pattern) ### Sensor Architecture (Calculator Pattern)
The sensor platform uses **Calculator Pattern** for clean separation of concerns (refactored Nov 2025): The sensor platform uses **Calculator Pattern** for clean separation of concerns (refactored Nov 2025):
| Component | Files | Lines | Responsibility | | Component | Files | Lines | Responsibility |
| ---------------- | ------------------------- | ----- | ------------------------------------------------------- | |-----------|-------|-------|----------------|
| **Entity Class** | `sensor/core.py` | 909 | Entity lifecycle, coordinator, delegates to calculators | | **Entity Class** | `sensor/core.py` | 909 | Entity lifecycle, coordinator, delegates to calculators |
| **Calculators** | `sensor/calculators/` | 1,838 | Business logic (8 specialized calculators) | | **Calculators** | `sensor/calculators/` | 1,838 | Business logic (8 specialized calculators) |
| **Attributes** | `sensor/attributes/` | 1,209 | State presentation (8 specialized modules) | | **Attributes** | `sensor/attributes/` | 1,209 | State presentation (8 specialized modules) |
| **Routing** | `sensor/value_getters.py` | 276 | Centralized sensor → calculator mapping | | **Routing** | `sensor/value_getters.py` | 276 | Centralized sensor → calculator mapping |
| **Chart Export** | `sensor/chart_data.py` | 144 | Service call handling, YAML parsing | | **Chart Export** | `sensor/chart_data.py` | 144 | Service call handling, YAML parsing |
| **Helpers** | `sensor/helpers.py` | 188 | Aggregation functions, utilities | | **Helpers** | `sensor/helpers.py` | 188 | Aggregation functions, utilities |
**Calculator Package** (`sensor/calculators/`): **Calculator Package** (`sensor/calculators/`):
- `base.py` - Abstract BaseCalculator with coordinator access - `base.py` - Abstract BaseCalculator with coordinator access
- `interval.py` - Single interval calculations (current/next/previous) - `interval.py` - Single interval calculations (current/next/previous)
- `rolling_hour.py` - 5-interval rolling windows - `rolling_hour.py` - 5-interval rolling windows
@ -231,7 +230,6 @@ The sensor platform uses **Calculator Pattern** for clean separation of concerns
- `metadata.py` - Home/metering metadata - `metadata.py` - Home/metering metadata
**Benefits:** **Benefits:**
- 58% reduction in core.py (2,170 → 909 lines) - 58% reduction in core.py (2,170 → 909 lines)
- Clear separation: Calculators (logic) vs Attributes (presentation) - Clear separation: Calculators (logic) vs Attributes (presentation)
- Independent testability for each calculator - Independent testability for each calculator
@ -239,12 +237,12 @@ The sensor platform uses **Calculator Pattern** for clean separation of concerns
### Helper Utilities ### Helper Utilities
| Utility | File | Purpose | | Utility | File | Purpose |
| ----------------- | ------------------ | ------------------------------------------------- | |---------|------|---------|
| **Price Utils** | `utils/price.py` | Rating calculation, enrichment, level aggregation | | **Price Utils** | `utils/price.py` | Rating calculation, enrichment, level aggregation |
| **Average Utils** | `utils/average.py` | Trailing/leading 24h average calculations | | **Average Utils** | `utils/average.py` | Trailing/leading 24h average calculations |
| **Entity Utils** | `entity_utils/` | Shared icon/color/attribute logic | | **Entity Utils** | `entity_utils/` | Shared icon/color/attribute logic |
| **Translations** | `const.py` | Translation loading and caching | | **Translations** | `const.py` | Translation loading and caching |
--- ---
@ -285,12 +283,12 @@ All quarter-hourly price intervals get augmented via `utils/price.py`:
- **API polling**: Every 15 minutes (coordinator fetch cycle) - **API polling**: Every 15 minutes (coordinator fetch cycle)
- **Entity updates**: On 00/15/30/45-minute boundaries via `coordinator/listeners.py` - **Entity updates**: On 00/15/30/45-minute boundaries via `coordinator/listeners.py`
- **Timer scheduling**: Uses `async_track_utc_time_change(minute=[0, 15, 30, 45], second=0)` - **Timer scheduling**: Uses `async_track_utc_time_change(minute=[0, 15, 30, 45], second=0)`
- HA may trigger ±few milliseconds before/after exact boundary - HA may trigger ±few milliseconds before/after exact boundary
- Smart boundary tolerance (±2 seconds) handles scheduling jitter in `sensor/helpers.py` - Smart boundary tolerance (±2 seconds) handles scheduling jitter in `sensor/helpers.py`
- If HA schedules at 14:59:58 → rounds to 15:00:00 (shows new interval data) - If HA schedules at 14:59:58 → rounds to 15:00:00 (shows new interval data)
- If HA restarts at 14:59:30 → stays at 14:45:00 (shows current interval data) - If HA restarts at 14:59:30 → stays at 14:45:00 (shows current interval data)
- **Absolute time tracking**: Timer plans for **all future boundaries** (not relative delays) - **Absolute time tracking**: Timer plans for **all future boundaries** (not relative delays)
- Prevents double-updates (if triggered at 14:59:58, next trigger is 15:15:00, not 15:00:00) - Prevents double-updates (if triggered at 14:59:58, next trigger is 15:15:00, not 15:00:00)
- **Result**: Current price sensors update without waiting for next API poll - **Result**: Current price sensors update without waiting for next API poll
### 4. Calculator Pattern (Sensor Platform) ### 4. Calculator Pattern (Sensor Platform)
@ -298,31 +296,26 @@ All quarter-hourly price intervals get augmented via `utils/price.py`:
Sensors organized by **calculation method** (refactored Nov 2025): Sensors organized by **calculation method** (refactored Nov 2025):
**Unified Handler Methods** (`sensor/core.py`): **Unified Handler Methods** (`sensor/core.py`):
- `_get_interval_value(offset, type)` - current/next/previous intervals - `_get_interval_value(offset, type)` - current/next/previous intervals
- `_get_rolling_hour_value(offset, type)` - 5-interval rolling windows - `_get_rolling_hour_value(offset, type)` - 5-interval rolling windows
- `_get_daily_stat_value(day, stat_func)` - calendar day min/max/avg - `_get_daily_stat_value(day, stat_func)` - calendar day min/max/avg
- `_get_24h_window_value(stat_func)` - trailing/leading statistics - `_get_24h_window_value(stat_func)` - trailing/leading statistics
**Routing** (`sensor/value_getters.py`): **Routing** (`sensor/value_getters.py`):
- Single source of truth mapping 80+ entity keys to calculator methods - Single source of truth mapping 80+ entity keys to calculator methods
- Organized by calculation type (Interval, Rolling Hour, Daily Stats, etc.) - Organized by calculation type (Interval, Rolling Hour, Daily Stats, etc.)
**Calculators** (`sensor/calculators/`): **Calculators** (`sensor/calculators/`):
- Each calculator inherits from `BaseCalculator` with coordinator access - Each calculator inherits from `BaseCalculator` with coordinator access
- Focused responsibility: `IntervalCalculator`, `TrendCalculator`, etc. - Focused responsibility: `IntervalCalculator`, `TrendCalculator`, etc.
- Complex logic isolated (e.g., `TrendCalculator` has internal caching) - Complex logic isolated (e.g., `TrendCalculator` has internal caching)
**Attributes** (`sensor/attributes/`): **Attributes** (`sensor/attributes/`):
- Separate from business logic, handles state presentation - Separate from business logic, handles state presentation
- Builds extra_state_attributes dicts for entity classes - Builds extra_state_attributes dicts for entity classes
- Unified builders: `build_sensor_attributes()`, `build_extra_state_attributes()` - Unified builders: `build_sensor_attributes()`, `build_extra_state_attributes()`
**Benefits:** **Benefits:**
- Minimal code duplication across 80+ sensors - Minimal code duplication across 80+ sensors
- Clear separation of concerns (calculation vs presentation) - Clear separation of concerns (calculation vs presentation)
- Easy to extend: Add sensor → choose pattern → add to routing - Easy to extend: Add sensor → choose pattern → add to routing
@ -340,12 +333,12 @@ Sensors organized by **calculation method** (refactored Nov 2025):
### CPU Optimization ### CPU Optimization
| Optimization | Location | Savings | | Optimization | Location | Savings |
| ------------------- | ------------------------ | ---------------------------- | |--------------|----------|---------|
| Config caching | `coordinator/*` | ~50% on config checks | | Config caching | `coordinator/*` | ~50% on config checks |
| Period caching | `coordinator/periods.py` | ~70% on period recalculation | | Period caching | `coordinator/periods.py` | ~70% on period recalculation |
| Lazy logging | Throughout | ~15% on log-heavy operations | | Lazy logging | Throughout | ~15% on log-heavy operations |
| Import optimization | Module structure | ~20% faster loading | | Import optimization | Module structure | ~20% faster loading |
### Memory Usage ### Memory Usage

View file

@ -24,13 +24,11 @@ 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. **Purpose:** Reduce API calls to Tibber by caching user data and price data between HA restarts.
**What is cached:** **What is cached:**
- **Price data** (`price_data`): Day before yesterday/yesterday/today/tomorrow price intervals with enriched fields (384 intervals total) - **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 - **User data** (`user_data`): Homes, subscriptions, features from Tibber GraphQL `viewer` query
- **Timestamps**: Last update times for validation - **Timestamps**: Last update times for validation
**Lifetime:** **Lifetime:**
- **Price data**: Until midnight turnover (cleared daily at 00:00 local time) - **Price data**: Until midnight turnover (cleared daily at 00:00 local time)
- **User data**: 24 hours (refreshed daily) - **User data**: 24 hours (refreshed daily)
- **Survives**: HA restarts via persistent Storage - **Survives**: HA restarts via persistent Storage
@ -38,31 +36,29 @@ The integration uses **4 distinct caching layers** with different purposes and l
**Invalidation triggers:** **Invalidation triggers:**
1. **Midnight turnover** (Timer #2 in coordinator): 1. **Midnight turnover** (Timer #2 in coordinator):
```python
```python # coordinator/day_transitions.py
# coordinator/day_transitions.py def _handle_midnight_turnover() -> None:
def _handle_midnight_turnover() -> None: self._cached_price_data = None # Force fresh fetch for new day
self._cached_price_data = None # Force fresh fetch for new day self._last_price_update = None
self._last_price_update = None await self.store_cache()
await self.store_cache() ```
```
2. **Cache validation on load**: 2. **Cache validation on load**:
```python
```python # coordinator/cache.py
# coordinator/cache.py def is_cache_valid(cache_data: CacheData) -> bool:
def is_cache_valid(cache_data: CacheData) -> bool: # Checks if price data is from a previous day
# Checks if price data is from a previous day if today_date < local_now.date(): # Yesterday's data
if today_date < local_now.date(): # Yesterday's data return False
return False ```
```
3. **Tomorrow data check** (after 13:00): 3. **Tomorrow data check** (after 13:00):
```python ```python
# coordinator/data_fetching.py # coordinator/data_fetching.py
if tomorrow_missing or tomorrow_invalid: if tomorrow_missing or tomorrow_invalid:
return "tomorrow_check" # Update needed return "tomorrow_check" # Update needed
``` ```
**Why this cache matters:** Reduces API load on Tibber (~192 intervals per fetch), speeds up HA restarts, enables offline operation until cache expires. **Why this cache matters:** Reduces API load on Tibber (~192 intervals per fetch), speeds up HA restarts, enables offline operation until cache expires.
@ -75,22 +71,18 @@ 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. **Purpose:** Avoid repeated file I/O when accessing entity descriptions, UI strings, etc.
**What is cached:** **What is cached:**
- **Standard translations** (`/translations/*.json`): Config flow, selector options, entity names - **Standard translations** (`/translations/*.json`): Config flow, selector options, entity names
- **Custom translations** (`/custom_translations/*.json`): Entity descriptions, usage tips, long descriptions - **Custom translations** (`/custom_translations/*.json`): Entity descriptions, usage tips, long descriptions
**Lifetime:** **Lifetime:**
- **Forever** (until HA restart) - **Forever** (until HA restart)
- No invalidation during runtime - No invalidation during runtime
**When populated:** **When populated:**
- At integration setup: `async_load_translations(hass, "en")` in `__init__.py` - At integration setup: `async_load_translations(hass, "en")` in `__init__.py`
- Lazy loading: If translation missing, attempts file load once - Lazy loading: If translation missing, attempts file load once
**Access pattern:** **Access pattern:**
```python ```python
# Non-blocking synchronous access from cached data # Non-blocking synchronous access from cached data
description = get_translation("binary_sensor.best_price_period.description", "en") description = get_translation("binary_sensor.best_price_period.description", "en")
@ -109,7 +101,6 @@ description = get_translation("binary_sensor.best_price_period.description", "en
**What is cached:** **What is cached:**
### DataTransformer Config Cache ### DataTransformer Config Cache
```python ```python
{ {
"thresholds": {"low": 15, "high": 35}, "thresholds": {"low": 15, "high": 35},
@ -119,7 +110,6 @@ description = get_translation("binary_sensor.best_price_period.description", "en
``` ```
### PeriodCalculator Config Cache ### PeriodCalculator Config Cache
```python ```python
{ {
"best": {"flex": 0.15, "min_distance_from_avg": 5.0, "min_period_length": 60}, "best": {"flex": 0.15, "min_distance_from_avg": 5.0, "min_period_length": 60},
@ -128,23 +118,20 @@ description = get_translation("binary_sensor.best_price_period.description", "en
``` ```
**Lifetime:** **Lifetime:**
- Until `invalidate_config_cache()` is called - Until `invalidate_config_cache()` is called
- Built once on first use per coordinator update cycle - Built once on first use per coordinator update cycle
**Invalidation trigger:** **Invalidation trigger:**
- **Options change** (user reconfigures integration): - **Options change** (user reconfigures integration):
```python ```python
# coordinator/core.py # coordinator/core.py
async def _handle_options_update(...) -> None: async def _handle_options_update(...) -> None:
self._data_transformer.invalidate_config_cache() self._data_transformer.invalidate_config_cache()
self._period_calculator.invalidate_config_cache() self._period_calculator.invalidate_config_cache()
await self.async_request_refresh() await self.async_request_refresh()
``` ```
**Performance impact:** **Performance impact:**
- **Before:** ~30 dict lookups + type conversions per update = ~50μs - **Before:** ~30 dict lookups + type conversions per update = ~50μs
- **After:** 1 cache check = ~1μs - **After:** 1 cache check = ~1μs
- **Savings:** ~98% (50μs → 1μs per update) - **Savings:** ~98% (50μs → 1μs per update)
@ -160,7 +147,6 @@ 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. **Purpose:** Avoid expensive period calculations (~100-500ms) when price data and config haven't changed.
**What is cached:** **What is cached:**
```python ```python
{ {
"best_price": { "best_price": {
@ -175,7 +161,6 @@ description = get_translation("binary_sensor.best_price_period.description", "en
``` ```
**Cache key:** Hash of relevant inputs **Cache key:** Hash of relevant inputs
```python ```python
hash_data = ( hash_data = (
today_signature, # (startsAt, rating_level) for each interval today_signature, # (startsAt, rating_level) for each interval
@ -187,7 +172,6 @@ hash_data = (
``` ```
**Lifetime:** **Lifetime:**
- Until price data changes (today's intervals modified) - Until price data changes (today's intervals modified)
- Until config changes (flex, thresholds, filters) - Until config changes (flex, thresholds, filters)
- Recalculated at midnight (new today data) - Recalculated at midnight (new today data)
@ -195,27 +179,24 @@ hash_data = (
**Invalidation triggers:** **Invalidation triggers:**
1. **Config change** (explicit): 1. **Config change** (explicit):
```python
```python def invalidate_config_cache() -> None:
def invalidate_config_cache() -> None: self._cached_periods = None
self._cached_periods = None self._last_periods_hash = None
self._last_periods_hash = None ```
```
2. **Price data change** (automatic via hash mismatch): 2. **Price data change** (automatic via hash mismatch):
```python ```python
current_hash = self._compute_periods_hash(price_info) current_hash = self._compute_periods_hash(price_info)
if self._last_periods_hash != current_hash: if self._last_periods_hash != current_hash:
# Cache miss - recalculate # Cache miss - recalculate
``` ```
**Cache hit rate:** **Cache hit rate:**
- **High:** During normal operation (coordinator updates every 15min, price data unchanged) - **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) - **Low:** After midnight (new today data) or when tomorrow data arrives (~13:00-14:00)
**Performance impact:** **Performance impact:**
- **Period calculation:** ~100-500ms (depends on interval count, relaxation attempts) - **Period calculation:** ~100-500ms (depends on interval count, relaxation attempts)
- **Cache hit:** `<`1ms (hash comparison + dict lookup) - **Cache hit:** `<`1ms (hash comparison + dict lookup)
- **Savings:** ~70% of calculation time (most updates hit cache) - **Savings:** ~70% of calculation time (most updates hit cache)
@ -231,7 +212,6 @@ hash_data = (
**Status:** ✅ **Clean separation** - enrichment only, no redundancy **Status:** ✅ **Clean separation** - enrichment only, no redundancy
**What is cached:** **What is cached:**
```python ```python
{ {
"timestamp": ..., "timestamp": ...,
@ -244,16 +224,14 @@ hash_data = (
**Purpose:** Avoid re-enriching price data when config unchanged between midnight checks. **Purpose:** Avoid re-enriching price data when config unchanged between midnight checks.
**Current behavior:** **Current behavior:**
- Caches **only enriched price data** (price + statistics) - Caches **only enriched price data** (price + statistics)
- **Does NOT cache periods** (handled by Period Calculation Cache) - **Does NOT cache periods** (handled by Period Calculation Cache)
- Invalidated when: - Invalidated when:
- Config changes (thresholds affect enrichment) - Config changes (thresholds affect enrichment)
- Midnight turnover detected - Midnight turnover detected
- New update cycle begins - New update cycle begins
**Architecture:** **Architecture:**
- DataTransformer: Handles price enrichment only - DataTransformer: Handles price enrichment only
- PeriodCalculator: Handles period calculation only (with hash-based cache) - PeriodCalculator: Handles period calculation only (with hash-based cache)
- Coordinator: Assembles final data on-demand from both caches - Coordinator: Assembles final data on-demand from both caches
@ -265,7 +243,6 @@ hash_data = (
## Cache Invalidation Flow ## Cache Invalidation Flow
### User Changes Options (Config Flow) ### User Changes Options (Config Flow)
``` ```
User saves options User saves options
@ -290,7 +267,6 @@ Fresh data fetch with new config
``` ```
### Midnight Turnover (Day Transition) ### Midnight Turnover (Day Transition)
``` ```
Timer #2 fires at 00:00 Timer #2 fires at 00:00
@ -310,7 +286,6 @@ Fresh API fetch for new day
``` ```
### Tomorrow Data Arrives (~13:00) ### Tomorrow Data Arrives (~13:00)
``` ```
Coordinator update cycle Coordinator update cycle
@ -352,14 +327,12 @@ API Data Cache (price_data, user_data)
``` ```
**No cache invalidation cascades:** **No cache invalidation cascades:**
- Config cache invalidation is **explicit** (on options update) - Config cache invalidation is **explicit** (on options update)
- Period cache invalidation is **automatic** (via hash mismatch) - Period cache invalidation is **automatic** (via hash mismatch)
- Transformation cache invalidation is **automatic** (on midnight/config change) - Transformation cache invalidation is **automatic** (on midnight/config change)
- Translation cache is **never invalidated** (read-only after load) - Translation cache is **never invalidated** (read-only after load)
**Thread safety:** **Thread safety:**
- All caches are accessed from `MainThread` only (Home Assistant event loop) - All caches are accessed from `MainThread` only (Home Assistant event loop)
- No locking needed (single-threaded execution model) - No locking needed (single-threaded execution model)
@ -368,7 +341,6 @@ API Data Cache (price_data, user_data)
## Performance Characteristics ## Performance Characteristics
### Typical Operation (No Changes) ### Typical Operation (No Changes)
``` ```
Coordinator Update (every 15 min) Coordinator Update (every 15 min)
├─> API fetch: SKIP (cache valid) ├─> API fetch: SKIP (cache valid)
@ -381,7 +353,6 @@ Total: ~16ms (down from ~600ms without caching)
``` ```
### After Midnight Turnover ### After Midnight Turnover
``` ```
Coordinator Update (00:00) Coordinator Update (00:00)
├─> API fetch: ~500ms (cache cleared, fetch new day) ├─> API fetch: ~500ms (cache cleared, fetch new day)
@ -394,7 +365,6 @@ Total: ~755ms (expected once per day)
``` ```
### After Config Change ### After Config Change
``` ```
Options Update Options Update
├─> Cache invalidation: `<`1ms ├─> Cache invalidation: `<`1ms
@ -411,25 +381,23 @@ Options Update
## Summary Table ## Summary Table
| Cache Type | Lifetime | Size | Invalidation | Purpose | | Cache Type | Lifetime | Size | Invalidation | Purpose |
| ---------------------- | ---------------------------- | ------ | ------------------------- | ------------------------------- | |------------|----------|------|--------------|---------|
| **API Data** | Hours to 1 day | ~50KB | Midnight, validation | Reduce API calls | | **API Data** | Hours to 1 day | ~50KB | Midnight, validation | Reduce API calls |
| **Translations** | Forever (until HA restart) | ~5KB | Never | Avoid file I/O | | **Translations** | Forever (until HA restart) | ~5KB | Never | Avoid file I/O |
| **Config Dicts** | Until options change | `<`1KB | Explicit (options update) | Avoid dict lookups | | **Config Dicts** | Until options change | `<`1KB | Explicit (options update) | Avoid dict lookups |
| **Period Calculation** | Until data/config change | ~10KB | Auto (hash mismatch) | Avoid CPU-intensive calculation | | **Period Calculation** | Until data/config change | ~10KB | Auto (hash mismatch) | Avoid CPU-intensive calculation |
| **Transformation** | Until midnight/config change | ~50KB | Auto (midnight/config) | Avoid re-enrichment | | **Transformation** | Until midnight/config change | ~50KB | Auto (midnight/config) | Avoid re-enrichment |
**Total memory overhead:** ~116KB per coordinator instance (main + subentries) **Total memory overhead:** ~116KB per coordinator instance (main + subentries)
**Benefits:** **Benefits:**
- 97% reduction in API calls (from every 15min to once per day) - 97% reduction in API calls (from every 15min to once per day)
- 70% reduction in period calculation time (cache hits during normal operation) - 70% reduction in period calculation time (cache hits during normal operation)
- 98% reduction in config access time (30+ lookups → 1 cache check) - 98% reduction in config access time (30+ lookups → 1 cache check)
- Zero file I/O during runtime (translations cached at startup) - Zero file I/O during runtime (translations cached at startup)
**Trade-offs:** **Trade-offs:**
- Memory usage: ~116KB per home (negligible for modern systems) - Memory usage: ~116KB per home (negligible for modern systems)
- Code complexity: 5 cache invalidation points (well-tested, documented) - Code complexity: 5 cache invalidation points (well-tested, documented)
- Debugging: Must understand cache lifetime when investigating stale data issues - Debugging: Must understand cache lifetime when investigating stale data issues
@ -439,9 +407,7 @@ Options Update
## Debugging Cache Issues ## Debugging Cache Issues
### Symptom: Stale data after config change ### Symptom: Stale data after config change
**Check:** **Check:**
1. Is `_handle_options_update()` called? (should see "Options updated" log) 1. Is `_handle_options_update()` called? (should see "Options updated" log)
2. Are `invalidate_config_cache()` methods executed? 2. Are `invalidate_config_cache()` methods executed?
3. Does `async_request_refresh()` trigger? 3. Does `async_request_refresh()` trigger?
@ -449,9 +415,7 @@ Options Update
**Fix:** Ensure `config_entry.add_update_listener()` is registered in coordinator init. **Fix:** Ensure `config_entry.add_update_listener()` is registered in coordinator init.
### Symptom: Period calculation not updating ### Symptom: Period calculation not updating
**Check:** **Check:**
1. Verify hash changes when data changes: `_compute_periods_hash()` 1. Verify hash changes when data changes: `_compute_periods_hash()`
2. Check `_last_periods_hash` vs `current_hash` 2. Check `_last_periods_hash` vs `current_hash`
3. Look for "Using cached period calculation" vs "Calculating periods" logs 3. Look for "Using cached period calculation" vs "Calculating periods" logs
@ -459,9 +423,7 @@ Options Update
**Fix:** Hash function may not include all relevant data. Review `_compute_periods_hash()` inputs. **Fix:** Hash function may not include all relevant data. Review `_compute_periods_hash()` inputs.
### Symptom: Yesterday's prices shown as today ### Symptom: Yesterday's prices shown as today
**Check:** **Check:**
1. `is_cache_valid()` logic in `coordinator/cache.py` 1. `is_cache_valid()` logic in `coordinator/cache.py`
2. Midnight turnover execution (Timer #2) 2. Midnight turnover execution (Timer #2)
3. Cache clear confirmation in logs 3. Cache clear confirmation in logs
@ -469,9 +431,7 @@ Options Update
**Fix:** Timer may not be firing. Check `_schedule_midnight_turnover()` registration. **Fix:** Timer may not be firing. Check `_schedule_midnight_turnover()` registration.
### Symptom: Missing translations ### Symptom: Missing translations
**Check:** **Check:**
1. `async_load_translations()` called at startup? 1. `async_load_translations()` called at startup?
2. Translation files exist in `/translations/` and `/custom_translations/`? 2. Translation files exist in `/translations/` and `/custom_translations/`?
3. Cache population: `_TRANSLATIONS_CACHE` keys 3. Cache population: `_TRANSLATIONS_CACHE` keys

View file

@ -8,10 +8,10 @@ comments: false
## Code Style ## Code Style
- **Formatter/Linter**: Ruff (replaces Black, Flake8, isort) - **Formatter/Linter**: Ruff (replaces Black, Flake8, isort)
- **Max line length**: 120 characters - **Max line length**: 120 characters
- **Max complexity**: 25 (McCabe) - **Max complexity**: 25 (McCabe)
- **Target**: Python 3.13 - **Target**: Python 3.13
Run before committing: Run before committing:
@ -41,14 +41,12 @@ class TimeService:
``` ```
**When prefix is required:** **When prefix is required:**
- Public classes used across multiple modules - Public classes used across multiple modules
- All exception classes - All exception classes
- All coordinator and entity classes - All coordinator and entity classes
- Data classes (dataclasses, NamedTuples) used as public APIs - Data classes (dataclasses, NamedTuples) used as public APIs
**When prefix can be omitted:** **When prefix can be omitted:**
- Private helper classes within a single module (prefix with `_` underscore) - Private helper classes within a single module (prefix with `_` underscore)
- Type aliases and callbacks (e.g., `TimeServiceCallback`) - Type aliases and callbacks (e.g., `TimeServiceCallback`)
- Small internal NamedTuples for function returns - Small internal NamedTuples for function returns
@ -73,7 +71,6 @@ class DataFetcher: # Should be TibberPricesDataFetcher
**Current Technical Debt:** **Current Technical Debt:**
Many existing classes lack the `TibberPrices` prefix. Before refactoring: Many existing classes lack the `TibberPrices` prefix. Before refactoring:
1. Document the plan in `/planning/class-naming-refactoring.md` 1. Document the plan in `/planning/class-naming-refactoring.md`
2. Use `multi_replace_string_in_file` for bulk renames 2. Use `multi_replace_string_in_file` for bulk renames
3. Test thoroughly after each module 3. Test thoroughly after each module

View file

@ -14,10 +14,10 @@ Welcome! This guide helps you contribute to the Tibber Prices integration.
1. Fork the repository on GitHub 1. Fork the repository on GitHub
2. Clone your fork: 2. Clone your fork:
```bash ```bash
git clone https://github.com/YOUR_USERNAME/hass.tibber_prices.git git clone https://github.com/YOUR_USERNAME/hass.tibber_prices.git
cd hass.tibber_prices cd hass.tibber_prices
``` ```
3. Open in VS Code 3. Open in VS Code
4. Click "Reopen in Container" when prompted 4. Click "Reopen in Container" when prompted
@ -34,7 +34,6 @@ git checkout -b fix/issue-123-description
``` ```
**Branch naming:** **Branch naming:**
- `feature/` - New features - `feature/` - New features
- `fix/` - Bug fixes - `fix/` - Bug fixes
- `docs/` - Documentation only - `docs/` - Documentation only
@ -46,7 +45,6 @@ git checkout -b fix/issue-123-description
Edit code, following [Coding Guidelines](coding-guidelines.md). Edit code, following [Coding Guidelines](coding-guidelines.md).
**Run checks frequently:** **Run checks frequently:**
```bash ```bash
./scripts/type-check # Pyright type checking ./scripts/type-check # Pyright type checking
./scripts/lint # Ruff linting (auto-fix) ./scripts/lint # Ruff linting (auto-fix)
@ -80,7 +78,6 @@ async def test_your_feature(hass, coordinator):
``` ```
Run your test: Run your test:
```bash ```bash
./scripts/test tests/test_your_feature.py -v ./scripts/test tests/test_your_feature.py -v
``` ```
@ -100,7 +97,6 @@ Impact: Users can predict when prices will stabilize or continue fluctuating."
``` ```
**Commit types:** **Commit types:**
- `feat:` - New feature - `feat:` - New feature
- `fix:` - Bug fix - `fix:` - Bug fix
- `docs:` - Documentation - `docs:` - Documentation
@ -109,7 +105,6 @@ Impact: Users can predict when prices will stabilize or continue fluctuating."
- `chore:` - Maintenance - `chore:` - Maintenance
**Add scope when relevant:** **Add scope when relevant:**
- `feat(sensors):` - Sensor platform - `feat(sensors):` - Sensor platform
- `fix(coordinator):` - Data coordinator - `fix(coordinator):` - Data coordinator
- `docs(user):` - User documentation - `docs(user):` - User documentation
@ -129,40 +124,32 @@ Then open Pull Request on GitHub.
Title: Short, descriptive (50 chars max) Title: Short, descriptive (50 chars max)
Description should include: Description should include:
```markdown ```markdown
## What ## What
Brief description of changes Brief description of changes
## Why ## Why
Problem being solved or feature rationale Problem being solved or feature rationale
## How ## How
Implementation approach Implementation approach
## Testing ## Testing
- [ ] Manual testing in Home Assistant - [ ] Manual testing in Home Assistant
- [ ] Unit tests added/updated - [ ] Unit tests added/updated
- [ ] Type checking passes - [ ] Type checking passes
- [ ] Linting passes - [ ] Linting passes
## Breaking Changes ## Breaking Changes
(If any - describe migration path) (If any - describe migration path)
## Related Issues ## Related Issues
Closes #123 Closes #123
``` ```
### PR Checklist ### PR Checklist
Before submitting: Before submitting:
- [ ] Code follows [Coding Guidelines](coding-guidelines.md) - [ ] Code follows [Coding Guidelines](coding-guidelines.md)
- [ ] All tests pass (`./scripts/test`) - [ ] All tests pass (`./scripts/test`)
- [ ] Type checking passes (`./scripts/type-check`) - [ ] Type checking passes (`./scripts/type-check`)
@ -183,7 +170,6 @@ Before submitting:
### What Reviewers Look For ### What Reviewers Look For
✅ **Good:** ✅ **Good:**
- Clear, self-explanatory code - Clear, self-explanatory code
- Appropriate comments for complex logic - Appropriate comments for complex logic
- Tests covering edge cases - Tests covering edge cases
@ -191,7 +177,6 @@ Before submitting:
- Follows existing patterns - Follows existing patterns
❌ **Avoid:** ❌ **Avoid:**
- Large PRs (>500 lines) - split into smaller ones - Large PRs (>500 lines) - split into smaller ones
- Mixing unrelated changes - Mixing unrelated changes
- Missing tests for new features - Missing tests for new features
@ -208,7 +193,6 @@ Before submitting:
## Finding Issues to Work On ## Finding Issues to Work On
Good first issues are labeled: Good first issues are labeled:
- `good first issue` - Beginner-friendly - `good first issue` - Beginner-friendly
- `help wanted` - Maintainers welcome contributions - `help wanted` - Maintainers welcome contributions
- `documentation` - Docs improvements - `documentation` - Docs improvements
@ -226,7 +210,6 @@ Be respectful, constructive, and patient. We're all volunteers! 🙏
--- ---
💡 **Related:** 💡 **Related:**
- [Setup Guide](setup.md) - DevContainer setup - [Setup Guide](setup.md) - DevContainer setup
- [Coding Guidelines](coding-guidelines.md) - Style guide - [Coding Guidelines](coding-guidelines.md) - Style guide
- [Testing](testing.md) - Writing tests - [Testing](testing.md) - Writing tests

View file

@ -12,7 +12,6 @@ comments: false
## 🎯 Why Are These Tests Critical? ## 🎯 Why Are These Tests Critical?
Home Assistant integrations run **continuously** in the background. Resource leaks lead to: Home Assistant integrations run **continuously** in the background. Resource leaks lead to:
- **Memory Leaks**: RAM usage grows over days/weeks until HA becomes unstable - **Memory Leaks**: RAM usage grows over days/weeks until HA becomes unstable
- **Callback Leaks**: Listeners remain registered after entity removal → CPU load increases - **Callback Leaks**: Listeners remain registered after entity removal → CPU load increases
- **Timer Leaks**: Timers continue running after unload → unnecessary background tasks - **Timer Leaks**: Timers continue running after unload → unnecessary background tasks
@ -27,7 +26,6 @@ Home Assistant integrations run **continuously** in the background. Resource lea
#### 1.1 Listener Cleanup ✅ #### 1.1 Listener Cleanup ✅
**What is tested:** **What is tested:**
- Time-sensitive listeners are correctly removed (`async_add_time_sensitive_listener()`) - Time-sensitive listeners are correctly removed (`async_add_time_sensitive_listener()`)
- Minute-update listeners are correctly removed (`async_add_minute_update_listener()`) - Minute-update listeners are correctly removed (`async_add_minute_update_listener()`)
- Lifecycle callbacks are correctly unregistered (`register_lifecycle_callback()`) - Lifecycle callbacks are correctly unregistered (`register_lifecycle_callback()`)
@ -35,13 +33,11 @@ Home Assistant integrations run **continuously** in the background. Resource lea
- Binary sensor cleanup removes ALL registered listeners - Binary sensor cleanup removes ALL registered listeners
**Why critical:** **Why critical:**
- Each registered listener holds references to Entity + Coordinator - Each registered listener holds references to Entity + Coordinator
- Without cleanup: Entities are not freed by GC → Memory Leak - Without cleanup: Entities are not freed by GC → Memory Leak
- With 80+ sensors × 3 listener types = 240+ callbacks that must be cleanly removed - With 80+ sensors × 3 listener types = 240+ callbacks that must be cleanly removed
**Code Locations:** **Code Locations:**
- `coordinator/listeners.py``async_add_time_sensitive_listener()`, `async_add_minute_update_listener()` - `coordinator/listeners.py``async_add_time_sensitive_listener()`, `async_add_minute_update_listener()`
- `coordinator/core.py``register_lifecycle_callback()` - `coordinator/core.py``register_lifecycle_callback()`
- `sensor/core.py``async_will_remove_from_hass()` - `sensor/core.py``async_will_remove_from_hass()`
@ -50,38 +46,32 @@ Home Assistant integrations run **continuously** in the background. Resource lea
#### 1.2 Timer Cleanup ✅ #### 1.2 Timer Cleanup ✅
**What is tested:** **What is tested:**
- Quarter-hour timer is cancelled and reference cleared - Quarter-hour timer is cancelled and reference cleared
- Minute timer is cancelled and reference cleared - Minute timer is cancelled and reference cleared
- Both timers are cancelled together - Both timers are cancelled together
- Cleanup works even when timers are `None` - Cleanup works even when timers are `None`
**Why critical:** **Why critical:**
- Uncancelled timers continue running after integration unload - Uncancelled timers continue running after integration unload
- HA's `async_track_utc_time_change()` creates persistent callbacks - HA's `async_track_utc_time_change()` creates persistent callbacks
- Without cleanup: Timers keep firing → CPU load + unnecessary coordinator updates - Without cleanup: Timers keep firing → CPU load + unnecessary coordinator updates
**Code Locations:** **Code Locations:**
- `coordinator/listeners.py``cancel_timers()` - `coordinator/listeners.py``cancel_timers()`
- `coordinator/core.py``async_shutdown()` - `coordinator/core.py``async_shutdown()`
#### 1.3 Config Entry Cleanup ✅ #### 1.3 Config Entry Cleanup ✅
**What is tested:** **What is tested:**
- Options update listener is registered via `async_on_unload()` - Options update listener is registered via `async_on_unload()`
- Cleanup function is correctly passed to `async_on_unload()` - Cleanup function is correctly passed to `async_on_unload()`
**Why critical:** **Why critical:**
- `entry.add_update_listener()` registers permanent callback - `entry.add_update_listener()` registers permanent callback
- Without `async_on_unload()`: Listener remains active after reload → duplicate updates - Without `async_on_unload()`: Listener remains active after reload → duplicate updates
- Pattern: `entry.async_on_unload(entry.add_update_listener(handler))` - Pattern: `entry.async_on_unload(entry.add_update_listener(handler))`
**Code Locations:** **Code Locations:**
- `coordinator/core.py``__init__()` (listener registration) - `coordinator/core.py``__init__()` (listener registration)
- `__init__.py``async_unload_entry()` - `__init__.py``async_unload_entry()`
@ -92,19 +82,16 @@ Home Assistant integrations run **continuously** in the background. Resource lea
#### 2.1 Config Cache Invalidation #### 2.1 Config Cache Invalidation
**What is tested:** **What is tested:**
- DataTransformer config cache is invalidated on options change - DataTransformer config cache is invalidated on options change
- PeriodCalculator config + period cache is invalidated - PeriodCalculator config + period cache is invalidated
- Trend calculator cache is cleared on coordinator update - Trend calculator cache is cleared on coordinator update
**Why critical:** **Why critical:**
- Stale config → Sensors use old user settings - Stale config → Sensors use old user settings
- Stale period cache → Incorrect best/peak price periods - Stale period cache → Incorrect best/peak price periods
- Stale trend cache → Outdated trend analysis - Stale trend cache → Outdated trend analysis
**Code Locations:** **Code Locations:**
- `coordinator/data_transformation.py``invalidate_config_cache()` - `coordinator/data_transformation.py``invalidate_config_cache()`
- `coordinator/periods.py``invalidate_config_cache()` - `coordinator/periods.py``invalidate_config_cache()`
- `sensor/calculators/trend.py``clear_trend_cache()` - `sensor/calculators/trend.py``clear_trend_cache()`
@ -116,18 +103,15 @@ Home Assistant integrations run **continuously** in the background. Resource lea
#### 3.1 Persistent Storage Removal #### 3.1 Persistent Storage Removal
**What is tested:** **What is tested:**
- Storage file is deleted on config entry removal - Storage file is deleted on config entry removal
- Cache is saved on shutdown (no data loss) - Cache is saved on shutdown (no data loss)
**Why critical:** **Why critical:**
- Without storage removal: Old files remain after uninstallation - Without storage removal: Old files remain after uninstallation
- Without cache save on shutdown: Data loss on HA restart - Without cache save on shutdown: Data loss on HA restart
- Storage path: `.storage/tibber_prices.{entry_id}` - Storage path: `.storage/tibber_prices.{entry_id}`
**Code Locations:** **Code Locations:**
- `__init__.py``async_remove_entry()` - `__init__.py``async_remove_entry()`
- `coordinator/core.py``async_shutdown()` - `coordinator/core.py``async_shutdown()`
@ -136,14 +120,12 @@ Home Assistant integrations run **continuously** in the background. Resource lea
**File:** `tests/test_timer_scheduling.py` **File:** `tests/test_timer_scheduling.py`
**What is tested:** **What is tested:**
- Quarter-hour timer is registered with correct parameters - Quarter-hour timer is registered with correct parameters
- Minute timer is registered with correct parameters - Minute timer is registered with correct parameters
- Timers can be re-scheduled (override old timer) - Timers can be re-scheduled (override old timer)
- Midnight turnover detection works correctly - Midnight turnover detection works correctly
**Why critical:** **Why critical:**
- Wrong timer parameters → Entities update at wrong times - Wrong timer parameters → Entities update at wrong times
- Without timer override on re-schedule → Multiple parallel timers → Performance problem - Without timer override on re-schedule → Multiple parallel timers → Performance problem
@ -152,14 +134,12 @@ Home Assistant integrations run **continuously** in the background. Resource lea
**File:** `tests/test_sensor_timer_assignment.py` **File:** `tests/test_sensor_timer_assignment.py`
**What is tested:** **What is tested:**
- All `TIME_SENSITIVE_ENTITY_KEYS` are valid entity keys - All `TIME_SENSITIVE_ENTITY_KEYS` are valid entity keys
- All `MINUTE_UPDATE_ENTITY_KEYS` are valid entity keys - All `MINUTE_UPDATE_ENTITY_KEYS` are valid entity keys
- Both lists are disjoint (no overlap) - Both lists are disjoint (no overlap)
- Sensor and binary sensor platforms are checked - Sensor and binary sensor platforms are checked
**Why critical:** **Why critical:**
- Wrong timer assignment → Sensors update at wrong times - Wrong timer assignment → Sensors update at wrong times
- Overlap → Duplicate updates → Performance problem - Overlap → Duplicate updates → Performance problem
@ -170,12 +150,10 @@ These patterns were analyzed and classified as **not critical**:
### 6. Async Task Management ### 6. Async Task Management
**Current Status:** Fire-and-forget pattern for short tasks **Current Status:** Fire-and-forget pattern for short tasks
- `sensor/core.py` → Chart data refresh (short-lived, max 1-2 seconds) - `sensor/core.py` → Chart data refresh (short-lived, max 1-2 seconds)
- `coordinator/core.py` → Cache storage (short-lived, max 100ms) - `coordinator/core.py` → Cache storage (short-lived, max 100ms)
**Why no tests needed:** **Why no tests needed:**
- No long-running tasks (all < 2 seconds) - No long-running tasks (all < 2 seconds)
- HA's event loop handles short tasks automatically - HA's event loop handles short tasks automatically
- Task exceptions are already logged - Task exceptions are already logged
@ -185,7 +163,6 @@ These patterns were analyzed and classified as **not critical**:
### 7. API Session Cleanup ### 7. API Session Cleanup
**Current Status:** ✅ Correctly implemented **Current Status:** ✅ Correctly implemented
- `async_get_clientsession(hass)` is used (shared session) - `async_get_clientsession(hass)` is used (shared session)
- No new sessions are created - No new sessions are created
- HA manages session lifecycle automatically - HA manages session lifecycle automatically
@ -195,7 +172,6 @@ These patterns were analyzed and classified as **not critical**:
### 8. Translation Cache Memory ### 8. Translation Cache Memory
**Current Status:** ✅ Bounded cache **Current Status:** ✅ Bounded cache
- Max ~5-10 languages × 5KB = 50KB total - Max ~5-10 languages × 5KB = 50KB total
- Module-level cache without re-loading - Module-level cache without re-loading
- Practically no memory issue - Practically no memory issue
@ -205,13 +181,11 @@ These patterns were analyzed and classified as **not critical**:
### 9. Coordinator Data Structure Integrity ### 9. Coordinator Data Structure Integrity
**Current Status:** Manually tested via `./scripts/develop` **Current Status:** Manually tested via `./scripts/develop`
- Midnight turnover works correctly (observed over several days) - Midnight turnover works correctly (observed over several days)
- Missing keys are handled via `.get()` with defaults - Missing keys are handled via `.get()` with defaults
- 80+ sensors access `coordinator.data` without errors - 80+ sensors access `coordinator.data` without errors
**Structure:** **Structure:**
```python ```python
coordinator.data = { coordinator.data = {
"user_data": {...}, "user_data": {...},
@ -223,7 +197,6 @@ coordinator.data = {
### 10. Service Response Memory ### 10. Service Response Memory
**Current Status:** HA's response lifecycle **Current Status:** HA's response lifecycle
- HA automatically frees service responses after return - HA automatically frees service responses after return
- ApexCharts ~20KB response is one-time per call - ApexCharts ~20KB response is one-time per call
- No response accumulation in integration code - No response accumulation in integration code
@ -234,30 +207,29 @@ coordinator.data = {
### ✅ Implemented Tests (41 total) ### ✅ Implemented Tests (41 total)
| Category | Status | Tests | File | Coverage | | Category | Status | Tests | File | Coverage |
| ----------------------- | ------ | ------ | --------------------------------- | ------------------- | |----------|--------|-------|------|----------|
| Listener Cleanup | ✅ | 5 | `test_resource_cleanup.py` | 100% | | Listener Cleanup | ✅ | 5 | `test_resource_cleanup.py` | 100% |
| Timer Cleanup | ✅ | 4 | `test_resource_cleanup.py` | 100% | | Timer Cleanup | ✅ | 4 | `test_resource_cleanup.py` | 100% |
| Config Entry Cleanup | ✅ | 1 | `test_resource_cleanup.py` | 100% | | Config Entry Cleanup | ✅ | 1 | `test_resource_cleanup.py` | 100% |
| Cache Invalidation | ✅ | 3 | `test_resource_cleanup.py` | 100% | | Cache Invalidation | ✅ | 3 | `test_resource_cleanup.py` | 100% |
| Storage Cleanup | ✅ | 1 | `test_resource_cleanup.py` | 100% | | Storage Cleanup | ✅ | 1 | `test_resource_cleanup.py` | 100% |
| Storage Persistence | ✅ | 2 | `test_coordinator_shutdown.py` | 100% | | Storage Persistence | ✅ | 2 | `test_coordinator_shutdown.py` | 100% |
| Timer Scheduling | ✅ | 8 | `test_timer_scheduling.py` | 100% | | Timer Scheduling | ✅ | 8 | `test_timer_scheduling.py` | 100% |
| Sensor-Timer Assignment | ✅ | 17 | `test_sensor_timer_assignment.py` | 100% | | Sensor-Timer Assignment | ✅ | 17 | `test_sensor_timer_assignment.py` | 100% |
| **TOTAL** | **✅** | **41** | | **100% (critical)** | | **TOTAL** | **✅** | **41** | | **100% (critical)** |
### 📋 Analyzed but Not Implemented (Nice-to-Have) ### 📋 Analyzed but Not Implemented (Nice-to-Have)
| Category | Status | Rationale | | Category | Status | Rationale |
| ------------------------ | ------ | ---------------------------------------------------- | |----------|--------|-----------|
| Async Task Management | 📋 | Fire-and-forget pattern used (no long-running tasks) | | Async Task Management | 📋 | Fire-and-forget pattern used (no long-running tasks) |
| API Session Cleanup | ✅ | Pattern correct (`async_get_clientsession` used) | | API Session Cleanup | ✅ | Pattern correct (`async_get_clientsession` used) |
| Translation Cache | ✅ | Cache size bounded (~50KB max for 10 languages) | | Translation Cache | ✅ | Cache size bounded (~50KB max for 10 languages) |
| Data Structure Integrity | 📋 | Would add test time without finding real issues | | Data Structure Integrity | 📋 | Would add test time without finding real issues |
| Service Response Memory | 📋 | HA automatically frees service responses | | Service Response Memory | 📋 | HA automatically frees service responses |
**Legend:** **Legend:**
- ✅ = Fully tested or pattern verified correct - ✅ = Fully tested or pattern verified correct
- 📋 = Analyzed, low priority for testing (no known issues) - 📋 = Analyzed, low priority for testing (no known issues)
@ -266,7 +238,6 @@ coordinator.data = {
### ✅ All Critical Patterns Tested ### ✅ All Critical Patterns Tested
All essential memory leak prevention patterns are covered by 41 tests: All essential memory leak prevention patterns are covered by 41 tests:
- ✅ Listeners are correctly removed (no callback leaks) - ✅ Listeners are correctly removed (no callback leaks)
- ✅ Timers are cancelled (no background task leaks) - ✅ Timers are cancelled (no background task leaks)
- ✅ Config entry cleanup works (no dangling listeners) - ✅ Config entry cleanup works (no dangling listeners)

View file

@ -10,9 +10,9 @@ Add to `configuration.yaml`:
```yaml ```yaml
logger: logger:
default: info default: info
logs: logs:
custom_components.tibber_prices: debug custom_components.tibber_prices: debug
``` ```
Restart Home Assistant to apply. Restart Home Assistant to apply.
@ -20,7 +20,6 @@ Restart Home Assistant to apply.
### Key Log Messages ### Key Log Messages
**Coordinator Updates:** **Coordinator Updates:**
``` ```
[custom_components.tibber_prices.coordinator] Successfully fetched price data [custom_components.tibber_prices.coordinator] Successfully fetched price data
[custom_components.tibber_prices.coordinator] Cache valid, using cached data [custom_components.tibber_prices.coordinator] Cache valid, using cached data
@ -28,7 +27,6 @@ Restart Home Assistant to apply.
``` ```
**Period Calculation:** **Period Calculation:**
``` ```
[custom_components.tibber_prices.coordinator.periods] Calculating BEST PRICE periods: flex=15.0% [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 [custom_components.tibber_prices.coordinator.periods] Day 2024-12-06: Found 2 periods
@ -36,7 +34,6 @@ Restart Home Assistant to apply.
``` ```
**API Errors:** **API Errors:**
``` ```
[custom_components.tibber_prices.api] API request failed: Unauthorized [custom_components.tibber_prices.api] API request failed: Unauthorized
[custom_components.tibber_prices.api] Retrying (attempt 2/3) after 2.0s [custom_components.tibber_prices.api] Retrying (attempt 2/3) after 2.0s
@ -50,27 +47,26 @@ Restart Home Assistant to apply.
```json ```json
{ {
"version": "0.2.0", "version": "0.2.0",
"configurations": [ "configurations": [
{ {
"name": "Home Assistant", "name": "Home Assistant",
"type": "debugpy", "type": "debugpy",
"request": "launch", "request": "launch",
"module": "homeassistant", "module": "homeassistant",
"args": ["-c", "config", "--debug"], "args": ["-c", "config", "--debug"],
"justMyCode": false, "justMyCode": false,
"env": { "env": {
"PYTHONPATH": "${workspaceFolder}/.venv/lib/python3.13/site-packages" "PYTHONPATH": "${workspaceFolder}/.venv/lib/python3.13/site-packages"
} }
} }
] ]
} }
``` ```
### Set Breakpoints ### Set Breakpoints
**Coordinator update:** **Coordinator update:**
```python ```python
# coordinator/core.py # coordinator/core.py
async def _async_update_data(self) -> dict: async def _async_update_data(self) -> dict:
@ -79,7 +75,6 @@ async def _async_update_data(self) -> dict:
``` ```
**Period calculation:** **Period calculation:**
```python ```python
# coordinator/period_handlers/core.py # coordinator/period_handlers/core.py
def calculate_periods(...) -> list[dict]: def calculate_periods(...) -> list[dict]:
@ -96,7 +91,6 @@ def calculate_periods(...) -> list[dict]:
``` ```
**Flags:** **Flags:**
- `-v` - Verbose output - `-v` - Verbose output
- `-s` - Show print statements - `-s` - Show print statements
- `-k pattern` - Run tests matching pattern - `-k pattern` - Run tests matching pattern
@ -108,7 +102,6 @@ Set breakpoint in test file, use "Debug Test" CodeLens.
### Useful Test Patterns ### Useful Test Patterns
**Print coordinator data:** **Print coordinator data:**
```python ```python
def test_something(coordinator): def test_something(coordinator):
print(f"Coordinator data: {coordinator.data}") print(f"Coordinator data: {coordinator.data}")
@ -116,7 +109,6 @@ def test_something(coordinator):
``` ```
**Inspect period attributes:** **Inspect period attributes:**
```python ```python
def test_periods(hass, coordinator): def test_periods(hass, coordinator):
periods = coordinator.data.get('best_price_periods', []) periods = coordinator.data.get('best_price_periods', [])
@ -130,13 +122,11 @@ def test_periods(hass, coordinator):
### Integration Not Loading ### Integration Not Loading
**Check:** **Check:**
```bash ```bash
grep "tibber_prices" config/home-assistant.log grep "tibber_prices" config/home-assistant.log
``` ```
**Common causes:** **Common causes:**
- Syntax error in Python code → Check logs for traceback - Syntax error in Python code → Check logs for traceback
- Missing dependency → Run `uv sync` - Missing dependency → Run `uv sync`
- Wrong file permissions → `chmod +x scripts/*` - Wrong file permissions → `chmod +x scripts/*`
@ -144,14 +134,12 @@ grep "tibber_prices" config/home-assistant.log
### Sensors Not Updating ### Sensors Not Updating
**Check coordinator state:** **Check coordinator state:**
```python ```python
# In Developer Tools > Template # In Developer Tools > Template
{{ states.sensor.tibber_home_current_interval_price.last_updated }} {{ states.sensor.tibber_home_current_interval_price.last_updated }}
``` ```
**Debug in code:** **Debug in code:**
```python ```python
# Add logging in sensor/core.py # Add logging in sensor/core.py
_LOGGER.debug("Updating sensor %s: old=%s new=%s", _LOGGER.debug("Updating sensor %s: old=%s new=%s",
@ -161,7 +149,6 @@ _LOGGER.debug("Updating sensor %s: old=%s new=%s",
### Period Calculation Wrong ### Period Calculation Wrong
**Enable detailed period logs:** **Enable detailed period logs:**
```python ```python
# coordinator/period_handlers/period_building.py # coordinator/period_handlers/period_building.py
_LOGGER.debug("Candidate intervals: %s", _LOGGER.debug("Candidate intervals: %s",
@ -169,7 +156,6 @@ _LOGGER.debug("Candidate intervals: %s",
``` ```
**Check filter statistics:** **Check filter statistics:**
``` ```
[period_building] Flex filter blocked: 45 intervals [period_building] Flex filter blocked: 45 intervals
[period_building] Min distance blocked: 12 intervals [period_building] Min distance blocked: 12 intervals
@ -214,7 +200,6 @@ python -m pstats profile.stats
### Remote Debugging with debugpy ### Remote Debugging with debugpy
Add to coordinator code: Add to coordinator code:
```python ```python
import debugpy import debugpy
debugpy.listen(5678) debugpy.listen(5678)
@ -227,13 +212,11 @@ Connect from VS Code with remote attach configuration.
### IPython REPL ### IPython REPL
Install in container: Install in container:
```bash ```bash
uv pip install ipython uv pip install ipython
``` ```
Add breakpoint: Add breakpoint:
```python ```python
from IPython import embed from IPython import embed
embed() # Drops into interactive shell embed() # Drops into interactive shell
@ -242,7 +225,6 @@ embed() # Drops into interactive shell
--- ---
💡 **Related:** 💡 **Related:**
- [Testing Guide](testing.md) - Writing and running tests - [Testing Guide](testing.md) - Writing and running tests
- [Setup Guide](setup.md) - Development environment - [Setup Guide](setup.md) - Development environment
- [Architecture](architecture.md) - Code structure - [Architecture](architecture.md) - Code structure

View file

@ -8,25 +8,25 @@ This is an independent, community-maintained custom integration for Home Assista
## 📚 Developer Guides ## 📚 Developer Guides
- **[Setup](setup.md)** - DevContainer, environment setup, and dependencies - **[Setup](setup.md)** - DevContainer, environment setup, and dependencies
- **[Architecture](architecture.md)** - Code structure, patterns, and conventions - **[Architecture](architecture.md)** - Code structure, patterns, and conventions
- **[Period Calculation Theory](period-calculation-theory.md)** - Mathematical foundations, Flex/Distance interaction, Relaxation strategy - **[Period Calculation Theory](period-calculation-theory.md)** - Mathematical foundations, Flex/Distance interaction, Relaxation strategy
- **[Timer Architecture](timer-architecture.md)** - Timer system, scheduling, coordination (3 independent timers) - **[Timer Architecture](timer-architecture.md)** - Timer system, scheduling, coordination (3 independent timers)
- **[Caching Strategy](caching-strategy.md)** - Cache layers, invalidation, debugging - **[Caching Strategy](caching-strategy.md)** - Cache layers, invalidation, debugging
- **[Testing](testing.md)** - How to run tests and write new test cases - **[Testing](testing.md)** - How to run tests and write new test cases
- **[Release Management](release-management.md)** - Release workflow and versioning process - **[Release Management](release-management.md)** - Release workflow and versioning process
- **[Coding Guidelines](coding-guidelines.md)** - Style guide, linting, and best practices - **[Coding Guidelines](coding-guidelines.md)** - Style guide, linting, and best practices
- **[Refactoring Guide](refactoring-guide.md)** - How to plan and execute major refactorings - **[Refactoring Guide](refactoring-guide.md)** - How to plan and execute major refactorings
## 🤖 AI Documentation ## 🤖 AI Documentation
The main AI/Copilot documentation is in [`AGENTS.md`](https://github.com/jpawlowski/hass.tibber_prices/blob/main/AGENTS.md). This file serves as long-term memory for AI assistants and contains: The main AI/Copilot documentation is in [`AGENTS.md`](https://github.com/jpawlowski/hass.tibber_prices/blob/main/AGENTS.md). This file serves as long-term memory for AI assistants and contains:
- Detailed architectural patterns - Detailed architectural patterns
- Code quality rules and conventions - Code quality rules and conventions
- Development workflow guidance - Development workflow guidance
- Common pitfalls and anti-patterns - Common pitfalls and anti-patterns
- Project-specific patterns and utilities - Project-specific patterns and utilities
**Important:** When proposing changes to patterns or conventions, always update [`AGENTS.md`](https://github.com/jpawlowski/hass.tibber_prices/blob/main/AGENTS.md) to keep AI guidance consistent. **Important:** When proposing changes to patterns or conventions, always update [`AGENTS.md`](https://github.com/jpawlowski/hass.tibber_prices/blob/main/AGENTS.md) to keep AI guidance consistent.
@ -34,32 +34,32 @@ The main AI/Copilot documentation is in [`AGENTS.md`](https://github.com/jpawlow
This integration is developed with extensive AI assistance (GitHub Copilot, Claude, and other AI tools). The AI handles: This integration is developed with extensive AI assistance (GitHub Copilot, Claude, and other AI tools). The AI handles:
- **Pattern Recognition**: Understanding and applying Home Assistant best practices - **Pattern Recognition**: Understanding and applying Home Assistant best practices
- **Code Generation**: Implementing features with proper type hints, error handling, and documentation - **Code Generation**: Implementing features with proper type hints, error handling, and documentation
- **Refactoring**: Maintaining consistency across the codebase during structural changes - **Refactoring**: Maintaining consistency across the codebase during structural changes
- **Translation Management**: Keeping 5 language files synchronized - **Translation Management**: Keeping 5 language files synchronized
- **Documentation**: Generating and maintaining comprehensive documentation - **Documentation**: Generating and maintaining comprehensive documentation
**Quality Assurance:** **Quality Assurance:**
- Automated linting with Ruff (120-char line length, max complexity 25) - Automated linting with Ruff (120-char line length, max complexity 25)
- Home Assistant's type checking and validation - Home Assistant's type checking and validation
- Real-world testing in development environment - Real-world testing in development environment
- Code review by maintainer before merging - Code review by maintainer before merging
**Benefits:** **Benefits:**
- Rapid feature development while maintaining quality - Rapid feature development while maintaining quality
- Consistent code patterns across all modules - Consistent code patterns across all modules
- Comprehensive documentation maintained alongside code - Comprehensive documentation maintained alongside code
- Quick bug fixes with proper understanding of context - Quick bug fixes with proper understanding of context
**Limitations:** **Limitations:**
- AI may occasionally miss edge cases or subtle bugs - AI may occasionally miss edge cases or subtle bugs
- Some complex Home Assistant patterns may need human review - Some complex Home Assistant patterns may need human review
- Translation quality depends on AI's understanding of target language - Translation quality depends on AI's understanding of target language
- User feedback is crucial for discovering real-world issues - User feedback is crucial for discovering real-world issues
If you're working with AI tools on this project, the [`AGENTS.md`](https://github.com/jpawlowski/hass.tibber_prices/blob/main/AGENTS.md) file provides the context and patterns that ensure consistency. If you're working with AI tools on this project, the [`AGENTS.md`](https://github.com/jpawlowski/hass.tibber_prices/blob/main/AGENTS.md) file provides the context and patterns that ensure consistency.
@ -80,15 +80,15 @@ If you're working with AI tools on this project, the [`AGENTS.md`](https://githu
The project includes several helper scripts in `./scripts/`: The project includes several helper scripts in `./scripts/`:
- `bootstrap` - Initial setup of dependencies - `bootstrap` - Initial setup of dependencies
- `develop` - Start Home Assistant in debug mode (auto-cleans .egg-info) - `develop` - Start Home Assistant in debug mode (auto-cleans .egg-info)
- `clean` - Remove build artifacts and caches - `clean` - Remove build artifacts and caches
- `lint` - Auto-fix code issues with ruff - `lint` - Auto-fix code issues with ruff
- `lint-check` - Check code without modifications (CI mode) - `lint-check` - Check code without modifications (CI mode)
- `hassfest` - Validate integration structure (JSON, Python syntax, required files) - `hassfest` - Validate integration structure (JSON, Python syntax, required files)
- `setup` - Install development tools (git-cliff, @github/copilot) - `setup` - Install development tools (git-cliff, @github/copilot)
- `prepare-release` - Prepare a new release (bump version, create tag) - `prepare-release` - Prepare a new release (bump version, create tag)
- `generate-release-notes` - Generate release notes from commits - `generate-release-notes` - Generate release notes from commits
## 📦 Project Structure ## 📦 Project Structure
@ -121,23 +121,23 @@ custom_components/tibber_prices/
**DataUpdateCoordinator Pattern:** **DataUpdateCoordinator Pattern:**
- Centralized data fetching and caching - Centralized data fetching and caching
- Automatic entity updates on data changes - Automatic entity updates on data changes
- Persistent storage via `Store` - Persistent storage via `Store`
- Quarter-hour boundary refresh scheduling - Quarter-hour boundary refresh scheduling
**Price Data Enrichment:** **Price Data Enrichment:**
- Raw API data is enriched with statistical analysis - Raw API data is enriched with statistical analysis
- Trailing/leading 24h averages calculated per interval - Trailing/leading 24h averages calculated per interval
- Price differences and ratings added - Price differences and ratings added
- All via pure functions in `price_utils.py` - All via pure functions in `price_utils.py`
**Translation System:** **Translation System:**
- Dual system: `/translations/` (HA schema) + `/custom_translations/` (extended) - Dual system: `/translations/` (HA schema) + `/custom_translations/` (extended)
- Both must stay in sync across all languages (de, en, nb, nl, sv) - Both must stay in sync across all languages (de, en, nb, nl, sv)
- Async loading at integration setup - Async loading at integration setup
## 🧪 Testing ## 🧪 Testing
@ -159,19 +159,18 @@ pytest --cov=custom_components.tibber_prices tests/
Documentation is organized in two Docusaurus sites: Documentation is organized in two Docusaurus sites:
- **User docs** (`docs/user/`): Installation, configuration, usage guides - **User docs** (`docs/user/`): Installation, configuration, usage guides
- Markdown files in `docs/user/docs/*.md` - Markdown files in `docs/user/docs/*.md`
- Navigation managed via `docs/user/sidebars.ts` - Navigation managed via `docs/user/sidebars.ts`
- **Developer docs** (`docs/developer/`): Architecture, patterns, contribution guides - **Developer docs** (`docs/developer/`): Architecture, patterns, contribution guides
- Markdown files in `docs/developer/docs/*.md` - Markdown files in `docs/developer/docs/*.md`
- Navigation managed via `docs/developer/sidebars.ts` - Navigation managed via `docs/developer/sidebars.ts`
- **AI guidance**: `AGENTS.md` (patterns, conventions, long-term memory) - **AI guidance**: `AGENTS.md` (patterns, conventions, long-term memory)
**Best practices:** **Best practices:**
- Use clear examples and code snippets
- Use clear examples and code snippets - Keep docs up-to-date with code changes
- Keep docs up-to-date with code changes - Add new pages to appropriate `sidebars.ts` for navigation
- Add new pages to appropriate `sidebars.ts` for navigation
## 🤝 Contributing ## 🤝 Contributing

View file

@ -5,7 +5,6 @@ Guidelines for maintaining and improving integration performance.
## Performance Goals ## Performance Goals
Target metrics: Target metrics:
- **Coordinator update**: &lt;500ms (typical: 200-300ms) - **Coordinator update**: &lt;500ms (typical: 200-300ms)
- **Sensor update**: &lt;10ms per sensor - **Sensor update**: &lt;10ms per sensor
- **Period calculation**: &lt;100ms (typical: 20-50ms) - **Period calculation**: &lt;100ms (typical: 20-50ms)
@ -65,7 +64,6 @@ python -m aioprof homeassistant -c config
### Caching ### Caching
**1. Persistent Cache** (API data): **1. Persistent Cache** (API data):
```python ```python
# Already implemented in coordinator/cache.py # Already implemented in coordinator/cache.py
store = Store(hass, STORAGE_VERSION, STORAGE_KEY) store = Store(hass, STORAGE_VERSION, STORAGE_KEY)
@ -73,7 +71,6 @@ data = await store.async_load()
``` ```
**2. Translation Cache** (in-memory): **2. Translation Cache** (in-memory):
```python ```python
# Already implemented in const.py # Already implemented in const.py
_TRANSLATION_CACHE: dict[str, dict] = {} _TRANSLATION_CACHE: dict[str, dict] = {}
@ -86,7 +83,6 @@ def get_translation(path: str, language: str) -> dict:
``` ```
**3. Config Cache** (invalidated on options change): **3. Config Cache** (invalidated on options change):
```python ```python
class DataTransformer: class DataTransformer:
def __init__(self): def __init__(self):
@ -104,7 +100,6 @@ class DataTransformer:
### Lazy Loading ### Lazy Loading
**Load data only when needed:** **Load data only when needed:**
```python ```python
@property @property
def extra_state_attributes(self) -> dict | None: def extra_state_attributes(self) -> dict | None:
@ -118,7 +113,6 @@ def extra_state_attributes(self) -> dict | None:
### Bulk Operations ### Bulk Operations
**Process multiple items at once:** **Process multiple items at once:**
```python ```python
# ❌ Slow - loop with individual operations # ❌ Slow - loop with individual operations
for interval in intervals: for interval in intervals:
@ -132,7 +126,6 @@ results = enrich_intervals_bulk(intervals)
### Async Best Practices ### Async Best Practices
**1. Concurrent API calls:** **1. Concurrent API calls:**
```python ```python
# ❌ Sequential (slow) # ❌ Sequential (slow)
user_data = await fetch_user_data() user_data = await fetch_user_data()
@ -146,7 +139,6 @@ user_data, price_data = await asyncio.gather(
``` ```
**2. Don't block event loop:** **2. Don't block event loop:**
```python ```python
# ❌ Blocking # ❌ Blocking
result = heavy_computation() # Blocks for seconds result = heavy_computation() # Blocks for seconds
@ -160,7 +152,6 @@ result = await hass.async_add_executor_job(heavy_computation)
### Avoid Memory Leaks ### Avoid Memory Leaks
**1. Clear references:** **1. Clear references:**
```python ```python
class Coordinator: class Coordinator:
async def async_shutdown(self): async def async_shutdown(self):
@ -171,7 +162,6 @@ class Coordinator:
``` ```
**2. Use weak references for callbacks:** **2. Use weak references for callbacks:**
```python ```python
import weakref import weakref
@ -186,7 +176,6 @@ class Manager:
### Efficient Data Structures ### Efficient Data Structures
**Use appropriate types:** **Use appropriate types:**
```python ```python
# ❌ List for lookups (O(n)) # ❌ List for lookups (O(n))
if timestamp in timestamp_list: if timestamp in timestamp_list:
@ -208,13 +197,11 @@ results = (x for x in items if condition(x))
### Minimize API Calls ### Minimize API Calls
**Already implemented:** **Already implemented:**
- Cache valid until midnight - Cache valid until midnight
- User data cached for 24h - User data cached for 24h
- Only poll when tomorrow data expected - Only poll when tomorrow data expected
**Monitor API usage:** **Monitor API usage:**
```python ```python
_LOGGER.debug("API call: %s (cache_age=%s)", _LOGGER.debug("API call: %s (cache_age=%s)",
endpoint, cache_age) endpoint, cache_age)
@ -223,7 +210,6 @@ _LOGGER.debug("API call: %s (cache_age=%s)",
### Smart Updates ### Smart Updates
**Only update when needed:** **Only update when needed:**
```python ```python
async def _async_update_data(self) -> dict: async def _async_update_data(self) -> dict:
"""Fetch data from API.""" """Fetch data from API."""
@ -240,7 +226,6 @@ async def _async_update_data(self) -> dict:
### State Class Selection ### State Class Selection
**Affects long-term statistics storage:** **Affects long-term statistics storage:**
```python ```python
# ❌ MEASUREMENT for prices (stores every change) # ❌ MEASUREMENT for prices (stores every change)
state_class=SensorStateClass.MEASUREMENT # ~35K records/year state_class=SensorStateClass.MEASUREMENT # ~35K records/year
@ -255,7 +240,6 @@ state_class=SensorStateClass.TOTAL # For cumulative values
### Attribute Size ### Attribute Size
**Keep attributes minimal:** **Keep attributes minimal:**
```python ```python
# ❌ Large nested structures (KB per update) # ❌ Large nested structures (KB per update)
attributes = { attributes = {
@ -333,7 +317,6 @@ _LOGGER.debug("Current memory usage: %.2f MB", memory_mb)
--- ---
💡 **Related:** 💡 **Related:**
- [Caching Strategy](caching-strategy.md) - Cache layers - [Caching Strategy](caching-strategy.md) - Cache layers
- [Architecture](architecture.md) - System design - [Architecture](architecture.md) - System design
- [Debugging](debugging.md) - Profiling tools - [Debugging](debugging.md) - Profiling tools

File diff suppressed because it is too large Load diff

View file

@ -29,7 +29,6 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
``` ```
**Key Points:** **Key Points:**
- Must be a **class attribute** (not instance attribute) - Must be a **class attribute** (not instance attribute)
- Use `frozenset` for immutability and performance - Use `frozenset` for immutability and performance
- Applied automatically by Home Assistant's Recorder component - Applied automatically by Home Assistant's Recorder component
@ -41,7 +40,6 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
**Attributes:** `description`, `usage_tips` **Attributes:** `description`, `usage_tips`
**Reason:** Static, large text strings (100-500 chars each) that: **Reason:** Static, large text strings (100-500 chars each) that:
- Never change or change very rarely - Never change or change very rarely
- Don't provide analytical value in history - Don't provide analytical value in history
- Consume significant database space when recorded every state change - Consume significant database space when recorded every state change
@ -52,7 +50,6 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
### 2. Large Nested Structures ### 2. Large Nested Structures
**Attributes:** **Attributes:**
- `periods` (binary_sensor) - Array of all period summaries - `periods` (binary_sensor) - Array of all period summaries
- `data` (chart_data_export) - Complete price data arrays - `data` (chart_data_export) - Complete price data arrays
- `trend_attributes` - Detailed trend analysis - `trend_attributes` - Detailed trend analysis
@ -61,7 +58,6 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
- `volatility_attributes` - Detailed volatility breakdown - `volatility_attributes` - Detailed volatility breakdown
**Reason:** Complex nested data structures that are: **Reason:** Complex nested data structures that are:
- Serialized to JSON for storage (expensive) - Serialized to JSON for storage (expensive)
- Create large database rows (2-20 KB each) - Create large database rows (2-20 KB each)
- Slow down history queries - Slow down history queries
@ -70,21 +66,20 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
**Impact:** ~10-30 KB saved per state change for affected sensors **Impact:** ~10-30 KB saved per state change for affected sensors
**Example - periods array:** **Example - periods array:**
```json ```json
{ {
"periods": [ "periods": [
{ {
"start": "2025-12-07T06:00:00+01:00", "start": "2025-12-07T06:00:00+01:00",
"end": "2025-12-07T08:00:00+01:00", "end": "2025-12-07T08:00:00+01:00",
"duration_minutes": 120, "duration_minutes": 120,
"price_mean": 18.5, "price_mean": 18.5,
"price_median": 18.3, "price_median": 18.3,
"price_min": 17.2, "price_min": 17.2,
"price_max": 19.8 "price_max": 19.8,
// ... 10+ more attributes × 10-20 periods // ... 10+ more attributes × 10-20 periods
} }
] ]
} }
``` ```
@ -93,7 +88,6 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
**Attributes:** `icon_color`, `cache_age`, `cache_validity`, `data_completeness`, `data_status` **Attributes:** `icon_color`, `cache_age`, `cache_validity`, `data_completeness`, `data_status`
**Reason:** **Reason:**
- Change every update cycle (every 15 minutes or more frequently) - Change every update cycle (every 15 minutes or more frequently)
- Don't provide long-term analytical value - Don't provide long-term analytical value
- Create state changes even when core values haven't changed - Create state changes even when core values haven't changed
@ -109,7 +103,6 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
**Attributes:** `tomorrow_expected_after`, `level_value`, `rating_value`, `level_id`, `rating_id`, `currency`, `resolution`, `yaxis_min`, `yaxis_max` **Attributes:** `tomorrow_expected_after`, `level_value`, `rating_value`, `level_id`, `rating_id`, `currency`, `resolution`, `yaxis_min`, `yaxis_max`
**Reason:** **Reason:**
- Configuration values that rarely change - Configuration values that rarely change
- Wastes space when recorded repeatedly - Wastes space when recorded repeatedly
- Can be derived from other attributes or from entity state - Can be derived from other attributes or from entity state
@ -121,7 +114,6 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
**Attributes:** `timestamp`, `next_api_poll`, `next_midnight_turnover`, `last_api_fetch`, `last_cache_update`, `last_turnover`, `last_error`, `error` **Attributes:** `timestamp`, `next_api_poll`, `next_midnight_turnover`, `last_api_fetch`, `last_cache_update`, `last_turnover`, `last_error`, `error`
**Reason:** **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 - `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 - `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 - Similar to `entity_picture` in HA core image entities
@ -137,7 +129,6 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
**Attributes:** `relaxation_level`, `relaxation_threshold_original_%`, `relaxation_threshold_applied_%` **Attributes:** `relaxation_level`, `relaxation_threshold_original_%`, `relaxation_threshold_applied_%`
**Reason:** **Reason:**
- Detailed technical information not needed for historical analysis - Detailed technical information not needed for historical analysis
- Only useful for debugging during active development - Only useful for debugging during active development
- Boolean `relaxation_active` is kept for high-level analysis - Boolean `relaxation_active` is kept for high-level analysis
@ -146,45 +137,39 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
### 7. Redundant/Derived Data ### 7. Redundant/Derived Data
**Attributes:** `price_spread`, `volatility`, `diff_%`, `rating_difference_%`, `period_price_diff_from_daily_min`, `period_price_diff_from_daily_min_%`, `period_count_total`, `period_count_remaining` **Attributes:** `price_spread`, `volatility`, `diff_%`, `rating_difference_%`, `period_price_diff_from_daily_min`, `period_price_diff_from_daily_min_%`, `period_count_total`, `periods_remaining`
**Reason:** **Reason:**
- Can be calculated from other attributes - Can be calculated from other attributes
- Redundant information - Redundant information
- Doesn't add analytical value to history - Doesn't add analytical value to history
**Impact:** ~100-200 bytes saved per state change **Impact:** ~100-200 bytes saved per state change
**Example:** `price_spread = price_max - price_min` (both are recorded, so spread can be calculated). `period_count_remaining = period_count_total - period_position` (both components are recorded). **Example:** `price_spread = price_max - price_min` (both are recorded, so spread can be calculated). `periods_remaining = period_count_total - period_position` (both components are recorded).
## Attributes That ARE Recorded ## Attributes That ARE Recorded
These attributes **remain in history** because they provide essential analytical value: These attributes **remain in history** because they provide essential analytical value:
### Time-Series Core ### Time-Series Core
- All price values - Core sensor states (the entity's `native_value` is always recorded separately) - All price values - Core sensor states (the entity's `native_value` is always recorded separately)
### Diagnostics & Tracking ### Diagnostics & Tracking
- `cache_age_minutes` - Numeric value for diagnostics tracking over time - `cache_age_minutes` - Numeric value for diagnostics tracking over time
- `updates_today` - Tracking API usage patterns - `updates_today` - Tracking API usage patterns
### Data Completeness ### Data Completeness
- `interval_count`, `intervals_available` - Data completeness metrics - `interval_count`, `intervals_available` - Data completeness metrics
- `yesterday_available`, `today_available`, `tomorrow_available` - Boolean status - `yesterday_available`, `today_available`, `tomorrow_available` - Boolean status
### Period Data ### Period Data
- `start`, `end`, `duration_minutes` - Core period timing - `start`, `end`, `duration_minutes` - Core period timing
- `price_mean`, `price_median`, `price_min`, `price_max` - Core price statistics - `price_mean`, `price_median`, `price_min`, `price_max` - Core price statistics
- `period_position` - Position of current period in the day's sequence - `period_position` - Position of current period in the day's sequence
- `period_count_today`, `period_count_tomorrow` - How many periods per day (useful in automations) - `period_count_today`, `period_count_tomorrow` - How many periods per day (useful in automations)
### High-Level Status ### High-Level Status
- `relaxation_active` - Whether relaxation was used (boolean, useful for analyzing when periods needed relaxation) - `relaxation_active` - Whether relaxation was used (boolean, useful for analyzing when periods needed relaxation)
## Expected Database Impact ## Expected Database Impact
@ -192,7 +177,6 @@ These attributes **remain in history** because they provide essential analytical
### Space Savings ### Space Savings
**Per state change:** **Per state change:**
- Before: ~3-8 KB average - Before: ~3-8 KB average
- After: ~0.5-1.5 KB average - After: ~0.5-1.5 KB average
- **Reduction: 60-85%** - **Reduction: 60-85%**
@ -214,7 +198,6 @@ These attributes **remain in history** because they provide essential analytical
### Real-World Impact ### Real-World Impact
For a typical installation with: For a typical installation with:
- 80+ sensors - 80+ sensors
- Updates every 15 minutes - Updates every 15 minutes
- ~10 sensors updating every minute - ~10 sensors updating every minute
@ -226,14 +209,14 @@ For a typical installation with:
## Implementation Files ## Implementation Files
- **Sensor Platform**: `custom_components/tibber_prices/sensor/core.py` - **Sensor Platform**: `custom_components/tibber_prices/sensor/core.py`
- Class: `TibberPricesSensor` - Class: `TibberPricesSensor`
- 46 attributes excluded - 46 attributes excluded
- **Binary Sensor Platform**: `custom_components/tibber_prices/binary_sensor/core.py` - **Binary Sensor Platform**: `custom_components/tibber_prices/binary_sensor/core.py`
- Class: `TibberPricesBinarySensor` - Class: `TibberPricesBinarySensor`
- 29 attributes excluded - 29 attributes excluded
## When to Update \_unrecorded_attributes ## When to Update _unrecorded_attributes
### Add to Exclusion List When: ### Add to Exclusion List When:
@ -255,24 +238,24 @@ For a typical installation with:
When adding a new attribute, ask: When adding a new attribute, ask:
1. **Will this be useful in history queries 1 week from now?** 1. **Will this be useful in history queries 1 week from now?**
- No → Exclude - No → Exclude
- Yes → Keep - Yes → Keep
2. **Can this be calculated from other recorded attributes?** 2. **Can this be calculated from other recorded attributes?**
- Yes → Exclude - Yes → Exclude
- No → Keep - No → Keep
3. **Is this primarily for current UI display?** 3. **Is this primarily for current UI display?**
- Yes → Exclude - Yes → Exclude
- No → Keep - No → Keep
4. **Does this change frequently without indicating state change?** 4. **Does this change frequently without indicating state change?**
- Yes → Exclude - Yes → Exclude
- No → Keep - No → Keep
5. **Is this larger than 100 bytes and not essential for analysis?** 5. **Is this larger than 100 bytes and not essential for analysis?**
- Yes → Exclude - Yes → Exclude
- No → Keep - No → Keep
## Testing ## Testing
@ -284,7 +267,6 @@ After modifying `_unrecorded_attributes`:
4. **Confirm excluded attributes** don't appear in new state writes 4. **Confirm excluded attributes** don't appear in new state writes
**SQL Query to check attribute presence:** **SQL Query to check attribute presence:**
```sql ```sql
SELECT SELECT
state_id, state_id,
@ -318,11 +300,11 @@ 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: For sensors with `device_class=SensorDeviceClass.MONETARY`, only two `state_class` values are valid:
| `state_class` | Statistics written | Frontend effect | | `state_class` | Statistics written | Frontend effect |
| ------------- | ------------------------- | ------------------------------------------------- | |---|---|---|
| `TOTAL` | ✅ Yes — unbounded growth | Statistics line-chart on entity detail page | | `TOTAL` | ✅ Yes — unbounded growth | Statistics line-chart on entity detail page |
| `None` | ❌ No | States timeline only (History panel, "Show More") | | `None` | ❌ No | States timeline only (History panel, "Show More") |
| `MEASUREMENT` | ❌ Blocked by hassfest | — | | `MEASUREMENT` | ❌ Blocked by hassfest | — |
`MEASUREMENT` causes a hassfest validation error for MONETARY sensors, leaving only `TOTAL` or `None`. `MEASUREMENT` causes a hassfest validation error for MONETARY sensors, leaving only `TOTAL` or `None`.
@ -330,21 +312,19 @@ 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: Only 3 of 26 MONETARY sensors keep `state_class=TOTAL` — those where long-term history is genuinely useful:
| Sensor | Reason | | Sensor | Reason |
| ----------------------------- | ------------------------------------ | |---|---|
| `current_interval_price` | Long-term price trend (weeks/months) | | `current_interval_price` | Long-term price trend (weeks/months) |
| `current_interval_price_base` | Required for Energy Dashboard | | `current_interval_price_base` | Required for Energy Dashboard |
| `average_price_today` | Seasonal daily average tracking | | `average_price_today` | Seasonal daily average tracking |
All other 23 MONETARY sensors use `state_class=None`: All other 23 MONETARY sensors use `state_class=None`:
- Forecast/future sensors (`next_avg_*h`) - Forecast/future sensors (`next_avg_*h`)
- Daily snapshots (`lowest/highest_price_today/tomorrow`) - Daily snapshots (`lowest/highest_price_today/tomorrow`)
- Rolling windows (`trailing/leading_24h_*`) - Rolling windows (`trailing/leading_24h_*`)
- Next/previous interval sensors - Next/previous interval sensors
**Effect of `state_class=None`:** **Effect of `state_class=None`:**
- ✅ Short-term state history (States timeline, ~10 days) still works normally - ✅ Short-term state history (States timeline, ~10 days) still works normally
- ✅ Templates, automations, and attributes are unaffected - ✅ Templates, automations, and attributes are unaffected
- ❌ Statistics line-chart removed from entity detail page for these sensors - ❌ Statistics line-chart removed from entity detail page for these sensors
@ -353,7 +333,6 @@ All other 23 MONETARY sensors use `state_class=None`:
### Expected Impact ### Expected Impact
Going from 26 → 3 sensors writing to the statistics tables: Going from 26 → 3 sensors writing to the statistics tables:
- **~88% reduction** in statistics table writes - **~88% reduction** in statistics table writes
- Prevents the primary cause of long-term database bloat - Prevents the primary cause of long-term database bloat
- Existing statistics data is retained (only new writes stop) - Existing statistics data is retained (only new writes stop)
@ -362,10 +341,10 @@ Going from 26 → 3 sensors writing to the statistics tables:
These are two independent mechanisms targeting different tables: These are two independent mechanisms targeting different tables:
| Mechanism | Table affected | Purged? | Controls | | Mechanism | Table affected | Purged? | Controls |
| ------------------------ | ------------------------------------- | ----------- | ----------------------------------------------- | |---|---|---|---|
| `_unrecorded_attributes` | `state_attributes` | ✅ ~10 days | Which attributes are stored per state write | | `_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 | | `state_class=None` | `statistics`, `statistics_short_term` | ❌ Never | Whether long-term statistics are written at all |
Both optimizations work together. `_unrecorded_attributes` reduces the size of each state write; `state_class=None` eliminates an entire category of unbounded writes. Both optimizations work together. `_unrecorded_attributes` reduces the size of each state write; `state_class=None` eliminates an entire category of unbounded writes.

View file

@ -8,22 +8,22 @@ Not every code change needs a detailed plan. Create a refactoring plan when:
🔴 **Major changes requiring planning:** 🔴 **Major changes requiring planning:**
- Splitting modules into packages (>5 files affected, >500 lines moved) - Splitting modules into packages (>5 files affected, >500 lines moved)
- Architectural changes (new packages, module restructuring) - Architectural changes (new packages, module restructuring)
- Breaking changes (API changes, config format migrations) - Breaking changes (API changes, config format migrations)
🟡 **Medium changes that might benefit from planning:** 🟡 **Medium changes that might benefit from planning:**
- Complex features with multiple moving parts - Complex features with multiple moving parts
- Changes affecting many files (>3 files, unclear best approach) - Changes affecting many files (>3 files, unclear best approach)
- Refactorings with unclear scope - Refactorings with unclear scope
🟢 **Small changes - no planning needed:** 🟢 **Small changes - no planning needed:**
- Bug fixes (straightforward, `<`100 lines) - Bug fixes (straightforward, `<`100 lines)
- Small features (`<`3 files, clear approach) - Small features (`<`3 files, clear approach)
- Documentation updates - Documentation updates
- Cosmetic changes (formatting, renaming) - Cosmetic changes (formatting, renaming)
## The Planning Process ## The Planning Process
@ -51,34 +51,34 @@ Every planning document should include:
## Problem Statement ## Problem Statement
- What's the issue? - What's the issue?
- Why does it need fixing? - Why does it need fixing?
- Current pain points - Current pain points
## Proposed Solution ## Proposed Solution
- High-level approach - High-level approach
- File structure (before/after) - File structure (before/after)
- Module responsibilities - Module responsibilities
## Migration Strategy ## Migration Strategy
- Phase-by-phase breakdown - Phase-by-phase breakdown
- File lifecycle (CREATE/MODIFY/DELETE/RENAME) - File lifecycle (CREATE/MODIFY/DELETE/RENAME)
- Dependencies between phases - Dependencies between phases
- Testing checkpoints - Testing checkpoints
## Risks & Mitigation ## Risks & Mitigation
- What could go wrong? - What could go wrong?
- How to prevent it? - How to prevent it?
- Rollback strategy - Rollback strategy
## Success Criteria ## Success Criteria
- Measurable improvements - Measurable improvements
- Testing requirements - Testing requirements
- Verification steps - Verification steps
``` ```
See `planning/README.md` for detailed template explanation. See `planning/README.md` for detailed template explanation.
@ -87,19 +87,19 @@ See `planning/README.md` for detailed template explanation.
Since `planning/` is git-ignored: Since `planning/` is git-ignored:
- Draft multiple versions - Draft multiple versions
- Get AI assistance without commit pressure - Get AI assistance without commit pressure
- Refine until the plan is solid - Refine until the plan is solid
- No need to clean up intermediate versions - No need to clean up intermediate versions
### 4. Implementation Phase ### 4. Implementation Phase
Once plan is approved: Once plan is approved:
- Follow the phases defined in the plan - Follow the phases defined in the plan
- Test after each phase (don't skip!) - Test after each phase (don't skip!)
- Update plan if issues discovered - Update plan if issues discovered
- Track progress through phase status - Track progress through phase status
### 5. After Completion ### 5. After Completion
@ -134,13 +134,13 @@ The **sensor/ package refactoring** (Nov 2025) is a successful example:
**Before:** **Before:**
- `sensor.py` - 2,574 lines, hard to navigate - `sensor.py` - 2,574 lines, hard to navigate
**After:** **After:**
- `sensor/` package with 5 focused modules - `sensor/` package with 5 focused modules
- Each module `<`800 lines - Each module `<`800 lines
- Clear separation of concerns - Clear separation of concerns
**Process:** **Process:**
@ -153,10 +153,10 @@ The **sensor/ package refactoring** (Nov 2025) is a successful example:
**Key learnings:** **Key learnings:**
- Temporary `_impl.py` files avoid Python package conflicts - Temporary `_impl.py` files avoid Python package conflicts
- Test after EVERY phase (don't accumulate changes) - Test after EVERY phase (don't accumulate changes)
- Clear file lifecycle (CREATE/MODIFY/DELETE/RENAME) - Clear file lifecycle (CREATE/MODIFY/DELETE/RENAME)
- Phase-by-phase approach enables safe rollback - Phase-by-phase approach enables safe rollback
**Note:** The complete module splitting plan was documented during implementation but has been superseded by the actual code structure. **Note:** The complete module splitting plan was documented during implementation but has been superseded by the actual code structure.
@ -166,11 +166,11 @@ The **sensor/ package refactoring** (Nov 2025) is a successful example:
Breaking refactorings into phases: Breaking refactorings into phases:
- ✅ Enables testing after each change (catch bugs early) - ✅ Enables testing after each change (catch bugs early)
- ✅ Allows rollback to last good state - ✅ Allows rollback to last good state
- ✅ Makes progress visible - ✅ Makes progress visible
- ✅ Reduces cognitive load (focus on one thing) - ✅ Reduces cognitive load (focus on one thing)
- ❌ Takes more time (but worth it!) - ❌ Takes more time (but worth it!)
### Phase Structure ### Phase Structure
@ -191,8 +191,8 @@ Each phase should:
**File Lifecycle**: **File Lifecycle**:
- ✨ CREATE `sensor/helpers.py` (utility functions) - ✨ CREATE `sensor/helpers.py` (utility functions)
- ✏️ MODIFY `sensor/core.py` (import from helpers.py) - ✏️ MODIFY `sensor/core.py` (import from helpers.py)
**Steps**: **Steps**:
@ -205,10 +205,10 @@ Each phase should:
**Success criteria**: **Success criteria**:
- ✅ All pure functions moved - ✅ All pure functions moved
- ✅ `./scripts/lint-check` passes - `./scripts/lint-check` passes
- ✅ HA starts successfully - ✅ HA starts successfully
- ✅ All entities work correctly - ✅ All entities work correctly
``` ```
## Testing Strategy ## Testing Strategy
@ -238,13 +238,13 @@ Minimum testing checklist:
After completing all phases: After completing all phases:
- Test all entities (sensors, binary sensors) - Test all entities (sensors, binary sensors)
- Test configuration flow (add/modify/remove) - Test configuration flow (add/modify/remove)
- Test options flow (change settings) - Test options flow (change settings)
- Test services (custom service calls) - Test services (custom service calls)
- Test error handling (disconnect API, invalid data) - Test error handling (disconnect API, invalid data)
- Test caching (restart HA, verify cache loads) - Test caching (restart HA, verify cache loads)
- Test time-based updates (quarter-hour refresh) - Test time-based updates (quarter-hour refresh)
## Common Pitfalls ## Common Pitfalls
@ -286,21 +286,21 @@ This project uses AI heavily (GitHub Copilot, Claude). The planning process supp
**AI reads from:** **AI reads from:**
- `AGENTS.md` - Long-term memory, patterns, conventions (AI-focused) - `AGENTS.md` - Long-term memory, patterns, conventions (AI-focused)
- `docs/development/` - Human-readable guides (human-focused) - `docs/development/` - Human-readable guides (human-focused)
- `planning/` - Active refactoring plans (shared context) - `planning/` - Active refactoring plans (shared context)
**AI updates:** **AI updates:**
- `AGENTS.md` - When patterns change - `AGENTS.md` - When patterns change
- `planning/*.md` - During refactoring implementation - `planning/*.md` - During refactoring implementation
- `docs/development/` - After successful completion - `docs/development/` - After successful completion
**Why separate AGENTS.md and docs/development/?** **Why separate AGENTS.md and docs/development/?**
- `AGENTS.md`: Technical, comprehensive, AI-optimized - `AGENTS.md`: Technical, comprehensive, AI-optimized
- `docs/development/`: Practical, focused, human-optimized - `docs/development/`: Practical, focused, human-optimized
- Both stay in sync but serve different audiences - Both stay in sync but serve different audiences
See [AGENTS.md](https://github.com/jpawlowski/hass.tibber_prices/blob/main/AGENTS.md) section "Planning Major Refactorings" for AI-specific guidance. See [AGENTS.md](https://github.com/jpawlowski/hass.tibber_prices/blob/main/AGENTS.md) section "Planning Major Refactorings" for AI-specific guidance.
@ -308,16 +308,16 @@ See [AGENTS.md](https://github.com/jpawlowski/hass.tibber_prices/blob/main/AGENT
### Planning Directory ### Planning Directory
- `planning/` - Git-ignored workspace for drafts - `planning/` - Git-ignored workspace for drafts
- `planning/README.md` - Detailed planning documentation - `planning/README.md` - Detailed planning documentation
- `planning/*.md` - Active refactoring plans - `planning/*.md` - Active refactoring plans
### Example Plans ### Example Plans
- `docs/development/module-splitting-plan.md` - ✅ Completed, archived - `docs/development/module-splitting-plan.md` - ✅ Completed, archived
- `planning/config-flow-refactoring-plan.md` - 🔄 Planned (1013 lines → 4 modules) - `planning/config-flow-refactoring-plan.md` - 🔄 Planned (1013 lines → 4 modules)
- `planning/binary-sensor-refactoring-plan.md` - 🔄 Planned (644 lines → 4 modules) - `planning/binary-sensor-refactoring-plan.md` - 🔄 Planned (644 lines → 4 modules)
- `planning/coordinator-refactoring-plan.md` - 🔄 Planned (1446 lines, high complexity) - `planning/coordinator-refactoring-plan.md` - 🔄 Planned (1446 lines, high complexity)
### Helper Scripts ### Helper Scripts
@ -341,21 +341,21 @@ Simple rule: If you can't describe the entire change in 3 sentences, create a pl
Good plan level: Good plan level:
- Lists all files affected (CREATE/MODIFY/DELETE) - Lists all files affected (CREATE/MODIFY/DELETE)
- Defines phases with clear boundaries - Defines phases with clear boundaries
- Includes testing strategy - Includes testing strategy
- Estimates time per phase - Estimates time per phase
Too detailed: Too detailed:
- Exact code snippets for every change - Exact code snippets for every change
- Line-by-line instructions - Line-by-line instructions
Too vague: Too vague:
- "Refactor sensor.py to be better" - "Refactor sensor.py to be better"
- No phase breakdown - No phase breakdown
- No testing strategy - No testing strategy
### Q: What if the plan changes during implementation? ### Q: What if the plan changes during implementation?
@ -363,9 +363,9 @@ Too vague:
If you discover: If you discover:
- Better approach → Update "Proposed Solution" - Better approach → Update "Proposed Solution"
- More phases needed → Add to "Migration Strategy" - More phases needed → Add to "Migration Strategy"
- New risks → Update "Risks & Mitigation" - New risks → Update "Risks & Mitigation"
Document WHY the plan changed (helps future refactorings). Document WHY the plan changed (helps future refactorings).
@ -373,9 +373,9 @@ Document WHY the plan changed (helps future refactorings).
**A:** No! Use judgment: **A:** No! Use judgment:
- **Small changes (`<`100 lines, clear approach)**: Just do it, no plan needed - **Small changes (`<`100 lines, clear approach)**: Just do it, no plan needed
- **Medium changes (unclear scope)**: Write rough outline, refine if needed - **Medium changes (unclear scope)**: Write rough outline, refine if needed
- **Large changes (>500 lines, >5 files)**: Full planning process - **Large changes (>500 lines, >5 files)**: Full planning process
### Q: How do I know when a refactoring is successful? ### Q: How do I know when a refactoring is successful?
@ -383,12 +383,12 @@ Document WHY the plan changed (helps future refactorings).
Typical criteria: Typical criteria:
- ✅ All linting checks pass - ✅ All linting checks pass
- ✅ HA starts without errors - ✅ HA starts without errors
- ✅ All entities functional - ✅ All entities functional
- ✅ No regressions (existing features work) - ✅ No regressions (existing features work)
- ✅ Code easier to understand/modify - ✅ Code easier to understand/modify
- ✅ Documentation updated - ✅ Documentation updated
If you can't tick all boxes, the refactoring isn't done. If you can't tick all boxes, the refactoring isn't done.
@ -409,6 +409,6 @@ If you can't tick all boxes, the refactoring isn't done.
**Next steps:** **Next steps:**
- Read `planning/README.md` for detailed template - Read `planning/README.md` for detailed template
- Check `docs/development/module-splitting-plan.md` for real example - Check `docs/development/module-splitting-plan.md` for real example
- Browse `planning/` for active refactoring plans - Browse `planning/` for active refactoring plans

View file

@ -112,7 +112,6 @@ In CI/CD (`$CI` or `$GITHUB_ACTIONS`), AI is automatically disabled.
**In DevContainer (automatic):** **In DevContainer (automatic):**
git-cliff is automatically installed when the DevContainer is built: git-cliff is automatically installed when the DevContainer is built:
- **Rust toolchain**: Installed via `ghcr.io/devcontainers/features/rust:1` (minimal profile) - **Rust toolchain**: Installed via `ghcr.io/devcontainers/features/rust:1` (minimal profile)
- **git-cliff**: Installed via cargo in `scripts/setup/setup` - **git-cliff**: Installed via cargo in `scripts/setup/setup`
@ -121,7 +120,6 @@ Simply rebuild the container (VS Code: "Dev Containers: Rebuild Container") and
**Manual installation (outside DevContainer):** **Manual installation (outside DevContainer):**
**git-cliff** (template-based): **git-cliff** (template-based):
```bash ```bash
# See: https://git-cliff.org/docs/installation # See: https://git-cliff.org/docs/installation
@ -192,13 +190,13 @@ All methods produce GitHub-flavored Markdown with emoji categories:
## 🎯 When to Use Which ## 🎯 When to Use Which
| Method | Use Case | Pros | Cons | | Method | Use Case | Pros | Cons |
| --------------------- | --------------------- | ----------------------------- | ------------------------ | |--------|----------|------|------|
| **Helper Script** | Normal releases | Foolproof, automatic | Requires script | | **Helper Script** | Normal releases | Foolproof, automatic | Requires script |
| **Auto-Tag Workflow** | Forgot script | Safety net, automatic tagging | Still need manifest bump | | **Auto-Tag Workflow** | Forgot script | Safety net, automatic tagging | Still need manifest bump |
| **GitHub Button** | Manual quick release | Easy, no script | Limited categorization | | **GitHub Button** | Manual quick release | Easy, no script | Limited categorization |
| **Local Script** | Testing release notes | Preview before release | Manual process | | **Local Script** | Testing release notes | Preview before release | Manual process |
| **CI/CD** | After tag push | Fully automatic | Needs tag first | | **CI/CD** | After tag push | Fully automatic | Needs tag first |
--- ---
@ -221,7 +219,6 @@ git push origin main v0.3.0
``` ```
**What happens:** **What happens:**
1. Script bumps manifest.json → commits → creates tag locally 1. Script bumps manifest.json → commits → creates tag locally
2. You push commit + tag together 2. You push commit + tag together
3. Release workflow sees tag → generates notes → creates release 3. Release workflow sees tag → generates notes → creates release
@ -245,7 +242,6 @@ git push
``` ```
**What happens:** **What happens:**
1. You push manifest.json change 1. You push manifest.json change
2. Auto-Tag workflow detects change → creates tag automatically 2. Auto-Tag workflow detects change → creates tag automatically
3. Release workflow sees new tag → creates release 3. Release workflow sees new tag → creates release
@ -267,7 +263,6 @@ git push origin main v0.3.0
``` ```
**What happens:** **What happens:**
1. You create and push tag manually 1. You create and push tag manually
2. Release workflow creates release 2. Release workflow creates release
3. Auto-Tag workflow skips (tag already exists) 3. Auto-Tag workflow skips (tag already exists)
@ -287,24 +282,19 @@ git push origin main v0.3.0
## 🛡️ Safety Features ## 🛡️ Safety Features
### 1. **Version Validation** ### 1. **Version Validation**
Both helper script and auto-tag workflow validate version format (X.Y.Z). Both helper script and auto-tag workflow validate version format (X.Y.Z).
### 2. **No Duplicate Tags** ### 2. **No Duplicate Tags**
- Helper script checks if tag exists (local + remote) - Helper script checks if tag exists (local + remote)
- Auto-tag workflow checks if tag exists before creating - Auto-tag workflow checks if tag exists before creating
### 3. **Atomic Operations** ### 3. **Atomic Operations**
Helper script creates commit + tag locally. You decide when to push. Helper script creates commit + tag locally. You decide when to push.
### 4. **Version Bumps Filtered** ### 4. **Version Bumps Filtered**
Release notes automatically exclude `chore(release): bump version` commits. Release notes automatically exclude `chore(release): bump version` commits.
### 5. **Rollback Instructions** ### 5. **Rollback Instructions**
Helper script shows how to undo if you change your mind. Helper script shows how to undo if you change your mind.
--- ---
@ -340,7 +330,6 @@ git push -f origin main v0.3.0
**Auto-tag didn't create tag:** **Auto-tag didn't create tag:**
Check workflow runs in GitHub Actions. Common causes: Check workflow runs in GitHub Actions. Common causes:
- Tag already exists remotely - Tag already exists remotely
- Invalid version format in manifest.json - Invalid version format in manifest.json
- manifest.json not in the commit that was pushed - manifest.json not in the commit that was pushed
@ -359,14 +348,13 @@ Check workflow runs in GitHub Actions. Common causes:
## 💡 Tips ## 💡 Tips
1. **Conventional Commits:** Use proper commit format for best results: 1. **Conventional Commits:** Use proper commit format for best results:
```
feat(scope): Add new feature
``` Detailed description of what changed.
feat(scope): Add new feature
Detailed description of what changed. Impact: Users can now do X and Y.
```
Impact: Users can now do X and Y.
```
2. **Impact Section:** Add `Impact:` in commit body for user-friendly descriptions 2. **Impact Section:** Add `Impact:` in commit body for user-friendly descriptions

View file

@ -7,7 +7,6 @@ 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. 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:** **Design Principles:**
- **Proactive**: Detect issues before they become critical - **Proactive**: Detect issues before they become critical
- **User-friendly**: Clear explanations with actionable guidance - **User-friendly**: Clear explanations with actionable guidance
- **Auto-clearing**: Repairs automatically disappear when conditions resolve - **Auto-clearing**: Repairs automatically disappear when conditions resolve
@ -20,12 +19,10 @@ The repairs system is implemented in `coordinator/repairs.py` via the `TibberPri
**Issue ID:** `tomorrow_data_missing_{entry_id}` **Issue ID:** `tomorrow_data_missing_{entry_id}`
**When triggered:** **When triggered:**
- Current time is after 18:00 (configurable via `TOMORROW_DATA_WARNING_HOUR`) - Current time is after 18:00 (configurable via `TOMORROW_DATA_WARNING_HOUR`)
- Tomorrow's electricity price data is still not available - Tomorrow's electricity price data is still not available
**When cleared:** **When cleared:**
- Tomorrow's data becomes available - Tomorrow's data becomes available
- Automatically checks on every successful API update - Automatically checks on every successful API update
@ -33,7 +30,6 @@ 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. Users cannot plan ahead for tomorrow's electricity usage optimization. Automations relying on tomorrow's prices will not work.
**Implementation:** **Implementation:**
```python ```python
# In coordinator update cycle # In coordinator update cycle
has_tomorrow_data = self._data_fetcher.has_tomorrow_data(result["priceInfo"]) has_tomorrow_data = self._data_fetcher.has_tomorrow_data(result["priceInfo"])
@ -44,7 +40,6 @@ await self._repair_manager.check_tomorrow_data_availability(
``` ```
**Translation placeholders:** **Translation placeholders:**
- `home_name`: Name of the affected home - `home_name`: Name of the affected home
- `warning_hour`: Hour after which warning appears (default: 18) - `warning_hour`: Hour after which warning appears (default: 18)
@ -53,12 +48,10 @@ await self._repair_manager.check_tomorrow_data_availability(
**Issue ID:** `rate_limit_exceeded_{entry_id}` **Issue ID:** `rate_limit_exceeded_{entry_id}`
**When triggered:** **When triggered:**
- Integration encounters 3 or more consecutive rate limit errors (HTTP 429) - Integration encounters 3 or more consecutive rate limit errors (HTTP 429)
- Threshold configurable via `RATE_LIMIT_WARNING_THRESHOLD` - Threshold configurable via `RATE_LIMIT_WARNING_THRESHOLD`
**When cleared:** **When cleared:**
- Successful API call completes (no rate limit error) - Successful API call completes (no rate limit error)
- Error counter resets to 0 - Error counter resets to 0
@ -66,7 +59,6 @@ await self._repair_manager.check_tomorrow_data_availability(
API requests are being throttled, causing stale data. Updates may be delayed until rate limit expires. API requests are being throttled, causing stale data. Updates may be delayed until rate limit expires.
**Implementation:** **Implementation:**
```python ```python
# In error handler # In error handler
is_rate_limit = ( is_rate_limit = (
@ -82,7 +74,6 @@ await self._repair_manager.clear_rate_limit_tracking()
``` ```
**Translation placeholders:** **Translation placeholders:**
- `home_name`: Name of the affected home - `home_name`: Name of the affected home
- `error_count`: Number of consecutive rate limit errors - `error_count`: Number of consecutive rate limit errors
@ -91,12 +82,10 @@ await self._repair_manager.clear_rate_limit_tracking()
**Issue ID:** `home_not_found_{entry_id}` **Issue ID:** `home_not_found_{entry_id}`
**When triggered:** **When triggered:**
- Home configured in this integration is no longer present in Tibber account - Home configured in this integration is no longer present in Tibber account
- Detected during user data refresh (daily check) - Detected during user data refresh (daily check)
**When cleared:** **When cleared:**
- Home reappears in Tibber account (unlikely - manual cleanup expected) - Home reappears in Tibber account (unlikely - manual cleanup expected)
- Integration entry is removed (shutdown cleanup) - Integration entry is removed (shutdown cleanup)
@ -104,7 +93,6 @@ 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. Integration cannot fetch data for a non-existent home. User must remove the config entry and re-add if needed.
**Implementation:** **Implementation:**
```python ```python
# After user data update # After user data update
home_exists = self._data_fetcher._check_home_exists(home_id) home_exists = self._data_fetcher._check_home_exists(home_id)
@ -115,7 +103,6 @@ else:
``` ```
**Translation placeholders:** **Translation placeholders:**
- `home_name`: Name of the missing home - `home_name`: Name of the missing home
- `entry_id`: Config entry ID for reference - `entry_id`: Config entry ID for reference
@ -166,7 +153,6 @@ Each repair type maintains internal state to avoid redundant operations:
### Lifecycle Integration ### Lifecycle Integration
**Coordinator Initialization:** **Coordinator Initialization:**
```python ```python
self._repair_manager = TibberPricesRepairManager( self._repair_manager = TibberPricesRepairManager(
hass=hass, hass=hass,
@ -176,7 +162,6 @@ self._repair_manager = TibberPricesRepairManager(
``` ```
**Update Cycle Integration:** **Update Cycle Integration:**
```python ```python
# Success path - check conditions # Success path - check conditions
if result and "priceInfo" in result: if result and "priceInfo" in result:
@ -193,7 +178,6 @@ if is_rate_limit:
``` ```
**Shutdown Cleanup:** **Shutdown Cleanup:**
```python ```python
async def async_shutdown(self) -> None: async def async_shutdown(self) -> None:
"""Shut down coordinator and clean up.""" """Shut down coordinator and clean up."""
@ -212,27 +196,24 @@ Repairs use Home Assistant's standard translation system. Translations are defin
- `/translations/sv.json` - `/translations/sv.json`
**Structure:** **Structure:**
```json ```json
{ {
"issues": { "issues": {
"tomorrow_data_missing": { "tomorrow_data_missing": {
"title": "Tomorrow's price data missing for {home_name}", "title": "Tomorrow's price data missing for {home_name}",
"description": "Detailed explanation with multiple paragraphs...\n\nPossible causes:\n- Cause 1\n- Cause 2" "description": "Detailed explanation with multiple paragraphs...\n\nPossible causes:\n- Cause 1\n- Cause 2"
}
} }
}
} }
``` ```
## Home Assistant Integration ## Home Assistant Integration
Repairs appear in: Repairs appear in:
- **Settings → System → Repairs** (main repairs panel) - **Settings → System → Repairs** (main repairs panel)
- **Notifications** (bell icon in UI shows repair count) - **Notifications** (bell icon in UI shows repair count)
Repair properties: Repair properties:
- **`is_fixable=False`**: No automated fix available (user action required) - **`is_fixable=False`**: No automated fix available (user action required)
- **`severity=IssueSeverity.WARNING`**: Yellow warning level (not critical) - **`severity=IssueSeverity.WARNING`**: Yellow warning level (not critical)
- **`translation_key`**: References `issues.{key}` in translation files - **`translation_key`**: References `issues.{key}` in translation files
@ -247,7 +228,6 @@ Repair properties:
4. When tomorrow data arrives (next API fetch), repair clears 4. When tomorrow data arrives (next API fetch), repair clears
**Manual trigger:** **Manual trigger:**
```python ```python
# Temporarily set warning hour to current hour for testing # Temporarily set warning hour to current hour for testing
TOMORROW_DATA_WARNING_HOUR = datetime.now().hour TOMORROW_DATA_WARNING_HOUR = datetime.now().hour
@ -260,7 +240,6 @@ TOMORROW_DATA_WARNING_HOUR = datetime.now().hour
3. Successful API call clears the repair 3. Successful API call clears the repair
**Manual test:** **Manual test:**
- Reduce API polling interval to trigger rate limiting - Reduce API polling interval to trigger rate limiting
- Or temporarily return HTTP 429 in API client - Or temporarily return HTTP 429 in API client
@ -284,7 +263,6 @@ To add a new repair type:
7. **Document** in this file 7. **Document** in this file
**Example template:** **Example template:**
```python ```python
async def check_new_condition(self, *, param: bool) -> None: async def check_new_condition(self, *, param: bool) -> None:
"""Check new condition and create/clear repair.""" """Check new condition and create/clear repair."""

View file

@ -4,9 +4,9 @@
## Prerequisites ## Prerequisites
- VS Code with Dev Container support - VS Code with Dev Container support
- Docker installed and running - Docker installed and running
- GitHub account (for Tibber API token) - GitHub account (for Tibber API token)
## Quick Setup ## Quick Setup
@ -26,11 +26,11 @@ code .
The DevContainer includes: The DevContainer includes:
- Python 3.13 with `.venv` at `/home/vscode/.venv/` - Python 3.13 with `.venv` at `/home/vscode/.venv/`
- `uv` package manager (fast, modern Python tooling) - `uv` package manager (fast, modern Python tooling)
- Home Assistant development dependencies - Home Assistant development dependencies
- Ruff linter/formatter - Ruff linter/formatter
- Git, GitHub CLI, Node.js, Rust toolchain - Git, GitHub CLI, Node.js, Rust toolchain
## Running the Integration ## Running the Integration

View file

@ -13,10 +13,10 @@ Before running tests or committing changes, validate the integration structure:
This lightweight script checks: This lightweight script checks:
- ✓ `config_flow.py` exists - `config_flow.py` exists
- ✓ `manifest.json` is valid JSON with required fields - `manifest.json` is valid JSON with required fields
- ✓ Translation files have valid JSON syntax - ✓ Translation files have valid JSON syntax
- ✓ All Python files compile without syntax errors - ✓ All Python files compile without syntax errors
**Note:** Full hassfest validation runs in GitHub Actions on push. **Note:** Full hassfest validation runs in GitHub Actions on push.
@ -42,10 +42,10 @@ pytest --cov=custom_components.tibber_prices tests/
Then test in Home Assistant UI: Then test in Home Assistant UI:
- Configuration flow - Configuration flow
- Sensor states and attributes - Sensor states and attributes
- Services - Services
- Translation strings - Translation strings
## Test Guidelines ## Test Guidelines

View file

@ -10,11 +10,11 @@ This document explains the timer/scheduler system in the Tibber Prices integrati
The integration uses **three independent timer mechanisms** for different purposes: The integration uses **three independent timer mechanisms** for different purposes:
| Timer | Type | Interval | Purpose | Trigger Method | | Timer | Type | Interval | Purpose | Trigger Method |
| ------------ | ----------- | ------------------ | -------------------- | ------------------------------- | |-------|------|----------|---------|----------------|
| **Timer #1** | HA built-in | 15 minutes | API data updates | `DataUpdateCoordinator` | | **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 #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()` | | **Timer #3** | Custom | Every minute | Countdown/progress | `async_track_utc_time_change()` |
**Key principle:** Timer #1 (HA) controls **data fetching**, Timer #2 controls **entity updates**, Timer #3 controls **timing displays**. **Key principle:** Timer #1 (HA) controls **data fetching**, Timer #2 controls **entity updates**, Timer #3 controls **timing displays**.
@ -27,7 +27,6 @@ The integration uses **three independent timer mechanisms** for different purpos
**Type:** Home Assistant's built-in `DataUpdateCoordinator` with `UPDATE_INTERVAL = 15 minutes` **Type:** Home Assistant's built-in `DataUpdateCoordinator` with `UPDATE_INTERVAL = 15 minutes`
**What it is:** **What it is:**
- HA provides this timer system automatically when you inherit from `DataUpdateCoordinator` - HA provides this timer system automatically when you inherit from `DataUpdateCoordinator`
- Triggers `_async_update_data()` method every 15 minutes - Triggers `_async_update_data()` method every 15 minutes
- **Not** synchronized to clock boundaries (each installation has different start time) - **Not** synchronized to clock boundaries (each installation has different start time)
@ -54,19 +53,16 @@ async def _async_update_data(self) -> TibberPricesData:
``` ```
**Load Distribution:** **Load Distribution:**
- Each HA installation starts Timer #1 at different times → natural 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 - 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 - Result: API load spread over ~30 minutes instead of all at once
**Midnight Coordination:** **Midnight Coordination:**
- Atomic check: `_check_midnight_turnover_needed(now)` compares dates only (no side effects) - Atomic check: `_check_midnight_turnover_needed(now)` compares dates only (no side effects)
- If midnight turnover needed → performs it and returns early - If midnight turnover needed → performs it and returns early
- Timer #2 will see turnover already done and skip gracefully - Timer #2 will see turnover already done and skip gracefully
**Why we use HA's timer:** **Why we use HA's timer:**
- Automatic restart after HA restart - Automatic restart after HA restart
- Built-in retry logic for temporary failures - Built-in retry logic for temporary failures
- Standard HA integration pattern - Standard HA integration pattern
@ -83,7 +79,6 @@ async def _async_update_data(self) -> TibberPricesData:
**Purpose:** Update time-sensitive entity states at interval boundaries **without waiting for API poll** **Purpose:** Update time-sensitive entity states at interval boundaries **without waiting for API poll**
**Problem it solves:** **Problem it solves:**
- Timer #1 runs every 15 minutes but NOT synchronized to clock (:03, :18, :33, :48) - 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 - 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 - Example: 14:00 new price, but Timer #1 ran at 13:58 → next update at 14:13 → users see old price until 14:13
@ -105,26 +100,22 @@ async def _handle_quarter_hour_refresh(self, now: datetime) -> None:
``` ```
**Smart Boundary Tolerance:** **Smart Boundary Tolerance:**
- Uses `round_to_nearest_quarter_hour()` with ±2 second 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 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) - HA restart at 14:59:30 → stays at 14:45:00 (shows current interval)
- See [Architecture](./architecture.md#3-quarter-hour-precision) for details - See [Architecture](./architecture.md#3-quarter-hour-precision) for details
**Absolute Time Scheduling:** **Absolute Time Scheduling:**
- `async_track_utc_time_change()` plans for **all future boundaries** (15:00, 15:15, 15:30, ...) - `async_track_utc_time_change()` plans for **all future boundaries** (15:00, 15:15, 15:30, ...)
- NOT relative delays ("in 15 minutes") - 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) - If triggered at 14:59:58 → next trigger is 15:15:00, NOT 15:00:00 (prevents double updates)
**Which entities listen:** **Which entities listen:**
- All sensors that depend on "current interval" (e.g., `current_interval_price`, `next_interval_price`) - 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`) - Binary sensors that check "is now in period?" (e.g., `best_price_period_active`)
- ~50-60 entities out of 120+ total - ~50-60 entities out of 120+ total
**Why custom timer:** **Why custom timer:**
- HA's built-in coordinator doesn't support exact boundary timing - HA's built-in coordinator doesn't support exact boundary timing
- We need **absolute time** triggers, not periodic intervals - We need **absolute time** triggers, not periodic intervals
- Allows fast entity updates without expensive data transformation - Allows fast entity updates without expensive data transformation
@ -149,7 +140,6 @@ async def _handle_minute_refresh(self, now: datetime) -> None:
``` ```
**Which entities listen:** **Which entities listen:**
- `best_price_remaining_minutes` - Countdown timer - `best_price_remaining_minutes` - Countdown timer
- `peak_price_remaining_minutes` - Countdown timer - `peak_price_remaining_minutes` - Countdown timer
- `best_price_progress` - Progress bar (0-100%) - `best_price_progress` - Progress bar (0-100%)
@ -157,13 +147,11 @@ async def _handle_minute_refresh(self, now: datetime) -> None:
- ~10 entities total - ~10 entities total
**Why custom timer:** **Why custom timer:**
- Users want smooth countdowns (not jumping 15 minutes at a time) - Users want smooth countdowns (not jumping 15 minutes at a time)
- Progress bars need minute-by-minute updates - Progress bars need minute-by-minute updates
- Very lightweight (no data processing, just state recalculation) - Very lightweight (no data processing, just state recalculation)
**Why NOT every second:** **Why NOT every second:**
- Minute precision sufficient for countdown UX - Minute precision sufficient for countdown UX
- Reduces CPU load (60× fewer updates than seconds) - Reduces CPU load (60× fewer updates than seconds)
- Home Assistant best practice (avoid sub-minute updates) - Home Assistant best practice (avoid sub-minute updates)
@ -206,7 +194,6 @@ class ListenerManager:
``` ```
**Why this pattern:** **Why this pattern:**
- Decouples timer logic from entity logic - Decouples timer logic from entity logic
- One timer can notify many entities efficiently - One timer can notify many entities efficiently
- Entities can unregister when removed (cleanup) - Entities can unregister when removed (cleanup)
@ -292,13 +279,11 @@ class ListenerManager:
### Reason 1: Load Distribution on Tibber API ### Reason 1: Load Distribution on Tibber API
If all installations used synchronized timers: If all installations used synchronized timers:
- ❌ Everyone fetches at 13:00:00 → Tibber API overload - ❌ Everyone fetches at 13:00:00 → Tibber API overload
- ❌ Everyone fetches at 14:00:00 → Tibber API overload - ❌ Everyone fetches at 14:00:00 → Tibber API overload
- ❌ "Thundering herd" problem - ❌ "Thundering herd" problem
With HA's unsynchronized timer: With HA's unsynchronized timer:
- ✅ Installation A: 13:03:12, 13:18:12, 13:33:12, ... - ✅ Installation A: 13:03:12, 13:18:12, 13:33:12, ...
- ✅ Installation B: 13:07:45, 13:22:45, 13:37:45, ... - ✅ Installation B: 13:07:45, 13:22:45, 13:37:45, ...
- ✅ Installation C: 13:11:28, 13:26:28, 13:41:28, ... - ✅ Installation C: 13:11:28, 13:26:28, 13:41:28, ...
@ -331,7 +316,6 @@ def _should_update_price_data(self) -> str:
**Most Timer #1 cycles:** Fast path (~2ms), no API call, just returns cached data. **Most Timer #1 cycles:** Fast path (~2ms), no API call, just returns cached data.
**API fetch only when:** **API fetch only when:**
- Tomorrow data missing/invalid (after 13:00) - Tomorrow data missing/invalid (after 13:00)
- Cache expired (midnight turnover) - Cache expired (midnight turnover)
- Explicit user refresh - Explicit user refresh
@ -355,7 +339,6 @@ def _should_update_price_data(self) -> str:
## Performance Characteristics ## Performance Characteristics
### Timer #1 (DataUpdateCoordinator) ### Timer #1 (DataUpdateCoordinator)
- **Triggers:** Every 15 minutes (unsynchronized) - **Triggers:** Every 15 minutes (unsynchronized)
- **Fast path:** ~2ms (cache check, return existing data) - **Fast path:** ~2ms (cache check, return existing data)
- **Slow path:** ~600ms (API fetch + transform + calculate) - **Slow path:** ~600ms (API fetch + transform + calculate)
@ -363,14 +346,12 @@ def _should_update_price_data(self) -> str:
- **API calls:** ~1-2 times/day (cached otherwise) - **API calls:** ~1-2 times/day (cached otherwise)
### Timer #2 (Quarter-Hour Refresh) ### Timer #2 (Quarter-Hour Refresh)
- **Triggers:** 96 times/day (exact boundaries) - **Triggers:** 96 times/day (exact boundaries)
- **Processing:** ~5ms (notify 60 entities) - **Processing:** ~5ms (notify 60 entities)
- **No API calls:** Uses cached/transformed data - **No API calls:** Uses cached/transformed data
- **No transformation:** Just entity state updates - **No transformation:** Just entity state updates
### Timer #3 (Minute Refresh) ### Timer #3 (Minute Refresh)
- **Triggers:** 1440 times/day (every minute) - **Triggers:** 1440 times/day (every minute)
- **Processing:** ~1ms (notify 10 entities) - **Processing:** ~1ms (notify 10 entities)
- **No API calls:** No data processing at all - **No API calls:** No data processing at all
@ -412,16 +393,16 @@ _LOGGER.setLevel(logging.DEBUG)
### Common Issues ### Common Issues
1. **Timer #2 not triggering:** 1. **Timer #2 not triggering:**
- Check: `schedule_quarter_hour_refresh()` called in `__init__`? - Check: `schedule_quarter_hour_refresh()` called in `__init__`?
- Check: `_quarter_hour_timer_cancel` properly stored? - Check: `_quarter_hour_timer_cancel` properly stored?
2. **Double updates at midnight:** 2. **Double updates at midnight:**
- Should NOT happen (atomic coordination) - Should NOT happen (atomic coordination)
- Check: Both timers use same date comparison logic? - Check: Both timers use same date comparison logic?
3. **API overload:** 3. **API overload:**
- Check: Random delay working? (0-30s jitter on tomorrow check) - Check: Random delay working? (0-30s jitter on tomorrow check)
- Check: Cache validation logic correct? - Check: Cache validation logic correct?
--- ---
@ -436,20 +417,17 @@ _LOGGER.setLevel(logging.DEBUG)
## Summary ## Summary
**Three independent timers:** **Three independent timers:**
1. **Timer #1** (HA built-in, 15 min, unsynchronized) → Data fetching (when needed) 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) 2. **Timer #2** (Custom, :00/:15/:30/:45) → Entity state updates (always)
3. **Timer #3** (Custom, every minute) → Countdown/progress (always) 3. **Timer #3** (Custom, every minute) → Countdown/progress (always)
**Key insights:** **Key insights:**
- Timer #1 unsynchronized = good (load distribution on API) - Timer #1 unsynchronized = good (load distribution on API)
- Timer #2 synchronized = good (user sees correct data immediately) - Timer #2 synchronized = good (user sees correct data immediately)
- Timer #3 synchronized = good (smooth countdown UX) - Timer #3 synchronized = good (smooth countdown UX)
- All three coordinate gracefully (atomic midnight checks, no conflicts) - All three coordinate gracefully (atomic midnight checks, no conflicts)
**"Listener" terminology:** **"Listener" terminology:**
- Timer = mechanism that triggers - Timer = mechanism that triggers
- Listener = callback that gets called - Listener = callback that gets called
- Observer pattern = entities register, coordinator notifies - Observer pattern = entities register, coordinator notifies

File diff suppressed because it is too large Load diff

View file

@ -1,50 +1,50 @@
{ {
"name": "docs-split-developer", "name": "docs-split-developer",
"version": "0.0.0", "version": "0.0.0",
"private": true, "private": true,
"scripts": { "scripts": {
"docusaurus": "docusaurus", "docusaurus": "docusaurus",
"start": "docusaurus start", "start": "docusaurus start",
"build": "docusaurus build", "build": "docusaurus build",
"swizzle": "docusaurus swizzle", "swizzle": "docusaurus swizzle",
"deploy": "docusaurus deploy", "deploy": "docusaurus deploy",
"clear": "docusaurus clear", "clear": "docusaurus clear",
"serve": "docusaurus serve", "serve": "docusaurus serve",
"write-translations": "docusaurus write-translations", "write-translations": "docusaurus write-translations",
"write-heading-ids": "docusaurus write-heading-ids", "write-heading-ids": "docusaurus write-heading-ids",
"typecheck": "tsc" "typecheck": "tsc"
}, },
"dependencies": { "dependencies": {
"@docusaurus/core": "^3.10.0", "@docusaurus/core": "^3.10.0",
"@docusaurus/faster": "^3.10.0", "@docusaurus/faster": "^3.10.0",
"@docusaurus/preset-classic": "^3.10.0", "@docusaurus/preset-classic": "^3.10.0",
"@docusaurus/theme-mermaid": "^3.10.0", "@docusaurus/theme-mermaid": "^3.10.0",
"@mdx-js/react": "^3.0.0", "@mdx-js/react": "^3.0.0",
"clsx": "^2.0.0", "clsx": "^2.0.0",
"docusaurus-lunr-search": "^3.6.0", "docusaurus-lunr-search": "^3.6.0",
"prism-react-renderer": "^2.3.0", "prism-react-renderer": "^2.3.0",
"react": "^19.2.5", "react": "^19.2.5",
"react-dom": "^19.2.5" "react-dom": "^19.2.5"
}, },
"devDependencies": { "devDependencies": {
"@docusaurus/module-type-aliases": "^3.10.0", "@docusaurus/module-type-aliases": "^3.10.0",
"@docusaurus/tsconfig": "^3.10.0", "@docusaurus/tsconfig": "^3.10.0",
"@docusaurus/types": "^3.10.0", "@docusaurus/types": "^3.10.0",
"typescript": "~6.0.2" "typescript": "~6.0.2"
}, },
"browserslist": { "browserslist": {
"production": [ "production": [
">0.5%", ">0.5%",
"not dead", "not dead",
"not op_mini all" "not op_mini all"
], ],
"development": [ "development": [
"last 3 chrome version", "last 3 chrome version",
"last 3 firefox version", "last 3 firefox version",
"last 5 safari version" "last 5 safari version"
] ]
}, },
"engines": { "engines": {
"node": ">=20.0" "node": ">=20.0"
} }
} }

View file

@ -22,30 +22,30 @@ Fetches home information and metadata:
```graphql ```graphql
query { query {
viewer { viewer {
homes { homes {
id id
appNickname appNickname
address { address {
address1 address1
postalCode postalCode
city city
country country
} }
timeZone timeZone
currentSubscription { currentSubscription {
priceInfo { priceInfo {
current { current {
currency currency
} }
}
}
meteringPointData {
consumptionEan
gridAreaCode
}
} }
}
meteringPointData {
consumptionEan
gridAreaCode
}
} }
}
} }
``` ```
@ -56,27 +56,26 @@ query {
Fetches quarter-hourly prices: Fetches quarter-hourly prices:
```graphql ```graphql
query ($homeId: ID!) { query($homeId: ID!) {
viewer { viewer {
home(id: $homeId) { home(id: $homeId) {
currentSubscription { currentSubscription {
priceInfo { priceInfo {
range(resolution: QUARTER_HOURLY, first: 384) { range(resolution: QUARTER_HOURLY, first: 384) {
nodes { nodes {
total total
startsAt startsAt
level level
}
}
}
} }
}
} }
}
} }
}
} }
``` ```
**Parameters:** **Parameters:**
- `homeId`: Tibber home identifier - `homeId`: Tibber home identifier
- `resolution`: Always `QUARTER_HOURLY` - `resolution`: Always `QUARTER_HOURLY`
- `first`: 384 intervals (4 days of data) - `first`: 384 intervals (4 days of data)
@ -86,12 +85,10 @@ query ($homeId: ID!) {
## Rate Limits ## Rate Limits
Tibber API rate limits (as of 2024): Tibber API rate limits (as of 2024):
- **5000 requests per hour** per token - **5000 requests per hour** per token
- **Burst limit:** 100 requests per minute - **Burst limit:** 100 requests per minute
Integration stays well below these limits: Integration stays well below these limits:
- Polls every 15 minutes = 96 requests/day - Polls every 15 minutes = 96 requests/day
- User data cached for 24h = 1 request/day - User data cached for 24h = 1 request/day
- **Total:** ~100 requests/day per home - **Total:** ~100 requests/day per home
@ -102,14 +99,13 @@ Integration stays well below these limits:
```json ```json
{ {
"total": 0.2456, "total": 0.2456,
"startsAt": "2024-12-06T14:00:00.000+01:00", "startsAt": "2024-12-06T14:00:00.000+01:00",
"level": "NORMAL" "level": "NORMAL"
} }
``` ```
**Fields:** **Fields:**
- `total`: Price including VAT and fees (currency's major unit, e.g., EUR) - `total`: Price including VAT and fees (currency's major unit, e.g., EUR)
- `startsAt`: ISO 8601 timestamp with timezone - `startsAt`: ISO 8601 timestamp with timezone
- `level`: Tibber's own classification (VERY_CHEAP, CHEAP, NORMAL, EXPENSIVE, VERY_EXPENSIVE) - `level`: Tibber's own classification (VERY_CHEAP, CHEAP, NORMAL, EXPENSIVE, VERY_EXPENSIVE)
@ -118,12 +114,11 @@ Integration stays well below these limits:
```json ```json
{ {
"currency": "EUR" "currency": "EUR"
} }
``` ```
Supported currencies: Supported currencies:
- `EUR` (Euro) - displayed as ct/kWh - `EUR` (Euro) - displayed as ct/kWh
- `NOK` (Norwegian Krone) - displayed as øre/kWh - `NOK` (Norwegian Krone) - displayed as øre/kWh
- `SEK` (Swedish Krona) - displayed as öre/kWh - `SEK` (Swedish Krona) - displayed as öre/kWh
@ -133,52 +128,42 @@ Supported currencies:
### Common Error Responses ### Common Error Responses
**Invalid Token:** **Invalid Token:**
```json ```json
{ {
"errors": [ "errors": [{
{ "message": "Unauthorized",
"message": "Unauthorized", "extensions": {
"extensions": { "code": "UNAUTHENTICATED"
"code": "UNAUTHENTICATED" }
} }]
}
]
} }
``` ```
**Rate Limit Exceeded:** **Rate Limit Exceeded:**
```json ```json
{ {
"errors": [ "errors": [{
{ "message": "Too Many Requests",
"message": "Too Many Requests", "extensions": {
"extensions": { "code": "RATE_LIMIT_EXCEEDED"
"code": "RATE_LIMIT_EXCEEDED" }
} }]
}
]
} }
``` ```
**Home Not Found:** **Home Not Found:**
```json ```json
{ {
"errors": [ "errors": [{
{ "message": "Home not found",
"message": "Home not found", "extensions": {
"extensions": { "code": "NOT_FOUND"
"code": "NOT_FOUND" }
} }]
}
]
} }
``` ```
Integration handles these with: Integration handles these with:
- Exponential backoff retry (3 attempts) - Exponential backoff retry (3 attempts)
- ConfigEntryAuthFailed for auth errors - ConfigEntryAuthFailed for auth errors
- ConfigEntryNotReady for temporary failures - ConfigEntryNotReady for temporary failures
@ -186,7 +171,6 @@ Integration handles these with:
## Data Transformation ## Data Transformation
Raw API data is enriched with: Raw API data is enriched with:
- **Trailing 24h average** - Calculated from previous intervals - **Trailing 24h average** - Calculated from previous intervals
- **Leading 24h average** - Calculated from future intervals - **Leading 24h average** - Calculated from future intervals
- **Price difference %** - Deviation from average - **Price difference %** - Deviation from average
@ -197,7 +181,6 @@ See `utils/price.py` for enrichment logic.
--- ---
💡 **External Resources:** 💡 **External Resources:**
- [Tibber API Documentation](https://developer.tibber.com/docs/overview) - [Tibber API Documentation](https://developer.tibber.com/docs/overview)
- [GraphQL Explorer](https://developer.tibber.com/explorer) - [GraphQL Explorer](https://developer.tibber.com/explorer)
- [Get API Token](https://developer.tibber.com/settings/access-token) - [Get API Token](https://developer.tibber.com/settings/access-token)

View file

@ -100,43 +100,43 @@ flowchart TB
### Flow Description ### Flow Description
1. **Setup** (`__init__.py`) 1. **Setup** (`__init__.py`)
- Integration loads, creates coordinator instance - Integration loads, creates coordinator instance
- Registers entity platforms (sensor, binary_sensor) - Registers entity platforms (sensor, binary_sensor)
- Sets up custom services - Sets up custom services
2. **Data Fetch** (every 15 minutes) 2. **Data Fetch** (every 15 minutes)
- Coordinator triggers update via `api.py` - Coordinator triggers update via `api.py`
- API client checks **persistent cache** first (`coordinator/cache.py`) - API client checks **persistent cache** first (`coordinator/cache.py`)
- If cache valid → return cached data - If cache valid → return cached data
- If cache stale → query Tibber GraphQL API - If cache stale → query Tibber GraphQL API
- Store fresh data in persistent cache (survives HA restart) - Store fresh data in persistent cache (survives HA restart)
3. **Price Enrichment** 3. **Price Enrichment**
- Coordinator passes raw prices to `DataTransformer` - Coordinator passes raw prices to `DataTransformer`
- Transformer checks **transformation cache** (memory) - Transformer checks **transformation cache** (memory)
- If cache valid → return enriched data - If cache valid → return enriched data
- If cache invalid → enrich via `price_utils.py` + `average_utils.py` - If cache invalid → enrich via `price_utils.py` + `average_utils.py`
- Calculate 24h trailing/leading averages - Calculate 24h trailing/leading averages
- Calculate price differences (% from average) - Calculate price differences (% from average)
- Assign rating levels (LOW/NORMAL/HIGH) - Assign rating levels (LOW/NORMAL/HIGH)
- Store enriched data in transformation cache - Store enriched data in transformation cache
4. **Period Calculation** 4. **Period Calculation**
- Coordinator passes enriched data to `PeriodCalculator` - Coordinator passes enriched data to `PeriodCalculator`
- Calculator computes **hash** from prices + config - Calculator computes **hash** from prices + config
- If hash matches cache → return cached periods - If hash matches cache → return cached periods
- If hash differs → recalculate best/peak price periods - If hash differs → recalculate best/peak price periods
- Store periods with new hash - Store periods with new hash
5. **Entity Updates** 5. **Entity Updates**
- Coordinator provides complete data (prices + periods) - Coordinator provides complete data (prices + periods)
- Sensors read values via unified handlers - Sensors read values via unified handlers
- Binary sensors evaluate period states - Binary sensors evaluate period states
- Entities update on quarter-hour boundaries (00/15/30/45) - Entities update on quarter-hour boundaries (00/15/30/45)
6. **Service Calls** 6. **Service Calls**
- Custom services access coordinator data directly - Custom services access coordinator data directly
- Return formatted responses (JSON, ApexCharts format) - Return formatted responses (JSON, ApexCharts format)
--- ---
@ -146,13 +146,13 @@ flowchart TB
The integration uses **5 independent caching layers** for optimal performance: The integration uses **5 independent caching layers** for optimal performance:
| Layer | Location | Lifetime | Invalidation | Memory | | Layer | Location | Lifetime | Invalidation | Memory |
| ------------------------ | ------------------------------------ | -------------------------------------- | ------------ | ------ | |-------|----------|----------|--------------|--------|
| **API Cache** | `coordinator/cache.py` | 24h (user)<br/>Until midnight (prices) | Automatic | 50KB | | **API Cache** | `coordinator/cache.py` | 24h (user)<br/>Until midnight (prices) | Automatic | 50KB |
| **Translation Cache** | `const.py` | Until HA restart | Never | 5KB | | **Translation Cache** | `const.py` | Until HA restart | Never | 5KB |
| **Config Cache** | `coordinator/*` | Until config change | Explicit | 1KB | | **Config Cache** | `coordinator/*` | Until config change | Explicit | 1KB |
| **Period Cache** | `coordinator/periods.py` | Until data/config change | Hash-based | 10KB | | **Period Cache** | `coordinator/periods.py` | Until data/config change | Hash-based | 10KB |
| **Transformation Cache** | `coordinator/data_transformation.py` | Until midnight/config | Automatic | 60KB | | **Transformation Cache** | `coordinator/data_transformation.py` | Until midnight/config | Automatic | 60KB |
**Total cache overhead:** ~126KB per coordinator instance (main entry + subentries) **Total cache overhead:** ~126KB per coordinator instance (main entry + subentries)
@ -195,31 +195,30 @@ For detailed cache behavior, see [Caching Strategy](./caching-strategy.md).
### Core Components ### Core Components
| Component | File | Responsibility | | Component | File | Responsibility |
| --------------------- | ------------------------------------ | ---------------------------------------------------------------------------------------- | |-----------|------|----------------|
| **API Client** | `api.py` | GraphQL queries to Tibber, retry logic, error handling | | **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 | | **Coordinator** | `coordinator.py` | Update orchestration, cache management, absolute-time scheduling with boundary tolerance |
| **Data Transformer** | `coordinator/data_transformation.py` | Price enrichment (averages, ratings, differences) | | **Data Transformer** | `coordinator/data_transformation.py` | Price enrichment (averages, ratings, differences) |
| **Period Calculator** | `coordinator/periods.py` | Best/peak price period calculation with relaxation | | **Period Calculator** | `coordinator/periods.py` | Best/peak price period calculation with relaxation |
| **Sensors** | `sensor/` | 80+ entities for prices, levels, ratings, statistics | | **Sensors** | `sensor/` | 80+ entities for prices, levels, ratings, statistics |
| **Binary Sensors** | `binary_sensor/` | Period indicators (best/peak price active) | | **Binary Sensors** | `binary_sensor/` | Period indicators (best/peak price active) |
| **Services** | `services/` | Custom service endpoints (get_chartdata, get_apexcharts_yaml, refresh_user_data) | | **Services** | `services/` | Custom service endpoints (get_chartdata, get_apexcharts_yaml, refresh_user_data) |
### Sensor Architecture (Calculator Pattern) ### Sensor Architecture (Calculator Pattern)
The sensor platform uses **Calculator Pattern** for clean separation of concerns (refactored Nov 2025): The sensor platform uses **Calculator Pattern** for clean separation of concerns (refactored Nov 2025):
| Component | Files | Lines | Responsibility | | Component | Files | Lines | Responsibility |
| ---------------- | ------------------------- | ----- | ------------------------------------------------------- | |-----------|-------|-------|----------------|
| **Entity Class** | `sensor/core.py` | 909 | Entity lifecycle, coordinator, delegates to calculators | | **Entity Class** | `sensor/core.py` | 909 | Entity lifecycle, coordinator, delegates to calculators |
| **Calculators** | `sensor/calculators/` | 1,838 | Business logic (8 specialized calculators) | | **Calculators** | `sensor/calculators/` | 1,838 | Business logic (8 specialized calculators) |
| **Attributes** | `sensor/attributes/` | 1,209 | State presentation (8 specialized modules) | | **Attributes** | `sensor/attributes/` | 1,209 | State presentation (8 specialized modules) |
| **Routing** | `sensor/value_getters.py` | 276 | Centralized sensor → calculator mapping | | **Routing** | `sensor/value_getters.py` | 276 | Centralized sensor → calculator mapping |
| **Chart Export** | `sensor/chart_data.py` | 144 | Service call handling, YAML parsing | | **Chart Export** | `sensor/chart_data.py` | 144 | Service call handling, YAML parsing |
| **Helpers** | `sensor/helpers.py` | 188 | Aggregation functions, utilities | | **Helpers** | `sensor/helpers.py` | 188 | Aggregation functions, utilities |
**Calculator Package** (`sensor/calculators/`): **Calculator Package** (`sensor/calculators/`):
- `base.py` - Abstract BaseCalculator with coordinator access - `base.py` - Abstract BaseCalculator with coordinator access
- `interval.py` - Single interval calculations (current/next/previous) - `interval.py` - Single interval calculations (current/next/previous)
- `rolling_hour.py` - 5-interval rolling windows - `rolling_hour.py` - 5-interval rolling windows
@ -231,7 +230,6 @@ The sensor platform uses **Calculator Pattern** for clean separation of concerns
- `metadata.py` - Home/metering metadata - `metadata.py` - Home/metering metadata
**Benefits:** **Benefits:**
- 58% reduction in core.py (2,170 → 909 lines) - 58% reduction in core.py (2,170 → 909 lines)
- Clear separation: Calculators (logic) vs Attributes (presentation) - Clear separation: Calculators (logic) vs Attributes (presentation)
- Independent testability for each calculator - Independent testability for each calculator
@ -239,12 +237,12 @@ The sensor platform uses **Calculator Pattern** for clean separation of concerns
### Helper Utilities ### Helper Utilities
| Utility | File | Purpose | | Utility | File | Purpose |
| ----------------- | ------------------ | ------------------------------------------------- | |---------|------|---------|
| **Price Utils** | `utils/price.py` | Rating calculation, enrichment, level aggregation | | **Price Utils** | `utils/price.py` | Rating calculation, enrichment, level aggregation |
| **Average Utils** | `utils/average.py` | Trailing/leading 24h average calculations | | **Average Utils** | `utils/average.py` | Trailing/leading 24h average calculations |
| **Entity Utils** | `entity_utils/` | Shared icon/color/attribute logic | | **Entity Utils** | `entity_utils/` | Shared icon/color/attribute logic |
| **Translations** | `const.py` | Translation loading and caching | | **Translations** | `const.py` | Translation loading and caching |
--- ---
@ -285,12 +283,12 @@ All quarter-hourly price intervals get augmented via `utils/price.py`:
- **API polling**: Every 15 minutes (coordinator fetch cycle) - **API polling**: Every 15 minutes (coordinator fetch cycle)
- **Entity updates**: On 00/15/30/45-minute boundaries via `coordinator/listeners.py` - **Entity updates**: On 00/15/30/45-minute boundaries via `coordinator/listeners.py`
- **Timer scheduling**: Uses `async_track_utc_time_change(minute=[0, 15, 30, 45], second=0)` - **Timer scheduling**: Uses `async_track_utc_time_change(minute=[0, 15, 30, 45], second=0)`
- HA may trigger ±few milliseconds before/after exact boundary - HA may trigger ±few milliseconds before/after exact boundary
- Smart boundary tolerance (±2 seconds) handles scheduling jitter in `sensor/helpers.py` - Smart boundary tolerance (±2 seconds) handles scheduling jitter in `sensor/helpers.py`
- If HA schedules at 14:59:58 → rounds to 15:00:00 (shows new interval data) - If HA schedules at 14:59:58 → rounds to 15:00:00 (shows new interval data)
- If HA restarts at 14:59:30 → stays at 14:45:00 (shows current interval data) - If HA restarts at 14:59:30 → stays at 14:45:00 (shows current interval data)
- **Absolute time tracking**: Timer plans for **all future boundaries** (not relative delays) - **Absolute time tracking**: Timer plans for **all future boundaries** (not relative delays)
- Prevents double-updates (if triggered at 14:59:58, next trigger is 15:15:00, not 15:00:00) - Prevents double-updates (if triggered at 14:59:58, next trigger is 15:15:00, not 15:00:00)
- **Result**: Current price sensors update without waiting for next API poll - **Result**: Current price sensors update without waiting for next API poll
### 4. Calculator Pattern (Sensor Platform) ### 4. Calculator Pattern (Sensor Platform)
@ -298,31 +296,26 @@ All quarter-hourly price intervals get augmented via `utils/price.py`:
Sensors organized by **calculation method** (refactored Nov 2025): Sensors organized by **calculation method** (refactored Nov 2025):
**Unified Handler Methods** (`sensor/core.py`): **Unified Handler Methods** (`sensor/core.py`):
- `_get_interval_value(offset, type)` - current/next/previous intervals - `_get_interval_value(offset, type)` - current/next/previous intervals
- `_get_rolling_hour_value(offset, type)` - 5-interval rolling windows - `_get_rolling_hour_value(offset, type)` - 5-interval rolling windows
- `_get_daily_stat_value(day, stat_func)` - calendar day min/max/avg - `_get_daily_stat_value(day, stat_func)` - calendar day min/max/avg
- `_get_24h_window_value(stat_func)` - trailing/leading statistics - `_get_24h_window_value(stat_func)` - trailing/leading statistics
**Routing** (`sensor/value_getters.py`): **Routing** (`sensor/value_getters.py`):
- Single source of truth mapping 80+ entity keys to calculator methods - Single source of truth mapping 80+ entity keys to calculator methods
- Organized by calculation type (Interval, Rolling Hour, Daily Stats, etc.) - Organized by calculation type (Interval, Rolling Hour, Daily Stats, etc.)
**Calculators** (`sensor/calculators/`): **Calculators** (`sensor/calculators/`):
- Each calculator inherits from `BaseCalculator` with coordinator access - Each calculator inherits from `BaseCalculator` with coordinator access
- Focused responsibility: `IntervalCalculator`, `TrendCalculator`, etc. - Focused responsibility: `IntervalCalculator`, `TrendCalculator`, etc.
- Complex logic isolated (e.g., `TrendCalculator` has internal caching) - Complex logic isolated (e.g., `TrendCalculator` has internal caching)
**Attributes** (`sensor/attributes/`): **Attributes** (`sensor/attributes/`):
- Separate from business logic, handles state presentation - Separate from business logic, handles state presentation
- Builds extra_state_attributes dicts for entity classes - Builds extra_state_attributes dicts for entity classes
- Unified builders: `build_sensor_attributes()`, `build_extra_state_attributes()` - Unified builders: `build_sensor_attributes()`, `build_extra_state_attributes()`
**Benefits:** **Benefits:**
- Minimal code duplication across 80+ sensors - Minimal code duplication across 80+ sensors
- Clear separation of concerns (calculation vs presentation) - Clear separation of concerns (calculation vs presentation)
- Easy to extend: Add sensor → choose pattern → add to routing - Easy to extend: Add sensor → choose pattern → add to routing
@ -340,12 +333,12 @@ Sensors organized by **calculation method** (refactored Nov 2025):
### CPU Optimization ### CPU Optimization
| Optimization | Location | Savings | | Optimization | Location | Savings |
| ------------------- | ------------------------ | ---------------------------- | |--------------|----------|---------|
| Config caching | `coordinator/*` | ~50% on config checks | | Config caching | `coordinator/*` | ~50% on config checks |
| Period caching | `coordinator/periods.py` | ~70% on period recalculation | | Period caching | `coordinator/periods.py` | ~70% on period recalculation |
| Lazy logging | Throughout | ~15% on log-heavy operations | | Lazy logging | Throughout | ~15% on log-heavy operations |
| Import optimization | Module structure | ~20% faster loading | | Import optimization | Module structure | ~20% faster loading |
### Memory Usage ### Memory Usage

View file

@ -24,13 +24,11 @@ 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. **Purpose:** Reduce API calls to Tibber by caching user data and price data between HA restarts.
**What is cached:** **What is cached:**
- **Price data** (`price_data`): Day before yesterday/yesterday/today/tomorrow price intervals with enriched fields (384 intervals total) - **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 - **User data** (`user_data`): Homes, subscriptions, features from Tibber GraphQL `viewer` query
- **Timestamps**: Last update times for validation - **Timestamps**: Last update times for validation
**Lifetime:** **Lifetime:**
- **Price data**: Until midnight turnover (cleared daily at 00:00 local time) - **Price data**: Until midnight turnover (cleared daily at 00:00 local time)
- **User data**: 24 hours (refreshed daily) - **User data**: 24 hours (refreshed daily)
- **Survives**: HA restarts via persistent Storage - **Survives**: HA restarts via persistent Storage
@ -38,31 +36,29 @@ The integration uses **4 distinct caching layers** with different purposes and l
**Invalidation triggers:** **Invalidation triggers:**
1. **Midnight turnover** (Timer #2 in coordinator): 1. **Midnight turnover** (Timer #2 in coordinator):
```python
```python # coordinator/day_transitions.py
# coordinator/day_transitions.py def _handle_midnight_turnover() -> None:
def _handle_midnight_turnover() -> None: self._cached_price_data = None # Force fresh fetch for new day
self._cached_price_data = None # Force fresh fetch for new day self._last_price_update = None
self._last_price_update = None await self.store_cache()
await self.store_cache() ```
```
2. **Cache validation on load**: 2. **Cache validation on load**:
```python
```python # coordinator/cache.py
# coordinator/cache.py def is_cache_valid(cache_data: CacheData) -> bool:
def is_cache_valid(cache_data: CacheData) -> bool: # Checks if price data is from a previous day
# Checks if price data is from a previous day if today_date < local_now.date(): # Yesterday's data
if today_date < local_now.date(): # Yesterday's data return False
return False ```
```
3. **Tomorrow data check** (after 13:00): 3. **Tomorrow data check** (after 13:00):
```python ```python
# coordinator/data_fetching.py # coordinator/data_fetching.py
if tomorrow_missing or tomorrow_invalid: if tomorrow_missing or tomorrow_invalid:
return "tomorrow_check" # Update needed return "tomorrow_check" # Update needed
``` ```
**Why this cache matters:** Reduces API load on Tibber (~192 intervals per fetch), speeds up HA restarts, enables offline operation until cache expires. **Why this cache matters:** Reduces API load on Tibber (~192 intervals per fetch), speeds up HA restarts, enables offline operation until cache expires.
@ -75,22 +71,18 @@ 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. **Purpose:** Avoid repeated file I/O when accessing entity descriptions, UI strings, etc.
**What is cached:** **What is cached:**
- **Standard translations** (`/translations/*.json`): Config flow, selector options, entity names - **Standard translations** (`/translations/*.json`): Config flow, selector options, entity names
- **Custom translations** (`/custom_translations/*.json`): Entity descriptions, usage tips, long descriptions - **Custom translations** (`/custom_translations/*.json`): Entity descriptions, usage tips, long descriptions
**Lifetime:** **Lifetime:**
- **Forever** (until HA restart) - **Forever** (until HA restart)
- No invalidation during runtime - No invalidation during runtime
**When populated:** **When populated:**
- At integration setup: `async_load_translations(hass, "en")` in `__init__.py` - At integration setup: `async_load_translations(hass, "en")` in `__init__.py`
- Lazy loading: If translation missing, attempts file load once - Lazy loading: If translation missing, attempts file load once
**Access pattern:** **Access pattern:**
```python ```python
# Non-blocking synchronous access from cached data # Non-blocking synchronous access from cached data
description = get_translation("binary_sensor.best_price_period.description", "en") description = get_translation("binary_sensor.best_price_period.description", "en")
@ -109,7 +101,6 @@ description = get_translation("binary_sensor.best_price_period.description", "en
**What is cached:** **What is cached:**
### DataTransformer Config Cache ### DataTransformer Config Cache
```python ```python
{ {
"thresholds": {"low": 15, "high": 35}, "thresholds": {"low": 15, "high": 35},
@ -119,7 +110,6 @@ description = get_translation("binary_sensor.best_price_period.description", "en
``` ```
### PeriodCalculator Config Cache ### PeriodCalculator Config Cache
```python ```python
{ {
"best": {"flex": 0.15, "min_distance_from_avg": 5.0, "min_period_length": 60}, "best": {"flex": 0.15, "min_distance_from_avg": 5.0, "min_period_length": 60},
@ -128,23 +118,20 @@ description = get_translation("binary_sensor.best_price_period.description", "en
``` ```
**Lifetime:** **Lifetime:**
- Until `invalidate_config_cache()` is called - Until `invalidate_config_cache()` is called
- Built once on first use per coordinator update cycle - Built once on first use per coordinator update cycle
**Invalidation trigger:** **Invalidation trigger:**
- **Options change** (user reconfigures integration): - **Options change** (user reconfigures integration):
```python ```python
# coordinator/core.py # coordinator/core.py
async def _handle_options_update(...) -> None: async def _handle_options_update(...) -> None:
self._data_transformer.invalidate_config_cache() self._data_transformer.invalidate_config_cache()
self._period_calculator.invalidate_config_cache() self._period_calculator.invalidate_config_cache()
await self.async_request_refresh() await self.async_request_refresh()
``` ```
**Performance impact:** **Performance impact:**
- **Before:** ~30 dict lookups + type conversions per update = ~50μs - **Before:** ~30 dict lookups + type conversions per update = ~50μs
- **After:** 1 cache check = ~1μs - **After:** 1 cache check = ~1μs
- **Savings:** ~98% (50μs → 1μs per update) - **Savings:** ~98% (50μs → 1μs per update)
@ -160,7 +147,6 @@ 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. **Purpose:** Avoid expensive period calculations (~100-500ms) when price data and config haven't changed.
**What is cached:** **What is cached:**
```python ```python
{ {
"best_price": { "best_price": {
@ -175,7 +161,6 @@ description = get_translation("binary_sensor.best_price_period.description", "en
``` ```
**Cache key:** Hash of relevant inputs **Cache key:** Hash of relevant inputs
```python ```python
hash_data = ( hash_data = (
today_signature, # (startsAt, rating_level) for each interval today_signature, # (startsAt, rating_level) for each interval
@ -187,7 +172,6 @@ hash_data = (
``` ```
**Lifetime:** **Lifetime:**
- Until price data changes (today's intervals modified) - Until price data changes (today's intervals modified)
- Until config changes (flex, thresholds, filters) - Until config changes (flex, thresholds, filters)
- Recalculated at midnight (new today data) - Recalculated at midnight (new today data)
@ -195,27 +179,24 @@ hash_data = (
**Invalidation triggers:** **Invalidation triggers:**
1. **Config change** (explicit): 1. **Config change** (explicit):
```python
```python def invalidate_config_cache() -> None:
def invalidate_config_cache() -> None: self._cached_periods = None
self._cached_periods = None self._last_periods_hash = None
self._last_periods_hash = None ```
```
2. **Price data change** (automatic via hash mismatch): 2. **Price data change** (automatic via hash mismatch):
```python ```python
current_hash = self._compute_periods_hash(price_info) current_hash = self._compute_periods_hash(price_info)
if self._last_periods_hash != current_hash: if self._last_periods_hash != current_hash:
# Cache miss - recalculate # Cache miss - recalculate
``` ```
**Cache hit rate:** **Cache hit rate:**
- **High:** During normal operation (coordinator updates every 15min, price data unchanged) - **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) - **Low:** After midnight (new today data) or when tomorrow data arrives (~13:00-14:00)
**Performance impact:** **Performance impact:**
- **Period calculation:** ~100-500ms (depends on interval count, relaxation attempts) - **Period calculation:** ~100-500ms (depends on interval count, relaxation attempts)
- **Cache hit:** `<`1ms (hash comparison + dict lookup) - **Cache hit:** `<`1ms (hash comparison + dict lookup)
- **Savings:** ~70% of calculation time (most updates hit cache) - **Savings:** ~70% of calculation time (most updates hit cache)
@ -231,7 +212,6 @@ hash_data = (
**Status:** ✅ **Clean separation** - enrichment only, no redundancy **Status:** ✅ **Clean separation** - enrichment only, no redundancy
**What is cached:** **What is cached:**
```python ```python
{ {
"timestamp": ..., "timestamp": ...,
@ -244,16 +224,14 @@ hash_data = (
**Purpose:** Avoid re-enriching price data when config unchanged between midnight checks. **Purpose:** Avoid re-enriching price data when config unchanged between midnight checks.
**Current behavior:** **Current behavior:**
- Caches **only enriched price data** (price + statistics) - Caches **only enriched price data** (price + statistics)
- **Does NOT cache periods** (handled by Period Calculation Cache) - **Does NOT cache periods** (handled by Period Calculation Cache)
- Invalidated when: - Invalidated when:
- Config changes (thresholds affect enrichment) - Config changes (thresholds affect enrichment)
- Midnight turnover detected - Midnight turnover detected
- New update cycle begins - New update cycle begins
**Architecture:** **Architecture:**
- DataTransformer: Handles price enrichment only - DataTransformer: Handles price enrichment only
- PeriodCalculator: Handles period calculation only (with hash-based cache) - PeriodCalculator: Handles period calculation only (with hash-based cache)
- Coordinator: Assembles final data on-demand from both caches - Coordinator: Assembles final data on-demand from both caches
@ -265,7 +243,6 @@ hash_data = (
## Cache Invalidation Flow ## Cache Invalidation Flow
### User Changes Options (Config Flow) ### User Changes Options (Config Flow)
``` ```
User saves options User saves options
@ -290,7 +267,6 @@ Fresh data fetch with new config
``` ```
### Midnight Turnover (Day Transition) ### Midnight Turnover (Day Transition)
``` ```
Timer #2 fires at 00:00 Timer #2 fires at 00:00
@ -310,7 +286,6 @@ Fresh API fetch for new day
``` ```
### Tomorrow Data Arrives (~13:00) ### Tomorrow Data Arrives (~13:00)
``` ```
Coordinator update cycle Coordinator update cycle
@ -352,14 +327,12 @@ API Data Cache (price_data, user_data)
``` ```
**No cache invalidation cascades:** **No cache invalidation cascades:**
- Config cache invalidation is **explicit** (on options update) - Config cache invalidation is **explicit** (on options update)
- Period cache invalidation is **automatic** (via hash mismatch) - Period cache invalidation is **automatic** (via hash mismatch)
- Transformation cache invalidation is **automatic** (on midnight/config change) - Transformation cache invalidation is **automatic** (on midnight/config change)
- Translation cache is **never invalidated** (read-only after load) - Translation cache is **never invalidated** (read-only after load)
**Thread safety:** **Thread safety:**
- All caches are accessed from `MainThread` only (Home Assistant event loop) - All caches are accessed from `MainThread` only (Home Assistant event loop)
- No locking needed (single-threaded execution model) - No locking needed (single-threaded execution model)
@ -368,7 +341,6 @@ API Data Cache (price_data, user_data)
## Performance Characteristics ## Performance Characteristics
### Typical Operation (No Changes) ### Typical Operation (No Changes)
``` ```
Coordinator Update (every 15 min) Coordinator Update (every 15 min)
├─> API fetch: SKIP (cache valid) ├─> API fetch: SKIP (cache valid)
@ -381,7 +353,6 @@ Total: ~16ms (down from ~600ms without caching)
``` ```
### After Midnight Turnover ### After Midnight Turnover
``` ```
Coordinator Update (00:00) Coordinator Update (00:00)
├─> API fetch: ~500ms (cache cleared, fetch new day) ├─> API fetch: ~500ms (cache cleared, fetch new day)
@ -394,7 +365,6 @@ Total: ~755ms (expected once per day)
``` ```
### After Config Change ### After Config Change
``` ```
Options Update Options Update
├─> Cache invalidation: `<`1ms ├─> Cache invalidation: `<`1ms
@ -411,25 +381,23 @@ Options Update
## Summary Table ## Summary Table
| Cache Type | Lifetime | Size | Invalidation | Purpose | | Cache Type | Lifetime | Size | Invalidation | Purpose |
| ---------------------- | ---------------------------- | ------ | ------------------------- | ------------------------------- | |------------|----------|------|--------------|---------|
| **API Data** | Hours to 1 day | ~50KB | Midnight, validation | Reduce API calls | | **API Data** | Hours to 1 day | ~50KB | Midnight, validation | Reduce API calls |
| **Translations** | Forever (until HA restart) | ~5KB | Never | Avoid file I/O | | **Translations** | Forever (until HA restart) | ~5KB | Never | Avoid file I/O |
| **Config Dicts** | Until options change | `<`1KB | Explicit (options update) | Avoid dict lookups | | **Config Dicts** | Until options change | `<`1KB | Explicit (options update) | Avoid dict lookups |
| **Period Calculation** | Until data/config change | ~10KB | Auto (hash mismatch) | Avoid CPU-intensive calculation | | **Period Calculation** | Until data/config change | ~10KB | Auto (hash mismatch) | Avoid CPU-intensive calculation |
| **Transformation** | Until midnight/config change | ~50KB | Auto (midnight/config) | Avoid re-enrichment | | **Transformation** | Until midnight/config change | ~50KB | Auto (midnight/config) | Avoid re-enrichment |
**Total memory overhead:** ~116KB per coordinator instance (main + subentries) **Total memory overhead:** ~116KB per coordinator instance (main + subentries)
**Benefits:** **Benefits:**
- 97% reduction in API calls (from every 15min to once per day) - 97% reduction in API calls (from every 15min to once per day)
- 70% reduction in period calculation time (cache hits during normal operation) - 70% reduction in period calculation time (cache hits during normal operation)
- 98% reduction in config access time (30+ lookups → 1 cache check) - 98% reduction in config access time (30+ lookups → 1 cache check)
- Zero file I/O during runtime (translations cached at startup) - Zero file I/O during runtime (translations cached at startup)
**Trade-offs:** **Trade-offs:**
- Memory usage: ~116KB per home (negligible for modern systems) - Memory usage: ~116KB per home (negligible for modern systems)
- Code complexity: 5 cache invalidation points (well-tested, documented) - Code complexity: 5 cache invalidation points (well-tested, documented)
- Debugging: Must understand cache lifetime when investigating stale data issues - Debugging: Must understand cache lifetime when investigating stale data issues
@ -439,9 +407,7 @@ Options Update
## Debugging Cache Issues ## Debugging Cache Issues
### Symptom: Stale data after config change ### Symptom: Stale data after config change
**Check:** **Check:**
1. Is `_handle_options_update()` called? (should see "Options updated" log) 1. Is `_handle_options_update()` called? (should see "Options updated" log)
2. Are `invalidate_config_cache()` methods executed? 2. Are `invalidate_config_cache()` methods executed?
3. Does `async_request_refresh()` trigger? 3. Does `async_request_refresh()` trigger?
@ -449,9 +415,7 @@ Options Update
**Fix:** Ensure `config_entry.add_update_listener()` is registered in coordinator init. **Fix:** Ensure `config_entry.add_update_listener()` is registered in coordinator init.
### Symptom: Period calculation not updating ### Symptom: Period calculation not updating
**Check:** **Check:**
1. Verify hash changes when data changes: `_compute_periods_hash()` 1. Verify hash changes when data changes: `_compute_periods_hash()`
2. Check `_last_periods_hash` vs `current_hash` 2. Check `_last_periods_hash` vs `current_hash`
3. Look for "Using cached period calculation" vs "Calculating periods" logs 3. Look for "Using cached period calculation" vs "Calculating periods" logs
@ -459,9 +423,7 @@ Options Update
**Fix:** Hash function may not include all relevant data. Review `_compute_periods_hash()` inputs. **Fix:** Hash function may not include all relevant data. Review `_compute_periods_hash()` inputs.
### Symptom: Yesterday's prices shown as today ### Symptom: Yesterday's prices shown as today
**Check:** **Check:**
1. `is_cache_valid()` logic in `coordinator/cache.py` 1. `is_cache_valid()` logic in `coordinator/cache.py`
2. Midnight turnover execution (Timer #2) 2. Midnight turnover execution (Timer #2)
3. Cache clear confirmation in logs 3. Cache clear confirmation in logs
@ -469,9 +431,7 @@ Options Update
**Fix:** Timer may not be firing. Check `_schedule_midnight_turnover()` registration. **Fix:** Timer may not be firing. Check `_schedule_midnight_turnover()` registration.
### Symptom: Missing translations ### Symptom: Missing translations
**Check:** **Check:**
1. `async_load_translations()` called at startup? 1. `async_load_translations()` called at startup?
2. Translation files exist in `/translations/` and `/custom_translations/`? 2. Translation files exist in `/translations/` and `/custom_translations/`?
3. Cache population: `_TRANSLATIONS_CACHE` keys 3. Cache population: `_TRANSLATIONS_CACHE` keys

View file

@ -8,10 +8,10 @@ comments: false
## Code Style ## Code Style
- **Formatter/Linter**: Ruff (replaces Black, Flake8, isort) - **Formatter/Linter**: Ruff (replaces Black, Flake8, isort)
- **Max line length**: 120 characters - **Max line length**: 120 characters
- **Max complexity**: 25 (McCabe) - **Max complexity**: 25 (McCabe)
- **Target**: Python 3.13 - **Target**: Python 3.13
Run before committing: Run before committing:
@ -41,14 +41,12 @@ class TimeService:
``` ```
**When prefix is required:** **When prefix is required:**
- Public classes used across multiple modules - Public classes used across multiple modules
- All exception classes - All exception classes
- All coordinator and entity classes - All coordinator and entity classes
- Data classes (dataclasses, NamedTuples) used as public APIs - Data classes (dataclasses, NamedTuples) used as public APIs
**When prefix can be omitted:** **When prefix can be omitted:**
- Private helper classes within a single module (prefix with `_` underscore) - Private helper classes within a single module (prefix with `_` underscore)
- Type aliases and callbacks (e.g., `TimeServiceCallback`) - Type aliases and callbacks (e.g., `TimeServiceCallback`)
- Small internal NamedTuples for function returns - Small internal NamedTuples for function returns
@ -73,7 +71,6 @@ class DataFetcher: # Should be TibberPricesDataFetcher
**Current Technical Debt:** **Current Technical Debt:**
Many existing classes lack the `TibberPrices` prefix. Before refactoring: Many existing classes lack the `TibberPrices` prefix. Before refactoring:
1. Document the plan in `/planning/class-naming-refactoring.md` 1. Document the plan in `/planning/class-naming-refactoring.md`
2. Use `multi_replace_string_in_file` for bulk renames 2. Use `multi_replace_string_in_file` for bulk renames
3. Test thoroughly after each module 3. Test thoroughly after each module

View file

@ -14,10 +14,10 @@ Welcome! This guide helps you contribute to the Tibber Prices integration.
1. Fork the repository on GitHub 1. Fork the repository on GitHub
2. Clone your fork: 2. Clone your fork:
```bash ```bash
git clone https://github.com/YOUR_USERNAME/hass.tibber_prices.git git clone https://github.com/YOUR_USERNAME/hass.tibber_prices.git
cd hass.tibber_prices cd hass.tibber_prices
``` ```
3. Open in VS Code 3. Open in VS Code
4. Click "Reopen in Container" when prompted 4. Click "Reopen in Container" when prompted
@ -34,7 +34,6 @@ git checkout -b fix/issue-123-description
``` ```
**Branch naming:** **Branch naming:**
- `feature/` - New features - `feature/` - New features
- `fix/` - Bug fixes - `fix/` - Bug fixes
- `docs/` - Documentation only - `docs/` - Documentation only
@ -46,7 +45,6 @@ git checkout -b fix/issue-123-description
Edit code, following [Coding Guidelines](coding-guidelines.md). Edit code, following [Coding Guidelines](coding-guidelines.md).
**Run checks frequently:** **Run checks frequently:**
```bash ```bash
./scripts/type-check # Pyright type checking ./scripts/type-check # Pyright type checking
./scripts/lint # Ruff linting (auto-fix) ./scripts/lint # Ruff linting (auto-fix)
@ -80,7 +78,6 @@ async def test_your_feature(hass, coordinator):
``` ```
Run your test: Run your test:
```bash ```bash
./scripts/test tests/test_your_feature.py -v ./scripts/test tests/test_your_feature.py -v
``` ```
@ -100,7 +97,6 @@ Impact: Users can predict when prices will stabilize or continue fluctuating."
``` ```
**Commit types:** **Commit types:**
- `feat:` - New feature - `feat:` - New feature
- `fix:` - Bug fix - `fix:` - Bug fix
- `docs:` - Documentation - `docs:` - Documentation
@ -109,7 +105,6 @@ Impact: Users can predict when prices will stabilize or continue fluctuating."
- `chore:` - Maintenance - `chore:` - Maintenance
**Add scope when relevant:** **Add scope when relevant:**
- `feat(sensors):` - Sensor platform - `feat(sensors):` - Sensor platform
- `fix(coordinator):` - Data coordinator - `fix(coordinator):` - Data coordinator
- `docs(user):` - User documentation - `docs(user):` - User documentation
@ -129,40 +124,32 @@ Then open Pull Request on GitHub.
Title: Short, descriptive (50 chars max) Title: Short, descriptive (50 chars max)
Description should include: Description should include:
```markdown ```markdown
## What ## What
Brief description of changes Brief description of changes
## Why ## Why
Problem being solved or feature rationale Problem being solved or feature rationale
## How ## How
Implementation approach Implementation approach
## Testing ## Testing
- [ ] Manual testing in Home Assistant - [ ] Manual testing in Home Assistant
- [ ] Unit tests added/updated - [ ] Unit tests added/updated
- [ ] Type checking passes - [ ] Type checking passes
- [ ] Linting passes - [ ] Linting passes
## Breaking Changes ## Breaking Changes
(If any - describe migration path) (If any - describe migration path)
## Related Issues ## Related Issues
Closes #123 Closes #123
``` ```
### PR Checklist ### PR Checklist
Before submitting: Before submitting:
- [ ] Code follows [Coding Guidelines](coding-guidelines.md) - [ ] Code follows [Coding Guidelines](coding-guidelines.md)
- [ ] All tests pass (`./scripts/test`) - [ ] All tests pass (`./scripts/test`)
- [ ] Type checking passes (`./scripts/type-check`) - [ ] Type checking passes (`./scripts/type-check`)
@ -183,7 +170,6 @@ Before submitting:
### What Reviewers Look For ### What Reviewers Look For
✅ **Good:** ✅ **Good:**
- Clear, self-explanatory code - Clear, self-explanatory code
- Appropriate comments for complex logic - Appropriate comments for complex logic
- Tests covering edge cases - Tests covering edge cases
@ -191,7 +177,6 @@ Before submitting:
- Follows existing patterns - Follows existing patterns
❌ **Avoid:** ❌ **Avoid:**
- Large PRs (>500 lines) - split into smaller ones - Large PRs (>500 lines) - split into smaller ones
- Mixing unrelated changes - Mixing unrelated changes
- Missing tests for new features - Missing tests for new features
@ -208,7 +193,6 @@ Before submitting:
## Finding Issues to Work On ## Finding Issues to Work On
Good first issues are labeled: Good first issues are labeled:
- `good first issue` - Beginner-friendly - `good first issue` - Beginner-friendly
- `help wanted` - Maintainers welcome contributions - `help wanted` - Maintainers welcome contributions
- `documentation` - Docs improvements - `documentation` - Docs improvements
@ -226,7 +210,6 @@ Be respectful, constructive, and patient. We're all volunteers! 🙏
--- ---
💡 **Related:** 💡 **Related:**
- [Setup Guide](setup.md) - DevContainer setup - [Setup Guide](setup.md) - DevContainer setup
- [Coding Guidelines](coding-guidelines.md) - Style guide - [Coding Guidelines](coding-guidelines.md) - Style guide
- [Testing](testing.md) - Writing tests - [Testing](testing.md) - Writing tests

View file

@ -12,7 +12,6 @@ comments: false
## 🎯 Why Are These Tests Critical? ## 🎯 Why Are These Tests Critical?
Home Assistant integrations run **continuously** in the background. Resource leaks lead to: Home Assistant integrations run **continuously** in the background. Resource leaks lead to:
- **Memory Leaks**: RAM usage grows over days/weeks until HA becomes unstable - **Memory Leaks**: RAM usage grows over days/weeks until HA becomes unstable
- **Callback Leaks**: Listeners remain registered after entity removal → CPU load increases - **Callback Leaks**: Listeners remain registered after entity removal → CPU load increases
- **Timer Leaks**: Timers continue running after unload → unnecessary background tasks - **Timer Leaks**: Timers continue running after unload → unnecessary background tasks
@ -27,7 +26,6 @@ Home Assistant integrations run **continuously** in the background. Resource lea
#### 1.1 Listener Cleanup ✅ #### 1.1 Listener Cleanup ✅
**What is tested:** **What is tested:**
- Time-sensitive listeners are correctly removed (`async_add_time_sensitive_listener()`) - Time-sensitive listeners are correctly removed (`async_add_time_sensitive_listener()`)
- Minute-update listeners are correctly removed (`async_add_minute_update_listener()`) - Minute-update listeners are correctly removed (`async_add_minute_update_listener()`)
- Lifecycle callbacks are correctly unregistered (`register_lifecycle_callback()`) - Lifecycle callbacks are correctly unregistered (`register_lifecycle_callback()`)
@ -35,13 +33,11 @@ Home Assistant integrations run **continuously** in the background. Resource lea
- Binary sensor cleanup removes ALL registered listeners - Binary sensor cleanup removes ALL registered listeners
**Why critical:** **Why critical:**
- Each registered listener holds references to Entity + Coordinator - Each registered listener holds references to Entity + Coordinator
- Without cleanup: Entities are not freed by GC → Memory Leak - Without cleanup: Entities are not freed by GC → Memory Leak
- With 80+ sensors × 3 listener types = 240+ callbacks that must be cleanly removed - With 80+ sensors × 3 listener types = 240+ callbacks that must be cleanly removed
**Code Locations:** **Code Locations:**
- `coordinator/listeners.py``async_add_time_sensitive_listener()`, `async_add_minute_update_listener()` - `coordinator/listeners.py``async_add_time_sensitive_listener()`, `async_add_minute_update_listener()`
- `coordinator/core.py``register_lifecycle_callback()` - `coordinator/core.py``register_lifecycle_callback()`
- `sensor/core.py``async_will_remove_from_hass()` - `sensor/core.py``async_will_remove_from_hass()`
@ -50,38 +46,32 @@ Home Assistant integrations run **continuously** in the background. Resource lea
#### 1.2 Timer Cleanup ✅ #### 1.2 Timer Cleanup ✅
**What is tested:** **What is tested:**
- Quarter-hour timer is cancelled and reference cleared - Quarter-hour timer is cancelled and reference cleared
- Minute timer is cancelled and reference cleared - Minute timer is cancelled and reference cleared
- Both timers are cancelled together - Both timers are cancelled together
- Cleanup works even when timers are `None` - Cleanup works even when timers are `None`
**Why critical:** **Why critical:**
- Uncancelled timers continue running after integration unload - Uncancelled timers continue running after integration unload
- HA's `async_track_utc_time_change()` creates persistent callbacks - HA's `async_track_utc_time_change()` creates persistent callbacks
- Without cleanup: Timers keep firing → CPU load + unnecessary coordinator updates - Without cleanup: Timers keep firing → CPU load + unnecessary coordinator updates
**Code Locations:** **Code Locations:**
- `coordinator/listeners.py``cancel_timers()` - `coordinator/listeners.py``cancel_timers()`
- `coordinator/core.py``async_shutdown()` - `coordinator/core.py``async_shutdown()`
#### 1.3 Config Entry Cleanup ✅ #### 1.3 Config Entry Cleanup ✅
**What is tested:** **What is tested:**
- Options update listener is registered via `async_on_unload()` - Options update listener is registered via `async_on_unload()`
- Cleanup function is correctly passed to `async_on_unload()` - Cleanup function is correctly passed to `async_on_unload()`
**Why critical:** **Why critical:**
- `entry.add_update_listener()` registers permanent callback - `entry.add_update_listener()` registers permanent callback
- Without `async_on_unload()`: Listener remains active after reload → duplicate updates - Without `async_on_unload()`: Listener remains active after reload → duplicate updates
- Pattern: `entry.async_on_unload(entry.add_update_listener(handler))` - Pattern: `entry.async_on_unload(entry.add_update_listener(handler))`
**Code Locations:** **Code Locations:**
- `coordinator/core.py``__init__()` (listener registration) - `coordinator/core.py``__init__()` (listener registration)
- `__init__.py``async_unload_entry()` - `__init__.py``async_unload_entry()`
@ -92,19 +82,16 @@ Home Assistant integrations run **continuously** in the background. Resource lea
#### 2.1 Config Cache Invalidation #### 2.1 Config Cache Invalidation
**What is tested:** **What is tested:**
- DataTransformer config cache is invalidated on options change - DataTransformer config cache is invalidated on options change
- PeriodCalculator config + period cache is invalidated - PeriodCalculator config + period cache is invalidated
- Trend calculator cache is cleared on coordinator update - Trend calculator cache is cleared on coordinator update
**Why critical:** **Why critical:**
- Stale config → Sensors use old user settings - Stale config → Sensors use old user settings
- Stale period cache → Incorrect best/peak price periods - Stale period cache → Incorrect best/peak price periods
- Stale trend cache → Outdated trend analysis - Stale trend cache → Outdated trend analysis
**Code Locations:** **Code Locations:**
- `coordinator/data_transformation.py``invalidate_config_cache()` - `coordinator/data_transformation.py``invalidate_config_cache()`
- `coordinator/periods.py``invalidate_config_cache()` - `coordinator/periods.py``invalidate_config_cache()`
- `sensor/calculators/trend.py``clear_trend_cache()` - `sensor/calculators/trend.py``clear_trend_cache()`
@ -116,18 +103,15 @@ Home Assistant integrations run **continuously** in the background. Resource lea
#### 3.1 Persistent Storage Removal #### 3.1 Persistent Storage Removal
**What is tested:** **What is tested:**
- Storage file is deleted on config entry removal - Storage file is deleted on config entry removal
- Cache is saved on shutdown (no data loss) - Cache is saved on shutdown (no data loss)
**Why critical:** **Why critical:**
- Without storage removal: Old files remain after uninstallation - Without storage removal: Old files remain after uninstallation
- Without cache save on shutdown: Data loss on HA restart - Without cache save on shutdown: Data loss on HA restart
- Storage path: `.storage/tibber_prices.{entry_id}` - Storage path: `.storage/tibber_prices.{entry_id}`
**Code Locations:** **Code Locations:**
- `__init__.py``async_remove_entry()` - `__init__.py``async_remove_entry()`
- `coordinator/core.py``async_shutdown()` - `coordinator/core.py``async_shutdown()`
@ -136,14 +120,12 @@ Home Assistant integrations run **continuously** in the background. Resource lea
**File:** `tests/test_timer_scheduling.py` **File:** `tests/test_timer_scheduling.py`
**What is tested:** **What is tested:**
- Quarter-hour timer is registered with correct parameters - Quarter-hour timer is registered with correct parameters
- Minute timer is registered with correct parameters - Minute timer is registered with correct parameters
- Timers can be re-scheduled (override old timer) - Timers can be re-scheduled (override old timer)
- Midnight turnover detection works correctly - Midnight turnover detection works correctly
**Why critical:** **Why critical:**
- Wrong timer parameters → Entities update at wrong times - Wrong timer parameters → Entities update at wrong times
- Without timer override on re-schedule → Multiple parallel timers → Performance problem - Without timer override on re-schedule → Multiple parallel timers → Performance problem
@ -152,14 +134,12 @@ Home Assistant integrations run **continuously** in the background. Resource lea
**File:** `tests/test_sensor_timer_assignment.py` **File:** `tests/test_sensor_timer_assignment.py`
**What is tested:** **What is tested:**
- All `TIME_SENSITIVE_ENTITY_KEYS` are valid entity keys - All `TIME_SENSITIVE_ENTITY_KEYS` are valid entity keys
- All `MINUTE_UPDATE_ENTITY_KEYS` are valid entity keys - All `MINUTE_UPDATE_ENTITY_KEYS` are valid entity keys
- Both lists are disjoint (no overlap) - Both lists are disjoint (no overlap)
- Sensor and binary sensor platforms are checked - Sensor and binary sensor platforms are checked
**Why critical:** **Why critical:**
- Wrong timer assignment → Sensors update at wrong times - Wrong timer assignment → Sensors update at wrong times
- Overlap → Duplicate updates → Performance problem - Overlap → Duplicate updates → Performance problem
@ -170,12 +150,10 @@ These patterns were analyzed and classified as **not critical**:
### 6. Async Task Management ### 6. Async Task Management
**Current Status:** Fire-and-forget pattern for short tasks **Current Status:** Fire-and-forget pattern for short tasks
- `sensor/core.py` → Chart data refresh (short-lived, max 1-2 seconds) - `sensor/core.py` → Chart data refresh (short-lived, max 1-2 seconds)
- `coordinator/core.py` → Cache storage (short-lived, max 100ms) - `coordinator/core.py` → Cache storage (short-lived, max 100ms)
**Why no tests needed:** **Why no tests needed:**
- No long-running tasks (all < 2 seconds) - No long-running tasks (all < 2 seconds)
- HA's event loop handles short tasks automatically - HA's event loop handles short tasks automatically
- Task exceptions are already logged - Task exceptions are already logged
@ -185,7 +163,6 @@ These patterns were analyzed and classified as **not critical**:
### 7. API Session Cleanup ### 7. API Session Cleanup
**Current Status:** ✅ Correctly implemented **Current Status:** ✅ Correctly implemented
- `async_get_clientsession(hass)` is used (shared session) - `async_get_clientsession(hass)` is used (shared session)
- No new sessions are created - No new sessions are created
- HA manages session lifecycle automatically - HA manages session lifecycle automatically
@ -195,7 +172,6 @@ These patterns were analyzed and classified as **not critical**:
### 8. Translation Cache Memory ### 8. Translation Cache Memory
**Current Status:** ✅ Bounded cache **Current Status:** ✅ Bounded cache
- Max ~5-10 languages × 5KB = 50KB total - Max ~5-10 languages × 5KB = 50KB total
- Module-level cache without re-loading - Module-level cache without re-loading
- Practically no memory issue - Practically no memory issue
@ -205,13 +181,11 @@ These patterns were analyzed and classified as **not critical**:
### 9. Coordinator Data Structure Integrity ### 9. Coordinator Data Structure Integrity
**Current Status:** Manually tested via `./scripts/develop` **Current Status:** Manually tested via `./scripts/develop`
- Midnight turnover works correctly (observed over several days) - Midnight turnover works correctly (observed over several days)
- Missing keys are handled via `.get()` with defaults - Missing keys are handled via `.get()` with defaults
- 80+ sensors access `coordinator.data` without errors - 80+ sensors access `coordinator.data` without errors
**Structure:** **Structure:**
```python ```python
coordinator.data = { coordinator.data = {
"user_data": {...}, "user_data": {...},
@ -223,7 +197,6 @@ coordinator.data = {
### 10. Service Response Memory ### 10. Service Response Memory
**Current Status:** HA's response lifecycle **Current Status:** HA's response lifecycle
- HA automatically frees service responses after return - HA automatically frees service responses after return
- ApexCharts ~20KB response is one-time per call - ApexCharts ~20KB response is one-time per call
- No response accumulation in integration code - No response accumulation in integration code
@ -234,30 +207,29 @@ coordinator.data = {
### ✅ Implemented Tests (41 total) ### ✅ Implemented Tests (41 total)
| Category | Status | Tests | File | Coverage | | Category | Status | Tests | File | Coverage |
| ----------------------- | ------ | ------ | --------------------------------- | ------------------- | |----------|--------|-------|------|----------|
| Listener Cleanup | ✅ | 5 | `test_resource_cleanup.py` | 100% | | Listener Cleanup | ✅ | 5 | `test_resource_cleanup.py` | 100% |
| Timer Cleanup | ✅ | 4 | `test_resource_cleanup.py` | 100% | | Timer Cleanup | ✅ | 4 | `test_resource_cleanup.py` | 100% |
| Config Entry Cleanup | ✅ | 1 | `test_resource_cleanup.py` | 100% | | Config Entry Cleanup | ✅ | 1 | `test_resource_cleanup.py` | 100% |
| Cache Invalidation | ✅ | 3 | `test_resource_cleanup.py` | 100% | | Cache Invalidation | ✅ | 3 | `test_resource_cleanup.py` | 100% |
| Storage Cleanup | ✅ | 1 | `test_resource_cleanup.py` | 100% | | Storage Cleanup | ✅ | 1 | `test_resource_cleanup.py` | 100% |
| Storage Persistence | ✅ | 2 | `test_coordinator_shutdown.py` | 100% | | Storage Persistence | ✅ | 2 | `test_coordinator_shutdown.py` | 100% |
| Timer Scheduling | ✅ | 8 | `test_timer_scheduling.py` | 100% | | Timer Scheduling | ✅ | 8 | `test_timer_scheduling.py` | 100% |
| Sensor-Timer Assignment | ✅ | 17 | `test_sensor_timer_assignment.py` | 100% | | Sensor-Timer Assignment | ✅ | 17 | `test_sensor_timer_assignment.py` | 100% |
| **TOTAL** | **✅** | **41** | | **100% (critical)** | | **TOTAL** | **✅** | **41** | | **100% (critical)** |
### 📋 Analyzed but Not Implemented (Nice-to-Have) ### 📋 Analyzed but Not Implemented (Nice-to-Have)
| Category | Status | Rationale | | Category | Status | Rationale |
| ------------------------ | ------ | ---------------------------------------------------- | |----------|--------|-----------|
| Async Task Management | 📋 | Fire-and-forget pattern used (no long-running tasks) | | Async Task Management | 📋 | Fire-and-forget pattern used (no long-running tasks) |
| API Session Cleanup | ✅ | Pattern correct (`async_get_clientsession` used) | | API Session Cleanup | ✅ | Pattern correct (`async_get_clientsession` used) |
| Translation Cache | ✅ | Cache size bounded (~50KB max for 10 languages) | | Translation Cache | ✅ | Cache size bounded (~50KB max for 10 languages) |
| Data Structure Integrity | 📋 | Would add test time without finding real issues | | Data Structure Integrity | 📋 | Would add test time without finding real issues |
| Service Response Memory | 📋 | HA automatically frees service responses | | Service Response Memory | 📋 | HA automatically frees service responses |
**Legend:** **Legend:**
- ✅ = Fully tested or pattern verified correct - ✅ = Fully tested or pattern verified correct
- 📋 = Analyzed, low priority for testing (no known issues) - 📋 = Analyzed, low priority for testing (no known issues)
@ -266,7 +238,6 @@ coordinator.data = {
### ✅ All Critical Patterns Tested ### ✅ All Critical Patterns Tested
All essential memory leak prevention patterns are covered by 41 tests: All essential memory leak prevention patterns are covered by 41 tests:
- ✅ Listeners are correctly removed (no callback leaks) - ✅ Listeners are correctly removed (no callback leaks)
- ✅ Timers are cancelled (no background task leaks) - ✅ Timers are cancelled (no background task leaks)
- ✅ Config entry cleanup works (no dangling listeners) - ✅ Config entry cleanup works (no dangling listeners)

View file

@ -10,9 +10,9 @@ Add to `configuration.yaml`:
```yaml ```yaml
logger: logger:
default: info default: info
logs: logs:
custom_components.tibber_prices: debug custom_components.tibber_prices: debug
``` ```
Restart Home Assistant to apply. Restart Home Assistant to apply.
@ -20,7 +20,6 @@ Restart Home Assistant to apply.
### Key Log Messages ### Key Log Messages
**Coordinator Updates:** **Coordinator Updates:**
``` ```
[custom_components.tibber_prices.coordinator] Successfully fetched price data [custom_components.tibber_prices.coordinator] Successfully fetched price data
[custom_components.tibber_prices.coordinator] Cache valid, using cached data [custom_components.tibber_prices.coordinator] Cache valid, using cached data
@ -28,7 +27,6 @@ Restart Home Assistant to apply.
``` ```
**Period Calculation:** **Period Calculation:**
``` ```
[custom_components.tibber_prices.coordinator.periods] Calculating BEST PRICE periods: flex=15.0% [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 [custom_components.tibber_prices.coordinator.periods] Day 2024-12-06: Found 2 periods
@ -36,7 +34,6 @@ Restart Home Assistant to apply.
``` ```
**API Errors:** **API Errors:**
``` ```
[custom_components.tibber_prices.api] API request failed: Unauthorized [custom_components.tibber_prices.api] API request failed: Unauthorized
[custom_components.tibber_prices.api] Retrying (attempt 2/3) after 2.0s [custom_components.tibber_prices.api] Retrying (attempt 2/3) after 2.0s
@ -50,27 +47,26 @@ Restart Home Assistant to apply.
```json ```json
{ {
"version": "0.2.0", "version": "0.2.0",
"configurations": [ "configurations": [
{ {
"name": "Home Assistant", "name": "Home Assistant",
"type": "debugpy", "type": "debugpy",
"request": "launch", "request": "launch",
"module": "homeassistant", "module": "homeassistant",
"args": ["-c", "config", "--debug"], "args": ["-c", "config", "--debug"],
"justMyCode": false, "justMyCode": false,
"env": { "env": {
"PYTHONPATH": "${workspaceFolder}/.venv/lib/python3.13/site-packages" "PYTHONPATH": "${workspaceFolder}/.venv/lib/python3.13/site-packages"
} }
} }
] ]
} }
``` ```
### Set Breakpoints ### Set Breakpoints
**Coordinator update:** **Coordinator update:**
```python ```python
# coordinator/core.py # coordinator/core.py
async def _async_update_data(self) -> dict: async def _async_update_data(self) -> dict:
@ -79,7 +75,6 @@ async def _async_update_data(self) -> dict:
``` ```
**Period calculation:** **Period calculation:**
```python ```python
# coordinator/period_handlers/core.py # coordinator/period_handlers/core.py
def calculate_periods(...) -> list[dict]: def calculate_periods(...) -> list[dict]:
@ -96,7 +91,6 @@ def calculate_periods(...) -> list[dict]:
``` ```
**Flags:** **Flags:**
- `-v` - Verbose output - `-v` - Verbose output
- `-s` - Show print statements - `-s` - Show print statements
- `-k pattern` - Run tests matching pattern - `-k pattern` - Run tests matching pattern
@ -108,7 +102,6 @@ Set breakpoint in test file, use "Debug Test" CodeLens.
### Useful Test Patterns ### Useful Test Patterns
**Print coordinator data:** **Print coordinator data:**
```python ```python
def test_something(coordinator): def test_something(coordinator):
print(f"Coordinator data: {coordinator.data}") print(f"Coordinator data: {coordinator.data}")
@ -116,7 +109,6 @@ def test_something(coordinator):
``` ```
**Inspect period attributes:** **Inspect period attributes:**
```python ```python
def test_periods(hass, coordinator): def test_periods(hass, coordinator):
periods = coordinator.data.get('best_price_periods', []) periods = coordinator.data.get('best_price_periods', [])
@ -130,13 +122,11 @@ def test_periods(hass, coordinator):
### Integration Not Loading ### Integration Not Loading
**Check:** **Check:**
```bash ```bash
grep "tibber_prices" config/home-assistant.log grep "tibber_prices" config/home-assistant.log
``` ```
**Common causes:** **Common causes:**
- Syntax error in Python code → Check logs for traceback - Syntax error in Python code → Check logs for traceback
- Missing dependency → Run `uv sync` - Missing dependency → Run `uv sync`
- Wrong file permissions → `chmod +x scripts/*` - Wrong file permissions → `chmod +x scripts/*`
@ -144,14 +134,12 @@ grep "tibber_prices" config/home-assistant.log
### Sensors Not Updating ### Sensors Not Updating
**Check coordinator state:** **Check coordinator state:**
```python ```python
# In Developer Tools > Template # In Developer Tools > Template
{{ states.sensor.tibber_home_current_interval_price.last_updated }} {{ states.sensor.tibber_home_current_interval_price.last_updated }}
``` ```
**Debug in code:** **Debug in code:**
```python ```python
# Add logging in sensor/core.py # Add logging in sensor/core.py
_LOGGER.debug("Updating sensor %s: old=%s new=%s", _LOGGER.debug("Updating sensor %s: old=%s new=%s",
@ -161,7 +149,6 @@ _LOGGER.debug("Updating sensor %s: old=%s new=%s",
### Period Calculation Wrong ### Period Calculation Wrong
**Enable detailed period logs:** **Enable detailed period logs:**
```python ```python
# coordinator/period_handlers/period_building.py # coordinator/period_handlers/period_building.py
_LOGGER.debug("Candidate intervals: %s", _LOGGER.debug("Candidate intervals: %s",
@ -169,7 +156,6 @@ _LOGGER.debug("Candidate intervals: %s",
``` ```
**Check filter statistics:** **Check filter statistics:**
``` ```
[period_building] Flex filter blocked: 45 intervals [period_building] Flex filter blocked: 45 intervals
[period_building] Min distance blocked: 12 intervals [period_building] Min distance blocked: 12 intervals
@ -214,7 +200,6 @@ python -m pstats profile.stats
### Remote Debugging with debugpy ### Remote Debugging with debugpy
Add to coordinator code: Add to coordinator code:
```python ```python
import debugpy import debugpy
debugpy.listen(5678) debugpy.listen(5678)
@ -227,13 +212,11 @@ Connect from VS Code with remote attach configuration.
### IPython REPL ### IPython REPL
Install in container: Install in container:
```bash ```bash
uv pip install ipython uv pip install ipython
``` ```
Add breakpoint: Add breakpoint:
```python ```python
from IPython import embed from IPython import embed
embed() # Drops into interactive shell embed() # Drops into interactive shell
@ -242,7 +225,6 @@ embed() # Drops into interactive shell
--- ---
💡 **Related:** 💡 **Related:**
- [Testing Guide](testing.md) - Writing and running tests - [Testing Guide](testing.md) - Writing and running tests
- [Setup Guide](setup.md) - Development environment - [Setup Guide](setup.md) - Development environment
- [Architecture](architecture.md) - Code structure - [Architecture](architecture.md) - Code structure

View file

@ -8,25 +8,25 @@ This is an independent, community-maintained custom integration for Home Assista
## 📚 Developer Guides ## 📚 Developer Guides
- **[Setup](setup.md)** - DevContainer, environment setup, and dependencies - **[Setup](setup.md)** - DevContainer, environment setup, and dependencies
- **[Architecture](architecture.md)** - Code structure, patterns, and conventions - **[Architecture](architecture.md)** - Code structure, patterns, and conventions
- **[Period Calculation Theory](period-calculation-theory.md)** - Mathematical foundations, Flex/Distance interaction, Relaxation strategy - **[Period Calculation Theory](period-calculation-theory.md)** - Mathematical foundations, Flex/Distance interaction, Relaxation strategy
- **[Timer Architecture](timer-architecture.md)** - Timer system, scheduling, coordination (3 independent timers) - **[Timer Architecture](timer-architecture.md)** - Timer system, scheduling, coordination (3 independent timers)
- **[Caching Strategy](caching-strategy.md)** - Cache layers, invalidation, debugging - **[Caching Strategy](caching-strategy.md)** - Cache layers, invalidation, debugging
- **[Testing](testing.md)** - How to run tests and write new test cases - **[Testing](testing.md)** - How to run tests and write new test cases
- **[Release Management](release-management.md)** - Release workflow and versioning process - **[Release Management](release-management.md)** - Release workflow and versioning process
- **[Coding Guidelines](coding-guidelines.md)** - Style guide, linting, and best practices - **[Coding Guidelines](coding-guidelines.md)** - Style guide, linting, and best practices
- **[Refactoring Guide](refactoring-guide.md)** - How to plan and execute major refactorings - **[Refactoring Guide](refactoring-guide.md)** - How to plan and execute major refactorings
## 🤖 AI Documentation ## 🤖 AI Documentation
The main AI/Copilot documentation is in [`AGENTS.md`](https://github.com/jpawlowski/hass.tibber_prices/blob/main/AGENTS.md). This file serves as long-term memory for AI assistants and contains: The main AI/Copilot documentation is in [`AGENTS.md`](https://github.com/jpawlowski/hass.tibber_prices/blob/main/AGENTS.md). This file serves as long-term memory for AI assistants and contains:
- Detailed architectural patterns - Detailed architectural patterns
- Code quality rules and conventions - Code quality rules and conventions
- Development workflow guidance - Development workflow guidance
- Common pitfalls and anti-patterns - Common pitfalls and anti-patterns
- Project-specific patterns and utilities - Project-specific patterns and utilities
**Important:** When proposing changes to patterns or conventions, always update [`AGENTS.md`](https://github.com/jpawlowski/hass.tibber_prices/blob/main/AGENTS.md) to keep AI guidance consistent. **Important:** When proposing changes to patterns or conventions, always update [`AGENTS.md`](https://github.com/jpawlowski/hass.tibber_prices/blob/main/AGENTS.md) to keep AI guidance consistent.
@ -34,32 +34,32 @@ The main AI/Copilot documentation is in [`AGENTS.md`](https://github.com/jpawlow
This integration is developed with extensive AI assistance (GitHub Copilot, Claude, and other AI tools). The AI handles: This integration is developed with extensive AI assistance (GitHub Copilot, Claude, and other AI tools). The AI handles:
- **Pattern Recognition**: Understanding and applying Home Assistant best practices - **Pattern Recognition**: Understanding and applying Home Assistant best practices
- **Code Generation**: Implementing features with proper type hints, error handling, and documentation - **Code Generation**: Implementing features with proper type hints, error handling, and documentation
- **Refactoring**: Maintaining consistency across the codebase during structural changes - **Refactoring**: Maintaining consistency across the codebase during structural changes
- **Translation Management**: Keeping 5 language files synchronized - **Translation Management**: Keeping 5 language files synchronized
- **Documentation**: Generating and maintaining comprehensive documentation - **Documentation**: Generating and maintaining comprehensive documentation
**Quality Assurance:** **Quality Assurance:**
- Automated linting with Ruff (120-char line length, max complexity 25) - Automated linting with Ruff (120-char line length, max complexity 25)
- Home Assistant's type checking and validation - Home Assistant's type checking and validation
- Real-world testing in development environment - Real-world testing in development environment
- Code review by maintainer before merging - Code review by maintainer before merging
**Benefits:** **Benefits:**
- Rapid feature development while maintaining quality - Rapid feature development while maintaining quality
- Consistent code patterns across all modules - Consistent code patterns across all modules
- Comprehensive documentation maintained alongside code - Comprehensive documentation maintained alongside code
- Quick bug fixes with proper understanding of context - Quick bug fixes with proper understanding of context
**Limitations:** **Limitations:**
- AI may occasionally miss edge cases or subtle bugs - AI may occasionally miss edge cases or subtle bugs
- Some complex Home Assistant patterns may need human review - Some complex Home Assistant patterns may need human review
- Translation quality depends on AI's understanding of target language - Translation quality depends on AI's understanding of target language
- User feedback is crucial for discovering real-world issues - User feedback is crucial for discovering real-world issues
If you're working with AI tools on this project, the [`AGENTS.md`](https://github.com/jpawlowski/hass.tibber_prices/blob/main/AGENTS.md) file provides the context and patterns that ensure consistency. If you're working with AI tools on this project, the [`AGENTS.md`](https://github.com/jpawlowski/hass.tibber_prices/blob/main/AGENTS.md) file provides the context and patterns that ensure consistency.
@ -80,15 +80,15 @@ If you're working with AI tools on this project, the [`AGENTS.md`](https://githu
The project includes several helper scripts in `./scripts/`: The project includes several helper scripts in `./scripts/`:
- `bootstrap` - Initial setup of dependencies - `bootstrap` - Initial setup of dependencies
- `develop` - Start Home Assistant in debug mode (auto-cleans .egg-info) - `develop` - Start Home Assistant in debug mode (auto-cleans .egg-info)
- `clean` - Remove build artifacts and caches - `clean` - Remove build artifacts and caches
- `lint` - Auto-fix code issues with ruff - `lint` - Auto-fix code issues with ruff
- `lint-check` - Check code without modifications (CI mode) - `lint-check` - Check code without modifications (CI mode)
- `hassfest` - Validate integration structure (JSON, Python syntax, required files) - `hassfest` - Validate integration structure (JSON, Python syntax, required files)
- `setup` - Install development tools (git-cliff, @github/copilot) - `setup` - Install development tools (git-cliff, @github/copilot)
- `prepare-release` - Prepare a new release (bump version, create tag) - `prepare-release` - Prepare a new release (bump version, create tag)
- `generate-release-notes` - Generate release notes from commits - `generate-release-notes` - Generate release notes from commits
## 📦 Project Structure ## 📦 Project Structure
@ -121,23 +121,23 @@ custom_components/tibber_prices/
**DataUpdateCoordinator Pattern:** **DataUpdateCoordinator Pattern:**
- Centralized data fetching and caching - Centralized data fetching and caching
- Automatic entity updates on data changes - Automatic entity updates on data changes
- Persistent storage via `Store` - Persistent storage via `Store`
- Quarter-hour boundary refresh scheduling - Quarter-hour boundary refresh scheduling
**Price Data Enrichment:** **Price Data Enrichment:**
- Raw API data is enriched with statistical analysis - Raw API data is enriched with statistical analysis
- Trailing/leading 24h averages calculated per interval - Trailing/leading 24h averages calculated per interval
- Price differences and ratings added - Price differences and ratings added
- All via pure functions in `price_utils.py` - All via pure functions in `price_utils.py`
**Translation System:** **Translation System:**
- Dual system: `/translations/` (HA schema) + `/custom_translations/` (extended) - Dual system: `/translations/` (HA schema) + `/custom_translations/` (extended)
- Both must stay in sync across all languages (de, en, nb, nl, sv) - Both must stay in sync across all languages (de, en, nb, nl, sv)
- Async loading at integration setup - Async loading at integration setup
## 🧪 Testing ## 🧪 Testing
@ -159,19 +159,18 @@ pytest --cov=custom_components.tibber_prices tests/
Documentation is organized in two Docusaurus sites: Documentation is organized in two Docusaurus sites:
- **User docs** (`docs/user/`): Installation, configuration, usage guides - **User docs** (`docs/user/`): Installation, configuration, usage guides
- Markdown files in `docs/user/docs/*.md` - Markdown files in `docs/user/docs/*.md`
- Navigation managed via `docs/user/sidebars.ts` - Navigation managed via `docs/user/sidebars.ts`
- **Developer docs** (`docs/developer/`): Architecture, patterns, contribution guides - **Developer docs** (`docs/developer/`): Architecture, patterns, contribution guides
- Markdown files in `docs/developer/docs/*.md` - Markdown files in `docs/developer/docs/*.md`
- Navigation managed via `docs/developer/sidebars.ts` - Navigation managed via `docs/developer/sidebars.ts`
- **AI guidance**: `AGENTS.md` (patterns, conventions, long-term memory) - **AI guidance**: `AGENTS.md` (patterns, conventions, long-term memory)
**Best practices:** **Best practices:**
- Use clear examples and code snippets
- Use clear examples and code snippets - Keep docs up-to-date with code changes
- Keep docs up-to-date with code changes - Add new pages to appropriate `sidebars.ts` for navigation
- Add new pages to appropriate `sidebars.ts` for navigation
## 🤝 Contributing ## 🤝 Contributing

View file

@ -5,7 +5,6 @@ Guidelines for maintaining and improving integration performance.
## Performance Goals ## Performance Goals
Target metrics: Target metrics:
- **Coordinator update**: &lt;500ms (typical: 200-300ms) - **Coordinator update**: &lt;500ms (typical: 200-300ms)
- **Sensor update**: &lt;10ms per sensor - **Sensor update**: &lt;10ms per sensor
- **Period calculation**: &lt;100ms (typical: 20-50ms) - **Period calculation**: &lt;100ms (typical: 20-50ms)
@ -65,7 +64,6 @@ python -m aioprof homeassistant -c config
### Caching ### Caching
**1. Persistent Cache** (API data): **1. Persistent Cache** (API data):
```python ```python
# Already implemented in coordinator/cache.py # Already implemented in coordinator/cache.py
store = Store(hass, STORAGE_VERSION, STORAGE_KEY) store = Store(hass, STORAGE_VERSION, STORAGE_KEY)
@ -73,7 +71,6 @@ data = await store.async_load()
``` ```
**2. Translation Cache** (in-memory): **2. Translation Cache** (in-memory):
```python ```python
# Already implemented in const.py # Already implemented in const.py
_TRANSLATION_CACHE: dict[str, dict] = {} _TRANSLATION_CACHE: dict[str, dict] = {}
@ -86,7 +83,6 @@ def get_translation(path: str, language: str) -> dict:
``` ```
**3. Config Cache** (invalidated on options change): **3. Config Cache** (invalidated on options change):
```python ```python
class DataTransformer: class DataTransformer:
def __init__(self): def __init__(self):
@ -104,7 +100,6 @@ class DataTransformer:
### Lazy Loading ### Lazy Loading
**Load data only when needed:** **Load data only when needed:**
```python ```python
@property @property
def extra_state_attributes(self) -> dict | None: def extra_state_attributes(self) -> dict | None:
@ -118,7 +113,6 @@ def extra_state_attributes(self) -> dict | None:
### Bulk Operations ### Bulk Operations
**Process multiple items at once:** **Process multiple items at once:**
```python ```python
# ❌ Slow - loop with individual operations # ❌ Slow - loop with individual operations
for interval in intervals: for interval in intervals:
@ -132,7 +126,6 @@ results = enrich_intervals_bulk(intervals)
### Async Best Practices ### Async Best Practices
**1. Concurrent API calls:** **1. Concurrent API calls:**
```python ```python
# ❌ Sequential (slow) # ❌ Sequential (slow)
user_data = await fetch_user_data() user_data = await fetch_user_data()
@ -146,7 +139,6 @@ user_data, price_data = await asyncio.gather(
``` ```
**2. Don't block event loop:** **2. Don't block event loop:**
```python ```python
# ❌ Blocking # ❌ Blocking
result = heavy_computation() # Blocks for seconds result = heavy_computation() # Blocks for seconds
@ -160,7 +152,6 @@ result = await hass.async_add_executor_job(heavy_computation)
### Avoid Memory Leaks ### Avoid Memory Leaks
**1. Clear references:** **1. Clear references:**
```python ```python
class Coordinator: class Coordinator:
async def async_shutdown(self): async def async_shutdown(self):
@ -171,7 +162,6 @@ class Coordinator:
``` ```
**2. Use weak references for callbacks:** **2. Use weak references for callbacks:**
```python ```python
import weakref import weakref
@ -186,7 +176,6 @@ class Manager:
### Efficient Data Structures ### Efficient Data Structures
**Use appropriate types:** **Use appropriate types:**
```python ```python
# ❌ List for lookups (O(n)) # ❌ List for lookups (O(n))
if timestamp in timestamp_list: if timestamp in timestamp_list:
@ -208,13 +197,11 @@ results = (x for x in items if condition(x))
### Minimize API Calls ### Minimize API Calls
**Already implemented:** **Already implemented:**
- Cache valid until midnight - Cache valid until midnight
- User data cached for 24h - User data cached for 24h
- Only poll when tomorrow data expected - Only poll when tomorrow data expected
**Monitor API usage:** **Monitor API usage:**
```python ```python
_LOGGER.debug("API call: %s (cache_age=%s)", _LOGGER.debug("API call: %s (cache_age=%s)",
endpoint, cache_age) endpoint, cache_age)
@ -223,7 +210,6 @@ _LOGGER.debug("API call: %s (cache_age=%s)",
### Smart Updates ### Smart Updates
**Only update when needed:** **Only update when needed:**
```python ```python
async def _async_update_data(self) -> dict: async def _async_update_data(self) -> dict:
"""Fetch data from API.""" """Fetch data from API."""
@ -240,7 +226,6 @@ async def _async_update_data(self) -> dict:
### State Class Selection ### State Class Selection
**Affects long-term statistics storage:** **Affects long-term statistics storage:**
```python ```python
# ❌ MEASUREMENT for prices (stores every change) # ❌ MEASUREMENT for prices (stores every change)
state_class=SensorStateClass.MEASUREMENT # ~35K records/year state_class=SensorStateClass.MEASUREMENT # ~35K records/year
@ -255,7 +240,6 @@ state_class=SensorStateClass.TOTAL # For cumulative values
### Attribute Size ### Attribute Size
**Keep attributes minimal:** **Keep attributes minimal:**
```python ```python
# ❌ Large nested structures (KB per update) # ❌ Large nested structures (KB per update)
attributes = { attributes = {
@ -333,7 +317,6 @@ _LOGGER.debug("Current memory usage: %.2f MB", memory_mb)
--- ---
💡 **Related:** 💡 **Related:**
- [Caching Strategy](caching-strategy.md) - Cache layers - [Caching Strategy](caching-strategy.md) - Cache layers
- [Architecture](architecture.md) - System design - [Architecture](architecture.md) - System design
- [Debugging](debugging.md) - Profiling tools - [Debugging](debugging.md) - Profiling tools

View file

@ -7,7 +7,6 @@ This document explains the mathematical foundations and design decisions behind
**Target Audience:** Developers maintaining or extending the period calculation logic. **Target Audience:** Developers maintaining or extending the period calculation logic.
**Related Files:** **Related Files:**
- `coordinator/period_handlers/core.py` - Main calculation entry point - `coordinator/period_handlers/core.py` - Main calculation entry point
- `coordinator/period_handlers/level_filtering.py` - Flex and distance filtering - `coordinator/period_handlers/level_filtering.py` - Flex and distance filtering
- `coordinator/period_handlers/relaxation.py` - Multi-phase relaxation strategy - `coordinator/period_handlers/relaxation.py` - Multi-phase relaxation strategy
@ -24,7 +23,6 @@ Period detection uses **three independent filters** (all must pass):
**Purpose:** Limit how far prices can deviate from the daily min/max. **Purpose:** Limit how far prices can deviate from the daily min/max.
**Logic:** **Logic:**
```python ```python
# Best Price: Price must be within flex% ABOVE daily minimum # Best Price: Price must be within flex% ABOVE daily minimum
in_flex = price <= (daily_min + daily_min × flex) in_flex = price <= (daily_min + daily_min × flex)
@ -34,7 +32,6 @@ in_flex = price >= (daily_max - daily_max × flex)
``` ```
**Example (Best Price):** **Example (Best Price):**
- Daily Min: 10 ct/kWh - Daily Min: 10 ct/kWh
- Flex: 15% - Flex: 15%
- Acceptance Range: 0 - 11.5 ct/kWh (10 + 10×0.15) - Acceptance Range: 0 - 11.5 ct/kWh (10 + 10×0.15)
@ -44,7 +41,6 @@ in_flex = price >= (daily_max - daily_max × flex)
**Purpose:** Ensure periods are **significantly** cheaper/more expensive than average, not just marginally better. **Purpose:** Ensure periods are **significantly** cheaper/more expensive than average, not just marginally better.
**Logic:** **Logic:**
```python ```python
# Best Price: Price must be at least min_distance% BELOW daily average # Best Price: Price must be at least min_distance% BELOW daily average
meets_distance = price <= (daily_avg × (1 - min_distance/100)) meets_distance = price <= (daily_avg × (1 - min_distance/100))
@ -54,7 +50,6 @@ meets_distance = price >= (daily_avg × (1 + min_distance/100))
``` ```
**Example (Best Price):** **Example (Best Price):**
- Daily Avg: 15 ct/kWh - Daily Avg: 15 ct/kWh
- Min Distance: 5% - Min Distance: 5%
- Acceptance Range: 0 - 14.25 ct/kWh (15 × 0.95) - Acceptance Range: 0 - 14.25 ct/kWh (15 × 0.95)
@ -70,17 +65,17 @@ meets_distance = price >= (daily_avg × (1 + min_distance/100))
The integration maintains **two independent sets** of volatility thresholds: The integration maintains **two independent sets** of volatility thresholds:
1. **Sensor Thresholds** (user-configurable via `CONF_VOLATILITY_*_THRESHOLD`) 1. **Sensor Thresholds** (user-configurable via `CONF_VOLATILITY_*_THRESHOLD`)
- Purpose: Display classification in `sensor.tibber_home_volatility_*` - Purpose: Display classification in `sensor.tibber_home_volatility_*`
- Default: LOW < 10%, MEDIUM < 20%, HIGH 20% - Default: LOW < 10%, MEDIUM < 20%, HIGH 20%
- User can adjust in config flow options - User can adjust in config flow options
- Affects: Sensor state/attributes only - Affects: Sensor state/attributes only
2. **Period Filter Thresholds** (internal, fixed) 2. **Period Filter Thresholds** (internal, fixed)
- Purpose: Level filter criteria when using `level="volatility_low"` etc. - Purpose: Level filter criteria when using `level="volatility_low"` etc.
- Source: `PRICE_LEVEL_THRESHOLDS` in `const.py` - Source: `PRICE_LEVEL_THRESHOLDS` in `const.py`
- Values: Same as sensor defaults (LOW < 10%, MEDIUM < 20%, HIGH 20%) - Values: Same as sensor defaults (LOW < 10%, MEDIUM < 20%, HIGH 20%)
- User **cannot** adjust these - User **cannot** adjust these
- Affects: Period candidate selection - Affects: Period candidate selection
**Rationale for Separation:** **Rationale for Separation:**
@ -91,7 +86,6 @@ 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 - Period calculation has many interacting filters (Flex, Distance, Level) - exposing all internals would be error-prone
**Implementation:** **Implementation:**
```python ```python
# Sensor classification uses user config # Sensor classification uses user config
user_low_threshold = config_entry.options.get(CONF_VOLATILITY_LOW_THRESHOLD, 10) user_low_threshold = config_entry.options.get(CONF_VOLATILITY_LOW_THRESHOLD, 10)
@ -113,42 +107,36 @@ period_low_threshold = PRICE_LEVEL_THRESHOLDS["volatility_low"] # Always 10%
#### Scenario: Best Price with Flex=50%, Min_Distance=5% #### Scenario: Best Price with Flex=50%, Min_Distance=5%
**Given:** **Given:**
- Daily Min: 10 ct/kWh - Daily Min: 10 ct/kWh
- Daily Avg: 15 ct/kWh - Daily Avg: 15 ct/kWh
- Daily Max: 20 ct/kWh - Daily Max: 20 ct/kWh
**Flex Filter (50%):** **Flex Filter (50%):**
``` ```
Max accepted = 10 + (10 × 0.50) = 15 ct/kWh Max accepted = 10 + (10 × 0.50) = 15 ct/kWh
``` ```
**Min Distance Filter (5%):** **Min Distance Filter (5%):**
``` ```
Max accepted = 15 × (1 - 0.05) = 14.25 ct/kWh Max accepted = 15 × (1 - 0.05) = 14.25 ct/kWh
``` ```
**Conflict:** **Conflict:**
- Interval at 14.8 ct/kWh: - Interval at 14.8 ct/kWh:
- ✅ Flex: 14.8 ≤ 15 (PASS) - ✅ Flex: 14.8 ≤ 15 (PASS)
- ❌ Distance: 14.8 > 14.25 (FAIL) - ❌ Distance: 14.8 > 14.25 (FAIL)
- **Result:** Rejected by Min_Distance even though Flex allows it! - **Result:** Rejected by Min_Distance even though Flex allows it!
**The Issue:** At high Flex values, Min_Distance becomes the dominant filter and blocks intervals that Flex would permit. This defeats the purpose of having high Flex. **The Issue:** At high Flex values, Min_Distance becomes the dominant filter and blocks intervals that Flex would permit. This defeats the purpose of having high Flex.
### Mathematical Analysis ### Mathematical Analysis
**Conflict condition for Best Price:** **Conflict condition for Best Price:**
``` ```
daily_min × (1 + flex) > daily_avg × (1 - min_distance/100) daily_min × (1 + flex) > daily_avg × (1 - min_distance/100)
``` ```
**Typical values:** **Typical values:**
- Min = 10, Avg = 15, Min_Distance = 5% - Min = 10, Avg = 15, Min_Distance = 5%
- Conflict occurs when: `10 × (1 + flex) > 14.25` - Conflict occurs when: `10 × (1 + flex) > 14.25`
- Simplify: `flex > 0.425` (42.5%) - Simplify: `flex > 0.425` (42.5%)
@ -161,7 +149,6 @@ daily_min × (1 + flex) > daily_avg × (1 - min_distance/100)
**Approach:** Reduce Min_Distance proportionally as Flex increases. **Approach:** Reduce Min_Distance proportionally as Flex increases.
**Formula:** **Formula:**
```python ```python
if flex > 0.20: # 20% threshold if flex > 0.20: # 20% threshold
flex_excess = flex - 0.20 flex_excess = flex - 0.20
@ -171,16 +158,15 @@ if flex > 0.20: # 20% threshold
**Scaling Table (Original Min_Distance = 5%):** **Scaling Table (Original Min_Distance = 5%):**
| Flex | Scale Factor | Adjusted Min_Distance | Rationale | | Flex | Scale Factor | Adjusted Min_Distance | Rationale |
| ---- | ------------ | --------------------- | --------------------------------- | |-------|--------------|----------------------|-----------|
| ≤20% | 1.00 | 5.0% | Standard - both filters relevant | | ≤20% | 1.00 | 5.0% | Standard - both filters relevant |
| 25% | 0.88 | 4.4% | Slight reduction | | 25% | 0.88 | 4.4% | Slight reduction |
| 30% | 0.75 | 3.75% | Moderate reduction | | 30% | 0.75 | 3.75% | Moderate reduction |
| 40% | 0.50 | 2.5% | Strong reduction - Flex dominates | | 40% | 0.50 | 2.5% | Strong reduction - Flex dominates |
| 50% | 0.25 | 1.25% | Minimal distance - Flex decides | | 50% | 0.25 | 1.25% | Minimal distance - Flex decides |
**Why stop at 25% of original?** **Why stop at 25% of original?**
- Min_Distance ensures periods are **significantly** different from average - Min_Distance ensures periods are **significantly** different from average
- Even at 1.25%, prevents "flat days" (little price variation) from accepting every interval - 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" - Maintains semantic meaning: "this is a meaningful best/peak price period"
@ -188,7 +174,6 @@ if flex > 0.20: # 20% threshold
**Implementation:** See `level_filtering.py``check_interval_criteria()` **Implementation:** See `level_filtering.py``check_interval_criteria()`
**Code Extract:** **Code Extract:**
```python ```python
# coordinator/period_handlers/level_filtering.py # coordinator/period_handlers/level_filtering.py
@ -224,14 +209,12 @@ def check_interval_criteria(price, criteria):
``` ```
**Why Linear Scaling?** **Why Linear Scaling?**
- Simple and predictable - Simple and predictable
- No abrupt behavior changes - No abrupt behavior changes
- Easy to reason about for users and developers - Easy to reason about for users and developers
- Alternative considered: Exponential scaling (rejected as too aggressive) - Alternative considered: Exponential scaling (rejected as too aggressive)
**Why 25% Minimum?** **Why 25% Minimum?**
- Below this, min_distance loses semantic meaning - Below this, min_distance loses semantic meaning
- Even on flat days, some quality filter needed - Even on flat days, some quality filter needed
- Prevents "every interval is a period" scenario - Prevents "every interval is a period" scenario
@ -244,14 +227,12 @@ def check_interval_criteria(price, criteria):
### Implementation Constants ### Implementation Constants
**Defined in `coordinator/period_handlers/core.py`:** **Defined in `coordinator/period_handlers/core.py`:**
```python ```python
MAX_SAFE_FLEX = 0.50 # 50% - hard cap: above this, period detection becomes unreliable 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 MAX_OUTLIER_FLEX = 0.25 # 25% - cap for outlier filtering: above this, spike detection too permissive
``` ```
**Defined in `const.py`:** **Defined in `const.py`:**
```python ```python
DEFAULT_BEST_PRICE_FLEX = 15 # 15% base - optimal for relaxation mode (default enabled) DEFAULT_BEST_PRICE_FLEX = 15 # 15% base - optimal for relaxation mode (default enabled)
DEFAULT_PEAK_PRICE_FLEX = -20 # 20% base (negative for peak detection) DEFAULT_PEAK_PRICE_FLEX = -20 # 20% base (negative for peak detection)
@ -274,19 +255,16 @@ The different defaults reflect fundamentally different use cases:
**Goal:** Find practical time windows for running appliances **Goal:** Find practical time windows for running appliances
**Constraints:** **Constraints:**
- Appliances need time to complete cycles (dishwasher: 2-3h, EV charging: 4-8h) - Appliances need time to complete cycles (dishwasher: 2-3h, EV charging: 4-8h)
- Short periods are impractical (not worth automation overhead) - Short periods are impractical (not worth automation overhead)
- User wants genuinely cheap times, not just "slightly below average" - User wants genuinely cheap times, not just "slightly below average"
**Defaults:** **Defaults:**
- **60 min minimum** - Ensures period is long enough for meaningful use - **60 min minimum** - Ensures period is long enough for meaningful use
- **15% flex** - Stricter selection, focuses on truly cheap times - **15% flex** - Stricter selection, focuses on truly cheap times
- **Reasoning:** Better to find fewer, higher-quality periods than many mediocre ones - **Reasoning:** Better to find fewer, higher-quality periods than many mediocre ones
**User behavior:** **User behavior:**
- Automations trigger actions (turn on devices) - Automations trigger actions (turn on devices)
- Wrong automation = wasted energy/money - Wrong automation = wasted energy/money
- Preference: Conservative (miss some savings) over aggressive (false positives) - Preference: Conservative (miss some savings) over aggressive (false positives)
@ -296,19 +274,16 @@ The different defaults reflect fundamentally different use cases:
**Goal:** Alert users to expensive periods for consumption reduction **Goal:** Alert users to expensive periods for consumption reduction
**Constraints:** **Constraints:**
- Brief price spikes still matter (even 15-30 min is worth avoiding) - Brief price spikes still matter (even 15-30 min is worth avoiding)
- Early warning more valuable than perfect accuracy - Early warning more valuable than perfect accuracy
- User can manually decide whether to react - User can manually decide whether to react
**Defaults:** **Defaults:**
- **30 min minimum** - Catches shorter expensive spikes - **30 min minimum** - Catches shorter expensive spikes
- **20% flex** - More permissive, earlier detection - **20% flex** - More permissive, earlier detection
- **Reasoning:** Better to warn early (even if not peak) than miss expensive periods - **Reasoning:** Better to warn early (even if not peak) than miss expensive periods
**User behavior:** **User behavior:**
- Notifications/alerts (informational) - Notifications/alerts (informational)
- Wrong alert = minor inconvenience, not cost - Wrong alert = minor inconvenience, not cost
- Preference: Sensitive (catch more) over specific (catch only extremes) - Preference: Sensitive (catch more) over specific (catch only extremes)
@ -318,20 +293,17 @@ The different defaults reflect fundamentally different use cases:
**Peak Price Volatility:** **Peak Price Volatility:**
Price curves tend to have: Price curves tend to have:
- **Sharp spikes** during peak hours (morning/evening) - **Sharp spikes** during peak hours (morning/evening)
- **Shorter duration** at maximum (1-2 hours typical) - **Shorter duration** at maximum (1-2 hours typical)
- **Higher variance** in peak times than cheap times - **Higher variance** in peak times than cheap times
**Example day:** **Example day:**
``` ```
Cheap period: 02:00-07:00 (5 hours at 10-12 ct) ← Gradual, stable 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 Expensive period: 17:00-18:30 (1.5 hours at 35-40 ct) ← Sharp, brief
``` ```
**Implication:** **Implication:**
- Stricter flex on peak (15%) might miss real expensive periods (too brief) - Stricter flex on peak (15%) might miss real expensive periods (too brief)
- Longer min_length (60 min) might exclude legitimate spikes - Longer min_length (60 min) might exclude legitimate spikes
- Solution: More flexible thresholds for peak detection - Solution: More flexible thresholds for peak detection
@ -339,19 +311,16 @@ Expensive period: 17:00-18:30 (1.5 hours at 35-40 ct) ← Sharp, brief
#### Design Alternatives Considered #### Design Alternatives Considered
**Option 1: Symmetric defaults (rejected)** **Option 1: Symmetric defaults (rejected)**
- Both 60 min, both 15% flex - Both 60 min, both 15% flex
- Problem: Misses short but expensive spikes - Problem: Misses short but expensive spikes
- User feedback: "Why didn't I get warned about the 30-min price spike?" - User feedback: "Why didn't I get warned about the 30-min price spike?"
**Option 2: Same defaults, let users figure it out (rejected)** **Option 2: Same defaults, let users figure it out (rejected)**
- No guidance on best practices - No guidance on best practices
- Users would need to experiment to find good values - Users would need to experiment to find good values
- Most users stick with defaults, so defaults matter - Most users stick with defaults, so defaults matter
**Option 3: Current approach (adopted)** **Option 3: Current approach (adopted)**
- **All values user-configurable** via config flow options - **All values user-configurable** via config flow options
- **Different installation defaults** for Best Price vs. Peak Price - **Different installation defaults** for Best Price vs. Peak Price
- Defaults reflect recommended practices for each use case - Defaults reflect recommended practices for each use case
@ -367,14 +336,12 @@ 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%) **Enforcement:** `core.py` caps `abs(flex)` at 0.50 (50%)
**Rationale:** **Rationale:**
- Above 50%, period detection becomes unreliable - Above 50%, period detection becomes unreliable
- Best Price: Almost entire day qualifies (Min + 50% typically covers 60-80% of intervals) - Best Price: Almost entire day qualifies (Min + 50% typically covers 60-80% of intervals)
- Peak Price: Similar issue with Max - 50% - Peak Price: Similar issue with Max - 50%
- **Result:** Either massive periods (entire day) or no periods (min_length not met) - **Result:** Either massive periods (entire day) or no periods (min_length not met)
**Warning Message:** **Warning Message:**
``` ```
Flex XX% exceeds maximum safe value! Capping at 50%. Flex XX% exceeds maximum safe value! Capping at 50%.
Recommendation: Use 15-20% with relaxation enabled, or 25-35% without relaxation. Recommendation: Use 15-20% with relaxation enabled, or 25-35% without relaxation.
@ -385,7 +352,6 @@ Recommendation: Use 15-20% with relaxation enabled, or 25-35% without relaxation
**Enforcement:** `core.py` caps outlier filtering flex at 0.25 (25%) **Enforcement:** `core.py` caps outlier filtering flex at 0.25 (25%)
**Rationale:** **Rationale:**
- Outlier filtering uses Flex to determine "stable context" threshold - Outlier filtering uses Flex to determine "stable context" threshold
- At > 25% Flex, almost any price swing is considered "stable" - At > 25% Flex, almost any price swing is considered "stable"
- **Result:** Legitimate price shifts aren't smoothed, breaking period formation - **Result:** Legitimate price shifts aren't smoothed, breaking period formation
@ -397,28 +363,23 @@ Recommendation: Use 15-20% with relaxation enabled, or 25-35% without relaxation
#### With Relaxation Enabled (Recommended) #### With Relaxation Enabled (Recommended)
**Optimal:** 10-20% **Optimal:** 10-20%
- Relaxation increases Flex incrementally: 15% → 18% → 21% → ... - Relaxation increases Flex incrementally: 15% → 18% → 21% → ...
- Low baseline ensures relaxation has room to work - Low baseline ensures relaxation has room to work
**Warning Threshold:** > 25% **Warning Threshold:** > 25%
- INFO log: "Base flex is on the high side" - INFO log: "Base flex is on the high side"
**High Warning:** > 30% **High Warning:** > 30%
- WARNING log: "Base flex is very high for relaxation mode!" - WARNING log: "Base flex is very high for relaxation mode!"
- Recommendation: Lower to 15-20% - Recommendation: Lower to 15-20%
#### Without Relaxation #### Without Relaxation
**Optimal:** 20-35% **Optimal:** 20-35%
- No automatic adjustment, must be sufficient from start - No automatic adjustment, must be sufficient from start
- Higher baseline acceptable since no relaxation fallback - Higher baseline acceptable since no relaxation fallback
**Maximum Useful:** ~50% **Maximum Useful:** ~50%
- Above this, period detection degrades (see Hard Limits) - Above this, period detection degrades (see Hard Limits)
--- ---
@ -434,7 +395,6 @@ Ensure **minimum periods per day** are found even when baseline filters are too
### Multi-Phase Approach ### Multi-Phase Approach
**Each day processed independently:** **Each day processed independently:**
1. Calculate baseline periods with user's config 1. Calculate baseline periods with user's config
2. If insufficient periods found, enter relaxation loop 2. If insufficient periods found, enter relaxation loop
3. Try progressively relaxed filter combinations 3. Try progressively relaxed filter combinations
@ -458,7 +418,6 @@ for attempt in range(max_relaxation_attempts):
``` ```
**Constants:** **Constants:**
```python ```python
FLEX_WARNING_THRESHOLD_RELAXATION = 0.25 # 25% - INFO: suggest lowering to 15-20% 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 FLEX_HIGH_THRESHOLD_RELAXATION = 0.30 # 30% - WARNING: very high for relaxation mode
@ -468,27 +427,26 @@ MAX_FLEX_HARD_LIMIT = 0.50 # 50% - absolute maximum (enforced in core.py)
**Design Decisions:** **Design Decisions:**
1. **Why 3% fixed increment?** 1. **Why 3% fixed increment?**
- Predictable escalation path (15% → 18% → 21% → ...) - Predictable escalation path (15% → 18% → 21% → ...)
- Independent of base flex (works consistently) - Independent of base flex (works consistently)
- 11 attempts covers full useful range (15% → 48%) - 11 attempts covers full useful range (15% → 48%)
- Balance: Not too slow (2%), not too fast (5%) - Balance: Not too slow (2%), not too fast (5%)
2. **Why hard-coded, not configurable?** 2. **Why hard-coded, not configurable?**
- Prevents user misconfiguration - Prevents user misconfiguration
- Simplifies mental model (fewer knobs to turn) - Simplifies mental model (fewer knobs to turn)
- Reliable behavior across all configurations - Reliable behavior across all configurations
- If needed, user adjusts `max_relaxation_attempts` (fewer/more steps) - If needed, user adjusts `max_relaxation_attempts` (fewer/more steps)
3. **Why warn at 25% base flex?** 3. **Why warn at 25% base flex?**
- At 25% base, first relaxation step reaches 28% - At 25% base, first relaxation step reaches 28%
- Above 30%, entering diminishing returns territory - Above 30%, entering diminishing returns territory
- User likely doesn't need relaxation with such high base flex - User likely doesn't need relaxation with such high base flex
- Should either: (a) lower base flex, or (b) disable relaxation - Should either: (a) lower base flex, or (b) disable relaxation
**Historical Context (Pre-November 2025):** **Historical Context (Pre-November 2025):**
The algorithm previously used percentage-based increments that scaled with base flex: The algorithm previously used percentage-based increments that scaled with base flex:
```python ```python
increment = base_flex × (step_pct / 100) # REMOVED increment = base_flex × (step_pct / 100) # REMOVED
``` ```
@ -496,7 +454,6 @@ 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. 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:** **Warning Messages:**
```python ```python
if base_flex >= FLEX_HIGH_THRESHOLD_RELAXATION: # 30% if base_flex >= FLEX_HIGH_THRESHOLD_RELAXATION: # 30%
_LOGGER.warning( _LOGGER.warning(
@ -515,14 +472,12 @@ elif base_flex >= FLEX_WARNING_THRESHOLD_RELAXATION: # 25%
### Filter Combination Strategy ### Filter Combination Strategy
**Per Flex level, try in order:** **Per Flex level, try in order:**
1. Original Level filter 1. Original Level filter
2. Level filter = "any" (disabled) 2. Level filter = "any" (disabled)
**Early Exit:** Stop immediately when target reached (don't try unnecessary combinations) **Early Exit:** Stop immediately when target reached (don't try unnecessary combinations)
**Example Flow (target=2 periods/day):** **Example Flow (target=2 periods/day):**
``` ```
Day 2025-11-19: Day 2025-11-19:
1. Baseline flex=15%: Found 1 period (need 2) 1. Baseline flex=15%: Found 1 period (need 2)
@ -537,7 +492,6 @@ Day 2025-11-19:
### Key Files and Functions ### Key Files and Functions
**Period Calculation Entry Point:** **Period Calculation Entry Point:**
```python ```python
# coordinator/period_handlers/core.py # coordinator/period_handlers/core.py
def calculate_periods( def calculate_periods(
@ -548,7 +502,6 @@ def calculate_periods(
``` ```
**Flex + Distance Filtering:** **Flex + Distance Filtering:**
```python ```python
# coordinator/period_handlers/level_filtering.py # coordinator/period_handlers/level_filtering.py
def check_interval_criteria( def check_interval_criteria(
@ -558,7 +511,6 @@ def check_interval_criteria(
``` ```
**Relaxation Orchestration:** **Relaxation Orchestration:**
```python ```python
# coordinator/period_handlers/relaxation.py # coordinator/period_handlers/relaxation.py
def calculate_periods_with_relaxation(...) -> tuple[dict, dict] def calculate_periods_with_relaxation(...) -> tuple[dict, dict]
@ -574,45 +526,43 @@ def relax_single_day(...) -> tuple[dict, dict]
**Algorithm Details:** **Algorithm Details:**
1. **Linear Regression Prediction:** 1. **Linear Regression Prediction:**
- Uses surrounding intervals to predict expected price - Uses surrounding intervals to predict expected price
- Window size: 3+ intervals (MIN_CONTEXT_SIZE) - Window size: 3+ intervals (MIN_CONTEXT_SIZE)
- Calculates trend slope and standard deviation - Calculates trend slope and standard deviation
- Formula: `predicted = mean + slope × (position - center)` - Formula: `predicted = mean + slope × (position - center)`
2. **Confidence Intervals:** 2. **Confidence Intervals:**
- 95% confidence level (2 standard deviations) - 95% confidence level (2 standard deviations)
- Tolerance = 2.0 × std_dev (CONFIDENCE_LEVEL constant) - Tolerance = 2.0 × std_dev (CONFIDENCE_LEVEL constant)
- Outlier if: `|actual - predicted| > tolerance` - Outlier if: `|actual - predicted| > tolerance`
- Accounts for natural price volatility in context window - Accounts for natural price volatility in context window
3. **Symmetry Check:** 3. **Symmetry Check:**
- Rejects asymmetric outliers (threshold: 1.5 std dev) - Rejects asymmetric outliers (threshold: 1.5 std dev)
- Preserves legitimate price shifts (morning/evening peaks) - Preserves legitimate price shifts (morning/evening peaks)
- Algorithm: - Algorithm:
```python
residual = abs(actual - predicted)
symmetry_threshold = 1.5 × std_dev
```python if residual > tolerance:
residual = abs(actual - predicted) # Check if spike is symmetric in context
symmetry_threshold = 1.5 × std_dev context_residuals = [abs(p - pred) for p, pred in context]
avg_context_residual = mean(context_residuals)
if residual > tolerance: if residual > symmetry_threshold × avg_context_residual:
# Check if spike is symmetric in context # Asymmetric spike → smooth it
context_residuals = [abs(p - pred) for p, pred in context] else:
avg_context_residual = mean(context_residuals) # Symmetric (part of trend) → keep it
```
if residual > symmetry_threshold × avg_context_residual:
# Asymmetric spike → smooth it
else:
# Symmetric (part of trend) → keep it
```
4. **Enhanced Zigzag Detection:** 4. **Enhanced Zigzag Detection:**
- Detects spike clusters via relative volatility - Detects spike clusters via relative volatility
- Threshold: 2.0× local volatility (RELATIVE_VOLATILITY_THRESHOLD) - Threshold: 2.0× local volatility (RELATIVE_VOLATILITY_THRESHOLD)
- Single-pass algorithm (no iteration needed) - Single-pass algorithm (no iteration needed)
- Catches patterns like: 18, 35, 19, 34, 18 (alternating spikes) - Catches patterns like: 18, 35, 19, 34, 18 (alternating spikes)
**Constants:** **Constants:**
```python ```python
# coordinator/period_handlers/outlier_filtering.py # coordinator/period_handlers/outlier_filtering.py
@ -623,21 +573,18 @@ MIN_CONTEXT_SIZE = 3 # Minimum intervals for regression
``` ```
**Data Integrity:** **Data Integrity:**
- Original prices stored in `_original_price` field - Original prices stored in `_original_price` field
- All statistics (daily min/max/avg) use original prices - All statistics (daily min/max/avg) use original prices
- Smoothing only affects period formation logic - Smoothing only affects period formation logic
- Smart counting: Only counts smoothing that changed period outcome - Smart counting: Only counts smoothing that changed period outcome
**Performance:** **Performance:**
- Single pass through price data - Single pass through price data
- O(n) complexity with small context window - O(n) complexity with small context window
- No iterative refinement needed - No iterative refinement needed
- Typical processing time: `<`1ms for 96 intervals - Typical processing time: `<`1ms for 96 intervals
**Example Debug Output:** **Example Debug Output:**
``` ```
DEBUG: [2025-11-11T14:30:00+01:00] Outlier detected: 35.2 ct 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 DEBUG: Context: 18.5, 19.1, 19.3, 19.8, 20.2 ct
@ -651,19 +598,19 @@ DEBUG: Asymmetry ratio: 3.2 (>1.5 threshold) → confirmed outlier
**Why This Approach?** **Why This Approach?**
1. **Linear regression over moving average:** 1. **Linear regression over moving average:**
- Accounts for price trends (morning ramp-up, evening decline) - Accounts for price trends (morning ramp-up, evening decline)
- Moving average can't predict direction, only level - Moving average can't predict direction, only level
- Better accuracy on non-stationary price curves - Better accuracy on non-stationary price curves
2. **Symmetry check over fixed threshold:** 2. **Symmetry check over fixed threshold:**
- Prevents false positives on legitimate price shifts - Prevents false positives on legitimate price shifts
- Adapts to local volatility patterns - Adapts to local volatility patterns
- Preserves user expectation: "expensive during peak hours" - Preserves user expectation: "expensive during peak hours"
3. **Single-pass over iterative:** 3. **Single-pass over iterative:**
- Predictable behavior (no convergence issues) - Predictable behavior (no convergence issues)
- Fast and deterministic - Fast and deterministic
- Easier to debug and reason about - Easier to debug and reason about
**Alternative Approaches Considered:** **Alternative Approaches Considered:**
@ -677,17 +624,15 @@ DEBUG: Asymmetry ratio: 3.2 (>1.5 threshold) → confirmed outlier
## Debugging Tips ## Debugging Tips
**Enable DEBUG logging:** **Enable DEBUG logging:**
```yaml ```yaml
# configuration.yaml # configuration.yaml
logger: logger:
default: info default: info
logs: logs:
custom_components.tibber_prices.coordinator.period_handlers: debug custom_components.tibber_prices.coordinator.period_handlers: debug
``` ```
**Key log messages to watch:** **Key log messages to watch:**
1. `"Filter statistics: X intervals checked"` - Shows how many intervals filtered by each criterion 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 2. `"After build_periods: X raw periods found"` - Periods before min_length filtering
3. `"Day X: Success with flex=Y%"` - Relaxation succeeded 3. `"Day X: Success with flex=Y%"` - Relaxation succeeded
@ -700,61 +645,52 @@ logger:
### ❌ Anti-Pattern 1: High Flex with Relaxation ### ❌ Anti-Pattern 1: High Flex with Relaxation
**Configuration:** **Configuration:**
```yaml ```yaml
best_price_flex: 40 best_price_flex: 40
enable_relaxation_best: true enable_relaxation_best: true
``` ```
**Problem:** **Problem:**
- Base Flex 40% already very permissive - Base Flex 40% already very permissive
- Relaxation increments further (43%, 46%, 49%, ...) - Relaxation increments further (43%, 46%, 49%, ...)
- Quickly approaches 50% cap with diminishing returns - Quickly approaches 50% cap with diminishing returns
**Solution:** **Solution:**
```yaml ```yaml
best_price_flex: 15 # Let relaxation increase it best_price_flex: 15 # Let relaxation increase it
enable_relaxation_best: true enable_relaxation_best: true
``` ```
### ❌ Anti-Pattern 2: Zero Min_Distance ### ❌ Anti-Pattern 2: Zero Min_Distance
**Configuration:** **Configuration:**
```yaml ```yaml
best_price_min_distance_from_avg: 0 best_price_min_distance_from_avg: 0
``` ```
**Problem:** **Problem:**
- "Flat days" (little price variation) accept all intervals - "Flat days" (little price variation) accept all intervals
- Periods lose semantic meaning ("significantly cheap") - Periods lose semantic meaning ("significantly cheap")
- May create periods during barely-below-average times - May create periods during barely-below-average times
**Solution:** **Solution:**
```yaml ```yaml
best_price_min_distance_from_avg: 5 # Use default 5% best_price_min_distance_from_avg: 5 # Use default 5%
``` ```
### ❌ Anti-Pattern 3: Conflicting Flex + Distance ### ❌ Anti-Pattern 3: Conflicting Flex + Distance
**Configuration:** **Configuration:**
```yaml ```yaml
best_price_flex: 45 best_price_flex: 45
best_price_min_distance_from_avg: 10 best_price_min_distance_from_avg: 10
``` ```
**Problem:** **Problem:**
- Distance filter dominates, making Flex irrelevant - Distance filter dominates, making Flex irrelevant
- Dynamic scaling helps but still suboptimal - Dynamic scaling helps but still suboptimal
**Solution:** **Solution:**
```yaml ```yaml
best_price_flex: 20 best_price_flex: 20
best_price_min_distance_from_avg: 5 best_price_min_distance_from_avg: 5
@ -770,13 +706,11 @@ best_price_min_distance_from_avg: 5
**Average:** 15 ct/kWh **Average:** 15 ct/kWh
**Expected Behavior:** **Expected Behavior:**
- Flex 15%: Should find 2-4 clear best price periods - Flex 15%: Should find 2-4 clear best price periods
- Flex 30%: Should find 4-8 periods (more lenient) - Flex 30%: Should find 4-8 periods (more lenient)
- Min_Distance 5%: Effective throughout range - Min_Distance 5%: Effective throughout range
**Debug Checks:** **Debug Checks:**
``` ```
DEBUG: Filter statistics: 96 intervals checked DEBUG: Filter statistics: 96 intervals checked
DEBUG: Filtered by FLEX: 12/96 (12.5%) ← Low percentage = good variation DEBUG: Filtered by FLEX: 12/96 (12.5%) ← Low percentage = good variation
@ -790,13 +724,11 @@ DEBUG: After build_periods: 3 raw periods found
**Average:** 15 ct/kWh **Average:** 15 ct/kWh
**Expected Behavior:** **Expected Behavior:**
- Flex 15%: May find 1-2 small periods (or zero if no clear winners) - 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 - Min_Distance 5%: Critical here - ensures only truly cheaper intervals qualify
- Without Min_Distance: Would accept almost entire day as "best price" - Without Min_Distance: Would accept almost entire day as "best price"
**Debug Checks:** **Debug Checks:**
``` ```
DEBUG: Filter statistics: 96 intervals checked DEBUG: Filter statistics: 96 intervals checked
DEBUG: Filtered by FLEX: 45/96 (46.9%) ← High percentage = poor variation DEBUG: Filtered by FLEX: 45/96 (46.9%) ← High percentage = poor variation
@ -811,13 +743,11 @@ DEBUG: Day 2025-11-11: Baseline insufficient (1 < 2), starting relaxation
**Average:** 18 ct/kWh **Average:** 18 ct/kWh
**Expected Behavior:** **Expected Behavior:**
- Flex 15%: Finds multiple very cheap periods (5-6 ct) - Flex 15%: Finds multiple very cheap periods (5-6 ct)
- Outlier filtering: May smooth isolated spikes (30-40 ct) - Outlier filtering: May smooth isolated spikes (30-40 ct)
- Distance filter: Less impactful (clear separation between cheap/expensive) - Distance filter: Less impactful (clear separation between cheap/expensive)
**Debug Checks:** **Debug Checks:**
``` ```
DEBUG: Outlier detected: 38.5 ct (threshold: 4.2 ct) DEBUG: Outlier detected: 38.5 ct (threshold: 4.2 ct)
DEBUG: Smoothed to: 20.1 ct (trend prediction) DEBUG: Smoothed to: 20.1 ct (trend prediction)
@ -832,7 +762,6 @@ DEBUG: After build_periods: 4 raw periods found
**Initial State:** Baseline finds 1 period, target is 2 **Initial State:** Baseline finds 1 period, target is 2
**Expected Flow:** **Expected Flow:**
``` ```
INFO: Calculating BEST PRICE periods: relaxation=ON, target=2/day, flex=15.0% INFO: Calculating BEST PRICE periods: relaxation=ON, target=2/day, flex=15.0%
DEBUG: Day 2025-11-11: Baseline found 1 period (need 2) DEBUG: Day 2025-11-11: Baseline found 1 period (need 2)
@ -848,7 +777,6 @@ INFO: Day 2025-11-11: Success after 1 relaxation phase (2 periods)
**Initial State:** Strict filters, very flat day **Initial State:** Strict filters, very flat day
**Expected Flow:** **Expected Flow:**
``` ```
INFO: Calculating BEST PRICE periods: relaxation=ON, target=2/day, flex=15.0% INFO: Calculating BEST PRICE periods: relaxation=ON, target=2/day, flex=15.0%
DEBUG: Day 2025-11-11: Baseline found 0 periods (need 2) DEBUG: Day 2025-11-11: Baseline found 0 periods (need 2)
@ -862,31 +790,31 @@ INFO: Period calculation completed: 1/2 days reached target
When debugging period calculation issues: When debugging period calculation issues:
1. **Check Filter Statistics** 1. **Check Filter Statistics**
- Which filter blocks most intervals? (flex, distance, or level) - Which filter blocks most intervals? (flex, distance, or level)
- High flex filtering (>30%) = Need more flexibility or relaxation - High flex filtering (>30%) = Need more flexibility or relaxation
- High distance filtering (>50%) = Min_distance too strict or flat day - High distance filtering (>50%) = Min_distance too strict or flat day
- High level filtering = Level filter too restrictive - High level filtering = Level filter too restrictive
2. **Check Relaxation Behavior** 2. **Check Relaxation Behavior**
- Did relaxation activate? Check for "Baseline insufficient" message - Did relaxation activate? Check for "Baseline insufficient" message
- Which phase succeeded? Early success (phase 1-3) = good config - Which phase succeeded? Early success (phase 1-3) = good config
- Late success (phase 8-11) = Consider adjusting base config - Late success (phase 8-11) = Consider adjusting base config
- Exhausted all phases = Unrealistic target for this day's price curve - Exhausted all phases = Unrealistic target for this day's price curve
3. **Check Flex Warnings** 3. **Check Flex Warnings**
- INFO at 25% base flex = On the high side - INFO at 25% base flex = On the high side
- WARNING at 30% base flex = Too high for relaxation - WARNING at 30% base flex = Too high for relaxation
- If seeing these: Lower base flex to 15-20% - If seeing these: Lower base flex to 15-20%
4. **Check Min_Distance Scaling** 4. **Check Min_Distance Scaling**
- Debug messages show "High flex X% detected: Reducing min_distance Y% → Z%" - Debug messages show "High flex X% detected: Reducing min_distance Y% → Z%"
- If scale factor `<`0.8 (20% reduction): High flex is active - If scale factor `<`0.8 (20% reduction): High flex is active
- If periods still not found: Filters conflict even with scaling - If periods still not found: Filters conflict even with scaling
5. **Check Outlier Filtering** 5. **Check Outlier Filtering**
- Look for "Outlier detected" messages - Look for "Outlier detected" messages
- Check `period_interval_smoothed_count` attribute - Check `period_interval_smoothed_count` attribute
- If no smoothing but periods fragmented: Not isolated spikes, but legitimate price levels - If no smoothing but periods fragmented: Not isolated spikes, but legitimate price levels
--- ---
@ -895,19 +823,19 @@ When debugging period calculation issues:
### Potential Improvements ### Potential Improvements
1. **Adaptive Flex Calculation:** 1. **Adaptive Flex Calculation:**
- Auto-adjust Flex based on daily price variation - Auto-adjust Flex based on daily price variation
- High variation days: Lower Flex needed - High variation days: Lower Flex needed
- Low variation days: Higher Flex needed - Low variation days: Higher Flex needed
2. **Machine Learning Approach:** 2. **Machine Learning Approach:**
- Learn optimal Flex/Distance from user feedback - Learn optimal Flex/Distance from user feedback
- Classify days by pattern (normal/flat/volatile/bimodal) - Classify days by pattern (normal/flat/volatile/bimodal)
- Apply pattern-specific defaults - Apply pattern-specific defaults
3. **Multi-Objective Optimization:** 3. **Multi-Objective Optimization:**
- Balance period count vs. quality - Balance period count vs. quality
- Consider period duration vs. price level - Consider period duration vs. price level
- Optimize for user's stated use case (EV charging vs. heat pump) - Optimize for user's stated use case (EV charging vs. heat pump)
### Known Limitations ### Known Limitations
@ -926,7 +854,6 @@ When debugging period calculation issues:
**Concept:** Auto-adjust Flex based on daily price variation **Concept:** Auto-adjust Flex based on daily price variation
**Algorithm:** **Algorithm:**
```python ```python
# Pseudo-code for adaptive flex # Pseudo-code for adaptive flex
variation = (daily_max - daily_min) / daily_avg variation = (daily_max - daily_min) / daily_avg
@ -940,13 +867,11 @@ else: # Normal day
``` ```
**Benefits:** **Benefits:**
- Eliminates need for relaxation on most days - Eliminates need for relaxation on most days
- Self-adjusting to market conditions - Self-adjusting to market conditions
- Better user experience (less configuration needed) - Better user experience (less configuration needed)
**Challenges:** **Challenges:**
- Harder to predict behavior (less transparent) - Harder to predict behavior (less transparent)
- May conflict with user's mental model - May conflict with user's mental model
- Needs extensive testing across different markets - Needs extensive testing across different markets
@ -958,20 +883,17 @@ else: # Normal day
**Concept:** Learn optimal Flex/Distance from user feedback **Concept:** Learn optimal Flex/Distance from user feedback
**Approach:** **Approach:**
- Track which periods user actually uses (automation triggers) - Track which periods user actually uses (automation triggers)
- Classify days by pattern (normal/flat/volatile/bimodal) - Classify days by pattern (normal/flat/volatile/bimodal)
- Apply pattern-specific defaults - Apply pattern-specific defaults
- Learn per-user preferences over time - Learn per-user preferences over time
**Benefits:** **Benefits:**
- Personalized to user's actual behavior - Personalized to user's actual behavior
- Adapts to local market patterns - Adapts to local market patterns
- Could discover non-obvious patterns - Could discover non-obvious patterns
**Challenges:** **Challenges:**
- Requires user feedback mechanism (not implemented) - Requires user feedback mechanism (not implemented)
- Privacy concerns (storing usage patterns) - Privacy concerns (storing usage patterns)
- Complexity for users to understand "why this period?" - Complexity for users to understand "why this period?"
@ -984,26 +906,22 @@ else: # Normal day
**Concept:** Balance multiple goals simultaneously **Concept:** Balance multiple goals simultaneously
**Goals:** **Goals:**
- Period count vs. quality (cheap vs. very cheap) - Period count vs. quality (cheap vs. very cheap)
- Period duration vs. price level (long mediocre vs. short excellent) - Period duration vs. price level (long mediocre vs. short excellent)
- Temporal distribution (spread throughout day vs. clustered) - Temporal distribution (spread throughout day vs. clustered)
- User's stated use case (EV charging vs. heat pump vs. dishwasher) - User's stated use case (EV charging vs. heat pump vs. dishwasher)
**Algorithm:** **Algorithm:**
- Pareto optimization (find trade-off frontier) - Pareto optimization (find trade-off frontier)
- User chooses point on frontier via preferences - User chooses point on frontier via preferences
- Genetic algorithm or simulated annealing - Genetic algorithm or simulated annealing
**Benefits:** **Benefits:**
- More sophisticated period selection - More sophisticated period selection
- Better match to user's actual needs - Better match to user's actual needs
- Could handle complex appliance requirements - Could handle complex appliance requirements
**Challenges:** **Challenges:**
- Much more complex to implement - Much more complex to implement
- Harder to explain to users - Harder to explain to users
- Computational cost (may need caching) - Computational cost (may need caching)
@ -1018,17 +936,14 @@ else: # Normal day
**Current:** 3% cap may be too aggressive for very low base Flex **Current:** 3% cap may be too aggressive for very low base Flex
**Example:** **Example:**
- Base flex 5% + 3% increment = 8% (60% increase!) - Base flex 5% + 3% increment = 8% (60% increase!)
- Base flex 15% + 3% increment = 18% (20% increase) - Base flex 15% + 3% increment = 18% (20% increase)
**Possible Solution:** **Possible Solution:**
- Percentage-based increment: `increment = max(base_flex × 0.20, 0.03)` - Percentage-based increment: `increment = max(base_flex × 0.20, 0.03)`
- This gives: 5% → 6% (20%), 15% → 18% (20%), 40% → 43% (7.5%) - This gives: 5% → 6% (20%), 15% → 18% (20%), 40% → 43% (7.5%)
**Why Not Implemented:** **Why Not Implemented:**
- Very low base flex (`<`10%) unusual - Very low base flex (`<`10%) unusual
- Users with strict requirements likely disable relaxation - Users with strict requirements likely disable relaxation
- Simplicity preferred over edge case optimization - Simplicity preferred over edge case optimization
@ -1038,7 +953,6 @@ else: # Normal day
**Current:** Linear scaling may be too aggressive/conservative **Current:** Linear scaling may be too aggressive/conservative
**Alternative:** Non-linear curve **Alternative:** Non-linear curve
```python ```python
# Example: Exponential scaling # Example: Exponential scaling
scale_factor = 0.25 + 0.75 × exp(-5 × (flex - 0.20)) scale_factor = 0.25 + 0.75 × exp(-5 × (flex - 0.20))
@ -1048,7 +962,6 @@ scale_factor = 0.25 + 0.75 / (1 + exp(10 × (flex - 0.35)))
``` ```
**Why Not Implemented:** **Why Not Implemented:**
- Linear is easier to reason about - Linear is easier to reason about
- No evidence that non-linear is better - No evidence that non-linear is better
- Would need extensive testing - Would need extensive testing
@ -1058,18 +971,15 @@ scale_factor = 0.25 + 0.75 / (1 + exp(10 × (flex - 0.35)))
**Issue:** May find all periods in one part of day **Issue:** May find all periods in one part of day
**Example:** **Example:**
- All 3 "best price" periods between 02:00-08:00 - All 3 "best price" periods between 02:00-08:00
- No periods in evening (when user might want to run appliances) - No periods in evening (when user might want to run appliances)
**Possible Solution:** **Possible Solution:**
- Add "spread" parameter (prefer distributed periods) - Add "spread" parameter (prefer distributed periods)
- Weight periods by time-of-day preferences - Weight periods by time-of-day preferences
- Consider user's typical usage patterns - Consider user's typical usage patterns
**Why Not Implemented:** **Why Not Implemented:**
- Adds complexity - Adds complexity
- Users can work around with multiple automations - Users can work around with multiple automations
- Different users have different needs (no one-size-fits-all) - Different users have different needs (no one-size-fits-all)
@ -1081,7 +991,6 @@ 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). **Design Principle:** Each interval is evaluated using its **own day's** reference prices (daily min/max/avg).
**Implementation:** **Implementation:**
```python ```python
# In period_building.py build_periods(): # In period_building.py build_periods():
for price_data in all_prices: for price_data in all_prices:
@ -1133,7 +1042,6 @@ Period crossing midnight: 23:45 Day 1 → 00:15 Day 2
**Trade-off: Periods May Break at Midnight** **Trade-off: Periods May Break at Midnight**
When days differ significantly, period can split: When days differ significantly, period can split:
``` ```
Day 1: Min=10ct, Avg=20ct, 23:45=11ct → ✅ Cheap (relative to Day 1) 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) Day 2: Min=25ct, Avg=35ct, 00:00=21ct → ❌ Expensive (relative to Day 2)
@ -1145,7 +1053,6 @@ This is **mathematically correct** - 21ct is genuinely expensive on a day where
**Market Reality Explains Price Jumps:** **Market Reality Explains Price Jumps:**
Day-ahead electricity markets (EPEX SPOT) set prices at 12:00 CET for all next-day hours: 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 - 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 - Early intervals (00:00): Priced ~12h before delivery → better forecasts → lower risk buffer
@ -1154,17 +1061,15 @@ This explains why absolute prices jump at midnight despite minimal demand change
**User-Facing Solution (Nov 2025):** **User-Facing Solution (Nov 2025):**
Added per-period day volatility attributes to detect when classification changes are meaningful: Added per-period day volatility attributes to detect when classification changes are meaningful:
- `day_volatility_%`: Percentage spread (span/avg × 100) - `day_volatility_%`: Percentage spread (span/avg × 100)
- `day_price_min`, `day_price_max`, `day_price_span`: Daily price range (ct/øre) - `day_price_min`, `day_price_max`, `day_price_span`: Daily price range (ct/øre)
Automations can check volatility before acting: Automations can check volatility before acting:
```yaml ```yaml
condition: condition:
- condition: template - condition: template
value_template: > value_template: >
{{ state_attr('binary_sensor.tibber_home_best_price_period', 'day_volatility_%') | float(0) > 15 }} {{ state_attr('binary_sensor.tibber_home_best_price_period', 'day_volatility_%') | float(0) > 15 }}
``` ```
Low volatility (< 15%) means classification changes are less economically significant. Low volatility (< 15%) means classification changes are less economically significant.
@ -1172,25 +1077,24 @@ Low volatility (< 15%) means classification changes are less economically signif
**Alternative Approaches Rejected:** **Alternative Approaches Rejected:**
1. **Use period start day for all intervals** 1. **Use period start day for all intervals**
- Problem: Mathematically incorrect - lends cheap day's criteria to expensive day - Problem: Mathematically incorrect - lends cheap day's criteria to expensive day
- Rejected: Violates relative evaluation principle - Rejected: Violates relative evaluation principle
2. **Adjust flex/distance at midnight** 2. **Adjust flex/distance at midnight**
- Problem: Complex, unpredictable, hides market reality - Problem: Complex, unpredictable, hides market reality
- Rejected: Users should understand price context, not have it hidden - Rejected: Users should understand price context, not have it hidden
3. **Split at midnight always** 3. **Split at midnight always**
- Problem: Artificially fragments natural periods - Problem: Artificially fragments natural periods
- Rejected: Worse user experience - Rejected: Worse user experience
4. **Use next day's reference after midnight** 4. **Use next day's reference after midnight**
- Problem: Period criteria inconsistent across duration - Problem: Period criteria inconsistent across duration
- Rejected: Confusing and unpredictable - Rejected: Confusing and unpredictable
**Status:** Per-day evaluation is intentional design prioritizing mathematical correctness. **Status:** Per-day evaluation is intentional design prioritizing mathematical correctness.
**See Also:** **See Also:**
- User documentation: `docs/user/docs/period-calculation.md` → "Midnight Price Classification Changes" - 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`) - Implementation: `coordinator/period_handlers/period_building.py` (line ~126: `ref_date = date_key`)
- Attributes: `coordinator/period_handlers/period_statistics.py` (day volatility calculation) - Attributes: `coordinator/period_handlers/period_statistics.py` (day volatility calculation)

View file

@ -29,7 +29,6 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
``` ```
**Key Points:** **Key Points:**
- Must be a **class attribute** (not instance attribute) - Must be a **class attribute** (not instance attribute)
- Use `frozenset` for immutability and performance - Use `frozenset` for immutability and performance
- Applied automatically by Home Assistant's Recorder component - Applied automatically by Home Assistant's Recorder component
@ -41,7 +40,6 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
**Attributes:** `description`, `usage_tips` **Attributes:** `description`, `usage_tips`
**Reason:** Static, large text strings (100-500 chars each) that: **Reason:** Static, large text strings (100-500 chars each) that:
- Never change or change very rarely - Never change or change very rarely
- Don't provide analytical value in history - Don't provide analytical value in history
- Consume significant database space when recorded every state change - Consume significant database space when recorded every state change
@ -52,7 +50,6 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
### 2. Large Nested Structures ### 2. Large Nested Structures
**Attributes:** **Attributes:**
- `periods` (binary_sensor) - Array of all period summaries - `periods` (binary_sensor) - Array of all period summaries
- `data` (chart_data_export) - Complete price data arrays - `data` (chart_data_export) - Complete price data arrays
- `trend_attributes` - Detailed trend analysis - `trend_attributes` - Detailed trend analysis
@ -61,7 +58,6 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
- `volatility_attributes` - Detailed volatility breakdown - `volatility_attributes` - Detailed volatility breakdown
**Reason:** Complex nested data structures that are: **Reason:** Complex nested data structures that are:
- Serialized to JSON for storage (expensive) - Serialized to JSON for storage (expensive)
- Create large database rows (2-20 KB each) - Create large database rows (2-20 KB each)
- Slow down history queries - Slow down history queries
@ -70,21 +66,20 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
**Impact:** ~10-30 KB saved per state change for affected sensors **Impact:** ~10-30 KB saved per state change for affected sensors
**Example - periods array:** **Example - periods array:**
```json ```json
{ {
"periods": [ "periods": [
{ {
"start": "2025-12-07T06:00:00+01:00", "start": "2025-12-07T06:00:00+01:00",
"end": "2025-12-07T08:00:00+01:00", "end": "2025-12-07T08:00:00+01:00",
"duration_minutes": 120, "duration_minutes": 120,
"price_mean": 18.5, "price_mean": 18.5,
"price_median": 18.3, "price_median": 18.3,
"price_min": 17.2, "price_min": 17.2,
"price_max": 19.8 "price_max": 19.8,
// ... 10+ more attributes × 10-20 periods // ... 10+ more attributes × 10-20 periods
} }
] ]
} }
``` ```
@ -93,7 +88,6 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
**Attributes:** `icon_color`, `cache_age`, `cache_validity`, `data_completeness`, `data_status` **Attributes:** `icon_color`, `cache_age`, `cache_validity`, `data_completeness`, `data_status`
**Reason:** **Reason:**
- Change every update cycle (every 15 minutes or more frequently) - Change every update cycle (every 15 minutes or more frequently)
- Don't provide long-term analytical value - Don't provide long-term analytical value
- Create state changes even when core values haven't changed - Create state changes even when core values haven't changed
@ -109,7 +103,6 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
**Attributes:** `tomorrow_expected_after`, `level_value`, `rating_value`, `level_id`, `rating_id`, `currency`, `resolution`, `yaxis_min`, `yaxis_max` **Attributes:** `tomorrow_expected_after`, `level_value`, `rating_value`, `level_id`, `rating_id`, `currency`, `resolution`, `yaxis_min`, `yaxis_max`
**Reason:** **Reason:**
- Configuration values that rarely change - Configuration values that rarely change
- Wastes space when recorded repeatedly - Wastes space when recorded repeatedly
- Can be derived from other attributes or from entity state - Can be derived from other attributes or from entity state
@ -121,7 +114,6 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
**Attributes:** `next_api_poll`, `next_midnight_turnover`, `last_api_fetch`, `last_cache_update`, `last_turnover`, `last_error`, `error` **Attributes:** `next_api_poll`, `next_midnight_turnover`, `last_api_fetch`, `last_cache_update`, `last_turnover`, `last_error`, `error`
**Reason:** **Reason:**
- Only relevant at moment of reading - Only relevant at moment of reading
- Won't be valid after some time - Won't be valid after some time
- Similar to `entity_picture` in HA core image entities - Similar to `entity_picture` in HA core image entities
@ -136,7 +128,6 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
**Attributes:** `relaxation_level`, `relaxation_threshold_original_%`, `relaxation_threshold_applied_%` **Attributes:** `relaxation_level`, `relaxation_threshold_original_%`, `relaxation_threshold_applied_%`
**Reason:** **Reason:**
- Detailed technical information not needed for historical analysis - Detailed technical information not needed for historical analysis
- Only useful for debugging during active development - Only useful for debugging during active development
- Boolean `relaxation_active` is kept for high-level analysis - Boolean `relaxation_active` is kept for high-level analysis
@ -148,7 +139,6 @@ 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` **Attributes:** `price_spread`, `volatility`, `diff_%`, `rating_difference_%`, `period_price_diff_from_daily_min`, `period_price_diff_from_daily_min_%`, `periods_total`, `periods_remaining`
**Reason:** **Reason:**
- Can be calculated from other attributes - Can be calculated from other attributes
- Redundant information - Redundant information
- Doesn't add analytical value to history - Doesn't add analytical value to history
@ -162,27 +152,22 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
These attributes **remain in history** because they provide essential analytical value: These attributes **remain in history** because they provide essential analytical value:
### Time-Series Core ### Time-Series Core
- `timestamp` - Critical for time-series analysis (ALWAYS FIRST) - `timestamp` - Critical for time-series analysis (ALWAYS FIRST)
- All price values - Core sensor states - All price values - Core sensor states
### Diagnostics & Tracking ### Diagnostics & Tracking
- `cache_age_minutes` - Numeric value for diagnostics tracking over time - `cache_age_minutes` - Numeric value for diagnostics tracking over time
- `updates_today` - Tracking API usage patterns - `updates_today` - Tracking API usage patterns
### Data Completeness ### Data Completeness
- `interval_count`, `intervals_available` - Data completeness metrics - `interval_count`, `intervals_available` - Data completeness metrics
- `yesterday_available`, `today_available`, `tomorrow_available` - Boolean status - `yesterday_available`, `today_available`, `tomorrow_available` - Boolean status
### Period Data ### Period Data
- `start`, `end`, `duration_minutes` - Core period timing - `start`, `end`, `duration_minutes` - Core period timing
- `price_mean`, `price_median`, `price_min`, `price_max` - Core price statistics - `price_mean`, `price_median`, `price_min`, `price_max` - Core price statistics
### High-Level Status ### High-Level Status
- `relaxation_active` - Whether relaxation was used (boolean, useful for analyzing when periods needed relaxation) - `relaxation_active` - Whether relaxation was used (boolean, useful for analyzing when periods needed relaxation)
## Expected Database Impact ## Expected Database Impact
@ -190,7 +175,6 @@ These attributes **remain in history** because they provide essential analytical
### Space Savings ### Space Savings
**Per state change:** **Per state change:**
- Before: ~3-8 KB average - Before: ~3-8 KB average
- After: ~0.5-1.5 KB average - After: ~0.5-1.5 KB average
- **Reduction: 60-85%** - **Reduction: 60-85%**
@ -212,7 +196,6 @@ These attributes **remain in history** because they provide essential analytical
### Real-World Impact ### Real-World Impact
For a typical installation with: For a typical installation with:
- 80+ sensors - 80+ sensors
- Updates every 15 minutes - Updates every 15 minutes
- ~10 sensors updating every minute - ~10 sensors updating every minute
@ -224,14 +207,14 @@ For a typical installation with:
## Implementation Files ## Implementation Files
- **Sensor Platform**: `custom_components/tibber_prices/sensor/core.py` - **Sensor Platform**: `custom_components/tibber_prices/sensor/core.py`
- Class: `TibberPricesSensor` - Class: `TibberPricesSensor`
- 47 attributes excluded - 47 attributes excluded
- **Binary Sensor Platform**: `custom_components/tibber_prices/binary_sensor/core.py` - **Binary Sensor Platform**: `custom_components/tibber_prices/binary_sensor/core.py`
- Class: `TibberPricesBinarySensor` - Class: `TibberPricesBinarySensor`
- 30 attributes excluded - 30 attributes excluded
## When to Update \_unrecorded_attributes ## When to Update _unrecorded_attributes
### Add to Exclusion List When: ### Add to Exclusion List When:
@ -253,24 +236,24 @@ For a typical installation with:
When adding a new attribute, ask: When adding a new attribute, ask:
1. **Will this be useful in history queries 1 week from now?** 1. **Will this be useful in history queries 1 week from now?**
- No → Exclude - No → Exclude
- Yes → Keep - Yes → Keep
2. **Can this be calculated from other recorded attributes?** 2. **Can this be calculated from other recorded attributes?**
- Yes → Exclude - Yes → Exclude
- No → Keep - No → Keep
3. **Is this primarily for current UI display?** 3. **Is this primarily for current UI display?**
- Yes → Exclude - Yes → Exclude
- No → Keep - No → Keep
4. **Does this change frequently without indicating state change?** 4. **Does this change frequently without indicating state change?**
- Yes → Exclude - Yes → Exclude
- No → Keep - No → Keep
5. **Is this larger than 100 bytes and not essential for analysis?** 5. **Is this larger than 100 bytes and not essential for analysis?**
- Yes → Exclude - Yes → Exclude
- No → Keep - No → Keep
## Testing ## Testing
@ -282,7 +265,6 @@ After modifying `_unrecorded_attributes`:
4. **Confirm excluded attributes** don't appear in new state writes 4. **Confirm excluded attributes** don't appear in new state writes
**SQL Query to check attribute presence:** **SQL Query to check attribute presence:**
```sql ```sql
SELECT SELECT
state_id, state_id,

View file

@ -8,22 +8,22 @@ Not every code change needs a detailed plan. Create a refactoring plan when:
🔴 **Major changes requiring planning:** 🔴 **Major changes requiring planning:**
- Splitting modules into packages (>5 files affected, >500 lines moved) - Splitting modules into packages (>5 files affected, >500 lines moved)
- Architectural changes (new packages, module restructuring) - Architectural changes (new packages, module restructuring)
- Breaking changes (API changes, config format migrations) - Breaking changes (API changes, config format migrations)
🟡 **Medium changes that might benefit from planning:** 🟡 **Medium changes that might benefit from planning:**
- Complex features with multiple moving parts - Complex features with multiple moving parts
- Changes affecting many files (>3 files, unclear best approach) - Changes affecting many files (>3 files, unclear best approach)
- Refactorings with unclear scope - Refactorings with unclear scope
🟢 **Small changes - no planning needed:** 🟢 **Small changes - no planning needed:**
- Bug fixes (straightforward, `<`100 lines) - Bug fixes (straightforward, `<`100 lines)
- Small features (`<`3 files, clear approach) - Small features (`<`3 files, clear approach)
- Documentation updates - Documentation updates
- Cosmetic changes (formatting, renaming) - Cosmetic changes (formatting, renaming)
## The Planning Process ## The Planning Process
@ -51,34 +51,34 @@ Every planning document should include:
## Problem Statement ## Problem Statement
- What's the issue? - What's the issue?
- Why does it need fixing? - Why does it need fixing?
- Current pain points - Current pain points
## Proposed Solution ## Proposed Solution
- High-level approach - High-level approach
- File structure (before/after) - File structure (before/after)
- Module responsibilities - Module responsibilities
## Migration Strategy ## Migration Strategy
- Phase-by-phase breakdown - Phase-by-phase breakdown
- File lifecycle (CREATE/MODIFY/DELETE/RENAME) - File lifecycle (CREATE/MODIFY/DELETE/RENAME)
- Dependencies between phases - Dependencies between phases
- Testing checkpoints - Testing checkpoints
## Risks & Mitigation ## Risks & Mitigation
- What could go wrong? - What could go wrong?
- How to prevent it? - How to prevent it?
- Rollback strategy - Rollback strategy
## Success Criteria ## Success Criteria
- Measurable improvements - Measurable improvements
- Testing requirements - Testing requirements
- Verification steps - Verification steps
``` ```
See `planning/README.md` for detailed template explanation. See `planning/README.md` for detailed template explanation.
@ -87,19 +87,19 @@ See `planning/README.md` for detailed template explanation.
Since `planning/` is git-ignored: Since `planning/` is git-ignored:
- Draft multiple versions - Draft multiple versions
- Get AI assistance without commit pressure - Get AI assistance without commit pressure
- Refine until the plan is solid - Refine until the plan is solid
- No need to clean up intermediate versions - No need to clean up intermediate versions
### 4. Implementation Phase ### 4. Implementation Phase
Once plan is approved: Once plan is approved:
- Follow the phases defined in the plan - Follow the phases defined in the plan
- Test after each phase (don't skip!) - Test after each phase (don't skip!)
- Update plan if issues discovered - Update plan if issues discovered
- Track progress through phase status - Track progress through phase status
### 5. After Completion ### 5. After Completion
@ -134,13 +134,13 @@ The **sensor/ package refactoring** (Nov 2025) is a successful example:
**Before:** **Before:**
- `sensor.py` - 2,574 lines, hard to navigate - `sensor.py` - 2,574 lines, hard to navigate
**After:** **After:**
- `sensor/` package with 5 focused modules - `sensor/` package with 5 focused modules
- Each module `<`800 lines - Each module `<`800 lines
- Clear separation of concerns - Clear separation of concerns
**Process:** **Process:**
@ -153,10 +153,10 @@ The **sensor/ package refactoring** (Nov 2025) is a successful example:
**Key learnings:** **Key learnings:**
- Temporary `_impl.py` files avoid Python package conflicts - Temporary `_impl.py` files avoid Python package conflicts
- Test after EVERY phase (don't accumulate changes) - Test after EVERY phase (don't accumulate changes)
- Clear file lifecycle (CREATE/MODIFY/DELETE/RENAME) - Clear file lifecycle (CREATE/MODIFY/DELETE/RENAME)
- Phase-by-phase approach enables safe rollback - Phase-by-phase approach enables safe rollback
**Note:** The complete module splitting plan was documented during implementation but has been superseded by the actual code structure. **Note:** The complete module splitting plan was documented during implementation but has been superseded by the actual code structure.
@ -166,11 +166,11 @@ The **sensor/ package refactoring** (Nov 2025) is a successful example:
Breaking refactorings into phases: Breaking refactorings into phases:
- ✅ Enables testing after each change (catch bugs early) - ✅ Enables testing after each change (catch bugs early)
- ✅ Allows rollback to last good state - ✅ Allows rollback to last good state
- ✅ Makes progress visible - ✅ Makes progress visible
- ✅ Reduces cognitive load (focus on one thing) - ✅ Reduces cognitive load (focus on one thing)
- ❌ Takes more time (but worth it!) - ❌ Takes more time (but worth it!)
### Phase Structure ### Phase Structure
@ -191,8 +191,8 @@ Each phase should:
**File Lifecycle**: **File Lifecycle**:
- ✨ CREATE `sensor/helpers.py` (utility functions) - ✨ CREATE `sensor/helpers.py` (utility functions)
- ✏️ MODIFY `sensor/core.py` (import from helpers.py) - ✏️ MODIFY `sensor/core.py` (import from helpers.py)
**Steps**: **Steps**:
@ -205,10 +205,10 @@ Each phase should:
**Success criteria**: **Success criteria**:
- ✅ All pure functions moved - ✅ All pure functions moved
- ✅ `./scripts/lint-check` passes - `./scripts/lint-check` passes
- ✅ HA starts successfully - ✅ HA starts successfully
- ✅ All entities work correctly - ✅ All entities work correctly
``` ```
## Testing Strategy ## Testing Strategy
@ -238,13 +238,13 @@ Minimum testing checklist:
After completing all phases: After completing all phases:
- Test all entities (sensors, binary sensors) - Test all entities (sensors, binary sensors)
- Test configuration flow (add/modify/remove) - Test configuration flow (add/modify/remove)
- Test options flow (change settings) - Test options flow (change settings)
- Test services (custom service calls) - Test services (custom service calls)
- Test error handling (disconnect API, invalid data) - Test error handling (disconnect API, invalid data)
- Test caching (restart HA, verify cache loads) - Test caching (restart HA, verify cache loads)
- Test time-based updates (quarter-hour refresh) - Test time-based updates (quarter-hour refresh)
## Common Pitfalls ## Common Pitfalls
@ -286,21 +286,21 @@ This project uses AI heavily (GitHub Copilot, Claude). The planning process supp
**AI reads from:** **AI reads from:**
- `AGENTS.md` - Long-term memory, patterns, conventions (AI-focused) - `AGENTS.md` - Long-term memory, patterns, conventions (AI-focused)
- `docs/development/` - Human-readable guides (human-focused) - `docs/development/` - Human-readable guides (human-focused)
- `planning/` - Active refactoring plans (shared context) - `planning/` - Active refactoring plans (shared context)
**AI updates:** **AI updates:**
- `AGENTS.md` - When patterns change - `AGENTS.md` - When patterns change
- `planning/*.md` - During refactoring implementation - `planning/*.md` - During refactoring implementation
- `docs/development/` - After successful completion - `docs/development/` - After successful completion
**Why separate AGENTS.md and docs/development/?** **Why separate AGENTS.md and docs/development/?**
- `AGENTS.md`: Technical, comprehensive, AI-optimized - `AGENTS.md`: Technical, comprehensive, AI-optimized
- `docs/development/`: Practical, focused, human-optimized - `docs/development/`: Practical, focused, human-optimized
- Both stay in sync but serve different audiences - Both stay in sync but serve different audiences
See [AGENTS.md](https://github.com/jpawlowski/hass.tibber_prices/blob/main/AGENTS.md) section "Planning Major Refactorings" for AI-specific guidance. See [AGENTS.md](https://github.com/jpawlowski/hass.tibber_prices/blob/main/AGENTS.md) section "Planning Major Refactorings" for AI-specific guidance.
@ -308,16 +308,16 @@ See [AGENTS.md](https://github.com/jpawlowski/hass.tibber_prices/blob/main/AGENT
### Planning Directory ### Planning Directory
- `planning/` - Git-ignored workspace for drafts - `planning/` - Git-ignored workspace for drafts
- `planning/README.md` - Detailed planning documentation - `planning/README.md` - Detailed planning documentation
- `planning/*.md` - Active refactoring plans - `planning/*.md` - Active refactoring plans
### Example Plans ### Example Plans
- `docs/development/module-splitting-plan.md` - ✅ Completed, archived - `docs/development/module-splitting-plan.md` - ✅ Completed, archived
- `planning/config-flow-refactoring-plan.md` - 🔄 Planned (1013 lines → 4 modules) - `planning/config-flow-refactoring-plan.md` - 🔄 Planned (1013 lines → 4 modules)
- `planning/binary-sensor-refactoring-plan.md` - 🔄 Planned (644 lines → 4 modules) - `planning/binary-sensor-refactoring-plan.md` - 🔄 Planned (644 lines → 4 modules)
- `planning/coordinator-refactoring-plan.md` - 🔄 Planned (1446 lines, high complexity) - `planning/coordinator-refactoring-plan.md` - 🔄 Planned (1446 lines, high complexity)
### Helper Scripts ### Helper Scripts
@ -341,21 +341,21 @@ Simple rule: If you can't describe the entire change in 3 sentences, create a pl
Good plan level: Good plan level:
- Lists all files affected (CREATE/MODIFY/DELETE) - Lists all files affected (CREATE/MODIFY/DELETE)
- Defines phases with clear boundaries - Defines phases with clear boundaries
- Includes testing strategy - Includes testing strategy
- Estimates time per phase - Estimates time per phase
Too detailed: Too detailed:
- Exact code snippets for every change - Exact code snippets for every change
- Line-by-line instructions - Line-by-line instructions
Too vague: Too vague:
- "Refactor sensor.py to be better" - "Refactor sensor.py to be better"
- No phase breakdown - No phase breakdown
- No testing strategy - No testing strategy
### Q: What if the plan changes during implementation? ### Q: What if the plan changes during implementation?
@ -363,9 +363,9 @@ Too vague:
If you discover: If you discover:
- Better approach → Update "Proposed Solution" - Better approach → Update "Proposed Solution"
- More phases needed → Add to "Migration Strategy" - More phases needed → Add to "Migration Strategy"
- New risks → Update "Risks & Mitigation" - New risks → Update "Risks & Mitigation"
Document WHY the plan changed (helps future refactorings). Document WHY the plan changed (helps future refactorings).
@ -373,9 +373,9 @@ Document WHY the plan changed (helps future refactorings).
**A:** No! Use judgment: **A:** No! Use judgment:
- **Small changes (`<`100 lines, clear approach)**: Just do it, no plan needed - **Small changes (`<`100 lines, clear approach)**: Just do it, no plan needed
- **Medium changes (unclear scope)**: Write rough outline, refine if needed - **Medium changes (unclear scope)**: Write rough outline, refine if needed
- **Large changes (>500 lines, >5 files)**: Full planning process - **Large changes (>500 lines, >5 files)**: Full planning process
### Q: How do I know when a refactoring is successful? ### Q: How do I know when a refactoring is successful?
@ -383,12 +383,12 @@ Document WHY the plan changed (helps future refactorings).
Typical criteria: Typical criteria:
- ✅ All linting checks pass - ✅ All linting checks pass
- ✅ HA starts without errors - ✅ HA starts without errors
- ✅ All entities functional - ✅ All entities functional
- ✅ No regressions (existing features work) - ✅ No regressions (existing features work)
- ✅ Code easier to understand/modify - ✅ Code easier to understand/modify
- ✅ Documentation updated - ✅ Documentation updated
If you can't tick all boxes, the refactoring isn't done. If you can't tick all boxes, the refactoring isn't done.
@ -409,6 +409,6 @@ If you can't tick all boxes, the refactoring isn't done.
**Next steps:** **Next steps:**
- Read `planning/README.md` for detailed template - Read `planning/README.md` for detailed template
- Check `docs/development/module-splitting-plan.md` for real example - Check `docs/development/module-splitting-plan.md` for real example
- Browse `planning/` for active refactoring plans - Browse `planning/` for active refactoring plans

View file

@ -112,7 +112,6 @@ In CI/CD (`$CI` or `$GITHUB_ACTIONS`), AI is automatically disabled.
**In DevContainer (automatic):** **In DevContainer (automatic):**
git-cliff is automatically installed when the DevContainer is built: git-cliff is automatically installed when the DevContainer is built:
- **Rust toolchain**: Installed via `ghcr.io/devcontainers/features/rust:1` (minimal profile) - **Rust toolchain**: Installed via `ghcr.io/devcontainers/features/rust:1` (minimal profile)
- **git-cliff**: Installed via cargo in `scripts/setup/setup` - **git-cliff**: Installed via cargo in `scripts/setup/setup`
@ -121,7 +120,6 @@ Simply rebuild the container (VS Code: "Dev Containers: Rebuild Container") and
**Manual installation (outside DevContainer):** **Manual installation (outside DevContainer):**
**git-cliff** (template-based): **git-cliff** (template-based):
```bash ```bash
# See: https://git-cliff.org/docs/installation # See: https://git-cliff.org/docs/installation
@ -192,13 +190,13 @@ All methods produce GitHub-flavored Markdown with emoji categories:
## 🎯 When to Use Which ## 🎯 When to Use Which
| Method | Use Case | Pros | Cons | | Method | Use Case | Pros | Cons |
| --------------------- | --------------------- | ----------------------------- | ------------------------ | |--------|----------|------|------|
| **Helper Script** | Normal releases | Foolproof, automatic | Requires script | | **Helper Script** | Normal releases | Foolproof, automatic | Requires script |
| **Auto-Tag Workflow** | Forgot script | Safety net, automatic tagging | Still need manifest bump | | **Auto-Tag Workflow** | Forgot script | Safety net, automatic tagging | Still need manifest bump |
| **GitHub Button** | Manual quick release | Easy, no script | Limited categorization | | **GitHub Button** | Manual quick release | Easy, no script | Limited categorization |
| **Local Script** | Testing release notes | Preview before release | Manual process | | **Local Script** | Testing release notes | Preview before release | Manual process |
| **CI/CD** | After tag push | Fully automatic | Needs tag first | | **CI/CD** | After tag push | Fully automatic | Needs tag first |
--- ---
@ -221,7 +219,6 @@ git push origin main v0.3.0
``` ```
**What happens:** **What happens:**
1. Script bumps manifest.json → commits → creates tag locally 1. Script bumps manifest.json → commits → creates tag locally
2. You push commit + tag together 2. You push commit + tag together
3. Release workflow sees tag → generates notes → creates release 3. Release workflow sees tag → generates notes → creates release
@ -245,7 +242,6 @@ git push
``` ```
**What happens:** **What happens:**
1. You push manifest.json change 1. You push manifest.json change
2. Auto-Tag workflow detects change → creates tag automatically 2. Auto-Tag workflow detects change → creates tag automatically
3. Release workflow sees new tag → creates release 3. Release workflow sees new tag → creates release
@ -267,7 +263,6 @@ git push origin main v0.3.0
``` ```
**What happens:** **What happens:**
1. You create and push tag manually 1. You create and push tag manually
2. Release workflow creates release 2. Release workflow creates release
3. Auto-Tag workflow skips (tag already exists) 3. Auto-Tag workflow skips (tag already exists)
@ -287,24 +282,19 @@ git push origin main v0.3.0
## 🛡️ Safety Features ## 🛡️ Safety Features
### 1. **Version Validation** ### 1. **Version Validation**
Both helper script and auto-tag workflow validate version format (X.Y.Z). Both helper script and auto-tag workflow validate version format (X.Y.Z).
### 2. **No Duplicate Tags** ### 2. **No Duplicate Tags**
- Helper script checks if tag exists (local + remote) - Helper script checks if tag exists (local + remote)
- Auto-tag workflow checks if tag exists before creating - Auto-tag workflow checks if tag exists before creating
### 3. **Atomic Operations** ### 3. **Atomic Operations**
Helper script creates commit + tag locally. You decide when to push. Helper script creates commit + tag locally. You decide when to push.
### 4. **Version Bumps Filtered** ### 4. **Version Bumps Filtered**
Release notes automatically exclude `chore(release): bump version` commits. Release notes automatically exclude `chore(release): bump version` commits.
### 5. **Rollback Instructions** ### 5. **Rollback Instructions**
Helper script shows how to undo if you change your mind. Helper script shows how to undo if you change your mind.
--- ---
@ -340,7 +330,6 @@ git push -f origin main v0.3.0
**Auto-tag didn't create tag:** **Auto-tag didn't create tag:**
Check workflow runs in GitHub Actions. Common causes: Check workflow runs in GitHub Actions. Common causes:
- Tag already exists remotely - Tag already exists remotely
- Invalid version format in manifest.json - Invalid version format in manifest.json
- manifest.json not in the commit that was pushed - manifest.json not in the commit that was pushed
@ -359,14 +348,13 @@ Check workflow runs in GitHub Actions. Common causes:
## 💡 Tips ## 💡 Tips
1. **Conventional Commits:** Use proper commit format for best results: 1. **Conventional Commits:** Use proper commit format for best results:
```
feat(scope): Add new feature
``` Detailed description of what changed.
feat(scope): Add new feature
Detailed description of what changed. Impact: Users can now do X and Y.
```
Impact: Users can now do X and Y.
```
2. **Impact Section:** Add `Impact:` in commit body for user-friendly descriptions 2. **Impact Section:** Add `Impact:` in commit body for user-friendly descriptions

View file

@ -7,7 +7,6 @@ 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. 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:** **Design Principles:**
- **Proactive**: Detect issues before they become critical - **Proactive**: Detect issues before they become critical
- **User-friendly**: Clear explanations with actionable guidance - **User-friendly**: Clear explanations with actionable guidance
- **Auto-clearing**: Repairs automatically disappear when conditions resolve - **Auto-clearing**: Repairs automatically disappear when conditions resolve
@ -20,12 +19,10 @@ The repairs system is implemented in `coordinator/repairs.py` via the `TibberPri
**Issue ID:** `tomorrow_data_missing_{entry_id}` **Issue ID:** `tomorrow_data_missing_{entry_id}`
**When triggered:** **When triggered:**
- Current time is after 18:00 (configurable via `TOMORROW_DATA_WARNING_HOUR`) - Current time is after 18:00 (configurable via `TOMORROW_DATA_WARNING_HOUR`)
- Tomorrow's electricity price data is still not available - Tomorrow's electricity price data is still not available
**When cleared:** **When cleared:**
- Tomorrow's data becomes available - Tomorrow's data becomes available
- Automatically checks on every successful API update - Automatically checks on every successful API update
@ -33,7 +30,6 @@ 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. Users cannot plan ahead for tomorrow's electricity usage optimization. Automations relying on tomorrow's prices will not work.
**Implementation:** **Implementation:**
```python ```python
# In coordinator update cycle # In coordinator update cycle
has_tomorrow_data = self._data_fetcher.has_tomorrow_data(result["priceInfo"]) has_tomorrow_data = self._data_fetcher.has_tomorrow_data(result["priceInfo"])
@ -44,7 +40,6 @@ await self._repair_manager.check_tomorrow_data_availability(
``` ```
**Translation placeholders:** **Translation placeholders:**
- `home_name`: Name of the affected home - `home_name`: Name of the affected home
- `warning_hour`: Hour after which warning appears (default: 18) - `warning_hour`: Hour after which warning appears (default: 18)
@ -53,12 +48,10 @@ await self._repair_manager.check_tomorrow_data_availability(
**Issue ID:** `rate_limit_exceeded_{entry_id}` **Issue ID:** `rate_limit_exceeded_{entry_id}`
**When triggered:** **When triggered:**
- Integration encounters 3 or more consecutive rate limit errors (HTTP 429) - Integration encounters 3 or more consecutive rate limit errors (HTTP 429)
- Threshold configurable via `RATE_LIMIT_WARNING_THRESHOLD` - Threshold configurable via `RATE_LIMIT_WARNING_THRESHOLD`
**When cleared:** **When cleared:**
- Successful API call completes (no rate limit error) - Successful API call completes (no rate limit error)
- Error counter resets to 0 - Error counter resets to 0
@ -66,7 +59,6 @@ await self._repair_manager.check_tomorrow_data_availability(
API requests are being throttled, causing stale data. Updates may be delayed until rate limit expires. API requests are being throttled, causing stale data. Updates may be delayed until rate limit expires.
**Implementation:** **Implementation:**
```python ```python
# In error handler # In error handler
is_rate_limit = ( is_rate_limit = (
@ -82,7 +74,6 @@ await self._repair_manager.clear_rate_limit_tracking()
``` ```
**Translation placeholders:** **Translation placeholders:**
- `home_name`: Name of the affected home - `home_name`: Name of the affected home
- `error_count`: Number of consecutive rate limit errors - `error_count`: Number of consecutive rate limit errors
@ -91,12 +82,10 @@ await self._repair_manager.clear_rate_limit_tracking()
**Issue ID:** `home_not_found_{entry_id}` **Issue ID:** `home_not_found_{entry_id}`
**When triggered:** **When triggered:**
- Home configured in this integration is no longer present in Tibber account - Home configured in this integration is no longer present in Tibber account
- Detected during user data refresh (daily check) - Detected during user data refresh (daily check)
**When cleared:** **When cleared:**
- Home reappears in Tibber account (unlikely - manual cleanup expected) - Home reappears in Tibber account (unlikely - manual cleanup expected)
- Integration entry is removed (shutdown cleanup) - Integration entry is removed (shutdown cleanup)
@ -104,7 +93,6 @@ 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. Integration cannot fetch data for a non-existent home. User must remove the config entry and re-add if needed.
**Implementation:** **Implementation:**
```python ```python
# After user data update # After user data update
home_exists = self._data_fetcher._check_home_exists(home_id) home_exists = self._data_fetcher._check_home_exists(home_id)
@ -115,7 +103,6 @@ else:
``` ```
**Translation placeholders:** **Translation placeholders:**
- `home_name`: Name of the missing home - `home_name`: Name of the missing home
- `entry_id`: Config entry ID for reference - `entry_id`: Config entry ID for reference
@ -166,7 +153,6 @@ Each repair type maintains internal state to avoid redundant operations:
### Lifecycle Integration ### Lifecycle Integration
**Coordinator Initialization:** **Coordinator Initialization:**
```python ```python
self._repair_manager = TibberPricesRepairManager( self._repair_manager = TibberPricesRepairManager(
hass=hass, hass=hass,
@ -176,7 +162,6 @@ self._repair_manager = TibberPricesRepairManager(
``` ```
**Update Cycle Integration:** **Update Cycle Integration:**
```python ```python
# Success path - check conditions # Success path - check conditions
if result and "priceInfo" in result: if result and "priceInfo" in result:
@ -193,7 +178,6 @@ if is_rate_limit:
``` ```
**Shutdown Cleanup:** **Shutdown Cleanup:**
```python ```python
async def async_shutdown(self) -> None: async def async_shutdown(self) -> None:
"""Shut down coordinator and clean up.""" """Shut down coordinator and clean up."""
@ -212,27 +196,24 @@ Repairs use Home Assistant's standard translation system. Translations are defin
- `/translations/sv.json` - `/translations/sv.json`
**Structure:** **Structure:**
```json ```json
{ {
"issues": { "issues": {
"tomorrow_data_missing": { "tomorrow_data_missing": {
"title": "Tomorrow's price data missing for {home_name}", "title": "Tomorrow's price data missing for {home_name}",
"description": "Detailed explanation with multiple paragraphs...\n\nPossible causes:\n- Cause 1\n- Cause 2" "description": "Detailed explanation with multiple paragraphs...\n\nPossible causes:\n- Cause 1\n- Cause 2"
}
} }
}
} }
``` ```
## Home Assistant Integration ## Home Assistant Integration
Repairs appear in: Repairs appear in:
- **Settings → System → Repairs** (main repairs panel) - **Settings → System → Repairs** (main repairs panel)
- **Notifications** (bell icon in UI shows repair count) - **Notifications** (bell icon in UI shows repair count)
Repair properties: Repair properties:
- **`is_fixable=False`**: No automated fix available (user action required) - **`is_fixable=False`**: No automated fix available (user action required)
- **`severity=IssueSeverity.WARNING`**: Yellow warning level (not critical) - **`severity=IssueSeverity.WARNING`**: Yellow warning level (not critical)
- **`translation_key`**: References `issues.{key}` in translation files - **`translation_key`**: References `issues.{key}` in translation files
@ -247,7 +228,6 @@ Repair properties:
4. When tomorrow data arrives (next API fetch), repair clears 4. When tomorrow data arrives (next API fetch), repair clears
**Manual trigger:** **Manual trigger:**
```python ```python
# Temporarily set warning hour to current hour for testing # Temporarily set warning hour to current hour for testing
TOMORROW_DATA_WARNING_HOUR = datetime.now().hour TOMORROW_DATA_WARNING_HOUR = datetime.now().hour
@ -260,7 +240,6 @@ TOMORROW_DATA_WARNING_HOUR = datetime.now().hour
3. Successful API call clears the repair 3. Successful API call clears the repair
**Manual test:** **Manual test:**
- Reduce API polling interval to trigger rate limiting - Reduce API polling interval to trigger rate limiting
- Or temporarily return HTTP 429 in API client - Or temporarily return HTTP 429 in API client
@ -284,7 +263,6 @@ To add a new repair type:
7. **Document** in this file 7. **Document** in this file
**Example template:** **Example template:**
```python ```python
async def check_new_condition(self, *, param: bool) -> None: async def check_new_condition(self, *, param: bool) -> None:
"""Check new condition and create/clear repair.""" """Check new condition and create/clear repair."""

View file

@ -4,9 +4,9 @@
## Prerequisites ## Prerequisites
- VS Code with Dev Container support - VS Code with Dev Container support
- Docker installed and running - Docker installed and running
- GitHub account (for Tibber API token) - GitHub account (for Tibber API token)
## Quick Setup ## Quick Setup
@ -26,11 +26,11 @@ code .
The DevContainer includes: The DevContainer includes:
- Python 3.13 with `.venv` at `/home/vscode/.venv/` - Python 3.13 with `.venv` at `/home/vscode/.venv/`
- `uv` package manager (fast, modern Python tooling) - `uv` package manager (fast, modern Python tooling)
- Home Assistant development dependencies - Home Assistant development dependencies
- Ruff linter/formatter - Ruff linter/formatter
- Git, GitHub CLI, Node.js, Rust toolchain - Git, GitHub CLI, Node.js, Rust toolchain
## Running the Integration ## Running the Integration

View file

@ -13,10 +13,10 @@ Before running tests or committing changes, validate the integration structure:
This lightweight script checks: This lightweight script checks:
- ✓ `config_flow.py` exists - `config_flow.py` exists
- ✓ `manifest.json` is valid JSON with required fields - `manifest.json` is valid JSON with required fields
- ✓ Translation files have valid JSON syntax - ✓ Translation files have valid JSON syntax
- ✓ All Python files compile without syntax errors - ✓ All Python files compile without syntax errors
**Note:** Full hassfest validation runs in GitHub Actions on push. **Note:** Full hassfest validation runs in GitHub Actions on push.
@ -42,10 +42,10 @@ pytest --cov=custom_components.tibber_prices tests/
Then test in Home Assistant UI: Then test in Home Assistant UI:
- Configuration flow - Configuration flow
- Sensor states and attributes - Sensor states and attributes
- Services - Services
- Translation strings - Translation strings
## Test Guidelines ## Test Guidelines

View file

@ -10,11 +10,11 @@ This document explains the timer/scheduler system in the Tibber Prices integrati
The integration uses **three independent timer mechanisms** for different purposes: The integration uses **three independent timer mechanisms** for different purposes:
| Timer | Type | Interval | Purpose | Trigger Method | | Timer | Type | Interval | Purpose | Trigger Method |
| ------------ | ----------- | ------------------ | -------------------- | ------------------------------- | |-------|------|----------|---------|----------------|
| **Timer #1** | HA built-in | 15 minutes | API data updates | `DataUpdateCoordinator` | | **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 #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()` | | **Timer #3** | Custom | Every minute | Countdown/progress | `async_track_utc_time_change()` |
**Key principle:** Timer #1 (HA) controls **data fetching**, Timer #2 controls **entity updates**, Timer #3 controls **timing displays**. **Key principle:** Timer #1 (HA) controls **data fetching**, Timer #2 controls **entity updates**, Timer #3 controls **timing displays**.
@ -27,7 +27,6 @@ The integration uses **three independent timer mechanisms** for different purpos
**Type:** Home Assistant's built-in `DataUpdateCoordinator` with `UPDATE_INTERVAL = 15 minutes` **Type:** Home Assistant's built-in `DataUpdateCoordinator` with `UPDATE_INTERVAL = 15 minutes`
**What it is:** **What it is:**
- HA provides this timer system automatically when you inherit from `DataUpdateCoordinator` - HA provides this timer system automatically when you inherit from `DataUpdateCoordinator`
- Triggers `_async_update_data()` method every 15 minutes - Triggers `_async_update_data()` method every 15 minutes
- **Not** synchronized to clock boundaries (each installation has different start time) - **Not** synchronized to clock boundaries (each installation has different start time)
@ -54,19 +53,16 @@ async def _async_update_data(self) -> TibberPricesData:
``` ```
**Load Distribution:** **Load Distribution:**
- Each HA installation starts Timer #1 at different times → natural 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 - 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 - Result: API load spread over ~30 minutes instead of all at once
**Midnight Coordination:** **Midnight Coordination:**
- Atomic check: `_check_midnight_turnover_needed(now)` compares dates only (no side effects) - Atomic check: `_check_midnight_turnover_needed(now)` compares dates only (no side effects)
- If midnight turnover needed → performs it and returns early - If midnight turnover needed → performs it and returns early
- Timer #2 will see turnover already done and skip gracefully - Timer #2 will see turnover already done and skip gracefully
**Why we use HA's timer:** **Why we use HA's timer:**
- Automatic restart after HA restart - Automatic restart after HA restart
- Built-in retry logic for temporary failures - Built-in retry logic for temporary failures
- Standard HA integration pattern - Standard HA integration pattern
@ -83,7 +79,6 @@ async def _async_update_data(self) -> TibberPricesData:
**Purpose:** Update time-sensitive entity states at interval boundaries **without waiting for API poll** **Purpose:** Update time-sensitive entity states at interval boundaries **without waiting for API poll**
**Problem it solves:** **Problem it solves:**
- Timer #1 runs every 15 minutes but NOT synchronized to clock (:03, :18, :33, :48) - 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 - 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 - Example: 14:00 new price, but Timer #1 ran at 13:58 → next update at 14:13 → users see old price until 14:13
@ -105,26 +100,22 @@ async def _handle_quarter_hour_refresh(self, now: datetime) -> None:
``` ```
**Smart Boundary Tolerance:** **Smart Boundary Tolerance:**
- Uses `round_to_nearest_quarter_hour()` with ±2 second 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 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) - HA restart at 14:59:30 → stays at 14:45:00 (shows current interval)
- See [Architecture](./architecture.md#3-quarter-hour-precision) for details - See [Architecture](./architecture.md#3-quarter-hour-precision) for details
**Absolute Time Scheduling:** **Absolute Time Scheduling:**
- `async_track_utc_time_change()` plans for **all future boundaries** (15:00, 15:15, 15:30, ...) - `async_track_utc_time_change()` plans for **all future boundaries** (15:00, 15:15, 15:30, ...)
- NOT relative delays ("in 15 minutes") - 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) - If triggered at 14:59:58 → next trigger is 15:15:00, NOT 15:00:00 (prevents double updates)
**Which entities listen:** **Which entities listen:**
- All sensors that depend on "current interval" (e.g., `current_interval_price`, `next_interval_price`) - 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`) - Binary sensors that check "is now in period?" (e.g., `best_price_period_active`)
- ~50-60 entities out of 120+ total - ~50-60 entities out of 120+ total
**Why custom timer:** **Why custom timer:**
- HA's built-in coordinator doesn't support exact boundary timing - HA's built-in coordinator doesn't support exact boundary timing
- We need **absolute time** triggers, not periodic intervals - We need **absolute time** triggers, not periodic intervals
- Allows fast entity updates without expensive data transformation - Allows fast entity updates without expensive data transformation
@ -149,7 +140,6 @@ async def _handle_minute_refresh(self, now: datetime) -> None:
``` ```
**Which entities listen:** **Which entities listen:**
- `best_price_remaining_minutes` - Countdown timer - `best_price_remaining_minutes` - Countdown timer
- `peak_price_remaining_minutes` - Countdown timer - `peak_price_remaining_minutes` - Countdown timer
- `best_price_progress` - Progress bar (0-100%) - `best_price_progress` - Progress bar (0-100%)
@ -157,13 +147,11 @@ async def _handle_minute_refresh(self, now: datetime) -> None:
- ~10 entities total - ~10 entities total
**Why custom timer:** **Why custom timer:**
- Users want smooth countdowns (not jumping 15 minutes at a time) - Users want smooth countdowns (not jumping 15 minutes at a time)
- Progress bars need minute-by-minute updates - Progress bars need minute-by-minute updates
- Very lightweight (no data processing, just state recalculation) - Very lightweight (no data processing, just state recalculation)
**Why NOT every second:** **Why NOT every second:**
- Minute precision sufficient for countdown UX - Minute precision sufficient for countdown UX
- Reduces CPU load (60× fewer updates than seconds) - Reduces CPU load (60× fewer updates than seconds)
- Home Assistant best practice (avoid sub-minute updates) - Home Assistant best practice (avoid sub-minute updates)
@ -206,7 +194,6 @@ class ListenerManager:
``` ```
**Why this pattern:** **Why this pattern:**
- Decouples timer logic from entity logic - Decouples timer logic from entity logic
- One timer can notify many entities efficiently - One timer can notify many entities efficiently
- Entities can unregister when removed (cleanup) - Entities can unregister when removed (cleanup)
@ -292,13 +279,11 @@ class ListenerManager:
### Reason 1: Load Distribution on Tibber API ### Reason 1: Load Distribution on Tibber API
If all installations used synchronized timers: If all installations used synchronized timers:
- ❌ Everyone fetches at 13:00:00 → Tibber API overload - ❌ Everyone fetches at 13:00:00 → Tibber API overload
- ❌ Everyone fetches at 14:00:00 → Tibber API overload - ❌ Everyone fetches at 14:00:00 → Tibber API overload
- ❌ "Thundering herd" problem - ❌ "Thundering herd" problem
With HA's unsynchronized timer: With HA's unsynchronized timer:
- ✅ Installation A: 13:03:12, 13:18:12, 13:33:12, ... - ✅ Installation A: 13:03:12, 13:18:12, 13:33:12, ...
- ✅ Installation B: 13:07:45, 13:22:45, 13:37:45, ... - ✅ Installation B: 13:07:45, 13:22:45, 13:37:45, ...
- ✅ Installation C: 13:11:28, 13:26:28, 13:41:28, ... - ✅ Installation C: 13:11:28, 13:26:28, 13:41:28, ...
@ -331,7 +316,6 @@ def _should_update_price_data(self) -> str:
**Most Timer #1 cycles:** Fast path (~2ms), no API call, just returns cached data. **Most Timer #1 cycles:** Fast path (~2ms), no API call, just returns cached data.
**API fetch only when:** **API fetch only when:**
- Tomorrow data missing/invalid (after 13:00) - Tomorrow data missing/invalid (after 13:00)
- Cache expired (midnight turnover) - Cache expired (midnight turnover)
- Explicit user refresh - Explicit user refresh
@ -355,7 +339,6 @@ def _should_update_price_data(self) -> str:
## Performance Characteristics ## Performance Characteristics
### Timer #1 (DataUpdateCoordinator) ### Timer #1 (DataUpdateCoordinator)
- **Triggers:** Every 15 minutes (unsynchronized) - **Triggers:** Every 15 minutes (unsynchronized)
- **Fast path:** ~2ms (cache check, return existing data) - **Fast path:** ~2ms (cache check, return existing data)
- **Slow path:** ~600ms (API fetch + transform + calculate) - **Slow path:** ~600ms (API fetch + transform + calculate)
@ -363,14 +346,12 @@ def _should_update_price_data(self) -> str:
- **API calls:** ~1-2 times/day (cached otherwise) - **API calls:** ~1-2 times/day (cached otherwise)
### Timer #2 (Quarter-Hour Refresh) ### Timer #2 (Quarter-Hour Refresh)
- **Triggers:** 96 times/day (exact boundaries) - **Triggers:** 96 times/day (exact boundaries)
- **Processing:** ~5ms (notify 60 entities) - **Processing:** ~5ms (notify 60 entities)
- **No API calls:** Uses cached/transformed data - **No API calls:** Uses cached/transformed data
- **No transformation:** Just entity state updates - **No transformation:** Just entity state updates
### Timer #3 (Minute Refresh) ### Timer #3 (Minute Refresh)
- **Triggers:** 1440 times/day (every minute) - **Triggers:** 1440 times/day (every minute)
- **Processing:** ~1ms (notify 10 entities) - **Processing:** ~1ms (notify 10 entities)
- **No API calls:** No data processing at all - **No API calls:** No data processing at all
@ -412,16 +393,16 @@ _LOGGER.setLevel(logging.DEBUG)
### Common Issues ### Common Issues
1. **Timer #2 not triggering:** 1. **Timer #2 not triggering:**
- Check: `schedule_quarter_hour_refresh()` called in `__init__`? - Check: `schedule_quarter_hour_refresh()` called in `__init__`?
- Check: `_quarter_hour_timer_cancel` properly stored? - Check: `_quarter_hour_timer_cancel` properly stored?
2. **Double updates at midnight:** 2. **Double updates at midnight:**
- Should NOT happen (atomic coordination) - Should NOT happen (atomic coordination)
- Check: Both timers use same date comparison logic? - Check: Both timers use same date comparison logic?
3. **API overload:** 3. **API overload:**
- Check: Random delay working? (0-30s jitter on tomorrow check) - Check: Random delay working? (0-30s jitter on tomorrow check)
- Check: Cache validation logic correct? - Check: Cache validation logic correct?
--- ---
@ -436,20 +417,17 @@ _LOGGER.setLevel(logging.DEBUG)
## Summary ## Summary
**Three independent timers:** **Three independent timers:**
1. **Timer #1** (HA built-in, 15 min, unsynchronized) → Data fetching (when needed) 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) 2. **Timer #2** (Custom, :00/:15/:30/:45) → Entity state updates (always)
3. **Timer #3** (Custom, every minute) → Countdown/progress (always) 3. **Timer #3** (Custom, every minute) → Countdown/progress (always)
**Key insights:** **Key insights:**
- Timer #1 unsynchronized = good (load distribution on API) - Timer #1 unsynchronized = good (load distribution on API)
- Timer #2 synchronized = good (user sees correct data immediately) - Timer #2 synchronized = good (user sees correct data immediately)
- Timer #3 synchronized = good (smooth countdown UX) - Timer #3 synchronized = good (smooth countdown UX)
- All three coordinate gracefully (atomic midnight checks, no conflicts) - All three coordinate gracefully (atomic midnight checks, no conflicts)
**"Listener" terminology:** **"Listener" terminology:**
- Timer = mechanism that triggers - Timer = mechanism that triggers
- Listener = callback that gets called - Listener = callback that gets called
- Observer pattern = entities register, coordinator notifies - Observer pattern = entities register, coordinator notifies

View file

@ -22,30 +22,30 @@ Fetches home information and metadata:
```graphql ```graphql
query { query {
viewer { viewer {
homes { homes {
id id
appNickname appNickname
address { address {
address1 address1
postalCode postalCode
city city
country country
} }
timeZone timeZone
currentSubscription { currentSubscription {
priceInfo { priceInfo {
current { current {
currency currency
} }
}
}
meteringPointData {
consumptionEan
gridAreaCode
}
} }
}
meteringPointData {
consumptionEan
gridAreaCode
}
} }
}
} }
``` ```
@ -56,27 +56,26 @@ query {
Fetches quarter-hourly prices: Fetches quarter-hourly prices:
```graphql ```graphql
query ($homeId: ID!) { query($homeId: ID!) {
viewer { viewer {
home(id: $homeId) { home(id: $homeId) {
currentSubscription { currentSubscription {
priceInfo { priceInfo {
range(resolution: QUARTER_HOURLY, first: 384) { range(resolution: QUARTER_HOURLY, first: 384) {
nodes { nodes {
total total
startsAt startsAt
level level
}
}
}
} }
}
} }
}
} }
}
} }
``` ```
**Parameters:** **Parameters:**
- `homeId`: Tibber home identifier - `homeId`: Tibber home identifier
- `resolution`: Always `QUARTER_HOURLY` - `resolution`: Always `QUARTER_HOURLY`
- `first`: 384 intervals (4 days of data) - `first`: 384 intervals (4 days of data)
@ -86,12 +85,10 @@ query ($homeId: ID!) {
## Rate Limits ## Rate Limits
Tibber API rate limits (as of 2024): Tibber API rate limits (as of 2024):
- **5000 requests per hour** per token - **5000 requests per hour** per token
- **Burst limit:** 100 requests per minute - **Burst limit:** 100 requests per minute
Integration stays well below these limits: Integration stays well below these limits:
- Polls every 15 minutes = 96 requests/day - Polls every 15 minutes = 96 requests/day
- User data cached for 24h = 1 request/day - User data cached for 24h = 1 request/day
- **Total:** ~100 requests/day per home - **Total:** ~100 requests/day per home
@ -102,14 +99,13 @@ Integration stays well below these limits:
```json ```json
{ {
"total": 0.2456, "total": 0.2456,
"startsAt": "2024-12-06T14:00:00.000+01:00", "startsAt": "2024-12-06T14:00:00.000+01:00",
"level": "NORMAL" "level": "NORMAL"
} }
``` ```
**Fields:** **Fields:**
- `total`: Price including VAT and fees (currency's major unit, e.g., EUR) - `total`: Price including VAT and fees (currency's major unit, e.g., EUR)
- `startsAt`: ISO 8601 timestamp with timezone - `startsAt`: ISO 8601 timestamp with timezone
- `level`: Tibber's own classification (VERY_CHEAP, CHEAP, NORMAL, EXPENSIVE, VERY_EXPENSIVE) - `level`: Tibber's own classification (VERY_CHEAP, CHEAP, NORMAL, EXPENSIVE, VERY_EXPENSIVE)
@ -118,12 +114,11 @@ Integration stays well below these limits:
```json ```json
{ {
"currency": "EUR" "currency": "EUR"
} }
``` ```
Supported currencies: Supported currencies:
- `EUR` (Euro) - displayed as ct/kWh - `EUR` (Euro) - displayed as ct/kWh
- `NOK` (Norwegian Krone) - displayed as øre/kWh - `NOK` (Norwegian Krone) - displayed as øre/kWh
- `SEK` (Swedish Krona) - displayed as öre/kWh - `SEK` (Swedish Krona) - displayed as öre/kWh
@ -133,52 +128,42 @@ Supported currencies:
### Common Error Responses ### Common Error Responses
**Invalid Token:** **Invalid Token:**
```json ```json
{ {
"errors": [ "errors": [{
{ "message": "Unauthorized",
"message": "Unauthorized", "extensions": {
"extensions": { "code": "UNAUTHENTICATED"
"code": "UNAUTHENTICATED" }
} }]
}
]
} }
``` ```
**Rate Limit Exceeded:** **Rate Limit Exceeded:**
```json ```json
{ {
"errors": [ "errors": [{
{ "message": "Too Many Requests",
"message": "Too Many Requests", "extensions": {
"extensions": { "code": "RATE_LIMIT_EXCEEDED"
"code": "RATE_LIMIT_EXCEEDED" }
} }]
}
]
} }
``` ```
**Home Not Found:** **Home Not Found:**
```json ```json
{ {
"errors": [ "errors": [{
{ "message": "Home not found",
"message": "Home not found", "extensions": {
"extensions": { "code": "NOT_FOUND"
"code": "NOT_FOUND" }
} }]
}
]
} }
``` ```
Integration handles these with: Integration handles these with:
- Exponential backoff retry (3 attempts) - Exponential backoff retry (3 attempts)
- ConfigEntryAuthFailed for auth errors - ConfigEntryAuthFailed for auth errors
- ConfigEntryNotReady for temporary failures - ConfigEntryNotReady for temporary failures
@ -186,7 +171,6 @@ Integration handles these with:
## Data Transformation ## Data Transformation
Raw API data is enriched with: Raw API data is enriched with:
- **Trailing 24h average** - Calculated from previous intervals - **Trailing 24h average** - Calculated from previous intervals
- **Leading 24h average** - Calculated from future intervals - **Leading 24h average** - Calculated from future intervals
- **Price difference %** - Deviation from average - **Price difference %** - Deviation from average
@ -197,7 +181,6 @@ See `utils/price.py` for enrichment logic.
--- ---
💡 **External Resources:** 💡 **External Resources:**
- [Tibber API Documentation](https://developer.tibber.com/docs/overview) - [Tibber API Documentation](https://developer.tibber.com/docs/overview)
- [GraphQL Explorer](https://developer.tibber.com/explorer) - [GraphQL Explorer](https://developer.tibber.com/explorer)
- [Get API Token](https://developer.tibber.com/settings/access-token) - [Get API Token](https://developer.tibber.com/settings/access-token)

View file

@ -100,43 +100,43 @@ flowchart TB
### Flow Description ### Flow Description
1. **Setup** (`__init__.py`) 1. **Setup** (`__init__.py`)
- Integration loads, creates coordinator instance - Integration loads, creates coordinator instance
- Registers entity platforms (sensor, binary_sensor) - Registers entity platforms (sensor, binary_sensor)
- Sets up custom services - Sets up custom services
2. **Data Fetch** (every 15 minutes) 2. **Data Fetch** (every 15 minutes)
- Coordinator triggers update via `api.py` - Coordinator triggers update via `api.py`
- API client checks **persistent cache** first (`coordinator/cache.py`) - API client checks **persistent cache** first (`coordinator/cache.py`)
- If cache valid → return cached data - If cache valid → return cached data
- If cache stale → query Tibber GraphQL API - If cache stale → query Tibber GraphQL API
- Store fresh data in persistent cache (survives HA restart) - Store fresh data in persistent cache (survives HA restart)
3. **Price Enrichment** 3. **Price Enrichment**
- Coordinator passes raw prices to `DataTransformer` - Coordinator passes raw prices to `DataTransformer`
- Transformer checks **transformation cache** (memory) - Transformer checks **transformation cache** (memory)
- If cache valid → return enriched data - If cache valid → return enriched data
- If cache invalid → enrich via `price_utils.py` + `average_utils.py` - If cache invalid → enrich via `price_utils.py` + `average_utils.py`
- Calculate 24h trailing/leading averages - Calculate 24h trailing/leading averages
- Calculate price differences (% from average) - Calculate price differences (% from average)
- Assign rating levels (LOW/NORMAL/HIGH) - Assign rating levels (LOW/NORMAL/HIGH)
- Store enriched data in transformation cache - Store enriched data in transformation cache
4. **Period Calculation** 4. **Period Calculation**
- Coordinator passes enriched data to `PeriodCalculator` - Coordinator passes enriched data to `PeriodCalculator`
- Calculator computes **hash** from prices + config - Calculator computes **hash** from prices + config
- If hash matches cache → return cached periods - If hash matches cache → return cached periods
- If hash differs → recalculate best/peak price periods - If hash differs → recalculate best/peak price periods
- Store periods with new hash - Store periods with new hash
5. **Entity Updates** 5. **Entity Updates**
- Coordinator provides complete data (prices + periods) - Coordinator provides complete data (prices + periods)
- Sensors read values via unified handlers - Sensors read values via unified handlers
- Binary sensors evaluate period states - Binary sensors evaluate period states
- Entities update on quarter-hour boundaries (00/15/30/45) - Entities update on quarter-hour boundaries (00/15/30/45)
6. **Service Calls** 6. **Service Calls**
- Custom services access coordinator data directly - Custom services access coordinator data directly
- Return formatted responses (JSON, ApexCharts format) - Return formatted responses (JSON, ApexCharts format)
--- ---
@ -146,13 +146,13 @@ flowchart TB
The integration uses **5 independent caching layers** for optimal performance: The integration uses **5 independent caching layers** for optimal performance:
| Layer | Location | Lifetime | Invalidation | Memory | | Layer | Location | Lifetime | Invalidation | Memory |
| ------------------------ | ------------------------------------ | -------------------------------------- | ------------ | ------ | |-------|----------|----------|--------------|--------|
| **API Cache** | `coordinator/cache.py` | 24h (user)<br/>Until midnight (prices) | Automatic | 50KB | | **API Cache** | `coordinator/cache.py` | 24h (user)<br/>Until midnight (prices) | Automatic | 50KB |
| **Translation Cache** | `const.py` | Until HA restart | Never | 5KB | | **Translation Cache** | `const.py` | Until HA restart | Never | 5KB |
| **Config Cache** | `coordinator/*` | Until config change | Explicit | 1KB | | **Config Cache** | `coordinator/*` | Until config change | Explicit | 1KB |
| **Period Cache** | `coordinator/periods.py` | Until data/config change | Hash-based | 10KB | | **Period Cache** | `coordinator/periods.py` | Until data/config change | Hash-based | 10KB |
| **Transformation Cache** | `coordinator/data_transformation.py` | Until midnight/config | Automatic | 60KB | | **Transformation Cache** | `coordinator/data_transformation.py` | Until midnight/config | Automatic | 60KB |
**Total cache overhead:** ~126KB per coordinator instance (main entry + subentries) **Total cache overhead:** ~126KB per coordinator instance (main entry + subentries)
@ -195,31 +195,30 @@ For detailed cache behavior, see [Caching Strategy](./caching-strategy.md).
### Core Components ### Core Components
| Component | File | Responsibility | | Component | File | Responsibility |
| --------------------- | ------------------------------------ | ---------------------------------------------------------------------------------------- | |-----------|------|----------------|
| **API Client** | `api.py` | GraphQL queries to Tibber, retry logic, error handling | | **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 | | **Coordinator** | `coordinator.py` | Update orchestration, cache management, absolute-time scheduling with boundary tolerance |
| **Data Transformer** | `coordinator/data_transformation.py` | Price enrichment (averages, ratings, differences) | | **Data Transformer** | `coordinator/data_transformation.py` | Price enrichment (averages, ratings, differences) |
| **Period Calculator** | `coordinator/periods.py` | Best/peak price period calculation with relaxation | | **Period Calculator** | `coordinator/periods.py` | Best/peak price period calculation with relaxation |
| **Sensors** | `sensor/` | 80+ entities for prices, levels, ratings, statistics | | **Sensors** | `sensor/` | 80+ entities for prices, levels, ratings, statistics |
| **Binary Sensors** | `binary_sensor/` | Period indicators (best/peak price active) | | **Binary Sensors** | `binary_sensor/` | Period indicators (best/peak price active) |
| **Services** | `services/` | Custom service endpoints (get_chartdata, get_apexcharts_yaml, refresh_user_data) | | **Services** | `services/` | Custom service endpoints (get_chartdata, get_apexcharts_yaml, refresh_user_data) |
### Sensor Architecture (Calculator Pattern) ### Sensor Architecture (Calculator Pattern)
The sensor platform uses **Calculator Pattern** for clean separation of concerns (refactored Nov 2025): The sensor platform uses **Calculator Pattern** for clean separation of concerns (refactored Nov 2025):
| Component | Files | Lines | Responsibility | | Component | Files | Lines | Responsibility |
| ---------------- | ------------------------- | ----- | ------------------------------------------------------- | |-----------|-------|-------|----------------|
| **Entity Class** | `sensor/core.py` | 909 | Entity lifecycle, coordinator, delegates to calculators | | **Entity Class** | `sensor/core.py` | 909 | Entity lifecycle, coordinator, delegates to calculators |
| **Calculators** | `sensor/calculators/` | 1,838 | Business logic (8 specialized calculators) | | **Calculators** | `sensor/calculators/` | 1,838 | Business logic (8 specialized calculators) |
| **Attributes** | `sensor/attributes/` | 1,209 | State presentation (8 specialized modules) | | **Attributes** | `sensor/attributes/` | 1,209 | State presentation (8 specialized modules) |
| **Routing** | `sensor/value_getters.py` | 276 | Centralized sensor → calculator mapping | | **Routing** | `sensor/value_getters.py` | 276 | Centralized sensor → calculator mapping |
| **Chart Export** | `sensor/chart_data.py` | 144 | Service call handling, YAML parsing | | **Chart Export** | `sensor/chart_data.py` | 144 | Service call handling, YAML parsing |
| **Helpers** | `sensor/helpers.py` | 188 | Aggregation functions, utilities | | **Helpers** | `sensor/helpers.py` | 188 | Aggregation functions, utilities |
**Calculator Package** (`sensor/calculators/`): **Calculator Package** (`sensor/calculators/`):
- `base.py` - Abstract BaseCalculator with coordinator access - `base.py` - Abstract BaseCalculator with coordinator access
- `interval.py` - Single interval calculations (current/next/previous) - `interval.py` - Single interval calculations (current/next/previous)
- `rolling_hour.py` - 5-interval rolling windows - `rolling_hour.py` - 5-interval rolling windows
@ -231,7 +230,6 @@ The sensor platform uses **Calculator Pattern** for clean separation of concerns
- `metadata.py` - Home/metering metadata - `metadata.py` - Home/metering metadata
**Benefits:** **Benefits:**
- 58% reduction in core.py (2,170 → 909 lines) - 58% reduction in core.py (2,170 → 909 lines)
- Clear separation: Calculators (logic) vs Attributes (presentation) - Clear separation: Calculators (logic) vs Attributes (presentation)
- Independent testability for each calculator - Independent testability for each calculator
@ -239,12 +237,12 @@ The sensor platform uses **Calculator Pattern** for clean separation of concerns
### Helper Utilities ### Helper Utilities
| Utility | File | Purpose | | Utility | File | Purpose |
| ----------------- | ------------------ | ------------------------------------------------- | |---------|------|---------|
| **Price Utils** | `utils/price.py` | Rating calculation, enrichment, level aggregation | | **Price Utils** | `utils/price.py` | Rating calculation, enrichment, level aggregation |
| **Average Utils** | `utils/average.py` | Trailing/leading 24h average calculations | | **Average Utils** | `utils/average.py` | Trailing/leading 24h average calculations |
| **Entity Utils** | `entity_utils/` | Shared icon/color/attribute logic | | **Entity Utils** | `entity_utils/` | Shared icon/color/attribute logic |
| **Translations** | `const.py` | Translation loading and caching | | **Translations** | `const.py` | Translation loading and caching |
--- ---
@ -285,12 +283,12 @@ All quarter-hourly price intervals get augmented via `utils/price.py`:
- **API polling**: Every 15 minutes (coordinator fetch cycle) - **API polling**: Every 15 minutes (coordinator fetch cycle)
- **Entity updates**: On 00/15/30/45-minute boundaries via `coordinator/listeners.py` - **Entity updates**: On 00/15/30/45-minute boundaries via `coordinator/listeners.py`
- **Timer scheduling**: Uses `async_track_utc_time_change(minute=[0, 15, 30, 45], second=0)` - **Timer scheduling**: Uses `async_track_utc_time_change(minute=[0, 15, 30, 45], second=0)`
- HA may trigger ±few milliseconds before/after exact boundary - HA may trigger ±few milliseconds before/after exact boundary
- Smart boundary tolerance (±2 seconds) handles scheduling jitter in `sensor/helpers.py` - Smart boundary tolerance (±2 seconds) handles scheduling jitter in `sensor/helpers.py`
- If HA schedules at 14:59:58 → rounds to 15:00:00 (shows new interval data) - If HA schedules at 14:59:58 → rounds to 15:00:00 (shows new interval data)
- If HA restarts at 14:59:30 → stays at 14:45:00 (shows current interval data) - If HA restarts at 14:59:30 → stays at 14:45:00 (shows current interval data)
- **Absolute time tracking**: Timer plans for **all future boundaries** (not relative delays) - **Absolute time tracking**: Timer plans for **all future boundaries** (not relative delays)
- Prevents double-updates (if triggered at 14:59:58, next trigger is 15:15:00, not 15:00:00) - Prevents double-updates (if triggered at 14:59:58, next trigger is 15:15:00, not 15:00:00)
- **Result**: Current price sensors update without waiting for next API poll - **Result**: Current price sensors update without waiting for next API poll
### 4. Calculator Pattern (Sensor Platform) ### 4. Calculator Pattern (Sensor Platform)
@ -298,31 +296,26 @@ All quarter-hourly price intervals get augmented via `utils/price.py`:
Sensors organized by **calculation method** (refactored Nov 2025): Sensors organized by **calculation method** (refactored Nov 2025):
**Unified Handler Methods** (`sensor/core.py`): **Unified Handler Methods** (`sensor/core.py`):
- `_get_interval_value(offset, type)` - current/next/previous intervals - `_get_interval_value(offset, type)` - current/next/previous intervals
- `_get_rolling_hour_value(offset, type)` - 5-interval rolling windows - `_get_rolling_hour_value(offset, type)` - 5-interval rolling windows
- `_get_daily_stat_value(day, stat_func)` - calendar day min/max/avg - `_get_daily_stat_value(day, stat_func)` - calendar day min/max/avg
- `_get_24h_window_value(stat_func)` - trailing/leading statistics - `_get_24h_window_value(stat_func)` - trailing/leading statistics
**Routing** (`sensor/value_getters.py`): **Routing** (`sensor/value_getters.py`):
- Single source of truth mapping 80+ entity keys to calculator methods - Single source of truth mapping 80+ entity keys to calculator methods
- Organized by calculation type (Interval, Rolling Hour, Daily Stats, etc.) - Organized by calculation type (Interval, Rolling Hour, Daily Stats, etc.)
**Calculators** (`sensor/calculators/`): **Calculators** (`sensor/calculators/`):
- Each calculator inherits from `BaseCalculator` with coordinator access - Each calculator inherits from `BaseCalculator` with coordinator access
- Focused responsibility: `IntervalCalculator`, `TrendCalculator`, etc. - Focused responsibility: `IntervalCalculator`, `TrendCalculator`, etc.
- Complex logic isolated (e.g., `TrendCalculator` has internal caching) - Complex logic isolated (e.g., `TrendCalculator` has internal caching)
**Attributes** (`sensor/attributes/`): **Attributes** (`sensor/attributes/`):
- Separate from business logic, handles state presentation - Separate from business logic, handles state presentation
- Builds extra_state_attributes dicts for entity classes - Builds extra_state_attributes dicts for entity classes
- Unified builders: `build_sensor_attributes()`, `build_extra_state_attributes()` - Unified builders: `build_sensor_attributes()`, `build_extra_state_attributes()`
**Benefits:** **Benefits:**
- Minimal code duplication across 80+ sensors - Minimal code duplication across 80+ sensors
- Clear separation of concerns (calculation vs presentation) - Clear separation of concerns (calculation vs presentation)
- Easy to extend: Add sensor → choose pattern → add to routing - Easy to extend: Add sensor → choose pattern → add to routing
@ -340,12 +333,12 @@ Sensors organized by **calculation method** (refactored Nov 2025):
### CPU Optimization ### CPU Optimization
| Optimization | Location | Savings | | Optimization | Location | Savings |
| ------------------- | ------------------------ | ---------------------------- | |--------------|----------|---------|
| Config caching | `coordinator/*` | ~50% on config checks | | Config caching | `coordinator/*` | ~50% on config checks |
| Period caching | `coordinator/periods.py` | ~70% on period recalculation | | Period caching | `coordinator/periods.py` | ~70% on period recalculation |
| Lazy logging | Throughout | ~15% on log-heavy operations | | Lazy logging | Throughout | ~15% on log-heavy operations |
| Import optimization | Module structure | ~20% faster loading | | Import optimization | Module structure | ~20% faster loading |
### Memory Usage ### Memory Usage

View file

@ -24,13 +24,11 @@ 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. **Purpose:** Reduce API calls to Tibber by caching user data and price data between HA restarts.
**What is cached:** **What is cached:**
- **Price data** (`price_data`): Day before yesterday/yesterday/today/tomorrow price intervals with enriched fields (384 intervals total) - **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 - **User data** (`user_data`): Homes, subscriptions, features from Tibber GraphQL `viewer` query
- **Timestamps**: Last update times for validation - **Timestamps**: Last update times for validation
**Lifetime:** **Lifetime:**
- **Price data**: Until midnight turnover (cleared daily at 00:00 local time) - **Price data**: Until midnight turnover (cleared daily at 00:00 local time)
- **User data**: 24 hours (refreshed daily) - **User data**: 24 hours (refreshed daily)
- **Survives**: HA restarts via persistent Storage - **Survives**: HA restarts via persistent Storage
@ -38,31 +36,29 @@ The integration uses **4 distinct caching layers** with different purposes and l
**Invalidation triggers:** **Invalidation triggers:**
1. **Midnight turnover** (Timer #2 in coordinator): 1. **Midnight turnover** (Timer #2 in coordinator):
```python
```python # coordinator/day_transitions.py
# coordinator/day_transitions.py def _handle_midnight_turnover() -> None:
def _handle_midnight_turnover() -> None: self._cached_price_data = None # Force fresh fetch for new day
self._cached_price_data = None # Force fresh fetch for new day self._last_price_update = None
self._last_price_update = None await self.store_cache()
await self.store_cache() ```
```
2. **Cache validation on load**: 2. **Cache validation on load**:
```python
```python # coordinator/cache.py
# coordinator/cache.py def is_cache_valid(cache_data: CacheData) -> bool:
def is_cache_valid(cache_data: CacheData) -> bool: # Checks if price data is from a previous day
# Checks if price data is from a previous day if today_date < local_now.date(): # Yesterday's data
if today_date < local_now.date(): # Yesterday's data return False
return False ```
```
3. **Tomorrow data check** (after 13:00): 3. **Tomorrow data check** (after 13:00):
```python ```python
# coordinator/data_fetching.py # coordinator/data_fetching.py
if tomorrow_missing or tomorrow_invalid: if tomorrow_missing or tomorrow_invalid:
return "tomorrow_check" # Update needed return "tomorrow_check" # Update needed
``` ```
**Why this cache matters:** Reduces API load on Tibber (~192 intervals per fetch), speeds up HA restarts, enables offline operation until cache expires. **Why this cache matters:** Reduces API load on Tibber (~192 intervals per fetch), speeds up HA restarts, enables offline operation until cache expires.
@ -75,22 +71,18 @@ 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. **Purpose:** Avoid repeated file I/O when accessing entity descriptions, UI strings, etc.
**What is cached:** **What is cached:**
- **Standard translations** (`/translations/*.json`): Config flow, selector options, entity names - **Standard translations** (`/translations/*.json`): Config flow, selector options, entity names
- **Custom translations** (`/custom_translations/*.json`): Entity descriptions, usage tips, long descriptions - **Custom translations** (`/custom_translations/*.json`): Entity descriptions, usage tips, long descriptions
**Lifetime:** **Lifetime:**
- **Forever** (until HA restart) - **Forever** (until HA restart)
- No invalidation during runtime - No invalidation during runtime
**When populated:** **When populated:**
- At integration setup: `async_load_translations(hass, "en")` in `__init__.py` - At integration setup: `async_load_translations(hass, "en")` in `__init__.py`
- Lazy loading: If translation missing, attempts file load once - Lazy loading: If translation missing, attempts file load once
**Access pattern:** **Access pattern:**
```python ```python
# Non-blocking synchronous access from cached data # Non-blocking synchronous access from cached data
description = get_translation("binary_sensor.best_price_period.description", "en") description = get_translation("binary_sensor.best_price_period.description", "en")
@ -109,7 +101,6 @@ description = get_translation("binary_sensor.best_price_period.description", "en
**What is cached:** **What is cached:**
### DataTransformer Config Cache ### DataTransformer Config Cache
```python ```python
{ {
"thresholds": {"low": 15, "high": 35}, "thresholds": {"low": 15, "high": 35},
@ -119,7 +110,6 @@ description = get_translation("binary_sensor.best_price_period.description", "en
``` ```
### PeriodCalculator Config Cache ### PeriodCalculator Config Cache
```python ```python
{ {
"best": {"flex": 0.15, "min_distance_from_avg": 5.0, "min_period_length": 60}, "best": {"flex": 0.15, "min_distance_from_avg": 5.0, "min_period_length": 60},
@ -128,23 +118,20 @@ description = get_translation("binary_sensor.best_price_period.description", "en
``` ```
**Lifetime:** **Lifetime:**
- Until `invalidate_config_cache()` is called - Until `invalidate_config_cache()` is called
- Built once on first use per coordinator update cycle - Built once on first use per coordinator update cycle
**Invalidation trigger:** **Invalidation trigger:**
- **Options change** (user reconfigures integration): - **Options change** (user reconfigures integration):
```python ```python
# coordinator/core.py # coordinator/core.py
async def _handle_options_update(...) -> None: async def _handle_options_update(...) -> None:
self._data_transformer.invalidate_config_cache() self._data_transformer.invalidate_config_cache()
self._period_calculator.invalidate_config_cache() self._period_calculator.invalidate_config_cache()
await self.async_request_refresh() await self.async_request_refresh()
``` ```
**Performance impact:** **Performance impact:**
- **Before:** ~30 dict lookups + type conversions per update = ~50μs - **Before:** ~30 dict lookups + type conversions per update = ~50μs
- **After:** 1 cache check = ~1μs - **After:** 1 cache check = ~1μs
- **Savings:** ~98% (50μs → 1μs per update) - **Savings:** ~98% (50μs → 1μs per update)
@ -160,7 +147,6 @@ 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. **Purpose:** Avoid expensive period calculations (~100-500ms) when price data and config haven't changed.
**What is cached:** **What is cached:**
```python ```python
{ {
"best_price": { "best_price": {
@ -175,7 +161,6 @@ description = get_translation("binary_sensor.best_price_period.description", "en
``` ```
**Cache key:** Hash of relevant inputs **Cache key:** Hash of relevant inputs
```python ```python
hash_data = ( hash_data = (
today_signature, # (startsAt, rating_level) for each interval today_signature, # (startsAt, rating_level) for each interval
@ -187,7 +172,6 @@ hash_data = (
``` ```
**Lifetime:** **Lifetime:**
- Until price data changes (today's intervals modified) - Until price data changes (today's intervals modified)
- Until config changes (flex, thresholds, filters) - Until config changes (flex, thresholds, filters)
- Recalculated at midnight (new today data) - Recalculated at midnight (new today data)
@ -195,27 +179,24 @@ hash_data = (
**Invalidation triggers:** **Invalidation triggers:**
1. **Config change** (explicit): 1. **Config change** (explicit):
```python
```python def invalidate_config_cache() -> None:
def invalidate_config_cache() -> None: self._cached_periods = None
self._cached_periods = None self._last_periods_hash = None
self._last_periods_hash = None ```
```
2. **Price data change** (automatic via hash mismatch): 2. **Price data change** (automatic via hash mismatch):
```python ```python
current_hash = self._compute_periods_hash(price_info) current_hash = self._compute_periods_hash(price_info)
if self._last_periods_hash != current_hash: if self._last_periods_hash != current_hash:
# Cache miss - recalculate # Cache miss - recalculate
``` ```
**Cache hit rate:** **Cache hit rate:**
- **High:** During normal operation (coordinator updates every 15min, price data unchanged) - **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) - **Low:** After midnight (new today data) or when tomorrow data arrives (~13:00-14:00)
**Performance impact:** **Performance impact:**
- **Period calculation:** ~100-500ms (depends on interval count, relaxation attempts) - **Period calculation:** ~100-500ms (depends on interval count, relaxation attempts)
- **Cache hit:** `<`1ms (hash comparison + dict lookup) - **Cache hit:** `<`1ms (hash comparison + dict lookup)
- **Savings:** ~70% of calculation time (most updates hit cache) - **Savings:** ~70% of calculation time (most updates hit cache)
@ -231,7 +212,6 @@ hash_data = (
**Status:** ✅ **Clean separation** - enrichment only, no redundancy **Status:** ✅ **Clean separation** - enrichment only, no redundancy
**What is cached:** **What is cached:**
```python ```python
{ {
"timestamp": ..., "timestamp": ...,
@ -244,16 +224,14 @@ hash_data = (
**Purpose:** Avoid re-enriching price data when config unchanged between midnight checks. **Purpose:** Avoid re-enriching price data when config unchanged between midnight checks.
**Current behavior:** **Current behavior:**
- Caches **only enriched price data** (price + statistics) - Caches **only enriched price data** (price + statistics)
- **Does NOT cache periods** (handled by Period Calculation Cache) - **Does NOT cache periods** (handled by Period Calculation Cache)
- Invalidated when: - Invalidated when:
- Config changes (thresholds affect enrichment) - Config changes (thresholds affect enrichment)
- Midnight turnover detected - Midnight turnover detected
- New update cycle begins - New update cycle begins
**Architecture:** **Architecture:**
- DataTransformer: Handles price enrichment only - DataTransformer: Handles price enrichment only
- PeriodCalculator: Handles period calculation only (with hash-based cache) - PeriodCalculator: Handles period calculation only (with hash-based cache)
- Coordinator: Assembles final data on-demand from both caches - Coordinator: Assembles final data on-demand from both caches
@ -265,7 +243,6 @@ hash_data = (
## Cache Invalidation Flow ## Cache Invalidation Flow
### User Changes Options (Config Flow) ### User Changes Options (Config Flow)
``` ```
User saves options User saves options
@ -290,7 +267,6 @@ Fresh data fetch with new config
``` ```
### Midnight Turnover (Day Transition) ### Midnight Turnover (Day Transition)
``` ```
Timer #2 fires at 00:00 Timer #2 fires at 00:00
@ -310,7 +286,6 @@ Fresh API fetch for new day
``` ```
### Tomorrow Data Arrives (~13:00) ### Tomorrow Data Arrives (~13:00)
``` ```
Coordinator update cycle Coordinator update cycle
@ -352,14 +327,12 @@ API Data Cache (price_data, user_data)
``` ```
**No cache invalidation cascades:** **No cache invalidation cascades:**
- Config cache invalidation is **explicit** (on options update) - Config cache invalidation is **explicit** (on options update)
- Period cache invalidation is **automatic** (via hash mismatch) - Period cache invalidation is **automatic** (via hash mismatch)
- Transformation cache invalidation is **automatic** (on midnight/config change) - Transformation cache invalidation is **automatic** (on midnight/config change)
- Translation cache is **never invalidated** (read-only after load) - Translation cache is **never invalidated** (read-only after load)
**Thread safety:** **Thread safety:**
- All caches are accessed from `MainThread` only (Home Assistant event loop) - All caches are accessed from `MainThread` only (Home Assistant event loop)
- No locking needed (single-threaded execution model) - No locking needed (single-threaded execution model)
@ -368,7 +341,6 @@ API Data Cache (price_data, user_data)
## Performance Characteristics ## Performance Characteristics
### Typical Operation (No Changes) ### Typical Operation (No Changes)
``` ```
Coordinator Update (every 15 min) Coordinator Update (every 15 min)
├─> API fetch: SKIP (cache valid) ├─> API fetch: SKIP (cache valid)
@ -381,7 +353,6 @@ Total: ~16ms (down from ~600ms without caching)
``` ```
### After Midnight Turnover ### After Midnight Turnover
``` ```
Coordinator Update (00:00) Coordinator Update (00:00)
├─> API fetch: ~500ms (cache cleared, fetch new day) ├─> API fetch: ~500ms (cache cleared, fetch new day)
@ -394,7 +365,6 @@ Total: ~755ms (expected once per day)
``` ```
### After Config Change ### After Config Change
``` ```
Options Update Options Update
├─> Cache invalidation: `<`1ms ├─> Cache invalidation: `<`1ms
@ -411,25 +381,23 @@ Options Update
## Summary Table ## Summary Table
| Cache Type | Lifetime | Size | Invalidation | Purpose | | Cache Type | Lifetime | Size | Invalidation | Purpose |
| ---------------------- | ---------------------------- | ------ | ------------------------- | ------------------------------- | |------------|----------|------|--------------|---------|
| **API Data** | Hours to 1 day | ~50KB | Midnight, validation | Reduce API calls | | **API Data** | Hours to 1 day | ~50KB | Midnight, validation | Reduce API calls |
| **Translations** | Forever (until HA restart) | ~5KB | Never | Avoid file I/O | | **Translations** | Forever (until HA restart) | ~5KB | Never | Avoid file I/O |
| **Config Dicts** | Until options change | `<`1KB | Explicit (options update) | Avoid dict lookups | | **Config Dicts** | Until options change | `<`1KB | Explicit (options update) | Avoid dict lookups |
| **Period Calculation** | Until data/config change | ~10KB | Auto (hash mismatch) | Avoid CPU-intensive calculation | | **Period Calculation** | Until data/config change | ~10KB | Auto (hash mismatch) | Avoid CPU-intensive calculation |
| **Transformation** | Until midnight/config change | ~50KB | Auto (midnight/config) | Avoid re-enrichment | | **Transformation** | Until midnight/config change | ~50KB | Auto (midnight/config) | Avoid re-enrichment |
**Total memory overhead:** ~116KB per coordinator instance (main + subentries) **Total memory overhead:** ~116KB per coordinator instance (main + subentries)
**Benefits:** **Benefits:**
- 97% reduction in API calls (from every 15min to once per day) - 97% reduction in API calls (from every 15min to once per day)
- 70% reduction in period calculation time (cache hits during normal operation) - 70% reduction in period calculation time (cache hits during normal operation)
- 98% reduction in config access time (30+ lookups → 1 cache check) - 98% reduction in config access time (30+ lookups → 1 cache check)
- Zero file I/O during runtime (translations cached at startup) - Zero file I/O during runtime (translations cached at startup)
**Trade-offs:** **Trade-offs:**
- Memory usage: ~116KB per home (negligible for modern systems) - Memory usage: ~116KB per home (negligible for modern systems)
- Code complexity: 5 cache invalidation points (well-tested, documented) - Code complexity: 5 cache invalidation points (well-tested, documented)
- Debugging: Must understand cache lifetime when investigating stale data issues - Debugging: Must understand cache lifetime when investigating stale data issues
@ -439,9 +407,7 @@ Options Update
## Debugging Cache Issues ## Debugging Cache Issues
### Symptom: Stale data after config change ### Symptom: Stale data after config change
**Check:** **Check:**
1. Is `_handle_options_update()` called? (should see "Options updated" log) 1. Is `_handle_options_update()` called? (should see "Options updated" log)
2. Are `invalidate_config_cache()` methods executed? 2. Are `invalidate_config_cache()` methods executed?
3. Does `async_request_refresh()` trigger? 3. Does `async_request_refresh()` trigger?
@ -449,9 +415,7 @@ Options Update
**Fix:** Ensure `config_entry.add_update_listener()` is registered in coordinator init. **Fix:** Ensure `config_entry.add_update_listener()` is registered in coordinator init.
### Symptom: Period calculation not updating ### Symptom: Period calculation not updating
**Check:** **Check:**
1. Verify hash changes when data changes: `_compute_periods_hash()` 1. Verify hash changes when data changes: `_compute_periods_hash()`
2. Check `_last_periods_hash` vs `current_hash` 2. Check `_last_periods_hash` vs `current_hash`
3. Look for "Using cached period calculation" vs "Calculating periods" logs 3. Look for "Using cached period calculation" vs "Calculating periods" logs
@ -459,9 +423,7 @@ Options Update
**Fix:** Hash function may not include all relevant data. Review `_compute_periods_hash()` inputs. **Fix:** Hash function may not include all relevant data. Review `_compute_periods_hash()` inputs.
### Symptom: Yesterday's prices shown as today ### Symptom: Yesterday's prices shown as today
**Check:** **Check:**
1. `is_cache_valid()` logic in `coordinator/cache.py` 1. `is_cache_valid()` logic in `coordinator/cache.py`
2. Midnight turnover execution (Timer #2) 2. Midnight turnover execution (Timer #2)
3. Cache clear confirmation in logs 3. Cache clear confirmation in logs
@ -469,9 +431,7 @@ Options Update
**Fix:** Timer may not be firing. Check `_schedule_midnight_turnover()` registration. **Fix:** Timer may not be firing. Check `_schedule_midnight_turnover()` registration.
### Symptom: Missing translations ### Symptom: Missing translations
**Check:** **Check:**
1. `async_load_translations()` called at startup? 1. `async_load_translations()` called at startup?
2. Translation files exist in `/translations/` and `/custom_translations/`? 2. Translation files exist in `/translations/` and `/custom_translations/`?
3. Cache population: `_TRANSLATIONS_CACHE` keys 3. Cache population: `_TRANSLATIONS_CACHE` keys

View file

@ -8,10 +8,10 @@ comments: false
## Code Style ## Code Style
- **Formatter/Linter**: Ruff (replaces Black, Flake8, isort) - **Formatter/Linter**: Ruff (replaces Black, Flake8, isort)
- **Max line length**: 120 characters - **Max line length**: 120 characters
- **Max complexity**: 25 (McCabe) - **Max complexity**: 25 (McCabe)
- **Target**: Python 3.13 - **Target**: Python 3.13
Run before committing: Run before committing:
@ -41,14 +41,12 @@ class TimeService:
``` ```
**When prefix is required:** **When prefix is required:**
- Public classes used across multiple modules - Public classes used across multiple modules
- All exception classes - All exception classes
- All coordinator and entity classes - All coordinator and entity classes
- Data classes (dataclasses, NamedTuples) used as public APIs - Data classes (dataclasses, NamedTuples) used as public APIs
**When prefix can be omitted:** **When prefix can be omitted:**
- Private helper classes within a single module (prefix with `_` underscore) - Private helper classes within a single module (prefix with `_` underscore)
- Type aliases and callbacks (e.g., `TimeServiceCallback`) - Type aliases and callbacks (e.g., `TimeServiceCallback`)
- Small internal NamedTuples for function returns - Small internal NamedTuples for function returns
@ -73,7 +71,6 @@ class DataFetcher: # Should be TibberPricesDataFetcher
**Current Technical Debt:** **Current Technical Debt:**
Many existing classes lack the `TibberPrices` prefix. Before refactoring: Many existing classes lack the `TibberPrices` prefix. Before refactoring:
1. Document the plan in `/planning/class-naming-refactoring.md` 1. Document the plan in `/planning/class-naming-refactoring.md`
2. Use `multi_replace_string_in_file` for bulk renames 2. Use `multi_replace_string_in_file` for bulk renames
3. Test thoroughly after each module 3. Test thoroughly after each module

View file

@ -14,10 +14,10 @@ Welcome! This guide helps you contribute to the Tibber Prices integration.
1. Fork the repository on GitHub 1. Fork the repository on GitHub
2. Clone your fork: 2. Clone your fork:
```bash ```bash
git clone https://github.com/YOUR_USERNAME/hass.tibber_prices.git git clone https://github.com/YOUR_USERNAME/hass.tibber_prices.git
cd hass.tibber_prices cd hass.tibber_prices
``` ```
3. Open in VS Code 3. Open in VS Code
4. Click "Reopen in Container" when prompted 4. Click "Reopen in Container" when prompted
@ -34,7 +34,6 @@ git checkout -b fix/issue-123-description
``` ```
**Branch naming:** **Branch naming:**
- `feature/` - New features - `feature/` - New features
- `fix/` - Bug fixes - `fix/` - Bug fixes
- `docs/` - Documentation only - `docs/` - Documentation only
@ -46,7 +45,6 @@ git checkout -b fix/issue-123-description
Edit code, following [Coding Guidelines](coding-guidelines.md). Edit code, following [Coding Guidelines](coding-guidelines.md).
**Run checks frequently:** **Run checks frequently:**
```bash ```bash
./scripts/type-check # Pyright type checking ./scripts/type-check # Pyright type checking
./scripts/lint # Ruff linting (auto-fix) ./scripts/lint # Ruff linting (auto-fix)
@ -80,7 +78,6 @@ async def test_your_feature(hass, coordinator):
``` ```
Run your test: Run your test:
```bash ```bash
./scripts/test tests/test_your_feature.py -v ./scripts/test tests/test_your_feature.py -v
``` ```
@ -100,7 +97,6 @@ Impact: Users can predict when prices will stabilize or continue fluctuating."
``` ```
**Commit types:** **Commit types:**
- `feat:` - New feature - `feat:` - New feature
- `fix:` - Bug fix - `fix:` - Bug fix
- `docs:` - Documentation - `docs:` - Documentation
@ -109,7 +105,6 @@ Impact: Users can predict when prices will stabilize or continue fluctuating."
- `chore:` - Maintenance - `chore:` - Maintenance
**Add scope when relevant:** **Add scope when relevant:**
- `feat(sensors):` - Sensor platform - `feat(sensors):` - Sensor platform
- `fix(coordinator):` - Data coordinator - `fix(coordinator):` - Data coordinator
- `docs(user):` - User documentation - `docs(user):` - User documentation
@ -129,40 +124,32 @@ Then open Pull Request on GitHub.
Title: Short, descriptive (50 chars max) Title: Short, descriptive (50 chars max)
Description should include: Description should include:
```markdown ```markdown
## What ## What
Brief description of changes Brief description of changes
## Why ## Why
Problem being solved or feature rationale Problem being solved or feature rationale
## How ## How
Implementation approach Implementation approach
## Testing ## Testing
- [ ] Manual testing in Home Assistant - [ ] Manual testing in Home Assistant
- [ ] Unit tests added/updated - [ ] Unit tests added/updated
- [ ] Type checking passes - [ ] Type checking passes
- [ ] Linting passes - [ ] Linting passes
## Breaking Changes ## Breaking Changes
(If any - describe migration path) (If any - describe migration path)
## Related Issues ## Related Issues
Closes #123 Closes #123
``` ```
### PR Checklist ### PR Checklist
Before submitting: Before submitting:
- [ ] Code follows [Coding Guidelines](coding-guidelines.md) - [ ] Code follows [Coding Guidelines](coding-guidelines.md)
- [ ] All tests pass (`./scripts/test`) - [ ] All tests pass (`./scripts/test`)
- [ ] Type checking passes (`./scripts/type-check`) - [ ] Type checking passes (`./scripts/type-check`)
@ -183,7 +170,6 @@ Before submitting:
### What Reviewers Look For ### What Reviewers Look For
✅ **Good:** ✅ **Good:**
- Clear, self-explanatory code - Clear, self-explanatory code
- Appropriate comments for complex logic - Appropriate comments for complex logic
- Tests covering edge cases - Tests covering edge cases
@ -191,7 +177,6 @@ Before submitting:
- Follows existing patterns - Follows existing patterns
❌ **Avoid:** ❌ **Avoid:**
- Large PRs (>500 lines) - split into smaller ones - Large PRs (>500 lines) - split into smaller ones
- Mixing unrelated changes - Mixing unrelated changes
- Missing tests for new features - Missing tests for new features
@ -208,7 +193,6 @@ Before submitting:
## Finding Issues to Work On ## Finding Issues to Work On
Good first issues are labeled: Good first issues are labeled:
- `good first issue` - Beginner-friendly - `good first issue` - Beginner-friendly
- `help wanted` - Maintainers welcome contributions - `help wanted` - Maintainers welcome contributions
- `documentation` - Docs improvements - `documentation` - Docs improvements
@ -226,7 +210,6 @@ Be respectful, constructive, and patient. We're all volunteers! 🙏
--- ---
💡 **Related:** 💡 **Related:**
- [Setup Guide](setup.md) - DevContainer setup - [Setup Guide](setup.md) - DevContainer setup
- [Coding Guidelines](coding-guidelines.md) - Style guide - [Coding Guidelines](coding-guidelines.md) - Style guide
- [Testing](testing.md) - Writing tests - [Testing](testing.md) - Writing tests

View file

@ -12,7 +12,6 @@ comments: false
## 🎯 Why Are These Tests Critical? ## 🎯 Why Are These Tests Critical?
Home Assistant integrations run **continuously** in the background. Resource leaks lead to: Home Assistant integrations run **continuously** in the background. Resource leaks lead to:
- **Memory Leaks**: RAM usage grows over days/weeks until HA becomes unstable - **Memory Leaks**: RAM usage grows over days/weeks until HA becomes unstable
- **Callback Leaks**: Listeners remain registered after entity removal → CPU load increases - **Callback Leaks**: Listeners remain registered after entity removal → CPU load increases
- **Timer Leaks**: Timers continue running after unload → unnecessary background tasks - **Timer Leaks**: Timers continue running after unload → unnecessary background tasks
@ -27,7 +26,6 @@ Home Assistant integrations run **continuously** in the background. Resource lea
#### 1.1 Listener Cleanup ✅ #### 1.1 Listener Cleanup ✅
**What is tested:** **What is tested:**
- Time-sensitive listeners are correctly removed (`async_add_time_sensitive_listener()`) - Time-sensitive listeners are correctly removed (`async_add_time_sensitive_listener()`)
- Minute-update listeners are correctly removed (`async_add_minute_update_listener()`) - Minute-update listeners are correctly removed (`async_add_minute_update_listener()`)
- Lifecycle callbacks are correctly unregistered (`register_lifecycle_callback()`) - Lifecycle callbacks are correctly unregistered (`register_lifecycle_callback()`)
@ -35,13 +33,11 @@ Home Assistant integrations run **continuously** in the background. Resource lea
- Binary sensor cleanup removes ALL registered listeners - Binary sensor cleanup removes ALL registered listeners
**Why critical:** **Why critical:**
- Each registered listener holds references to Entity + Coordinator - Each registered listener holds references to Entity + Coordinator
- Without cleanup: Entities are not freed by GC → Memory Leak - Without cleanup: Entities are not freed by GC → Memory Leak
- With 80+ sensors × 3 listener types = 240+ callbacks that must be cleanly removed - With 80+ sensors × 3 listener types = 240+ callbacks that must be cleanly removed
**Code Locations:** **Code Locations:**
- `coordinator/listeners.py``async_add_time_sensitive_listener()`, `async_add_minute_update_listener()` - `coordinator/listeners.py``async_add_time_sensitive_listener()`, `async_add_minute_update_listener()`
- `coordinator/core.py``register_lifecycle_callback()` - `coordinator/core.py``register_lifecycle_callback()`
- `sensor/core.py``async_will_remove_from_hass()` - `sensor/core.py``async_will_remove_from_hass()`
@ -50,38 +46,32 @@ Home Assistant integrations run **continuously** in the background. Resource lea
#### 1.2 Timer Cleanup ✅ #### 1.2 Timer Cleanup ✅
**What is tested:** **What is tested:**
- Quarter-hour timer is cancelled and reference cleared - Quarter-hour timer is cancelled and reference cleared
- Minute timer is cancelled and reference cleared - Minute timer is cancelled and reference cleared
- Both timers are cancelled together - Both timers are cancelled together
- Cleanup works even when timers are `None` - Cleanup works even when timers are `None`
**Why critical:** **Why critical:**
- Uncancelled timers continue running after integration unload - Uncancelled timers continue running after integration unload
- HA's `async_track_utc_time_change()` creates persistent callbacks - HA's `async_track_utc_time_change()` creates persistent callbacks
- Without cleanup: Timers keep firing → CPU load + unnecessary coordinator updates - Without cleanup: Timers keep firing → CPU load + unnecessary coordinator updates
**Code Locations:** **Code Locations:**
- `coordinator/listeners.py``cancel_timers()` - `coordinator/listeners.py``cancel_timers()`
- `coordinator/core.py``async_shutdown()` - `coordinator/core.py``async_shutdown()`
#### 1.3 Config Entry Cleanup ✅ #### 1.3 Config Entry Cleanup ✅
**What is tested:** **What is tested:**
- Options update listener is registered via `async_on_unload()` - Options update listener is registered via `async_on_unload()`
- Cleanup function is correctly passed to `async_on_unload()` - Cleanup function is correctly passed to `async_on_unload()`
**Why critical:** **Why critical:**
- `entry.add_update_listener()` registers permanent callback - `entry.add_update_listener()` registers permanent callback
- Without `async_on_unload()`: Listener remains active after reload → duplicate updates - Without `async_on_unload()`: Listener remains active after reload → duplicate updates
- Pattern: `entry.async_on_unload(entry.add_update_listener(handler))` - Pattern: `entry.async_on_unload(entry.add_update_listener(handler))`
**Code Locations:** **Code Locations:**
- `coordinator/core.py``__init__()` (listener registration) - `coordinator/core.py``__init__()` (listener registration)
- `__init__.py``async_unload_entry()` - `__init__.py``async_unload_entry()`
@ -92,19 +82,16 @@ Home Assistant integrations run **continuously** in the background. Resource lea
#### 2.1 Config Cache Invalidation #### 2.1 Config Cache Invalidation
**What is tested:** **What is tested:**
- DataTransformer config cache is invalidated on options change - DataTransformer config cache is invalidated on options change
- PeriodCalculator config + period cache is invalidated - PeriodCalculator config + period cache is invalidated
- Trend calculator cache is cleared on coordinator update - Trend calculator cache is cleared on coordinator update
**Why critical:** **Why critical:**
- Stale config → Sensors use old user settings - Stale config → Sensors use old user settings
- Stale period cache → Incorrect best/peak price periods - Stale period cache → Incorrect best/peak price periods
- Stale trend cache → Outdated trend analysis - Stale trend cache → Outdated trend analysis
**Code Locations:** **Code Locations:**
- `coordinator/data_transformation.py``invalidate_config_cache()` - `coordinator/data_transformation.py``invalidate_config_cache()`
- `coordinator/periods.py``invalidate_config_cache()` - `coordinator/periods.py``invalidate_config_cache()`
- `sensor/calculators/trend.py``clear_trend_cache()` - `sensor/calculators/trend.py``clear_trend_cache()`
@ -116,18 +103,15 @@ Home Assistant integrations run **continuously** in the background. Resource lea
#### 3.1 Persistent Storage Removal #### 3.1 Persistent Storage Removal
**What is tested:** **What is tested:**
- Storage file is deleted on config entry removal - Storage file is deleted on config entry removal
- Cache is saved on shutdown (no data loss) - Cache is saved on shutdown (no data loss)
**Why critical:** **Why critical:**
- Without storage removal: Old files remain after uninstallation - Without storage removal: Old files remain after uninstallation
- Without cache save on shutdown: Data loss on HA restart - Without cache save on shutdown: Data loss on HA restart
- Storage path: `.storage/tibber_prices.{entry_id}` - Storage path: `.storage/tibber_prices.{entry_id}`
**Code Locations:** **Code Locations:**
- `__init__.py``async_remove_entry()` - `__init__.py``async_remove_entry()`
- `coordinator/core.py``async_shutdown()` - `coordinator/core.py``async_shutdown()`
@ -136,14 +120,12 @@ Home Assistant integrations run **continuously** in the background. Resource lea
**File:** `tests/test_timer_scheduling.py` **File:** `tests/test_timer_scheduling.py`
**What is tested:** **What is tested:**
- Quarter-hour timer is registered with correct parameters - Quarter-hour timer is registered with correct parameters
- Minute timer is registered with correct parameters - Minute timer is registered with correct parameters
- Timers can be re-scheduled (override old timer) - Timers can be re-scheduled (override old timer)
- Midnight turnover detection works correctly - Midnight turnover detection works correctly
**Why critical:** **Why critical:**
- Wrong timer parameters → Entities update at wrong times - Wrong timer parameters → Entities update at wrong times
- Without timer override on re-schedule → Multiple parallel timers → Performance problem - Without timer override on re-schedule → Multiple parallel timers → Performance problem
@ -152,14 +134,12 @@ Home Assistant integrations run **continuously** in the background. Resource lea
**File:** `tests/test_sensor_timer_assignment.py` **File:** `tests/test_sensor_timer_assignment.py`
**What is tested:** **What is tested:**
- All `TIME_SENSITIVE_ENTITY_KEYS` are valid entity keys - All `TIME_SENSITIVE_ENTITY_KEYS` are valid entity keys
- All `MINUTE_UPDATE_ENTITY_KEYS` are valid entity keys - All `MINUTE_UPDATE_ENTITY_KEYS` are valid entity keys
- Both lists are disjoint (no overlap) - Both lists are disjoint (no overlap)
- Sensor and binary sensor platforms are checked - Sensor and binary sensor platforms are checked
**Why critical:** **Why critical:**
- Wrong timer assignment → Sensors update at wrong times - Wrong timer assignment → Sensors update at wrong times
- Overlap → Duplicate updates → Performance problem - Overlap → Duplicate updates → Performance problem
@ -170,12 +150,10 @@ These patterns were analyzed and classified as **not critical**:
### 6. Async Task Management ### 6. Async Task Management
**Current Status:** Fire-and-forget pattern for short tasks **Current Status:** Fire-and-forget pattern for short tasks
- `sensor/core.py` → Chart data refresh (short-lived, max 1-2 seconds) - `sensor/core.py` → Chart data refresh (short-lived, max 1-2 seconds)
- `coordinator/core.py` → Cache storage (short-lived, max 100ms) - `coordinator/core.py` → Cache storage (short-lived, max 100ms)
**Why no tests needed:** **Why no tests needed:**
- No long-running tasks (all < 2 seconds) - No long-running tasks (all < 2 seconds)
- HA's event loop handles short tasks automatically - HA's event loop handles short tasks automatically
- Task exceptions are already logged - Task exceptions are already logged
@ -185,7 +163,6 @@ These patterns were analyzed and classified as **not critical**:
### 7. API Session Cleanup ### 7. API Session Cleanup
**Current Status:** ✅ Correctly implemented **Current Status:** ✅ Correctly implemented
- `async_get_clientsession(hass)` is used (shared session) - `async_get_clientsession(hass)` is used (shared session)
- No new sessions are created - No new sessions are created
- HA manages session lifecycle automatically - HA manages session lifecycle automatically
@ -195,7 +172,6 @@ These patterns were analyzed and classified as **not critical**:
### 8. Translation Cache Memory ### 8. Translation Cache Memory
**Current Status:** ✅ Bounded cache **Current Status:** ✅ Bounded cache
- Max ~5-10 languages × 5KB = 50KB total - Max ~5-10 languages × 5KB = 50KB total
- Module-level cache without re-loading - Module-level cache without re-loading
- Practically no memory issue - Practically no memory issue
@ -205,13 +181,11 @@ These patterns were analyzed and classified as **not critical**:
### 9. Coordinator Data Structure Integrity ### 9. Coordinator Data Structure Integrity
**Current Status:** Manually tested via `./scripts/develop` **Current Status:** Manually tested via `./scripts/develop`
- Midnight turnover works correctly (observed over several days) - Midnight turnover works correctly (observed over several days)
- Missing keys are handled via `.get()` with defaults - Missing keys are handled via `.get()` with defaults
- 80+ sensors access `coordinator.data` without errors - 80+ sensors access `coordinator.data` without errors
**Structure:** **Structure:**
```python ```python
coordinator.data = { coordinator.data = {
"user_data": {...}, "user_data": {...},
@ -223,7 +197,6 @@ coordinator.data = {
### 10. Service Response Memory ### 10. Service Response Memory
**Current Status:** HA's response lifecycle **Current Status:** HA's response lifecycle
- HA automatically frees service responses after return - HA automatically frees service responses after return
- ApexCharts ~20KB response is one-time per call - ApexCharts ~20KB response is one-time per call
- No response accumulation in integration code - No response accumulation in integration code
@ -234,30 +207,29 @@ coordinator.data = {
### ✅ Implemented Tests (41 total) ### ✅ Implemented Tests (41 total)
| Category | Status | Tests | File | Coverage | | Category | Status | Tests | File | Coverage |
| ----------------------- | ------ | ------ | --------------------------------- | ------------------- | |----------|--------|-------|------|----------|
| Listener Cleanup | ✅ | 5 | `test_resource_cleanup.py` | 100% | | Listener Cleanup | ✅ | 5 | `test_resource_cleanup.py` | 100% |
| Timer Cleanup | ✅ | 4 | `test_resource_cleanup.py` | 100% | | Timer Cleanup | ✅ | 4 | `test_resource_cleanup.py` | 100% |
| Config Entry Cleanup | ✅ | 1 | `test_resource_cleanup.py` | 100% | | Config Entry Cleanup | ✅ | 1 | `test_resource_cleanup.py` | 100% |
| Cache Invalidation | ✅ | 3 | `test_resource_cleanup.py` | 100% | | Cache Invalidation | ✅ | 3 | `test_resource_cleanup.py` | 100% |
| Storage Cleanup | ✅ | 1 | `test_resource_cleanup.py` | 100% | | Storage Cleanup | ✅ | 1 | `test_resource_cleanup.py` | 100% |
| Storage Persistence | ✅ | 2 | `test_coordinator_shutdown.py` | 100% | | Storage Persistence | ✅ | 2 | `test_coordinator_shutdown.py` | 100% |
| Timer Scheduling | ✅ | 8 | `test_timer_scheduling.py` | 100% | | Timer Scheduling | ✅ | 8 | `test_timer_scheduling.py` | 100% |
| Sensor-Timer Assignment | ✅ | 17 | `test_sensor_timer_assignment.py` | 100% | | Sensor-Timer Assignment | ✅ | 17 | `test_sensor_timer_assignment.py` | 100% |
| **TOTAL** | **✅** | **41** | | **100% (critical)** | | **TOTAL** | **✅** | **41** | | **100% (critical)** |
### 📋 Analyzed but Not Implemented (Nice-to-Have) ### 📋 Analyzed but Not Implemented (Nice-to-Have)
| Category | Status | Rationale | | Category | Status | Rationale |
| ------------------------ | ------ | ---------------------------------------------------- | |----------|--------|-----------|
| Async Task Management | 📋 | Fire-and-forget pattern used (no long-running tasks) | | Async Task Management | 📋 | Fire-and-forget pattern used (no long-running tasks) |
| API Session Cleanup | ✅ | Pattern correct (`async_get_clientsession` used) | | API Session Cleanup | ✅ | Pattern correct (`async_get_clientsession` used) |
| Translation Cache | ✅ | Cache size bounded (~50KB max for 10 languages) | | Translation Cache | ✅ | Cache size bounded (~50KB max for 10 languages) |
| Data Structure Integrity | 📋 | Would add test time without finding real issues | | Data Structure Integrity | 📋 | Would add test time without finding real issues |
| Service Response Memory | 📋 | HA automatically frees service responses | | Service Response Memory | 📋 | HA automatically frees service responses |
**Legend:** **Legend:**
- ✅ = Fully tested or pattern verified correct - ✅ = Fully tested or pattern verified correct
- 📋 = Analyzed, low priority for testing (no known issues) - 📋 = Analyzed, low priority for testing (no known issues)
@ -266,7 +238,6 @@ coordinator.data = {
### ✅ All Critical Patterns Tested ### ✅ All Critical Patterns Tested
All essential memory leak prevention patterns are covered by 41 tests: All essential memory leak prevention patterns are covered by 41 tests:
- ✅ Listeners are correctly removed (no callback leaks) - ✅ Listeners are correctly removed (no callback leaks)
- ✅ Timers are cancelled (no background task leaks) - ✅ Timers are cancelled (no background task leaks)
- ✅ Config entry cleanup works (no dangling listeners) - ✅ Config entry cleanup works (no dangling listeners)

View file

@ -10,9 +10,9 @@ Add to `configuration.yaml`:
```yaml ```yaml
logger: logger:
default: info default: info
logs: logs:
custom_components.tibber_prices: debug custom_components.tibber_prices: debug
``` ```
Restart Home Assistant to apply. Restart Home Assistant to apply.
@ -20,7 +20,6 @@ Restart Home Assistant to apply.
### Key Log Messages ### Key Log Messages
**Coordinator Updates:** **Coordinator Updates:**
``` ```
[custom_components.tibber_prices.coordinator] Successfully fetched price data [custom_components.tibber_prices.coordinator] Successfully fetched price data
[custom_components.tibber_prices.coordinator] Cache valid, using cached data [custom_components.tibber_prices.coordinator] Cache valid, using cached data
@ -28,7 +27,6 @@ Restart Home Assistant to apply.
``` ```
**Period Calculation:** **Period Calculation:**
``` ```
[custom_components.tibber_prices.coordinator.periods] Calculating BEST PRICE periods: flex=15.0% [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 [custom_components.tibber_prices.coordinator.periods] Day 2024-12-06: Found 2 periods
@ -36,7 +34,6 @@ Restart Home Assistant to apply.
``` ```
**API Errors:** **API Errors:**
``` ```
[custom_components.tibber_prices.api] API request failed: Unauthorized [custom_components.tibber_prices.api] API request failed: Unauthorized
[custom_components.tibber_prices.api] Retrying (attempt 2/3) after 2.0s [custom_components.tibber_prices.api] Retrying (attempt 2/3) after 2.0s
@ -50,27 +47,26 @@ Restart Home Assistant to apply.
```json ```json
{ {
"version": "0.2.0", "version": "0.2.0",
"configurations": [ "configurations": [
{ {
"name": "Home Assistant", "name": "Home Assistant",
"type": "debugpy", "type": "debugpy",
"request": "launch", "request": "launch",
"module": "homeassistant", "module": "homeassistant",
"args": ["-c", "config", "--debug"], "args": ["-c", "config", "--debug"],
"justMyCode": false, "justMyCode": false,
"env": { "env": {
"PYTHONPATH": "${workspaceFolder}/.venv/lib/python3.13/site-packages" "PYTHONPATH": "${workspaceFolder}/.venv/lib/python3.13/site-packages"
} }
} }
] ]
} }
``` ```
### Set Breakpoints ### Set Breakpoints
**Coordinator update:** **Coordinator update:**
```python ```python
# coordinator/core.py # coordinator/core.py
async def _async_update_data(self) -> dict: async def _async_update_data(self) -> dict:
@ -79,7 +75,6 @@ async def _async_update_data(self) -> dict:
``` ```
**Period calculation:** **Period calculation:**
```python ```python
# coordinator/period_handlers/core.py # coordinator/period_handlers/core.py
def calculate_periods(...) -> list[dict]: def calculate_periods(...) -> list[dict]:
@ -96,7 +91,6 @@ def calculate_periods(...) -> list[dict]:
``` ```
**Flags:** **Flags:**
- `-v` - Verbose output - `-v` - Verbose output
- `-s` - Show print statements - `-s` - Show print statements
- `-k pattern` - Run tests matching pattern - `-k pattern` - Run tests matching pattern
@ -108,7 +102,6 @@ Set breakpoint in test file, use "Debug Test" CodeLens.
### Useful Test Patterns ### Useful Test Patterns
**Print coordinator data:** **Print coordinator data:**
```python ```python
def test_something(coordinator): def test_something(coordinator):
print(f"Coordinator data: {coordinator.data}") print(f"Coordinator data: {coordinator.data}")
@ -116,7 +109,6 @@ def test_something(coordinator):
``` ```
**Inspect period attributes:** **Inspect period attributes:**
```python ```python
def test_periods(hass, coordinator): def test_periods(hass, coordinator):
periods = coordinator.data.get('best_price_periods', []) periods = coordinator.data.get('best_price_periods', [])
@ -130,13 +122,11 @@ def test_periods(hass, coordinator):
### Integration Not Loading ### Integration Not Loading
**Check:** **Check:**
```bash ```bash
grep "tibber_prices" config/home-assistant.log grep "tibber_prices" config/home-assistant.log
``` ```
**Common causes:** **Common causes:**
- Syntax error in Python code → Check logs for traceback - Syntax error in Python code → Check logs for traceback
- Missing dependency → Run `uv sync` - Missing dependency → Run `uv sync`
- Wrong file permissions → `chmod +x scripts/*` - Wrong file permissions → `chmod +x scripts/*`
@ -144,14 +134,12 @@ grep "tibber_prices" config/home-assistant.log
### Sensors Not Updating ### Sensors Not Updating
**Check coordinator state:** **Check coordinator state:**
```python ```python
# In Developer Tools > Template # In Developer Tools > Template
{{ states.sensor.tibber_home_current_interval_price.last_updated }} {{ states.sensor.tibber_home_current_interval_price.last_updated }}
``` ```
**Debug in code:** **Debug in code:**
```python ```python
# Add logging in sensor/core.py # Add logging in sensor/core.py
_LOGGER.debug("Updating sensor %s: old=%s new=%s", _LOGGER.debug("Updating sensor %s: old=%s new=%s",
@ -161,7 +149,6 @@ _LOGGER.debug("Updating sensor %s: old=%s new=%s",
### Period Calculation Wrong ### Period Calculation Wrong
**Enable detailed period logs:** **Enable detailed period logs:**
```python ```python
# coordinator/period_handlers/period_building.py # coordinator/period_handlers/period_building.py
_LOGGER.debug("Candidate intervals: %s", _LOGGER.debug("Candidate intervals: %s",
@ -169,7 +156,6 @@ _LOGGER.debug("Candidate intervals: %s",
``` ```
**Check filter statistics:** **Check filter statistics:**
``` ```
[period_building] Flex filter blocked: 45 intervals [period_building] Flex filter blocked: 45 intervals
[period_building] Min distance blocked: 12 intervals [period_building] Min distance blocked: 12 intervals
@ -214,7 +200,6 @@ python -m pstats profile.stats
### Remote Debugging with debugpy ### Remote Debugging with debugpy
Add to coordinator code: Add to coordinator code:
```python ```python
import debugpy import debugpy
debugpy.listen(5678) debugpy.listen(5678)
@ -227,13 +212,11 @@ Connect from VS Code with remote attach configuration.
### IPython REPL ### IPython REPL
Install in container: Install in container:
```bash ```bash
uv pip install ipython uv pip install ipython
``` ```
Add breakpoint: Add breakpoint:
```python ```python
from IPython import embed from IPython import embed
embed() # Drops into interactive shell embed() # Drops into interactive shell
@ -242,7 +225,6 @@ embed() # Drops into interactive shell
--- ---
💡 **Related:** 💡 **Related:**
- [Testing Guide](testing.md) - Writing and running tests - [Testing Guide](testing.md) - Writing and running tests
- [Setup Guide](setup.md) - Development environment - [Setup Guide](setup.md) - Development environment
- [Architecture](architecture.md) - Code structure - [Architecture](architecture.md) - Code structure

View file

@ -8,25 +8,25 @@ This is an independent, community-maintained custom integration for Home Assista
## 📚 Developer Guides ## 📚 Developer Guides
- **[Setup](setup.md)** - DevContainer, environment setup, and dependencies - **[Setup](setup.md)** - DevContainer, environment setup, and dependencies
- **[Architecture](architecture.md)** - Code structure, patterns, and conventions - **[Architecture](architecture.md)** - Code structure, patterns, and conventions
- **[Period Calculation Theory](period-calculation-theory.md)** - Mathematical foundations, Flex/Distance interaction, Relaxation strategy - **[Period Calculation Theory](period-calculation-theory.md)** - Mathematical foundations, Flex/Distance interaction, Relaxation strategy
- **[Timer Architecture](timer-architecture.md)** - Timer system, scheduling, coordination (3 independent timers) - **[Timer Architecture](timer-architecture.md)** - Timer system, scheduling, coordination (3 independent timers)
- **[Caching Strategy](caching-strategy.md)** - Cache layers, invalidation, debugging - **[Caching Strategy](caching-strategy.md)** - Cache layers, invalidation, debugging
- **[Testing](testing.md)** - How to run tests and write new test cases - **[Testing](testing.md)** - How to run tests and write new test cases
- **[Release Management](release-management.md)** - Release workflow and versioning process - **[Release Management](release-management.md)** - Release workflow and versioning process
- **[Coding Guidelines](coding-guidelines.md)** - Style guide, linting, and best practices - **[Coding Guidelines](coding-guidelines.md)** - Style guide, linting, and best practices
- **[Refactoring Guide](refactoring-guide.md)** - How to plan and execute major refactorings - **[Refactoring Guide](refactoring-guide.md)** - How to plan and execute major refactorings
## 🤖 AI Documentation ## 🤖 AI Documentation
The main AI/Copilot documentation is in [`AGENTS.md`](https://github.com/jpawlowski/hass.tibber_prices/blob/main/AGENTS.md). This file serves as long-term memory for AI assistants and contains: The main AI/Copilot documentation is in [`AGENTS.md`](https://github.com/jpawlowski/hass.tibber_prices/blob/main/AGENTS.md). This file serves as long-term memory for AI assistants and contains:
- Detailed architectural patterns - Detailed architectural patterns
- Code quality rules and conventions - Code quality rules and conventions
- Development workflow guidance - Development workflow guidance
- Common pitfalls and anti-patterns - Common pitfalls and anti-patterns
- Project-specific patterns and utilities - Project-specific patterns and utilities
**Important:** When proposing changes to patterns or conventions, always update [`AGENTS.md`](https://github.com/jpawlowski/hass.tibber_prices/blob/main/AGENTS.md) to keep AI guidance consistent. **Important:** When proposing changes to patterns or conventions, always update [`AGENTS.md`](https://github.com/jpawlowski/hass.tibber_prices/blob/main/AGENTS.md) to keep AI guidance consistent.
@ -34,32 +34,32 @@ The main AI/Copilot documentation is in [`AGENTS.md`](https://github.com/jpawlow
This integration is developed with extensive AI assistance (GitHub Copilot, Claude, and other AI tools). The AI handles: This integration is developed with extensive AI assistance (GitHub Copilot, Claude, and other AI tools). The AI handles:
- **Pattern Recognition**: Understanding and applying Home Assistant best practices - **Pattern Recognition**: Understanding and applying Home Assistant best practices
- **Code Generation**: Implementing features with proper type hints, error handling, and documentation - **Code Generation**: Implementing features with proper type hints, error handling, and documentation
- **Refactoring**: Maintaining consistency across the codebase during structural changes - **Refactoring**: Maintaining consistency across the codebase during structural changes
- **Translation Management**: Keeping 5 language files synchronized - **Translation Management**: Keeping 5 language files synchronized
- **Documentation**: Generating and maintaining comprehensive documentation - **Documentation**: Generating and maintaining comprehensive documentation
**Quality Assurance:** **Quality Assurance:**
- Automated linting with Ruff (120-char line length, max complexity 25) - Automated linting with Ruff (120-char line length, max complexity 25)
- Home Assistant's type checking and validation - Home Assistant's type checking and validation
- Real-world testing in development environment - Real-world testing in development environment
- Code review by maintainer before merging - Code review by maintainer before merging
**Benefits:** **Benefits:**
- Rapid feature development while maintaining quality - Rapid feature development while maintaining quality
- Consistent code patterns across all modules - Consistent code patterns across all modules
- Comprehensive documentation maintained alongside code - Comprehensive documentation maintained alongside code
- Quick bug fixes with proper understanding of context - Quick bug fixes with proper understanding of context
**Limitations:** **Limitations:**
- AI may occasionally miss edge cases or subtle bugs - AI may occasionally miss edge cases or subtle bugs
- Some complex Home Assistant patterns may need human review - Some complex Home Assistant patterns may need human review
- Translation quality depends on AI's understanding of target language - Translation quality depends on AI's understanding of target language
- User feedback is crucial for discovering real-world issues - User feedback is crucial for discovering real-world issues
If you're working with AI tools on this project, the [`AGENTS.md`](https://github.com/jpawlowski/hass.tibber_prices/blob/main/AGENTS.md) file provides the context and patterns that ensure consistency. If you're working with AI tools on this project, the [`AGENTS.md`](https://github.com/jpawlowski/hass.tibber_prices/blob/main/AGENTS.md) file provides the context and patterns that ensure consistency.
@ -80,15 +80,15 @@ If you're working with AI tools on this project, the [`AGENTS.md`](https://githu
The project includes several helper scripts in `./scripts/`: The project includes several helper scripts in `./scripts/`:
- `bootstrap` - Initial setup of dependencies - `bootstrap` - Initial setup of dependencies
- `develop` - Start Home Assistant in debug mode (auto-cleans .egg-info) - `develop` - Start Home Assistant in debug mode (auto-cleans .egg-info)
- `clean` - Remove build artifacts and caches - `clean` - Remove build artifacts and caches
- `lint` - Auto-fix code issues with ruff - `lint` - Auto-fix code issues with ruff
- `lint-check` - Check code without modifications (CI mode) - `lint-check` - Check code without modifications (CI mode)
- `hassfest` - Validate integration structure (JSON, Python syntax, required files) - `hassfest` - Validate integration structure (JSON, Python syntax, required files)
- `setup` - Install development tools (git-cliff, @github/copilot) - `setup` - Install development tools (git-cliff, @github/copilot)
- `prepare-release` - Prepare a new release (bump version, create tag) - `prepare-release` - Prepare a new release (bump version, create tag)
- `generate-release-notes` - Generate release notes from commits - `generate-release-notes` - Generate release notes from commits
## 📦 Project Structure ## 📦 Project Structure
@ -121,23 +121,23 @@ custom_components/tibber_prices/
**DataUpdateCoordinator Pattern:** **DataUpdateCoordinator Pattern:**
- Centralized data fetching and caching - Centralized data fetching and caching
- Automatic entity updates on data changes - Automatic entity updates on data changes
- Persistent storage via `Store` - Persistent storage via `Store`
- Quarter-hour boundary refresh scheduling - Quarter-hour boundary refresh scheduling
**Price Data Enrichment:** **Price Data Enrichment:**
- Raw API data is enriched with statistical analysis - Raw API data is enriched with statistical analysis
- Trailing/leading 24h averages calculated per interval - Trailing/leading 24h averages calculated per interval
- Price differences and ratings added - Price differences and ratings added
- All via pure functions in `price_utils.py` - All via pure functions in `price_utils.py`
**Translation System:** **Translation System:**
- Dual system: `/translations/` (HA schema) + `/custom_translations/` (extended) - Dual system: `/translations/` (HA schema) + `/custom_translations/` (extended)
- Both must stay in sync across all languages (de, en, nb, nl, sv) - Both must stay in sync across all languages (de, en, nb, nl, sv)
- Async loading at integration setup - Async loading at integration setup
## 🧪 Testing ## 🧪 Testing
@ -159,19 +159,18 @@ pytest --cov=custom_components.tibber_prices tests/
Documentation is organized in two Docusaurus sites: Documentation is organized in two Docusaurus sites:
- **User docs** (`docs/user/`): Installation, configuration, usage guides - **User docs** (`docs/user/`): Installation, configuration, usage guides
- Markdown files in `docs/user/docs/*.md` - Markdown files in `docs/user/docs/*.md`
- Navigation managed via `docs/user/sidebars.ts` - Navigation managed via `docs/user/sidebars.ts`
- **Developer docs** (`docs/developer/`): Architecture, patterns, contribution guides - **Developer docs** (`docs/developer/`): Architecture, patterns, contribution guides
- Markdown files in `docs/developer/docs/*.md` - Markdown files in `docs/developer/docs/*.md`
- Navigation managed via `docs/developer/sidebars.ts` - Navigation managed via `docs/developer/sidebars.ts`
- **AI guidance**: `AGENTS.md` (patterns, conventions, long-term memory) - **AI guidance**: `AGENTS.md` (patterns, conventions, long-term memory)
**Best practices:** **Best practices:**
- Use clear examples and code snippets
- Use clear examples and code snippets - Keep docs up-to-date with code changes
- Keep docs up-to-date with code changes - Add new pages to appropriate `sidebars.ts` for navigation
- Add new pages to appropriate `sidebars.ts` for navigation
## 🤝 Contributing ## 🤝 Contributing

View file

@ -5,7 +5,6 @@ Guidelines for maintaining and improving integration performance.
## Performance Goals ## Performance Goals
Target metrics: Target metrics:
- **Coordinator update**: &lt;500ms (typical: 200-300ms) - **Coordinator update**: &lt;500ms (typical: 200-300ms)
- **Sensor update**: &lt;10ms per sensor - **Sensor update**: &lt;10ms per sensor
- **Period calculation**: &lt;100ms (typical: 20-50ms) - **Period calculation**: &lt;100ms (typical: 20-50ms)
@ -65,7 +64,6 @@ python -m aioprof homeassistant -c config
### Caching ### Caching
**1. Persistent Cache** (API data): **1. Persistent Cache** (API data):
```python ```python
# Already implemented in coordinator/cache.py # Already implemented in coordinator/cache.py
store = Store(hass, STORAGE_VERSION, STORAGE_KEY) store = Store(hass, STORAGE_VERSION, STORAGE_KEY)
@ -73,7 +71,6 @@ data = await store.async_load()
``` ```
**2. Translation Cache** (in-memory): **2. Translation Cache** (in-memory):
```python ```python
# Already implemented in const.py # Already implemented in const.py
_TRANSLATION_CACHE: dict[str, dict] = {} _TRANSLATION_CACHE: dict[str, dict] = {}
@ -86,7 +83,6 @@ def get_translation(path: str, language: str) -> dict:
``` ```
**3. Config Cache** (invalidated on options change): **3. Config Cache** (invalidated on options change):
```python ```python
class DataTransformer: class DataTransformer:
def __init__(self): def __init__(self):
@ -104,7 +100,6 @@ class DataTransformer:
### Lazy Loading ### Lazy Loading
**Load data only when needed:** **Load data only when needed:**
```python ```python
@property @property
def extra_state_attributes(self) -> dict | None: def extra_state_attributes(self) -> dict | None:
@ -118,7 +113,6 @@ def extra_state_attributes(self) -> dict | None:
### Bulk Operations ### Bulk Operations
**Process multiple items at once:** **Process multiple items at once:**
```python ```python
# ❌ Slow - loop with individual operations # ❌ Slow - loop with individual operations
for interval in intervals: for interval in intervals:
@ -132,7 +126,6 @@ results = enrich_intervals_bulk(intervals)
### Async Best Practices ### Async Best Practices
**1. Concurrent API calls:** **1. Concurrent API calls:**
```python ```python
# ❌ Sequential (slow) # ❌ Sequential (slow)
user_data = await fetch_user_data() user_data = await fetch_user_data()
@ -146,7 +139,6 @@ user_data, price_data = await asyncio.gather(
``` ```
**2. Don't block event loop:** **2. Don't block event loop:**
```python ```python
# ❌ Blocking # ❌ Blocking
result = heavy_computation() # Blocks for seconds result = heavy_computation() # Blocks for seconds
@ -160,7 +152,6 @@ result = await hass.async_add_executor_job(heavy_computation)
### Avoid Memory Leaks ### Avoid Memory Leaks
**1. Clear references:** **1. Clear references:**
```python ```python
class Coordinator: class Coordinator:
async def async_shutdown(self): async def async_shutdown(self):
@ -171,7 +162,6 @@ class Coordinator:
``` ```
**2. Use weak references for callbacks:** **2. Use weak references for callbacks:**
```python ```python
import weakref import weakref
@ -186,7 +176,6 @@ class Manager:
### Efficient Data Structures ### Efficient Data Structures
**Use appropriate types:** **Use appropriate types:**
```python ```python
# ❌ List for lookups (O(n)) # ❌ List for lookups (O(n))
if timestamp in timestamp_list: if timestamp in timestamp_list:
@ -208,13 +197,11 @@ results = (x for x in items if condition(x))
### Minimize API Calls ### Minimize API Calls
**Already implemented:** **Already implemented:**
- Cache valid until midnight - Cache valid until midnight
- User data cached for 24h - User data cached for 24h
- Only poll when tomorrow data expected - Only poll when tomorrow data expected
**Monitor API usage:** **Monitor API usage:**
```python ```python
_LOGGER.debug("API call: %s (cache_age=%s)", _LOGGER.debug("API call: %s (cache_age=%s)",
endpoint, cache_age) endpoint, cache_age)
@ -223,7 +210,6 @@ _LOGGER.debug("API call: %s (cache_age=%s)",
### Smart Updates ### Smart Updates
**Only update when needed:** **Only update when needed:**
```python ```python
async def _async_update_data(self) -> dict: async def _async_update_data(self) -> dict:
"""Fetch data from API.""" """Fetch data from API."""
@ -240,7 +226,6 @@ async def _async_update_data(self) -> dict:
### State Class Selection ### State Class Selection
**Affects long-term statistics storage:** **Affects long-term statistics storage:**
```python ```python
# ❌ MEASUREMENT for prices (stores every change) # ❌ MEASUREMENT for prices (stores every change)
state_class=SensorStateClass.MEASUREMENT # ~35K records/year state_class=SensorStateClass.MEASUREMENT # ~35K records/year
@ -255,7 +240,6 @@ state_class=SensorStateClass.TOTAL # For cumulative values
### Attribute Size ### Attribute Size
**Keep attributes minimal:** **Keep attributes minimal:**
```python ```python
# ❌ Large nested structures (KB per update) # ❌ Large nested structures (KB per update)
attributes = { attributes = {
@ -333,7 +317,6 @@ _LOGGER.debug("Current memory usage: %.2f MB", memory_mb)
--- ---
💡 **Related:** 💡 **Related:**
- [Caching Strategy](caching-strategy.md) - Cache layers - [Caching Strategy](caching-strategy.md) - Cache layers
- [Architecture](architecture.md) - System design - [Architecture](architecture.md) - System design
- [Debugging](debugging.md) - Profiling tools - [Debugging](debugging.md) - Profiling tools

View file

@ -7,7 +7,6 @@ This document explains the mathematical foundations and design decisions behind
**Target Audience:** Developers maintaining or extending the period calculation logic. **Target Audience:** Developers maintaining or extending the period calculation logic.
**Related Files:** **Related Files:**
- `coordinator/period_handlers/core.py` - Main calculation entry point - `coordinator/period_handlers/core.py` - Main calculation entry point
- `coordinator/period_handlers/level_filtering.py` - Flex and distance filtering - `coordinator/period_handlers/level_filtering.py` - Flex and distance filtering
- `coordinator/period_handlers/relaxation.py` - Multi-phase relaxation strategy - `coordinator/period_handlers/relaxation.py` - Multi-phase relaxation strategy
@ -24,7 +23,6 @@ Period detection uses **three independent filters** (all must pass):
**Purpose:** Limit how far prices can deviate from the daily min/max. **Purpose:** Limit how far prices can deviate from the daily min/max.
**Logic:** **Logic:**
```python ```python
# Best Price: Price must be within flex% ABOVE daily minimum # Best Price: Price must be within flex% ABOVE daily minimum
in_flex = price <= (daily_min + daily_min × flex) in_flex = price <= (daily_min + daily_min × flex)
@ -34,7 +32,6 @@ in_flex = price >= (daily_max - daily_max × flex)
``` ```
**Example (Best Price):** **Example (Best Price):**
- Daily Min: 10 ct/kWh - Daily Min: 10 ct/kWh
- Flex: 15% - Flex: 15%
- Acceptance Range: 0 - 11.5 ct/kWh (10 + 10×0.15) - Acceptance Range: 0 - 11.5 ct/kWh (10 + 10×0.15)
@ -44,7 +41,6 @@ in_flex = price >= (daily_max - daily_max × flex)
**Purpose:** Ensure periods are **significantly** cheaper/more expensive than average, not just marginally better. **Purpose:** Ensure periods are **significantly** cheaper/more expensive than average, not just marginally better.
**Logic:** **Logic:**
```python ```python
# Best Price: Price must be at least min_distance% BELOW daily average # Best Price: Price must be at least min_distance% BELOW daily average
meets_distance = price <= (daily_avg × (1 - min_distance/100)) meets_distance = price <= (daily_avg × (1 - min_distance/100))
@ -54,7 +50,6 @@ meets_distance = price >= (daily_avg × (1 + min_distance/100))
``` ```
**Example (Best Price):** **Example (Best Price):**
- Daily Avg: 15 ct/kWh - Daily Avg: 15 ct/kWh
- Min Distance: 5% - Min Distance: 5%
- Acceptance Range: 0 - 14.25 ct/kWh (15 × 0.95) - Acceptance Range: 0 - 14.25 ct/kWh (15 × 0.95)
@ -70,17 +65,17 @@ meets_distance = price >= (daily_avg × (1 + min_distance/100))
The integration maintains **two independent sets** of volatility thresholds: The integration maintains **two independent sets** of volatility thresholds:
1. **Sensor Thresholds** (user-configurable via `CONF_VOLATILITY_*_THRESHOLD`) 1. **Sensor Thresholds** (user-configurable via `CONF_VOLATILITY_*_THRESHOLD`)
- Purpose: Display classification in `sensor.tibber_home_volatility_*` - Purpose: Display classification in `sensor.tibber_home_volatility_*`
- Default: LOW < 10%, MEDIUM < 20%, HIGH 20% - Default: LOW < 10%, MEDIUM < 20%, HIGH 20%
- User can adjust in config flow options - User can adjust in config flow options
- Affects: Sensor state/attributes only - Affects: Sensor state/attributes only
2. **Period Filter Thresholds** (internal, fixed) 2. **Period Filter Thresholds** (internal, fixed)
- Purpose: Level filter criteria when using `level="volatility_low"` etc. - Purpose: Level filter criteria when using `level="volatility_low"` etc.
- Source: `PRICE_LEVEL_THRESHOLDS` in `const.py` - Source: `PRICE_LEVEL_THRESHOLDS` in `const.py`
- Values: Same as sensor defaults (LOW < 10%, MEDIUM < 20%, HIGH 20%) - Values: Same as sensor defaults (LOW < 10%, MEDIUM < 20%, HIGH 20%)
- User **cannot** adjust these - User **cannot** adjust these
- Affects: Period candidate selection - Affects: Period candidate selection
**Rationale for Separation:** **Rationale for Separation:**
@ -91,7 +86,6 @@ 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 - Period calculation has many interacting filters (Flex, Distance, Level) - exposing all internals would be error-prone
**Implementation:** **Implementation:**
```python ```python
# Sensor classification uses user config # Sensor classification uses user config
user_low_threshold = config_entry.options.get(CONF_VOLATILITY_LOW_THRESHOLD, 10) user_low_threshold = config_entry.options.get(CONF_VOLATILITY_LOW_THRESHOLD, 10)
@ -113,42 +107,36 @@ period_low_threshold = PRICE_LEVEL_THRESHOLDS["volatility_low"] # Always 10%
#### Scenario: Best Price with Flex=50%, Min_Distance=5% #### Scenario: Best Price with Flex=50%, Min_Distance=5%
**Given:** **Given:**
- Daily Min: 10 ct/kWh - Daily Min: 10 ct/kWh
- Daily Avg: 15 ct/kWh - Daily Avg: 15 ct/kWh
- Daily Max: 20 ct/kWh - Daily Max: 20 ct/kWh
**Flex Filter (50%):** **Flex Filter (50%):**
``` ```
Max accepted = 10 + (10 × 0.50) = 15 ct/kWh Max accepted = 10 + (10 × 0.50) = 15 ct/kWh
``` ```
**Min Distance Filter (5%):** **Min Distance Filter (5%):**
``` ```
Max accepted = 15 × (1 - 0.05) = 14.25 ct/kWh Max accepted = 15 × (1 - 0.05) = 14.25 ct/kWh
``` ```
**Conflict:** **Conflict:**
- Interval at 14.8 ct/kWh: - Interval at 14.8 ct/kWh:
- ✅ Flex: 14.8 ≤ 15 (PASS) - ✅ Flex: 14.8 ≤ 15 (PASS)
- ❌ Distance: 14.8 > 14.25 (FAIL) - ❌ Distance: 14.8 > 14.25 (FAIL)
- **Result:** Rejected by Min_Distance even though Flex allows it! - **Result:** Rejected by Min_Distance even though Flex allows it!
**The Issue:** At high Flex values, Min_Distance becomes the dominant filter and blocks intervals that Flex would permit. This defeats the purpose of having high Flex. **The Issue:** At high Flex values, Min_Distance becomes the dominant filter and blocks intervals that Flex would permit. This defeats the purpose of having high Flex.
### Mathematical Analysis ### Mathematical Analysis
**Conflict condition for Best Price:** **Conflict condition for Best Price:**
``` ```
daily_min × (1 + flex) > daily_avg × (1 - min_distance/100) daily_min × (1 + flex) > daily_avg × (1 - min_distance/100)
``` ```
**Typical values:** **Typical values:**
- Min = 10, Avg = 15, Min_Distance = 5% - Min = 10, Avg = 15, Min_Distance = 5%
- Conflict occurs when: `10 × (1 + flex) > 14.25` - Conflict occurs when: `10 × (1 + flex) > 14.25`
- Simplify: `flex > 0.425` (42.5%) - Simplify: `flex > 0.425` (42.5%)
@ -161,7 +149,6 @@ daily_min × (1 + flex) > daily_avg × (1 - min_distance/100)
**Approach:** Reduce Min_Distance proportionally as Flex increases. **Approach:** Reduce Min_Distance proportionally as Flex increases.
**Formula:** **Formula:**
```python ```python
if flex > 0.20: # 20% threshold if flex > 0.20: # 20% threshold
flex_excess = flex - 0.20 flex_excess = flex - 0.20
@ -171,16 +158,15 @@ if flex > 0.20: # 20% threshold
**Scaling Table (Original Min_Distance = 5%):** **Scaling Table (Original Min_Distance = 5%):**
| Flex | Scale Factor | Adjusted Min_Distance | Rationale | | Flex | Scale Factor | Adjusted Min_Distance | Rationale |
| ---- | ------------ | --------------------- | --------------------------------- | |-------|--------------|----------------------|-----------|
| ≤20% | 1.00 | 5.0% | Standard - both filters relevant | | ≤20% | 1.00 | 5.0% | Standard - both filters relevant |
| 25% | 0.88 | 4.4% | Slight reduction | | 25% | 0.88 | 4.4% | Slight reduction |
| 30% | 0.75 | 3.75% | Moderate reduction | | 30% | 0.75 | 3.75% | Moderate reduction |
| 40% | 0.50 | 2.5% | Strong reduction - Flex dominates | | 40% | 0.50 | 2.5% | Strong reduction - Flex dominates |
| 50% | 0.25 | 1.25% | Minimal distance - Flex decides | | 50% | 0.25 | 1.25% | Minimal distance - Flex decides |
**Why stop at 25% of original?** **Why stop at 25% of original?**
- Min_Distance ensures periods are **significantly** different from average - Min_Distance ensures periods are **significantly** different from average
- Even at 1.25%, prevents "flat days" (little price variation) from accepting every interval - 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" - Maintains semantic meaning: "this is a meaningful best/peak price period"
@ -188,7 +174,6 @@ if flex > 0.20: # 20% threshold
**Implementation:** See `level_filtering.py``check_interval_criteria()` **Implementation:** See `level_filtering.py``check_interval_criteria()`
**Code Extract:** **Code Extract:**
```python ```python
# coordinator/period_handlers/level_filtering.py # coordinator/period_handlers/level_filtering.py
@ -224,14 +209,12 @@ def check_interval_criteria(price, criteria):
``` ```
**Why Linear Scaling?** **Why Linear Scaling?**
- Simple and predictable - Simple and predictable
- No abrupt behavior changes - No abrupt behavior changes
- Easy to reason about for users and developers - Easy to reason about for users and developers
- Alternative considered: Exponential scaling (rejected as too aggressive) - Alternative considered: Exponential scaling (rejected as too aggressive)
**Why 25% Minimum?** **Why 25% Minimum?**
- Below this, min_distance loses semantic meaning - Below this, min_distance loses semantic meaning
- Even on flat days, some quality filter needed - Even on flat days, some quality filter needed
- Prevents "every interval is a period" scenario - Prevents "every interval is a period" scenario
@ -244,14 +227,12 @@ def check_interval_criteria(price, criteria):
### Implementation Constants ### Implementation Constants
**Defined in `coordinator/period_handlers/core.py`:** **Defined in `coordinator/period_handlers/core.py`:**
```python ```python
MAX_SAFE_FLEX = 0.50 # 50% - hard cap: above this, period detection becomes unreliable 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 MAX_OUTLIER_FLEX = 0.25 # 25% - cap for outlier filtering: above this, spike detection too permissive
``` ```
**Defined in `const.py`:** **Defined in `const.py`:**
```python ```python
DEFAULT_BEST_PRICE_FLEX = 15 # 15% base - optimal for relaxation mode (default enabled) DEFAULT_BEST_PRICE_FLEX = 15 # 15% base - optimal for relaxation mode (default enabled)
DEFAULT_PEAK_PRICE_FLEX = -20 # 20% base (negative for peak detection) DEFAULT_PEAK_PRICE_FLEX = -20 # 20% base (negative for peak detection)
@ -274,19 +255,16 @@ The different defaults reflect fundamentally different use cases:
**Goal:** Find practical time windows for running appliances **Goal:** Find practical time windows for running appliances
**Constraints:** **Constraints:**
- Appliances need time to complete cycles (dishwasher: 2-3h, EV charging: 4-8h) - Appliances need time to complete cycles (dishwasher: 2-3h, EV charging: 4-8h)
- Short periods are impractical (not worth automation overhead) - Short periods are impractical (not worth automation overhead)
- User wants genuinely cheap times, not just "slightly below average" - User wants genuinely cheap times, not just "slightly below average"
**Defaults:** **Defaults:**
- **60 min minimum** - Ensures period is long enough for meaningful use - **60 min minimum** - Ensures period is long enough for meaningful use
- **15% flex** - Stricter selection, focuses on truly cheap times - **15% flex** - Stricter selection, focuses on truly cheap times
- **Reasoning:** Better to find fewer, higher-quality periods than many mediocre ones - **Reasoning:** Better to find fewer, higher-quality periods than many mediocre ones
**User behavior:** **User behavior:**
- Automations trigger actions (turn on devices) - Automations trigger actions (turn on devices)
- Wrong automation = wasted energy/money - Wrong automation = wasted energy/money
- Preference: Conservative (miss some savings) over aggressive (false positives) - Preference: Conservative (miss some savings) over aggressive (false positives)
@ -296,19 +274,16 @@ The different defaults reflect fundamentally different use cases:
**Goal:** Alert users to expensive periods for consumption reduction **Goal:** Alert users to expensive periods for consumption reduction
**Constraints:** **Constraints:**
- Brief price spikes still matter (even 15-30 min is worth avoiding) - Brief price spikes still matter (even 15-30 min is worth avoiding)
- Early warning more valuable than perfect accuracy - Early warning more valuable than perfect accuracy
- User can manually decide whether to react - User can manually decide whether to react
**Defaults:** **Defaults:**
- **30 min minimum** - Catches shorter expensive spikes - **30 min minimum** - Catches shorter expensive spikes
- **20% flex** - More permissive, earlier detection - **20% flex** - More permissive, earlier detection
- **Reasoning:** Better to warn early (even if not peak) than miss expensive periods - **Reasoning:** Better to warn early (even if not peak) than miss expensive periods
**User behavior:** **User behavior:**
- Notifications/alerts (informational) - Notifications/alerts (informational)
- Wrong alert = minor inconvenience, not cost - Wrong alert = minor inconvenience, not cost
- Preference: Sensitive (catch more) over specific (catch only extremes) - Preference: Sensitive (catch more) over specific (catch only extremes)
@ -318,20 +293,17 @@ The different defaults reflect fundamentally different use cases:
**Peak Price Volatility:** **Peak Price Volatility:**
Price curves tend to have: Price curves tend to have:
- **Sharp spikes** during peak hours (morning/evening) - **Sharp spikes** during peak hours (morning/evening)
- **Shorter duration** at maximum (1-2 hours typical) - **Shorter duration** at maximum (1-2 hours typical)
- **Higher variance** in peak times than cheap times - **Higher variance** in peak times than cheap times
**Example day:** **Example day:**
``` ```
Cheap period: 02:00-07:00 (5 hours at 10-12 ct) ← Gradual, stable 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 Expensive period: 17:00-18:30 (1.5 hours at 35-40 ct) ← Sharp, brief
``` ```
**Implication:** **Implication:**
- Stricter flex on peak (15%) might miss real expensive periods (too brief) - Stricter flex on peak (15%) might miss real expensive periods (too brief)
- Longer min_length (60 min) might exclude legitimate spikes - Longer min_length (60 min) might exclude legitimate spikes
- Solution: More flexible thresholds for peak detection - Solution: More flexible thresholds for peak detection
@ -339,19 +311,16 @@ Expensive period: 17:00-18:30 (1.5 hours at 35-40 ct) ← Sharp, brief
#### Design Alternatives Considered #### Design Alternatives Considered
**Option 1: Symmetric defaults (rejected)** **Option 1: Symmetric defaults (rejected)**
- Both 60 min, both 15% flex - Both 60 min, both 15% flex
- Problem: Misses short but expensive spikes - Problem: Misses short but expensive spikes
- User feedback: "Why didn't I get warned about the 30-min price spike?" - User feedback: "Why didn't I get warned about the 30-min price spike?"
**Option 2: Same defaults, let users figure it out (rejected)** **Option 2: Same defaults, let users figure it out (rejected)**
- No guidance on best practices - No guidance on best practices
- Users would need to experiment to find good values - Users would need to experiment to find good values
- Most users stick with defaults, so defaults matter - Most users stick with defaults, so defaults matter
**Option 3: Current approach (adopted)** **Option 3: Current approach (adopted)**
- **All values user-configurable** via config flow options - **All values user-configurable** via config flow options
- **Different installation defaults** for Best Price vs. Peak Price - **Different installation defaults** for Best Price vs. Peak Price
- Defaults reflect recommended practices for each use case - Defaults reflect recommended practices for each use case
@ -367,14 +336,12 @@ 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%) **Enforcement:** `core.py` caps `abs(flex)` at 0.50 (50%)
**Rationale:** **Rationale:**
- Above 50%, period detection becomes unreliable - Above 50%, period detection becomes unreliable
- Best Price: Almost entire day qualifies (Min + 50% typically covers 60-80% of intervals) - Best Price: Almost entire day qualifies (Min + 50% typically covers 60-80% of intervals)
- Peak Price: Similar issue with Max - 50% - Peak Price: Similar issue with Max - 50%
- **Result:** Either massive periods (entire day) or no periods (min_length not met) - **Result:** Either massive periods (entire day) or no periods (min_length not met)
**Warning Message:** **Warning Message:**
``` ```
Flex XX% exceeds maximum safe value! Capping at 50%. Flex XX% exceeds maximum safe value! Capping at 50%.
Recommendation: Use 15-20% with relaxation enabled, or 25-35% without relaxation. Recommendation: Use 15-20% with relaxation enabled, or 25-35% without relaxation.
@ -385,7 +352,6 @@ Recommendation: Use 15-20% with relaxation enabled, or 25-35% without relaxation
**Enforcement:** `core.py` caps outlier filtering flex at 0.25 (25%) **Enforcement:** `core.py` caps outlier filtering flex at 0.25 (25%)
**Rationale:** **Rationale:**
- Outlier filtering uses Flex to determine "stable context" threshold - Outlier filtering uses Flex to determine "stable context" threshold
- At > 25% Flex, almost any price swing is considered "stable" - At > 25% Flex, almost any price swing is considered "stable"
- **Result:** Legitimate price shifts aren't smoothed, breaking period formation - **Result:** Legitimate price shifts aren't smoothed, breaking period formation
@ -397,28 +363,23 @@ Recommendation: Use 15-20% with relaxation enabled, or 25-35% without relaxation
#### With Relaxation Enabled (Recommended) #### With Relaxation Enabled (Recommended)
**Optimal:** 10-20% **Optimal:** 10-20%
- Relaxation increases Flex incrementally: 15% → 18% → 21% → ... - Relaxation increases Flex incrementally: 15% → 18% → 21% → ...
- Low baseline ensures relaxation has room to work - Low baseline ensures relaxation has room to work
**Warning Threshold:** > 25% **Warning Threshold:** > 25%
- INFO log: "Base flex is on the high side" - INFO log: "Base flex is on the high side"
**High Warning:** > 30% **High Warning:** > 30%
- WARNING log: "Base flex is very high for relaxation mode!" - WARNING log: "Base flex is very high for relaxation mode!"
- Recommendation: Lower to 15-20% - Recommendation: Lower to 15-20%
#### Without Relaxation #### Without Relaxation
**Optimal:** 20-35% **Optimal:** 20-35%
- No automatic adjustment, must be sufficient from start - No automatic adjustment, must be sufficient from start
- Higher baseline acceptable since no relaxation fallback - Higher baseline acceptable since no relaxation fallback
**Maximum Useful:** ~50% **Maximum Useful:** ~50%
- Above this, period detection degrades (see Hard Limits) - Above this, period detection degrades (see Hard Limits)
--- ---
@ -434,7 +395,6 @@ Ensure **minimum periods per day** are found even when baseline filters are too
### Multi-Phase Approach ### Multi-Phase Approach
**Each day processed independently:** **Each day processed independently:**
1. Calculate baseline periods with user's config 1. Calculate baseline periods with user's config
2. If insufficient periods found, enter relaxation loop 2. If insufficient periods found, enter relaxation loop
3. Try progressively relaxed filter combinations 3. Try progressively relaxed filter combinations
@ -458,7 +418,6 @@ for attempt in range(max_relaxation_attempts):
``` ```
**Constants:** **Constants:**
```python ```python
FLEX_WARNING_THRESHOLD_RELAXATION = 0.25 # 25% - INFO: suggest lowering to 15-20% 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 FLEX_HIGH_THRESHOLD_RELAXATION = 0.30 # 30% - WARNING: very high for relaxation mode
@ -468,27 +427,26 @@ MAX_FLEX_HARD_LIMIT = 0.50 # 50% - absolute maximum (enforced in core.py)
**Design Decisions:** **Design Decisions:**
1. **Why 3% fixed increment?** 1. **Why 3% fixed increment?**
- Predictable escalation path (15% → 18% → 21% → ...) - Predictable escalation path (15% → 18% → 21% → ...)
- Independent of base flex (works consistently) - Independent of base flex (works consistently)
- 11 attempts covers full useful range (15% → 48%) - 11 attempts covers full useful range (15% → 48%)
- Balance: Not too slow (2%), not too fast (5%) - Balance: Not too slow (2%), not too fast (5%)
2. **Why hard-coded, not configurable?** 2. **Why hard-coded, not configurable?**
- Prevents user misconfiguration - Prevents user misconfiguration
- Simplifies mental model (fewer knobs to turn) - Simplifies mental model (fewer knobs to turn)
- Reliable behavior across all configurations - Reliable behavior across all configurations
- If needed, user adjusts `max_relaxation_attempts` (fewer/more steps) - If needed, user adjusts `max_relaxation_attempts` (fewer/more steps)
3. **Why warn at 25% base flex?** 3. **Why warn at 25% base flex?**
- At 25% base, first relaxation step reaches 28% - At 25% base, first relaxation step reaches 28%
- Above 30%, entering diminishing returns territory - Above 30%, entering diminishing returns territory
- User likely doesn't need relaxation with such high base flex - User likely doesn't need relaxation with such high base flex
- Should either: (a) lower base flex, or (b) disable relaxation - Should either: (a) lower base flex, or (b) disable relaxation
**Historical Context (Pre-November 2025):** **Historical Context (Pre-November 2025):**
The algorithm previously used percentage-based increments that scaled with base flex: The algorithm previously used percentage-based increments that scaled with base flex:
```python ```python
increment = base_flex × (step_pct / 100) # REMOVED increment = base_flex × (step_pct / 100) # REMOVED
``` ```
@ -496,7 +454,6 @@ 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. 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:** **Warning Messages:**
```python ```python
if base_flex >= FLEX_HIGH_THRESHOLD_RELAXATION: # 30% if base_flex >= FLEX_HIGH_THRESHOLD_RELAXATION: # 30%
_LOGGER.warning( _LOGGER.warning(
@ -515,14 +472,12 @@ elif base_flex >= FLEX_WARNING_THRESHOLD_RELAXATION: # 25%
### Filter Combination Strategy ### Filter Combination Strategy
**Per Flex level, try in order:** **Per Flex level, try in order:**
1. Original Level filter 1. Original Level filter
2. Level filter = "any" (disabled) 2. Level filter = "any" (disabled)
**Early Exit:** Stop immediately when target reached (don't try unnecessary combinations) **Early Exit:** Stop immediately when target reached (don't try unnecessary combinations)
**Example Flow (target=2 periods/day):** **Example Flow (target=2 periods/day):**
``` ```
Day 2025-11-19: Day 2025-11-19:
1. Baseline flex=15%: Found 1 period (need 2) 1. Baseline flex=15%: Found 1 period (need 2)
@ -537,7 +492,6 @@ Day 2025-11-19:
### Key Files and Functions ### Key Files and Functions
**Period Calculation Entry Point:** **Period Calculation Entry Point:**
```python ```python
# coordinator/period_handlers/core.py # coordinator/period_handlers/core.py
def calculate_periods( def calculate_periods(
@ -548,7 +502,6 @@ def calculate_periods(
``` ```
**Flex + Distance Filtering:** **Flex + Distance Filtering:**
```python ```python
# coordinator/period_handlers/level_filtering.py # coordinator/period_handlers/level_filtering.py
def check_interval_criteria( def check_interval_criteria(
@ -558,7 +511,6 @@ def check_interval_criteria(
``` ```
**Relaxation Orchestration:** **Relaxation Orchestration:**
```python ```python
# coordinator/period_handlers/relaxation.py # coordinator/period_handlers/relaxation.py
def calculate_periods_with_relaxation(...) -> tuple[dict, dict] def calculate_periods_with_relaxation(...) -> tuple[dict, dict]
@ -574,45 +526,43 @@ def relax_single_day(...) -> tuple[dict, dict]
**Algorithm Details:** **Algorithm Details:**
1. **Linear Regression Prediction:** 1. **Linear Regression Prediction:**
- Uses surrounding intervals to predict expected price - Uses surrounding intervals to predict expected price
- Window size: 3+ intervals (MIN_CONTEXT_SIZE) - Window size: 3+ intervals (MIN_CONTEXT_SIZE)
- Calculates trend slope and standard deviation - Calculates trend slope and standard deviation
- Formula: `predicted = mean + slope × (position - center)` - Formula: `predicted = mean + slope × (position - center)`
2. **Confidence Intervals:** 2. **Confidence Intervals:**
- 95% confidence level (2 standard deviations) - 95% confidence level (2 standard deviations)
- Tolerance = 2.0 × std_dev (CONFIDENCE_LEVEL constant) - Tolerance = 2.0 × std_dev (CONFIDENCE_LEVEL constant)
- Outlier if: `|actual - predicted| > tolerance` - Outlier if: `|actual - predicted| > tolerance`
- Accounts for natural price volatility in context window - Accounts for natural price volatility in context window
3. **Symmetry Check:** 3. **Symmetry Check:**
- Rejects asymmetric outliers (threshold: 1.5 std dev) - Rejects asymmetric outliers (threshold: 1.5 std dev)
- Preserves legitimate price shifts (morning/evening peaks) - Preserves legitimate price shifts (morning/evening peaks)
- Algorithm: - Algorithm:
```python
residual = abs(actual - predicted)
symmetry_threshold = 1.5 × std_dev
```python if residual > tolerance:
residual = abs(actual - predicted) # Check if spike is symmetric in context
symmetry_threshold = 1.5 × std_dev context_residuals = [abs(p - pred) for p, pred in context]
avg_context_residual = mean(context_residuals)
if residual > tolerance: if residual > symmetry_threshold × avg_context_residual:
# Check if spike is symmetric in context # Asymmetric spike → smooth it
context_residuals = [abs(p - pred) for p, pred in context] else:
avg_context_residual = mean(context_residuals) # Symmetric (part of trend) → keep it
```
if residual > symmetry_threshold × avg_context_residual:
# Asymmetric spike → smooth it
else:
# Symmetric (part of trend) → keep it
```
4. **Enhanced Zigzag Detection:** 4. **Enhanced Zigzag Detection:**
- Detects spike clusters via relative volatility - Detects spike clusters via relative volatility
- Threshold: 2.0× local volatility (RELATIVE_VOLATILITY_THRESHOLD) - Threshold: 2.0× local volatility (RELATIVE_VOLATILITY_THRESHOLD)
- Single-pass algorithm (no iteration needed) - Single-pass algorithm (no iteration needed)
- Catches patterns like: 18, 35, 19, 34, 18 (alternating spikes) - Catches patterns like: 18, 35, 19, 34, 18 (alternating spikes)
**Constants:** **Constants:**
```python ```python
# coordinator/period_handlers/outlier_filtering.py # coordinator/period_handlers/outlier_filtering.py
@ -623,21 +573,18 @@ MIN_CONTEXT_SIZE = 3 # Minimum intervals for regression
``` ```
**Data Integrity:** **Data Integrity:**
- Original prices stored in `_original_price` field - Original prices stored in `_original_price` field
- All statistics (daily min/max/avg) use original prices - All statistics (daily min/max/avg) use original prices
- Smoothing only affects period formation logic - Smoothing only affects period formation logic
- Smart counting: Only counts smoothing that changed period outcome - Smart counting: Only counts smoothing that changed period outcome
**Performance:** **Performance:**
- Single pass through price data - Single pass through price data
- O(n) complexity with small context window - O(n) complexity with small context window
- No iterative refinement needed - No iterative refinement needed
- Typical processing time: `<`1ms for 96 intervals - Typical processing time: `<`1ms for 96 intervals
**Example Debug Output:** **Example Debug Output:**
``` ```
DEBUG: [2025-11-11T14:30:00+01:00] Outlier detected: 35.2 ct 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 DEBUG: Context: 18.5, 19.1, 19.3, 19.8, 20.2 ct
@ -651,19 +598,19 @@ DEBUG: Asymmetry ratio: 3.2 (>1.5 threshold) → confirmed outlier
**Why This Approach?** **Why This Approach?**
1. **Linear regression over moving average:** 1. **Linear regression over moving average:**
- Accounts for price trends (morning ramp-up, evening decline) - Accounts for price trends (morning ramp-up, evening decline)
- Moving average can't predict direction, only level - Moving average can't predict direction, only level
- Better accuracy on non-stationary price curves - Better accuracy on non-stationary price curves
2. **Symmetry check over fixed threshold:** 2. **Symmetry check over fixed threshold:**
- Prevents false positives on legitimate price shifts - Prevents false positives on legitimate price shifts
- Adapts to local volatility patterns - Adapts to local volatility patterns
- Preserves user expectation: "expensive during peak hours" - Preserves user expectation: "expensive during peak hours"
3. **Single-pass over iterative:** 3. **Single-pass over iterative:**
- Predictable behavior (no convergence issues) - Predictable behavior (no convergence issues)
- Fast and deterministic - Fast and deterministic
- Easier to debug and reason about - Easier to debug and reason about
**Alternative Approaches Considered:** **Alternative Approaches Considered:**
@ -677,17 +624,15 @@ DEBUG: Asymmetry ratio: 3.2 (>1.5 threshold) → confirmed outlier
## Debugging Tips ## Debugging Tips
**Enable DEBUG logging:** **Enable DEBUG logging:**
```yaml ```yaml
# configuration.yaml # configuration.yaml
logger: logger:
default: info default: info
logs: logs:
custom_components.tibber_prices.coordinator.period_handlers: debug custom_components.tibber_prices.coordinator.period_handlers: debug
``` ```
**Key log messages to watch:** **Key log messages to watch:**
1. `"Filter statistics: X intervals checked"` - Shows how many intervals filtered by each criterion 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 2. `"After build_periods: X raw periods found"` - Periods before min_length filtering
3. `"Day X: Success with flex=Y%"` - Relaxation succeeded 3. `"Day X: Success with flex=Y%"` - Relaxation succeeded
@ -700,61 +645,52 @@ logger:
### ❌ Anti-Pattern 1: High Flex with Relaxation ### ❌ Anti-Pattern 1: High Flex with Relaxation
**Configuration:** **Configuration:**
```yaml ```yaml
best_price_flex: 40 best_price_flex: 40
enable_relaxation_best: true enable_relaxation_best: true
``` ```
**Problem:** **Problem:**
- Base Flex 40% already very permissive - Base Flex 40% already very permissive
- Relaxation increments further (43%, 46%, 49%, ...) - Relaxation increments further (43%, 46%, 49%, ...)
- Quickly approaches 50% cap with diminishing returns - Quickly approaches 50% cap with diminishing returns
**Solution:** **Solution:**
```yaml ```yaml
best_price_flex: 15 # Let relaxation increase it best_price_flex: 15 # Let relaxation increase it
enable_relaxation_best: true enable_relaxation_best: true
``` ```
### ❌ Anti-Pattern 2: Zero Min_Distance ### ❌ Anti-Pattern 2: Zero Min_Distance
**Configuration:** **Configuration:**
```yaml ```yaml
best_price_min_distance_from_avg: 0 best_price_min_distance_from_avg: 0
``` ```
**Problem:** **Problem:**
- "Flat days" (little price variation) accept all intervals - "Flat days" (little price variation) accept all intervals
- Periods lose semantic meaning ("significantly cheap") - Periods lose semantic meaning ("significantly cheap")
- May create periods during barely-below-average times - May create periods during barely-below-average times
**Solution:** **Solution:**
```yaml ```yaml
best_price_min_distance_from_avg: 5 # Use default 5% best_price_min_distance_from_avg: 5 # Use default 5%
``` ```
### ❌ Anti-Pattern 3: Conflicting Flex + Distance ### ❌ Anti-Pattern 3: Conflicting Flex + Distance
**Configuration:** **Configuration:**
```yaml ```yaml
best_price_flex: 45 best_price_flex: 45
best_price_min_distance_from_avg: 10 best_price_min_distance_from_avg: 10
``` ```
**Problem:** **Problem:**
- Distance filter dominates, making Flex irrelevant - Distance filter dominates, making Flex irrelevant
- Dynamic scaling helps but still suboptimal - Dynamic scaling helps but still suboptimal
**Solution:** **Solution:**
```yaml ```yaml
best_price_flex: 20 best_price_flex: 20
best_price_min_distance_from_avg: 5 best_price_min_distance_from_avg: 5
@ -770,13 +706,11 @@ best_price_min_distance_from_avg: 5
**Average:** 15 ct/kWh **Average:** 15 ct/kWh
**Expected Behavior:** **Expected Behavior:**
- Flex 15%: Should find 2-4 clear best price periods - Flex 15%: Should find 2-4 clear best price periods
- Flex 30%: Should find 4-8 periods (more lenient) - Flex 30%: Should find 4-8 periods (more lenient)
- Min_Distance 5%: Effective throughout range - Min_Distance 5%: Effective throughout range
**Debug Checks:** **Debug Checks:**
``` ```
DEBUG: Filter statistics: 96 intervals checked DEBUG: Filter statistics: 96 intervals checked
DEBUG: Filtered by FLEX: 12/96 (12.5%) ← Low percentage = good variation DEBUG: Filtered by FLEX: 12/96 (12.5%) ← Low percentage = good variation
@ -790,13 +724,11 @@ DEBUG: After build_periods: 3 raw periods found
**Average:** 15 ct/kWh **Average:** 15 ct/kWh
**Expected Behavior:** **Expected Behavior:**
- Flex 15%: May find 1-2 small periods (or zero if no clear winners) - 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 - Min_Distance 5%: Critical here - ensures only truly cheaper intervals qualify
- Without Min_Distance: Would accept almost entire day as "best price" - Without Min_Distance: Would accept almost entire day as "best price"
**Debug Checks:** **Debug Checks:**
``` ```
DEBUG: Filter statistics: 96 intervals checked DEBUG: Filter statistics: 96 intervals checked
DEBUG: Filtered by FLEX: 45/96 (46.9%) ← High percentage = poor variation DEBUG: Filtered by FLEX: 45/96 (46.9%) ← High percentage = poor variation
@ -811,13 +743,11 @@ DEBUG: Day 2025-11-11: Baseline insufficient (1 < 2), starting relaxation
**Average:** 18 ct/kWh **Average:** 18 ct/kWh
**Expected Behavior:** **Expected Behavior:**
- Flex 15%: Finds multiple very cheap periods (5-6 ct) - Flex 15%: Finds multiple very cheap periods (5-6 ct)
- Outlier filtering: May smooth isolated spikes (30-40 ct) - Outlier filtering: May smooth isolated spikes (30-40 ct)
- Distance filter: Less impactful (clear separation between cheap/expensive) - Distance filter: Less impactful (clear separation between cheap/expensive)
**Debug Checks:** **Debug Checks:**
``` ```
DEBUG: Outlier detected: 38.5 ct (threshold: 4.2 ct) DEBUG: Outlier detected: 38.5 ct (threshold: 4.2 ct)
DEBUG: Smoothed to: 20.1 ct (trend prediction) DEBUG: Smoothed to: 20.1 ct (trend prediction)
@ -832,7 +762,6 @@ DEBUG: After build_periods: 4 raw periods found
**Initial State:** Baseline finds 1 period, target is 2 **Initial State:** Baseline finds 1 period, target is 2
**Expected Flow:** **Expected Flow:**
``` ```
INFO: Calculating BEST PRICE periods: relaxation=ON, target=2/day, flex=15.0% INFO: Calculating BEST PRICE periods: relaxation=ON, target=2/day, flex=15.0%
DEBUG: Day 2025-11-11: Baseline found 1 period (need 2) DEBUG: Day 2025-11-11: Baseline found 1 period (need 2)
@ -848,7 +777,6 @@ INFO: Day 2025-11-11: Success after 1 relaxation phase (2 periods)
**Initial State:** Strict filters, very flat day **Initial State:** Strict filters, very flat day
**Expected Flow:** **Expected Flow:**
``` ```
INFO: Calculating BEST PRICE periods: relaxation=ON, target=2/day, flex=15.0% INFO: Calculating BEST PRICE periods: relaxation=ON, target=2/day, flex=15.0%
DEBUG: Day 2025-11-11: Baseline found 0 periods (need 2) DEBUG: Day 2025-11-11: Baseline found 0 periods (need 2)
@ -862,31 +790,31 @@ INFO: Period calculation completed: 1/2 days reached target
When debugging period calculation issues: When debugging period calculation issues:
1. **Check Filter Statistics** 1. **Check Filter Statistics**
- Which filter blocks most intervals? (flex, distance, or level) - Which filter blocks most intervals? (flex, distance, or level)
- High flex filtering (>30%) = Need more flexibility or relaxation - High flex filtering (>30%) = Need more flexibility or relaxation
- High distance filtering (>50%) = Min_distance too strict or flat day - High distance filtering (>50%) = Min_distance too strict or flat day
- High level filtering = Level filter too restrictive - High level filtering = Level filter too restrictive
2. **Check Relaxation Behavior** 2. **Check Relaxation Behavior**
- Did relaxation activate? Check for "Baseline insufficient" message - Did relaxation activate? Check for "Baseline insufficient" message
- Which phase succeeded? Early success (phase 1-3) = good config - Which phase succeeded? Early success (phase 1-3) = good config
- Late success (phase 8-11) = Consider adjusting base config - Late success (phase 8-11) = Consider adjusting base config
- Exhausted all phases = Unrealistic target for this day's price curve - Exhausted all phases = Unrealistic target for this day's price curve
3. **Check Flex Warnings** 3. **Check Flex Warnings**
- INFO at 25% base flex = On the high side - INFO at 25% base flex = On the high side
- WARNING at 30% base flex = Too high for relaxation - WARNING at 30% base flex = Too high for relaxation
- If seeing these: Lower base flex to 15-20% - If seeing these: Lower base flex to 15-20%
4. **Check Min_Distance Scaling** 4. **Check Min_Distance Scaling**
- Debug messages show "High flex X% detected: Reducing min_distance Y% → Z%" - Debug messages show "High flex X% detected: Reducing min_distance Y% → Z%"
- If scale factor `<`0.8 (20% reduction): High flex is active - If scale factor `<`0.8 (20% reduction): High flex is active
- If periods still not found: Filters conflict even with scaling - If periods still not found: Filters conflict even with scaling
5. **Check Outlier Filtering** 5. **Check Outlier Filtering**
- Look for "Outlier detected" messages - Look for "Outlier detected" messages
- Check `period_interval_smoothed_count` attribute - Check `period_interval_smoothed_count` attribute
- If no smoothing but periods fragmented: Not isolated spikes, but legitimate price levels - If no smoothing but periods fragmented: Not isolated spikes, but legitimate price levels
--- ---
@ -895,19 +823,19 @@ When debugging period calculation issues:
### Potential Improvements ### Potential Improvements
1. **Adaptive Flex Calculation:** 1. **Adaptive Flex Calculation:**
- Auto-adjust Flex based on daily price variation - Auto-adjust Flex based on daily price variation
- High variation days: Lower Flex needed - High variation days: Lower Flex needed
- Low variation days: Higher Flex needed - Low variation days: Higher Flex needed
2. **Machine Learning Approach:** 2. **Machine Learning Approach:**
- Learn optimal Flex/Distance from user feedback - Learn optimal Flex/Distance from user feedback
- Classify days by pattern (normal/flat/volatile/bimodal) - Classify days by pattern (normal/flat/volatile/bimodal)
- Apply pattern-specific defaults - Apply pattern-specific defaults
3. **Multi-Objective Optimization:** 3. **Multi-Objective Optimization:**
- Balance period count vs. quality - Balance period count vs. quality
- Consider period duration vs. price level - Consider period duration vs. price level
- Optimize for user's stated use case (EV charging vs. heat pump) - Optimize for user's stated use case (EV charging vs. heat pump)
### Known Limitations ### Known Limitations
@ -926,7 +854,6 @@ When debugging period calculation issues:
**Concept:** Auto-adjust Flex based on daily price variation **Concept:** Auto-adjust Flex based on daily price variation
**Algorithm:** **Algorithm:**
```python ```python
# Pseudo-code for adaptive flex # Pseudo-code for adaptive flex
variation = (daily_max - daily_min) / daily_avg variation = (daily_max - daily_min) / daily_avg
@ -940,13 +867,11 @@ else: # Normal day
``` ```
**Benefits:** **Benefits:**
- Eliminates need for relaxation on most days - Eliminates need for relaxation on most days
- Self-adjusting to market conditions - Self-adjusting to market conditions
- Better user experience (less configuration needed) - Better user experience (less configuration needed)
**Challenges:** **Challenges:**
- Harder to predict behavior (less transparent) - Harder to predict behavior (less transparent)
- May conflict with user's mental model - May conflict with user's mental model
- Needs extensive testing across different markets - Needs extensive testing across different markets
@ -958,20 +883,17 @@ else: # Normal day
**Concept:** Learn optimal Flex/Distance from user feedback **Concept:** Learn optimal Flex/Distance from user feedback
**Approach:** **Approach:**
- Track which periods user actually uses (automation triggers) - Track which periods user actually uses (automation triggers)
- Classify days by pattern (normal/flat/volatile/bimodal) - Classify days by pattern (normal/flat/volatile/bimodal)
- Apply pattern-specific defaults - Apply pattern-specific defaults
- Learn per-user preferences over time - Learn per-user preferences over time
**Benefits:** **Benefits:**
- Personalized to user's actual behavior - Personalized to user's actual behavior
- Adapts to local market patterns - Adapts to local market patterns
- Could discover non-obvious patterns - Could discover non-obvious patterns
**Challenges:** **Challenges:**
- Requires user feedback mechanism (not implemented) - Requires user feedback mechanism (not implemented)
- Privacy concerns (storing usage patterns) - Privacy concerns (storing usage patterns)
- Complexity for users to understand "why this period?" - Complexity for users to understand "why this period?"
@ -984,26 +906,22 @@ else: # Normal day
**Concept:** Balance multiple goals simultaneously **Concept:** Balance multiple goals simultaneously
**Goals:** **Goals:**
- Period count vs. quality (cheap vs. very cheap) - Period count vs. quality (cheap vs. very cheap)
- Period duration vs. price level (long mediocre vs. short excellent) - Period duration vs. price level (long mediocre vs. short excellent)
- Temporal distribution (spread throughout day vs. clustered) - Temporal distribution (spread throughout day vs. clustered)
- User's stated use case (EV charging vs. heat pump vs. dishwasher) - User's stated use case (EV charging vs. heat pump vs. dishwasher)
**Algorithm:** **Algorithm:**
- Pareto optimization (find trade-off frontier) - Pareto optimization (find trade-off frontier)
- User chooses point on frontier via preferences - User chooses point on frontier via preferences
- Genetic algorithm or simulated annealing - Genetic algorithm or simulated annealing
**Benefits:** **Benefits:**
- More sophisticated period selection - More sophisticated period selection
- Better match to user's actual needs - Better match to user's actual needs
- Could handle complex appliance requirements - Could handle complex appliance requirements
**Challenges:** **Challenges:**
- Much more complex to implement - Much more complex to implement
- Harder to explain to users - Harder to explain to users
- Computational cost (may need caching) - Computational cost (may need caching)
@ -1018,17 +936,14 @@ else: # Normal day
**Current:** 3% cap may be too aggressive for very low base Flex **Current:** 3% cap may be too aggressive for very low base Flex
**Example:** **Example:**
- Base flex 5% + 3% increment = 8% (60% increase!) - Base flex 5% + 3% increment = 8% (60% increase!)
- Base flex 15% + 3% increment = 18% (20% increase) - Base flex 15% + 3% increment = 18% (20% increase)
**Possible Solution:** **Possible Solution:**
- Percentage-based increment: `increment = max(base_flex × 0.20, 0.03)` - Percentage-based increment: `increment = max(base_flex × 0.20, 0.03)`
- This gives: 5% → 6% (20%), 15% → 18% (20%), 40% → 43% (7.5%) - This gives: 5% → 6% (20%), 15% → 18% (20%), 40% → 43% (7.5%)
**Why Not Implemented:** **Why Not Implemented:**
- Very low base flex (`<`10%) unusual - Very low base flex (`<`10%) unusual
- Users with strict requirements likely disable relaxation - Users with strict requirements likely disable relaxation
- Simplicity preferred over edge case optimization - Simplicity preferred over edge case optimization
@ -1038,7 +953,6 @@ else: # Normal day
**Current:** Linear scaling may be too aggressive/conservative **Current:** Linear scaling may be too aggressive/conservative
**Alternative:** Non-linear curve **Alternative:** Non-linear curve
```python ```python
# Example: Exponential scaling # Example: Exponential scaling
scale_factor = 0.25 + 0.75 × exp(-5 × (flex - 0.20)) scale_factor = 0.25 + 0.75 × exp(-5 × (flex - 0.20))
@ -1048,7 +962,6 @@ scale_factor = 0.25 + 0.75 / (1 + exp(10 × (flex - 0.35)))
``` ```
**Why Not Implemented:** **Why Not Implemented:**
- Linear is easier to reason about - Linear is easier to reason about
- No evidence that non-linear is better - No evidence that non-linear is better
- Would need extensive testing - Would need extensive testing
@ -1058,18 +971,15 @@ scale_factor = 0.25 + 0.75 / (1 + exp(10 × (flex - 0.35)))
**Issue:** May find all periods in one part of day **Issue:** May find all periods in one part of day
**Example:** **Example:**
- All 3 "best price" periods between 02:00-08:00 - All 3 "best price" periods between 02:00-08:00
- No periods in evening (when user might want to run appliances) - No periods in evening (when user might want to run appliances)
**Possible Solution:** **Possible Solution:**
- Add "spread" parameter (prefer distributed periods) - Add "spread" parameter (prefer distributed periods)
- Weight periods by time-of-day preferences - Weight periods by time-of-day preferences
- Consider user's typical usage patterns - Consider user's typical usage patterns
**Why Not Implemented:** **Why Not Implemented:**
- Adds complexity - Adds complexity
- Users can work around with multiple automations - Users can work around with multiple automations
- Different users have different needs (no one-size-fits-all) - Different users have different needs (no one-size-fits-all)
@ -1081,7 +991,6 @@ 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). **Design Principle:** Each interval is evaluated using its **own day's** reference prices (daily min/max/avg).
**Implementation:** **Implementation:**
```python ```python
# In period_building.py build_periods(): # In period_building.py build_periods():
for price_data in all_prices: for price_data in all_prices:
@ -1133,7 +1042,6 @@ Period crossing midnight: 23:45 Day 1 → 00:15 Day 2
**Trade-off: Periods May Break at Midnight** **Trade-off: Periods May Break at Midnight**
When days differ significantly, period can split: When days differ significantly, period can split:
``` ```
Day 1: Min=10ct, Avg=20ct, 23:45=11ct → ✅ Cheap (relative to Day 1) 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) Day 2: Min=25ct, Avg=35ct, 00:00=21ct → ❌ Expensive (relative to Day 2)
@ -1145,7 +1053,6 @@ This is **mathematically correct** - 21ct is genuinely expensive on a day where
**Market Reality Explains Price Jumps:** **Market Reality Explains Price Jumps:**
Day-ahead electricity markets (EPEX SPOT) set prices at 12:00 CET for all next-day hours: 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 - 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 - Early intervals (00:00): Priced ~12h before delivery → better forecasts → lower risk buffer
@ -1154,17 +1061,15 @@ This explains why absolute prices jump at midnight despite minimal demand change
**User-Facing Solution (Nov 2025):** **User-Facing Solution (Nov 2025):**
Added per-period day volatility attributes to detect when classification changes are meaningful: Added per-period day volatility attributes to detect when classification changes are meaningful:
- `day_volatility_%`: Percentage spread (span/avg × 100) - `day_volatility_%`: Percentage spread (span/avg × 100)
- `day_price_min`, `day_price_max`, `day_price_span`: Daily price range (ct/øre) - `day_price_min`, `day_price_max`, `day_price_span`: Daily price range (ct/øre)
Automations can check volatility before acting: Automations can check volatility before acting:
```yaml ```yaml
condition: condition:
- condition: template - condition: template
value_template: > value_template: >
{{ state_attr('binary_sensor.tibber_home_best_price_period', 'day_volatility_%') | float(0) > 15 }} {{ state_attr('binary_sensor.tibber_home_best_price_period', 'day_volatility_%') | float(0) > 15 }}
``` ```
Low volatility (< 15%) means classification changes are less economically significant. Low volatility (< 15%) means classification changes are less economically significant.
@ -1172,25 +1077,24 @@ Low volatility (< 15%) means classification changes are less economically signif
**Alternative Approaches Rejected:** **Alternative Approaches Rejected:**
1. **Use period start day for all intervals** 1. **Use period start day for all intervals**
- Problem: Mathematically incorrect - lends cheap day's criteria to expensive day - Problem: Mathematically incorrect - lends cheap day's criteria to expensive day
- Rejected: Violates relative evaluation principle - Rejected: Violates relative evaluation principle
2. **Adjust flex/distance at midnight** 2. **Adjust flex/distance at midnight**
- Problem: Complex, unpredictable, hides market reality - Problem: Complex, unpredictable, hides market reality
- Rejected: Users should understand price context, not have it hidden - Rejected: Users should understand price context, not have it hidden
3. **Split at midnight always** 3. **Split at midnight always**
- Problem: Artificially fragments natural periods - Problem: Artificially fragments natural periods
- Rejected: Worse user experience - Rejected: Worse user experience
4. **Use next day's reference after midnight** 4. **Use next day's reference after midnight**
- Problem: Period criteria inconsistent across duration - Problem: Period criteria inconsistent across duration
- Rejected: Confusing and unpredictable - Rejected: Confusing and unpredictable
**Status:** Per-day evaluation is intentional design prioritizing mathematical correctness. **Status:** Per-day evaluation is intentional design prioritizing mathematical correctness.
**See Also:** **See Also:**
- User documentation: `docs/user/docs/period-calculation.md` → "Midnight Price Classification Changes" - 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`) - Implementation: `coordinator/period_handlers/period_building.py` (line ~126: `ref_date = date_key`)
- Attributes: `coordinator/period_handlers/period_statistics.py` (day volatility calculation) - Attributes: `coordinator/period_handlers/period_statistics.py` (day volatility calculation)

View file

@ -18,7 +18,7 @@ Both `TibberPricesSensor` and `TibberPricesBinarySensor` implement `_unrecorded_
```python ```python
class TibberPricesSensor(TibberPricesEntity, SensorEntity): class TibberPricesSensor(TibberPricesEntity, SensorEntity):
"""tibber_prices Sensor class.""" """tibber_prices Sensor class."""
_unrecorded_attributes = frozenset( _unrecorded_attributes = frozenset(
{ {
"description", "description",
@ -29,7 +29,6 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
``` ```
**Key Points:** **Key Points:**
- Must be a **class attribute** (not instance attribute) - Must be a **class attribute** (not instance attribute)
- Use `frozenset` for immutability and performance - Use `frozenset` for immutability and performance
- Applied automatically by Home Assistant's Recorder component - Applied automatically by Home Assistant's Recorder component
@ -41,7 +40,6 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
**Attributes:** `description`, `usage_tips` **Attributes:** `description`, `usage_tips`
**Reason:** Static, large text strings (100-500 chars each) that: **Reason:** Static, large text strings (100-500 chars each) that:
- Never change or change very rarely - Never change or change very rarely
- Don't provide analytical value in history - Don't provide analytical value in history
- Consume significant database space when recorded every state change - Consume significant database space when recorded every state change
@ -52,7 +50,6 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
### 2. Large Nested Structures ### 2. Large Nested Structures
**Attributes:** **Attributes:**
- `periods` (binary_sensor) - Array of all period summaries - `periods` (binary_sensor) - Array of all period summaries
- `data` (chart_data_export) - Complete price data arrays - `data` (chart_data_export) - Complete price data arrays
- `trend_attributes` - Detailed trend analysis - `trend_attributes` - Detailed trend analysis
@ -61,7 +58,6 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
- `volatility_attributes` - Detailed volatility breakdown - `volatility_attributes` - Detailed volatility breakdown
**Reason:** Complex nested data structures that are: **Reason:** Complex nested data structures that are:
- Serialized to JSON for storage (expensive) - Serialized to JSON for storage (expensive)
- Create large database rows (2-20 KB each) - Create large database rows (2-20 KB each)
- Slow down history queries - Slow down history queries
@ -70,21 +66,20 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
**Impact:** ~10-30 KB saved per state change for affected sensors **Impact:** ~10-30 KB saved per state change for affected sensors
**Example - periods array:** **Example - periods array:**
```json ```json
{ {
"periods": [ "periods": [
{ {
"start": "2025-12-07T06:00:00+01:00", "start": "2025-12-07T06:00:00+01:00",
"end": "2025-12-07T08:00:00+01:00", "end": "2025-12-07T08:00:00+01:00",
"duration_minutes": 120, "duration_minutes": 120,
"price_mean": 18.5, "price_mean": 18.5,
"price_median": 18.3, "price_median": 18.3,
"price_min": 17.2, "price_min": 17.2,
"price_max": 19.8 "price_max": 19.8,
// ... 10+ more attributes × 10-20 periods // ... 10+ more attributes × 10-20 periods
} }
] ]
} }
``` ```
@ -93,7 +88,6 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
**Attributes:** `icon_color`, `cache_age`, `cache_validity`, `data_completeness`, `data_status` **Attributes:** `icon_color`, `cache_age`, `cache_validity`, `data_completeness`, `data_status`
**Reason:** **Reason:**
- Change every update cycle (every 15 minutes or more frequently) - Change every update cycle (every 15 minutes or more frequently)
- Don't provide long-term analytical value - Don't provide long-term analytical value
- Create state changes even when core values haven't changed - Create state changes even when core values haven't changed
@ -109,7 +103,6 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
**Attributes:** `tomorrow_expected_after`, `level_value`, `rating_value`, `level_id`, `rating_id`, `currency`, `resolution`, `yaxis_min`, `yaxis_max` **Attributes:** `tomorrow_expected_after`, `level_value`, `rating_value`, `level_id`, `rating_id`, `currency`, `resolution`, `yaxis_min`, `yaxis_max`
**Reason:** **Reason:**
- Configuration values that rarely change - Configuration values that rarely change
- Wastes space when recorded repeatedly - Wastes space when recorded repeatedly
- Can be derived from other attributes or from entity state - Can be derived from other attributes or from entity state
@ -121,7 +114,6 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
**Attributes:** `next_api_poll`, `next_midnight_turnover`, `last_api_fetch`, `last_cache_update`, `last_turnover`, `last_error`, `error` **Attributes:** `next_api_poll`, `next_midnight_turnover`, `last_api_fetch`, `last_cache_update`, `last_turnover`, `last_error`, `error`
**Reason:** **Reason:**
- Only relevant at moment of reading - Only relevant at moment of reading
- Won't be valid after some time - Won't be valid after some time
- Similar to `entity_picture` in HA core image entities - Similar to `entity_picture` in HA core image entities
@ -136,7 +128,6 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
**Attributes:** `relaxation_level`, `relaxation_threshold_original_%`, `relaxation_threshold_applied_%` **Attributes:** `relaxation_level`, `relaxation_threshold_original_%`, `relaxation_threshold_applied_%`
**Reason:** **Reason:**
- Detailed technical information not needed for historical analysis - Detailed technical information not needed for historical analysis
- Only useful for debugging during active development - Only useful for debugging during active development
- Boolean `relaxation_active` is kept for high-level analysis - Boolean `relaxation_active` is kept for high-level analysis
@ -148,7 +139,6 @@ 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` **Attributes:** `price_spread`, `volatility`, `diff_%`, `rating_difference_%`, `period_price_diff_from_daily_min`, `period_price_diff_from_daily_min_%`, `periods_total`, `periods_remaining`
**Reason:** **Reason:**
- Can be calculated from other attributes - Can be calculated from other attributes
- Redundant information - Redundant information
- Doesn't add analytical value to history - Doesn't add analytical value to history
@ -162,27 +152,22 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
These attributes **remain in history** because they provide essential analytical value: These attributes **remain in history** because they provide essential analytical value:
### Time-Series Core ### Time-Series Core
- `timestamp` - Critical for time-series analysis (ALWAYS FIRST) - `timestamp` - Critical for time-series analysis (ALWAYS FIRST)
- All price values - Core sensor states - All price values - Core sensor states
### Diagnostics & Tracking ### Diagnostics & Tracking
- `cache_age_minutes` - Numeric value for diagnostics tracking over time - `cache_age_minutes` - Numeric value for diagnostics tracking over time
- `updates_today` - Tracking API usage patterns - `updates_today` - Tracking API usage patterns
### Data Completeness ### Data Completeness
- `interval_count`, `intervals_available` - Data completeness metrics - `interval_count`, `intervals_available` - Data completeness metrics
- `yesterday_available`, `today_available`, `tomorrow_available` - Boolean status - `yesterday_available`, `today_available`, `tomorrow_available` - Boolean status
### Period Data ### Period Data
- `start`, `end`, `duration_minutes` - Core period timing - `start`, `end`, `duration_minutes` - Core period timing
- `price_mean`, `price_median`, `price_min`, `price_max` - Core price statistics - `price_mean`, `price_median`, `price_min`, `price_max` - Core price statistics
### High-Level Status ### High-Level Status
- `relaxation_active` - Whether relaxation was used (boolean, useful for analyzing when periods needed relaxation) - `relaxation_active` - Whether relaxation was used (boolean, useful for analyzing when periods needed relaxation)
## Expected Database Impact ## Expected Database Impact
@ -190,7 +175,6 @@ These attributes **remain in history** because they provide essential analytical
### Space Savings ### Space Savings
**Per state change:** **Per state change:**
- Before: ~3-8 KB average - Before: ~3-8 KB average
- After: ~0.5-1.5 KB average - After: ~0.5-1.5 KB average
- **Reduction: 60-85%** - **Reduction: 60-85%**
@ -212,7 +196,6 @@ These attributes **remain in history** because they provide essential analytical
### Real-World Impact ### Real-World Impact
For a typical installation with: For a typical installation with:
- 80+ sensors - 80+ sensors
- Updates every 15 minutes - Updates every 15 minutes
- ~10 sensors updating every minute - ~10 sensors updating every minute
@ -224,14 +207,14 @@ For a typical installation with:
## Implementation Files ## Implementation Files
- **Sensor Platform**: `custom_components/tibber_prices/sensor/core.py` - **Sensor Platform**: `custom_components/tibber_prices/sensor/core.py`
- Class: `TibberPricesSensor` - Class: `TibberPricesSensor`
- 47 attributes excluded - 47 attributes excluded
- **Binary Sensor Platform**: `custom_components/tibber_prices/binary_sensor/core.py` - **Binary Sensor Platform**: `custom_components/tibber_prices/binary_sensor/core.py`
- Class: `TibberPricesBinarySensor` - Class: `TibberPricesBinarySensor`
- 30 attributes excluded - 30 attributes excluded
## When to Update \_unrecorded_attributes ## When to Update _unrecorded_attributes
### Add to Exclusion List When: ### Add to Exclusion List When:
@ -253,24 +236,24 @@ For a typical installation with:
When adding a new attribute, ask: When adding a new attribute, ask:
1. **Will this be useful in history queries 1 week from now?** 1. **Will this be useful in history queries 1 week from now?**
- No → Exclude - No → Exclude
- Yes → Keep - Yes → Keep
2. **Can this be calculated from other recorded attributes?** 2. **Can this be calculated from other recorded attributes?**
- Yes → Exclude - Yes → Exclude
- No → Keep - No → Keep
3. **Is this primarily for current UI display?** 3. **Is this primarily for current UI display?**
- Yes → Exclude - Yes → Exclude
- No → Keep - No → Keep
4. **Does this change frequently without indicating state change?** 4. **Does this change frequently without indicating state change?**
- Yes → Exclude - Yes → Exclude
- No → Keep - No → Keep
5. **Is this larger than 100 bytes and not essential for analysis?** 5. **Is this larger than 100 bytes and not essential for analysis?**
- Yes → Exclude - Yes → Exclude
- No → Keep - No → Keep
## Testing ## Testing
@ -282,14 +265,13 @@ After modifying `_unrecorded_attributes`:
4. **Confirm excluded attributes** don't appear in new state writes 4. **Confirm excluded attributes** don't appear in new state writes
**SQL Query to check attribute presence:** **SQL Query to check attribute presence:**
```sql ```sql
SELECT SELECT
state_id, state_id,
attributes attributes
FROM states FROM states
WHERE entity_id = 'sensor.tibber_home_current_interval_price' WHERE entity_id = 'sensor.tibber_home_current_interval_price'
ORDER BY last_updated DESC ORDER BY last_updated DESC
LIMIT 5; LIMIT 5;
``` ```

View file

@ -8,22 +8,22 @@ Not every code change needs a detailed plan. Create a refactoring plan when:
🔴 **Major changes requiring planning:** 🔴 **Major changes requiring planning:**
- Splitting modules into packages (>5 files affected, >500 lines moved) - Splitting modules into packages (>5 files affected, >500 lines moved)
- Architectural changes (new packages, module restructuring) - Architectural changes (new packages, module restructuring)
- Breaking changes (API changes, config format migrations) - Breaking changes (API changes, config format migrations)
🟡 **Medium changes that might benefit from planning:** 🟡 **Medium changes that might benefit from planning:**
- Complex features with multiple moving parts - Complex features with multiple moving parts
- Changes affecting many files (>3 files, unclear best approach) - Changes affecting many files (>3 files, unclear best approach)
- Refactorings with unclear scope - Refactorings with unclear scope
🟢 **Small changes - no planning needed:** 🟢 **Small changes - no planning needed:**
- Bug fixes (straightforward, `<`100 lines) - Bug fixes (straightforward, `<`100 lines)
- Small features (`<`3 files, clear approach) - Small features (`<`3 files, clear approach)
- Documentation updates - Documentation updates
- Cosmetic changes (formatting, renaming) - Cosmetic changes (formatting, renaming)
## The Planning Process ## The Planning Process
@ -51,34 +51,34 @@ Every planning document should include:
## Problem Statement ## Problem Statement
- What's the issue? - What's the issue?
- Why does it need fixing? - Why does it need fixing?
- Current pain points - Current pain points
## Proposed Solution ## Proposed Solution
- High-level approach - High-level approach
- File structure (before/after) - File structure (before/after)
- Module responsibilities - Module responsibilities
## Migration Strategy ## Migration Strategy
- Phase-by-phase breakdown - Phase-by-phase breakdown
- File lifecycle (CREATE/MODIFY/DELETE/RENAME) - File lifecycle (CREATE/MODIFY/DELETE/RENAME)
- Dependencies between phases - Dependencies between phases
- Testing checkpoints - Testing checkpoints
## Risks & Mitigation ## Risks & Mitigation
- What could go wrong? - What could go wrong?
- How to prevent it? - How to prevent it?
- Rollback strategy - Rollback strategy
## Success Criteria ## Success Criteria
- Measurable improvements - Measurable improvements
- Testing requirements - Testing requirements
- Verification steps - Verification steps
``` ```
See `planning/README.md` for detailed template explanation. See `planning/README.md` for detailed template explanation.
@ -87,19 +87,19 @@ See `planning/README.md` for detailed template explanation.
Since `planning/` is git-ignored: Since `planning/` is git-ignored:
- Draft multiple versions - Draft multiple versions
- Get AI assistance without commit pressure - Get AI assistance without commit pressure
- Refine until the plan is solid - Refine until the plan is solid
- No need to clean up intermediate versions - No need to clean up intermediate versions
### 4. Implementation Phase ### 4. Implementation Phase
Once plan is approved: Once plan is approved:
- Follow the phases defined in the plan - Follow the phases defined in the plan
- Test after each phase (don't skip!) - Test after each phase (don't skip!)
- Update plan if issues discovered - Update plan if issues discovered
- Track progress through phase status - Track progress through phase status
### 5. After Completion ### 5. After Completion
@ -134,13 +134,13 @@ The **sensor/ package refactoring** (Nov 2025) is a successful example:
**Before:** **Before:**
- `sensor.py` - 2,574 lines, hard to navigate - `sensor.py` - 2,574 lines, hard to navigate
**After:** **After:**
- `sensor/` package with 5 focused modules - `sensor/` package with 5 focused modules
- Each module `<`800 lines - Each module `<`800 lines
- Clear separation of concerns - Clear separation of concerns
**Process:** **Process:**
@ -153,10 +153,10 @@ The **sensor/ package refactoring** (Nov 2025) is a successful example:
**Key learnings:** **Key learnings:**
- Temporary `_impl.py` files avoid Python package conflicts - Temporary `_impl.py` files avoid Python package conflicts
- Test after EVERY phase (don't accumulate changes) - Test after EVERY phase (don't accumulate changes)
- Clear file lifecycle (CREATE/MODIFY/DELETE/RENAME) - Clear file lifecycle (CREATE/MODIFY/DELETE/RENAME)
- Phase-by-phase approach enables safe rollback - Phase-by-phase approach enables safe rollback
**Note:** The complete module splitting plan was documented during implementation but has been superseded by the actual code structure. **Note:** The complete module splitting plan was documented during implementation but has been superseded by the actual code structure.
@ -166,11 +166,11 @@ The **sensor/ package refactoring** (Nov 2025) is a successful example:
Breaking refactorings into phases: Breaking refactorings into phases:
- ✅ Enables testing after each change (catch bugs early) - ✅ Enables testing after each change (catch bugs early)
- ✅ Allows rollback to last good state - ✅ Allows rollback to last good state
- ✅ Makes progress visible - ✅ Makes progress visible
- ✅ Reduces cognitive load (focus on one thing) - ✅ Reduces cognitive load (focus on one thing)
- ❌ Takes more time (but worth it!) - ❌ Takes more time (but worth it!)
### Phase Structure ### Phase Structure
@ -191,8 +191,8 @@ Each phase should:
**File Lifecycle**: **File Lifecycle**:
- ✨ CREATE `sensor/helpers.py` (utility functions) - ✨ CREATE `sensor/helpers.py` (utility functions)
- ✏️ MODIFY `sensor/core.py` (import from helpers.py) - ✏️ MODIFY `sensor/core.py` (import from helpers.py)
**Steps**: **Steps**:
@ -205,10 +205,10 @@ Each phase should:
**Success criteria**: **Success criteria**:
- ✅ All pure functions moved - ✅ All pure functions moved
- ✅ `./scripts/lint-check` passes - `./scripts/lint-check` passes
- ✅ HA starts successfully - ✅ HA starts successfully
- ✅ All entities work correctly - ✅ All entities work correctly
``` ```
## Testing Strategy ## Testing Strategy
@ -238,13 +238,13 @@ Minimum testing checklist:
After completing all phases: After completing all phases:
- Test all entities (sensors, binary sensors) - Test all entities (sensors, binary sensors)
- Test configuration flow (add/modify/remove) - Test configuration flow (add/modify/remove)
- Test options flow (change settings) - Test options flow (change settings)
- Test services (custom service calls) - Test services (custom service calls)
- Test error handling (disconnect API, invalid data) - Test error handling (disconnect API, invalid data)
- Test caching (restart HA, verify cache loads) - Test caching (restart HA, verify cache loads)
- Test time-based updates (quarter-hour refresh) - Test time-based updates (quarter-hour refresh)
## Common Pitfalls ## Common Pitfalls
@ -286,21 +286,21 @@ This project uses AI heavily (GitHub Copilot, Claude). The planning process supp
**AI reads from:** **AI reads from:**
- `AGENTS.md` - Long-term memory, patterns, conventions (AI-focused) - `AGENTS.md` - Long-term memory, patterns, conventions (AI-focused)
- `docs/development/` - Human-readable guides (human-focused) - `docs/development/` - Human-readable guides (human-focused)
- `planning/` - Active refactoring plans (shared context) - `planning/` - Active refactoring plans (shared context)
**AI updates:** **AI updates:**
- `AGENTS.md` - When patterns change - `AGENTS.md` - When patterns change
- `planning/*.md` - During refactoring implementation - `planning/*.md` - During refactoring implementation
- `docs/development/` - After successful completion - `docs/development/` - After successful completion
**Why separate AGENTS.md and docs/development/?** **Why separate AGENTS.md and docs/development/?**
- `AGENTS.md`: Technical, comprehensive, AI-optimized - `AGENTS.md`: Technical, comprehensive, AI-optimized
- `docs/development/`: Practical, focused, human-optimized - `docs/development/`: Practical, focused, human-optimized
- Both stay in sync but serve different audiences - Both stay in sync but serve different audiences
See [AGENTS.md](https://github.com/jpawlowski/hass.tibber_prices/blob/main/AGENTS.md) section "Planning Major Refactorings" for AI-specific guidance. See [AGENTS.md](https://github.com/jpawlowski/hass.tibber_prices/blob/main/AGENTS.md) section "Planning Major Refactorings" for AI-specific guidance.
@ -308,16 +308,16 @@ See [AGENTS.md](https://github.com/jpawlowski/hass.tibber_prices/blob/main/AGENT
### Planning Directory ### Planning Directory
- `planning/` - Git-ignored workspace for drafts - `planning/` - Git-ignored workspace for drafts
- `planning/README.md` - Detailed planning documentation - `planning/README.md` - Detailed planning documentation
- `planning/*.md` - Active refactoring plans - `planning/*.md` - Active refactoring plans
### Example Plans ### Example Plans
- `docs/development/module-splitting-plan.md` - ✅ Completed, archived - `docs/development/module-splitting-plan.md` - ✅ Completed, archived
- `planning/config-flow-refactoring-plan.md` - 🔄 Planned (1013 lines → 4 modules) - `planning/config-flow-refactoring-plan.md` - 🔄 Planned (1013 lines → 4 modules)
- `planning/binary-sensor-refactoring-plan.md` - 🔄 Planned (644 lines → 4 modules) - `planning/binary-sensor-refactoring-plan.md` - 🔄 Planned (644 lines → 4 modules)
- `planning/coordinator-refactoring-plan.md` - 🔄 Planned (1446 lines, high complexity) - `planning/coordinator-refactoring-plan.md` - 🔄 Planned (1446 lines, high complexity)
### Helper Scripts ### Helper Scripts
@ -341,21 +341,21 @@ Simple rule: If you can't describe the entire change in 3 sentences, create a pl
Good plan level: Good plan level:
- Lists all files affected (CREATE/MODIFY/DELETE) - Lists all files affected (CREATE/MODIFY/DELETE)
- Defines phases with clear boundaries - Defines phases with clear boundaries
- Includes testing strategy - Includes testing strategy
- Estimates time per phase - Estimates time per phase
Too detailed: Too detailed:
- Exact code snippets for every change - Exact code snippets for every change
- Line-by-line instructions - Line-by-line instructions
Too vague: Too vague:
- "Refactor sensor.py to be better" - "Refactor sensor.py to be better"
- No phase breakdown - No phase breakdown
- No testing strategy - No testing strategy
### Q: What if the plan changes during implementation? ### Q: What if the plan changes during implementation?
@ -363,9 +363,9 @@ Too vague:
If you discover: If you discover:
- Better approach → Update "Proposed Solution" - Better approach → Update "Proposed Solution"
- More phases needed → Add to "Migration Strategy" - More phases needed → Add to "Migration Strategy"
- New risks → Update "Risks & Mitigation" - New risks → Update "Risks & Mitigation"
Document WHY the plan changed (helps future refactorings). Document WHY the plan changed (helps future refactorings).
@ -373,9 +373,9 @@ Document WHY the plan changed (helps future refactorings).
**A:** No! Use judgment: **A:** No! Use judgment:
- **Small changes (`<`100 lines, clear approach)**: Just do it, no plan needed - **Small changes (`<`100 lines, clear approach)**: Just do it, no plan needed
- **Medium changes (unclear scope)**: Write rough outline, refine if needed - **Medium changes (unclear scope)**: Write rough outline, refine if needed
- **Large changes (>500 lines, >5 files)**: Full planning process - **Large changes (>500 lines, >5 files)**: Full planning process
### Q: How do I know when a refactoring is successful? ### Q: How do I know when a refactoring is successful?
@ -383,12 +383,12 @@ Document WHY the plan changed (helps future refactorings).
Typical criteria: Typical criteria:
- ✅ All linting checks pass - ✅ All linting checks pass
- ✅ HA starts without errors - ✅ HA starts without errors
- ✅ All entities functional - ✅ All entities functional
- ✅ No regressions (existing features work) - ✅ No regressions (existing features work)
- ✅ Code easier to understand/modify - ✅ Code easier to understand/modify
- ✅ Documentation updated - ✅ Documentation updated
If you can't tick all boxes, the refactoring isn't done. If you can't tick all boxes, the refactoring isn't done.
@ -409,6 +409,6 @@ If you can't tick all boxes, the refactoring isn't done.
**Next steps:** **Next steps:**
- Read `planning/README.md` for detailed template - Read `planning/README.md` for detailed template
- Check `docs/development/module-splitting-plan.md` for real example - Check `docs/development/module-splitting-plan.md` for real example
- Browse `planning/` for active refactoring plans - Browse `planning/` for active refactoring plans

View file

@ -112,7 +112,6 @@ In CI/CD (`$CI` or `$GITHUB_ACTIONS`), AI is automatically disabled.
**In DevContainer (automatic):** **In DevContainer (automatic):**
git-cliff is automatically installed when the DevContainer is built: git-cliff is automatically installed when the DevContainer is built:
- **Rust toolchain**: Installed via `ghcr.io/devcontainers/features/rust:1` (minimal profile) - **Rust toolchain**: Installed via `ghcr.io/devcontainers/features/rust:1` (minimal profile)
- **git-cliff**: Installed via cargo in `scripts/setup/setup` - **git-cliff**: Installed via cargo in `scripts/setup/setup`
@ -121,7 +120,6 @@ Simply rebuild the container (VS Code: "Dev Containers: Rebuild Container") and
**Manual installation (outside DevContainer):** **Manual installation (outside DevContainer):**
**git-cliff** (template-based): **git-cliff** (template-based):
```bash ```bash
# See: https://git-cliff.org/docs/installation # See: https://git-cliff.org/docs/installation
@ -192,13 +190,13 @@ All methods produce GitHub-flavored Markdown with emoji categories:
## 🎯 When to Use Which ## 🎯 When to Use Which
| Method | Use Case | Pros | Cons | | Method | Use Case | Pros | Cons |
| --------------------- | --------------------- | ----------------------------- | ------------------------ | |--------|----------|------|------|
| **Helper Script** | Normal releases | Foolproof, automatic | Requires script | | **Helper Script** | Normal releases | Foolproof, automatic | Requires script |
| **Auto-Tag Workflow** | Forgot script | Safety net, automatic tagging | Still need manifest bump | | **Auto-Tag Workflow** | Forgot script | Safety net, automatic tagging | Still need manifest bump |
| **GitHub Button** | Manual quick release | Easy, no script | Limited categorization | | **GitHub Button** | Manual quick release | Easy, no script | Limited categorization |
| **Local Script** | Testing release notes | Preview before release | Manual process | | **Local Script** | Testing release notes | Preview before release | Manual process |
| **CI/CD** | After tag push | Fully automatic | Needs tag first | | **CI/CD** | After tag push | Fully automatic | Needs tag first |
--- ---
@ -221,7 +219,6 @@ git push origin main v0.3.0
``` ```
**What happens:** **What happens:**
1. Script bumps manifest.json → commits → creates tag locally 1. Script bumps manifest.json → commits → creates tag locally
2. You push commit + tag together 2. You push commit + tag together
3. Release workflow sees tag → generates notes → creates release 3. Release workflow sees tag → generates notes → creates release
@ -245,7 +242,6 @@ git push
``` ```
**What happens:** **What happens:**
1. You push manifest.json change 1. You push manifest.json change
2. Auto-Tag workflow detects change → creates tag automatically 2. Auto-Tag workflow detects change → creates tag automatically
3. Release workflow sees new tag → creates release 3. Release workflow sees new tag → creates release
@ -267,7 +263,6 @@ git push origin main v0.3.0
``` ```
**What happens:** **What happens:**
1. You create and push tag manually 1. You create and push tag manually
2. Release workflow creates release 2. Release workflow creates release
3. Auto-Tag workflow skips (tag already exists) 3. Auto-Tag workflow skips (tag already exists)
@ -287,24 +282,19 @@ git push origin main v0.3.0
## 🛡️ Safety Features ## 🛡️ Safety Features
### 1. **Version Validation** ### 1. **Version Validation**
Both helper script and auto-tag workflow validate version format (X.Y.Z). Both helper script and auto-tag workflow validate version format (X.Y.Z).
### 2. **No Duplicate Tags** ### 2. **No Duplicate Tags**
- Helper script checks if tag exists (local + remote) - Helper script checks if tag exists (local + remote)
- Auto-tag workflow checks if tag exists before creating - Auto-tag workflow checks if tag exists before creating
### 3. **Atomic Operations** ### 3. **Atomic Operations**
Helper script creates commit + tag locally. You decide when to push. Helper script creates commit + tag locally. You decide when to push.
### 4. **Version Bumps Filtered** ### 4. **Version Bumps Filtered**
Release notes automatically exclude `chore(release): bump version` commits. Release notes automatically exclude `chore(release): bump version` commits.
### 5. **Rollback Instructions** ### 5. **Rollback Instructions**
Helper script shows how to undo if you change your mind. Helper script shows how to undo if you change your mind.
--- ---
@ -340,7 +330,6 @@ git push -f origin main v0.3.0
**Auto-tag didn't create tag:** **Auto-tag didn't create tag:**
Check workflow runs in GitHub Actions. Common causes: Check workflow runs in GitHub Actions. Common causes:
- Tag already exists remotely - Tag already exists remotely
- Invalid version format in manifest.json - Invalid version format in manifest.json
- manifest.json not in the commit that was pushed - manifest.json not in the commit that was pushed
@ -359,14 +348,13 @@ Check workflow runs in GitHub Actions. Common causes:
## 💡 Tips ## 💡 Tips
1. **Conventional Commits:** Use proper commit format for best results: 1. **Conventional Commits:** Use proper commit format for best results:
```
feat(scope): Add new feature
``` Detailed description of what changed.
feat(scope): Add new feature
Detailed description of what changed. Impact: Users can now do X and Y.
```
Impact: Users can now do X and Y.
```
2. **Impact Section:** Add `Impact:` in commit body for user-friendly descriptions 2. **Impact Section:** Add `Impact:` in commit body for user-friendly descriptions

View file

@ -7,7 +7,6 @@ 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. 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:** **Design Principles:**
- **Proactive**: Detect issues before they become critical - **Proactive**: Detect issues before they become critical
- **User-friendly**: Clear explanations with actionable guidance - **User-friendly**: Clear explanations with actionable guidance
- **Auto-clearing**: Repairs automatically disappear when conditions resolve - **Auto-clearing**: Repairs automatically disappear when conditions resolve
@ -20,12 +19,10 @@ The repairs system is implemented in `coordinator/repairs.py` via the `TibberPri
**Issue ID:** `tomorrow_data_missing_{entry_id}` **Issue ID:** `tomorrow_data_missing_{entry_id}`
**When triggered:** **When triggered:**
- Current time is after 18:00 (configurable via `TOMORROW_DATA_WARNING_HOUR`) - Current time is after 18:00 (configurable via `TOMORROW_DATA_WARNING_HOUR`)
- Tomorrow's electricity price data is still not available - Tomorrow's electricity price data is still not available
**When cleared:** **When cleared:**
- Tomorrow's data becomes available - Tomorrow's data becomes available
- Automatically checks on every successful API update - Automatically checks on every successful API update
@ -33,7 +30,6 @@ 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. Users cannot plan ahead for tomorrow's electricity usage optimization. Automations relying on tomorrow's prices will not work.
**Implementation:** **Implementation:**
```python ```python
# In coordinator update cycle # In coordinator update cycle
has_tomorrow_data = self._data_fetcher.has_tomorrow_data(result["priceInfo"]) has_tomorrow_data = self._data_fetcher.has_tomorrow_data(result["priceInfo"])
@ -44,7 +40,6 @@ await self._repair_manager.check_tomorrow_data_availability(
``` ```
**Translation placeholders:** **Translation placeholders:**
- `home_name`: Name of the affected home - `home_name`: Name of the affected home
- `warning_hour`: Hour after which warning appears (default: 18) - `warning_hour`: Hour after which warning appears (default: 18)
@ -53,12 +48,10 @@ await self._repair_manager.check_tomorrow_data_availability(
**Issue ID:** `rate_limit_exceeded_{entry_id}` **Issue ID:** `rate_limit_exceeded_{entry_id}`
**When triggered:** **When triggered:**
- Integration encounters 3 or more consecutive rate limit errors (HTTP 429) - Integration encounters 3 or more consecutive rate limit errors (HTTP 429)
- Threshold configurable via `RATE_LIMIT_WARNING_THRESHOLD` - Threshold configurable via `RATE_LIMIT_WARNING_THRESHOLD`
**When cleared:** **When cleared:**
- Successful API call completes (no rate limit error) - Successful API call completes (no rate limit error)
- Error counter resets to 0 - Error counter resets to 0
@ -66,7 +59,6 @@ await self._repair_manager.check_tomorrow_data_availability(
API requests are being throttled, causing stale data. Updates may be delayed until rate limit expires. API requests are being throttled, causing stale data. Updates may be delayed until rate limit expires.
**Implementation:** **Implementation:**
```python ```python
# In error handler # In error handler
is_rate_limit = ( is_rate_limit = (
@ -82,7 +74,6 @@ await self._repair_manager.clear_rate_limit_tracking()
``` ```
**Translation placeholders:** **Translation placeholders:**
- `home_name`: Name of the affected home - `home_name`: Name of the affected home
- `error_count`: Number of consecutive rate limit errors - `error_count`: Number of consecutive rate limit errors
@ -91,12 +82,10 @@ await self._repair_manager.clear_rate_limit_tracking()
**Issue ID:** `home_not_found_{entry_id}` **Issue ID:** `home_not_found_{entry_id}`
**When triggered:** **When triggered:**
- Home configured in this integration is no longer present in Tibber account - Home configured in this integration is no longer present in Tibber account
- Detected during user data refresh (daily check) - Detected during user data refresh (daily check)
**When cleared:** **When cleared:**
- Home reappears in Tibber account (unlikely - manual cleanup expected) - Home reappears in Tibber account (unlikely - manual cleanup expected)
- Integration entry is removed (shutdown cleanup) - Integration entry is removed (shutdown cleanup)
@ -104,7 +93,6 @@ 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. Integration cannot fetch data for a non-existent home. User must remove the config entry and re-add if needed.
**Implementation:** **Implementation:**
```python ```python
# After user data update # After user data update
home_exists = self._data_fetcher._check_home_exists(home_id) home_exists = self._data_fetcher._check_home_exists(home_id)
@ -115,7 +103,6 @@ else:
``` ```
**Translation placeholders:** **Translation placeholders:**
- `home_name`: Name of the missing home - `home_name`: Name of the missing home
- `entry_id`: Config entry ID for reference - `entry_id`: Config entry ID for reference
@ -166,7 +153,6 @@ Each repair type maintains internal state to avoid redundant operations:
### Lifecycle Integration ### Lifecycle Integration
**Coordinator Initialization:** **Coordinator Initialization:**
```python ```python
self._repair_manager = TibberPricesRepairManager( self._repair_manager = TibberPricesRepairManager(
hass=hass, hass=hass,
@ -176,7 +162,6 @@ self._repair_manager = TibberPricesRepairManager(
``` ```
**Update Cycle Integration:** **Update Cycle Integration:**
```python ```python
# Success path - check conditions # Success path - check conditions
if result and "priceInfo" in result: if result and "priceInfo" in result:
@ -193,7 +178,6 @@ if is_rate_limit:
``` ```
**Shutdown Cleanup:** **Shutdown Cleanup:**
```python ```python
async def async_shutdown(self) -> None: async def async_shutdown(self) -> None:
"""Shut down coordinator and clean up.""" """Shut down coordinator and clean up."""
@ -212,27 +196,24 @@ Repairs use Home Assistant's standard translation system. Translations are defin
- `/translations/sv.json` - `/translations/sv.json`
**Structure:** **Structure:**
```json ```json
{ {
"issues": { "issues": {
"tomorrow_data_missing": { "tomorrow_data_missing": {
"title": "Tomorrow's price data missing for {home_name}", "title": "Tomorrow's price data missing for {home_name}",
"description": "Detailed explanation with multiple paragraphs...\n\nPossible causes:\n- Cause 1\n- Cause 2" "description": "Detailed explanation with multiple paragraphs...\n\nPossible causes:\n- Cause 1\n- Cause 2"
}
} }
}
} }
``` ```
## Home Assistant Integration ## Home Assistant Integration
Repairs appear in: Repairs appear in:
- **Settings → System → Repairs** (main repairs panel) - **Settings → System → Repairs** (main repairs panel)
- **Notifications** (bell icon in UI shows repair count) - **Notifications** (bell icon in UI shows repair count)
Repair properties: Repair properties:
- **`is_fixable=False`**: No automated fix available (user action required) - **`is_fixable=False`**: No automated fix available (user action required)
- **`severity=IssueSeverity.WARNING`**: Yellow warning level (not critical) - **`severity=IssueSeverity.WARNING`**: Yellow warning level (not critical)
- **`translation_key`**: References `issues.{key}` in translation files - **`translation_key`**: References `issues.{key}` in translation files
@ -247,7 +228,6 @@ Repair properties:
4. When tomorrow data arrives (next API fetch), repair clears 4. When tomorrow data arrives (next API fetch), repair clears
**Manual trigger:** **Manual trigger:**
```python ```python
# Temporarily set warning hour to current hour for testing # Temporarily set warning hour to current hour for testing
TOMORROW_DATA_WARNING_HOUR = datetime.now().hour TOMORROW_DATA_WARNING_HOUR = datetime.now().hour
@ -260,7 +240,6 @@ TOMORROW_DATA_WARNING_HOUR = datetime.now().hour
3. Successful API call clears the repair 3. Successful API call clears the repair
**Manual test:** **Manual test:**
- Reduce API polling interval to trigger rate limiting - Reduce API polling interval to trigger rate limiting
- Or temporarily return HTTP 429 in API client - Or temporarily return HTTP 429 in API client
@ -284,7 +263,6 @@ To add a new repair type:
7. **Document** in this file 7. **Document** in this file
**Example template:** **Example template:**
```python ```python
async def check_new_condition(self, *, param: bool) -> None: async def check_new_condition(self, *, param: bool) -> None:
"""Check new condition and create/clear repair.""" """Check new condition and create/clear repair."""

View file

@ -4,9 +4,9 @@
## Prerequisites ## Prerequisites
- VS Code with Dev Container support - VS Code with Dev Container support
- Docker installed and running - Docker installed and running
- GitHub account (for Tibber API token) - GitHub account (for Tibber API token)
## Quick Setup ## Quick Setup
@ -26,11 +26,11 @@ code .
The DevContainer includes: The DevContainer includes:
- Python 3.13 with `.venv` at `/home/vscode/.venv/` - Python 3.13 with `.venv` at `/home/vscode/.venv/`
- `uv` package manager (fast, modern Python tooling) - `uv` package manager (fast, modern Python tooling)
- Home Assistant development dependencies - Home Assistant development dependencies
- Ruff linter/formatter - Ruff linter/formatter
- Git, GitHub CLI, Node.js, Rust toolchain - Git, GitHub CLI, Node.js, Rust toolchain
## Running the Integration ## Running the Integration

View file

@ -13,10 +13,10 @@ Before running tests or committing changes, validate the integration structure:
This lightweight script checks: This lightweight script checks:
- ✓ `config_flow.py` exists - `config_flow.py` exists
- ✓ `manifest.json` is valid JSON with required fields - `manifest.json` is valid JSON with required fields
- ✓ Translation files have valid JSON syntax - ✓ Translation files have valid JSON syntax
- ✓ All Python files compile without syntax errors - ✓ All Python files compile without syntax errors
**Note:** Full hassfest validation runs in GitHub Actions on push. **Note:** Full hassfest validation runs in GitHub Actions on push.
@ -42,10 +42,10 @@ pytest --cov=custom_components.tibber_prices tests/
Then test in Home Assistant UI: Then test in Home Assistant UI:
- Configuration flow - Configuration flow
- Sensor states and attributes - Sensor states and attributes
- Services - Services
- Translation strings - Translation strings
## Test Guidelines ## Test Guidelines

View file

@ -10,11 +10,11 @@ This document explains the timer/scheduler system in the Tibber Prices integrati
The integration uses **three independent timer mechanisms** for different purposes: The integration uses **three independent timer mechanisms** for different purposes:
| Timer | Type | Interval | Purpose | Trigger Method | | Timer | Type | Interval | Purpose | Trigger Method |
| ------------ | ----------- | ------------------ | -------------------- | ------------------------------- | |-------|------|----------|---------|----------------|
| **Timer #1** | HA built-in | 15 minutes | API data updates | `DataUpdateCoordinator` | | **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 #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()` | | **Timer #3** | Custom | Every minute | Countdown/progress | `async_track_utc_time_change()` |
**Key principle:** Timer #1 (HA) controls **data fetching**, Timer #2 controls **entity updates**, Timer #3 controls **timing displays**. **Key principle:** Timer #1 (HA) controls **data fetching**, Timer #2 controls **entity updates**, Timer #3 controls **timing displays**.
@ -27,7 +27,6 @@ The integration uses **three independent timer mechanisms** for different purpos
**Type:** Home Assistant's built-in `DataUpdateCoordinator` with `UPDATE_INTERVAL = 15 minutes` **Type:** Home Assistant's built-in `DataUpdateCoordinator` with `UPDATE_INTERVAL = 15 minutes`
**What it is:** **What it is:**
- HA provides this timer system automatically when you inherit from `DataUpdateCoordinator` - HA provides this timer system automatically when you inherit from `DataUpdateCoordinator`
- Triggers `_async_update_data()` method every 15 minutes - Triggers `_async_update_data()` method every 15 minutes
- **Not** synchronized to clock boundaries (each installation has different start time) - **Not** synchronized to clock boundaries (each installation has different start time)
@ -54,19 +53,16 @@ async def _async_update_data(self) -> TibberPricesData:
``` ```
**Load Distribution:** **Load Distribution:**
- Each HA installation starts Timer #1 at different times → natural 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 - 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 - Result: API load spread over ~30 minutes instead of all at once
**Midnight Coordination:** **Midnight Coordination:**
- Atomic check: `_check_midnight_turnover_needed(now)` compares dates only (no side effects) - Atomic check: `_check_midnight_turnover_needed(now)` compares dates only (no side effects)
- If midnight turnover needed → performs it and returns early - If midnight turnover needed → performs it and returns early
- Timer #2 will see turnover already done and skip gracefully - Timer #2 will see turnover already done and skip gracefully
**Why we use HA's timer:** **Why we use HA's timer:**
- Automatic restart after HA restart - Automatic restart after HA restart
- Built-in retry logic for temporary failures - Built-in retry logic for temporary failures
- Standard HA integration pattern - Standard HA integration pattern
@ -83,7 +79,6 @@ async def _async_update_data(self) -> TibberPricesData:
**Purpose:** Update time-sensitive entity states at interval boundaries **without waiting for API poll** **Purpose:** Update time-sensitive entity states at interval boundaries **without waiting for API poll**
**Problem it solves:** **Problem it solves:**
- Timer #1 runs every 15 minutes but NOT synchronized to clock (:03, :18, :33, :48) - 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 - 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 - Example: 14:00 new price, but Timer #1 ran at 13:58 → next update at 14:13 → users see old price until 14:13
@ -105,26 +100,22 @@ async def _handle_quarter_hour_refresh(self, now: datetime) -> None:
``` ```
**Smart Boundary Tolerance:** **Smart Boundary Tolerance:**
- Uses `round_to_nearest_quarter_hour()` with ±2 second 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 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) - HA restart at 14:59:30 → stays at 14:45:00 (shows current interval)
- See [Architecture](./architecture.md#3-quarter-hour-precision) for details - See [Architecture](./architecture.md#3-quarter-hour-precision) for details
**Absolute Time Scheduling:** **Absolute Time Scheduling:**
- `async_track_utc_time_change()` plans for **all future boundaries** (15:00, 15:15, 15:30, ...) - `async_track_utc_time_change()` plans for **all future boundaries** (15:00, 15:15, 15:30, ...)
- NOT relative delays ("in 15 minutes") - 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) - If triggered at 14:59:58 → next trigger is 15:15:00, NOT 15:00:00 (prevents double updates)
**Which entities listen:** **Which entities listen:**
- All sensors that depend on "current interval" (e.g., `current_interval_price`, `next_interval_price`) - 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`) - Binary sensors that check "is now in period?" (e.g., `best_price_period_active`)
- ~50-60 entities out of 120+ total - ~50-60 entities out of 120+ total
**Why custom timer:** **Why custom timer:**
- HA's built-in coordinator doesn't support exact boundary timing - HA's built-in coordinator doesn't support exact boundary timing
- We need **absolute time** triggers, not periodic intervals - We need **absolute time** triggers, not periodic intervals
- Allows fast entity updates without expensive data transformation - Allows fast entity updates without expensive data transformation
@ -149,7 +140,6 @@ async def _handle_minute_refresh(self, now: datetime) -> None:
``` ```
**Which entities listen:** **Which entities listen:**
- `best_price_remaining_minutes` - Countdown timer - `best_price_remaining_minutes` - Countdown timer
- `peak_price_remaining_minutes` - Countdown timer - `peak_price_remaining_minutes` - Countdown timer
- `best_price_progress` - Progress bar (0-100%) - `best_price_progress` - Progress bar (0-100%)
@ -157,13 +147,11 @@ async def _handle_minute_refresh(self, now: datetime) -> None:
- ~10 entities total - ~10 entities total
**Why custom timer:** **Why custom timer:**
- Users want smooth countdowns (not jumping 15 minutes at a time) - Users want smooth countdowns (not jumping 15 minutes at a time)
- Progress bars need minute-by-minute updates - Progress bars need minute-by-minute updates
- Very lightweight (no data processing, just state recalculation) - Very lightweight (no data processing, just state recalculation)
**Why NOT every second:** **Why NOT every second:**
- Minute precision sufficient for countdown UX - Minute precision sufficient for countdown UX
- Reduces CPU load (60× fewer updates than seconds) - Reduces CPU load (60× fewer updates than seconds)
- Home Assistant best practice (avoid sub-minute updates) - Home Assistant best practice (avoid sub-minute updates)
@ -206,7 +194,6 @@ class ListenerManager:
``` ```
**Why this pattern:** **Why this pattern:**
- Decouples timer logic from entity logic - Decouples timer logic from entity logic
- One timer can notify many entities efficiently - One timer can notify many entities efficiently
- Entities can unregister when removed (cleanup) - Entities can unregister when removed (cleanup)
@ -292,13 +279,11 @@ class ListenerManager:
### Reason 1: Load Distribution on Tibber API ### Reason 1: Load Distribution on Tibber API
If all installations used synchronized timers: If all installations used synchronized timers:
- ❌ Everyone fetches at 13:00:00 → Tibber API overload - ❌ Everyone fetches at 13:00:00 → Tibber API overload
- ❌ Everyone fetches at 14:00:00 → Tibber API overload - ❌ Everyone fetches at 14:00:00 → Tibber API overload
- ❌ "Thundering herd" problem - ❌ "Thundering herd" problem
With HA's unsynchronized timer: With HA's unsynchronized timer:
- ✅ Installation A: 13:03:12, 13:18:12, 13:33:12, ... - ✅ Installation A: 13:03:12, 13:18:12, 13:33:12, ...
- ✅ Installation B: 13:07:45, 13:22:45, 13:37:45, ... - ✅ Installation B: 13:07:45, 13:22:45, 13:37:45, ...
- ✅ Installation C: 13:11:28, 13:26:28, 13:41:28, ... - ✅ Installation C: 13:11:28, 13:26:28, 13:41:28, ...
@ -331,7 +316,6 @@ def _should_update_price_data(self) -> str:
**Most Timer #1 cycles:** Fast path (~2ms), no API call, just returns cached data. **Most Timer #1 cycles:** Fast path (~2ms), no API call, just returns cached data.
**API fetch only when:** **API fetch only when:**
- Tomorrow data missing/invalid (after 13:00) - Tomorrow data missing/invalid (after 13:00)
- Cache expired (midnight turnover) - Cache expired (midnight turnover)
- Explicit user refresh - Explicit user refresh
@ -355,7 +339,6 @@ def _should_update_price_data(self) -> str:
## Performance Characteristics ## Performance Characteristics
### Timer #1 (DataUpdateCoordinator) ### Timer #1 (DataUpdateCoordinator)
- **Triggers:** Every 15 minutes (unsynchronized) - **Triggers:** Every 15 minutes (unsynchronized)
- **Fast path:** ~2ms (cache check, return existing data) - **Fast path:** ~2ms (cache check, return existing data)
- **Slow path:** ~600ms (API fetch + transform + calculate) - **Slow path:** ~600ms (API fetch + transform + calculate)
@ -363,14 +346,12 @@ def _should_update_price_data(self) -> str:
- **API calls:** ~1-2 times/day (cached otherwise) - **API calls:** ~1-2 times/day (cached otherwise)
### Timer #2 (Quarter-Hour Refresh) ### Timer #2 (Quarter-Hour Refresh)
- **Triggers:** 96 times/day (exact boundaries) - **Triggers:** 96 times/day (exact boundaries)
- **Processing:** ~5ms (notify 60 entities) - **Processing:** ~5ms (notify 60 entities)
- **No API calls:** Uses cached/transformed data - **No API calls:** Uses cached/transformed data
- **No transformation:** Just entity state updates - **No transformation:** Just entity state updates
### Timer #3 (Minute Refresh) ### Timer #3 (Minute Refresh)
- **Triggers:** 1440 times/day (every minute) - **Triggers:** 1440 times/day (every minute)
- **Processing:** ~1ms (notify 10 entities) - **Processing:** ~1ms (notify 10 entities)
- **No API calls:** No data processing at all - **No API calls:** No data processing at all
@ -412,16 +393,16 @@ _LOGGER.setLevel(logging.DEBUG)
### Common Issues ### Common Issues
1. **Timer #2 not triggering:** 1. **Timer #2 not triggering:**
- Check: `schedule_quarter_hour_refresh()` called in `__init__`? - Check: `schedule_quarter_hour_refresh()` called in `__init__`?
- Check: `_quarter_hour_timer_cancel` properly stored? - Check: `_quarter_hour_timer_cancel` properly stored?
2. **Double updates at midnight:** 2. **Double updates at midnight:**
- Should NOT happen (atomic coordination) - Should NOT happen (atomic coordination)
- Check: Both timers use same date comparison logic? - Check: Both timers use same date comparison logic?
3. **API overload:** 3. **API overload:**
- Check: Random delay working? (0-30s jitter on tomorrow check) - Check: Random delay working? (0-30s jitter on tomorrow check)
- Check: Cache validation logic correct? - Check: Cache validation logic correct?
--- ---
@ -436,20 +417,17 @@ _LOGGER.setLevel(logging.DEBUG)
## Summary ## Summary
**Three independent timers:** **Three independent timers:**
1. **Timer #1** (HA built-in, 15 min, unsynchronized) → Data fetching (when needed) 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) 2. **Timer #2** (Custom, :00/:15/:30/:45) → Entity state updates (always)
3. **Timer #3** (Custom, every minute) → Countdown/progress (always) 3. **Timer #3** (Custom, every minute) → Countdown/progress (always)
**Key insights:** **Key insights:**
- Timer #1 unsynchronized = good (load distribution on API) - Timer #1 unsynchronized = good (load distribution on API)
- Timer #2 synchronized = good (user sees correct data immediately) - Timer #2 synchronized = good (user sees correct data immediately)
- Timer #3 synchronized = good (smooth countdown UX) - Timer #3 synchronized = good (smooth countdown UX)
- All three coordinate gracefully (atomic midnight checks, no conflicts) - All three coordinate gracefully (atomic midnight checks, no conflicts)
**"Listener" terminology:** **"Listener" terminology:**
- Timer = mechanism that triggers - Timer = mechanism that triggers
- Listener = callback that gets called - Listener = callback that gets called
- Observer pattern = entities register, coordinator notifies - Observer pattern = entities register, coordinator notifies

View file

@ -22,30 +22,30 @@ Fetches home information and metadata:
```graphql ```graphql
query { query {
viewer { viewer {
homes { homes {
id id
appNickname appNickname
address { address {
address1 address1
postalCode postalCode
city city
country country
} }
timeZone timeZone
currentSubscription { currentSubscription {
priceInfo { priceInfo {
current { current {
currency currency
} }
}
}
meteringPointData {
consumptionEan
gridAreaCode
}
} }
}
meteringPointData {
consumptionEan
gridAreaCode
}
} }
}
} }
``` ```
@ -56,27 +56,26 @@ query {
Fetches quarter-hourly prices: Fetches quarter-hourly prices:
```graphql ```graphql
query ($homeId: ID!) { query($homeId: ID!) {
viewer { viewer {
home(id: $homeId) { home(id: $homeId) {
currentSubscription { currentSubscription {
priceInfo { priceInfo {
range(resolution: QUARTER_HOURLY, first: 384) { range(resolution: QUARTER_HOURLY, first: 384) {
nodes { nodes {
total total
startsAt startsAt
level level
}
}
}
} }
}
} }
}
} }
}
} }
``` ```
**Parameters:** **Parameters:**
- `homeId`: Tibber home identifier - `homeId`: Tibber home identifier
- `resolution`: Always `QUARTER_HOURLY` - `resolution`: Always `QUARTER_HOURLY`
- `first`: 384 intervals (4 days of data) - `first`: 384 intervals (4 days of data)
@ -86,12 +85,10 @@ query ($homeId: ID!) {
## Rate Limits ## Rate Limits
Tibber API rate limits (as of 2024): Tibber API rate limits (as of 2024):
- **5000 requests per hour** per token - **5000 requests per hour** per token
- **Burst limit:** 100 requests per minute - **Burst limit:** 100 requests per minute
Integration stays well below these limits: Integration stays well below these limits:
- Polls every 15 minutes = 96 requests/day - Polls every 15 minutes = 96 requests/day
- User data cached for 24h = 1 request/day - User data cached for 24h = 1 request/day
- **Total:** ~100 requests/day per home - **Total:** ~100 requests/day per home
@ -102,14 +99,13 @@ Integration stays well below these limits:
```json ```json
{ {
"total": 0.2456, "total": 0.2456,
"startsAt": "2024-12-06T14:00:00.000+01:00", "startsAt": "2024-12-06T14:00:00.000+01:00",
"level": "NORMAL" "level": "NORMAL"
} }
``` ```
**Fields:** **Fields:**
- `total`: Price including VAT and fees (currency's major unit, e.g., EUR) - `total`: Price including VAT and fees (currency's major unit, e.g., EUR)
- `startsAt`: ISO 8601 timestamp with timezone - `startsAt`: ISO 8601 timestamp with timezone
- `level`: Tibber's own classification (VERY_CHEAP, CHEAP, NORMAL, EXPENSIVE, VERY_EXPENSIVE) - `level`: Tibber's own classification (VERY_CHEAP, CHEAP, NORMAL, EXPENSIVE, VERY_EXPENSIVE)
@ -118,12 +114,11 @@ Integration stays well below these limits:
```json ```json
{ {
"currency": "EUR" "currency": "EUR"
} }
``` ```
Supported currencies: Supported currencies:
- `EUR` (Euro) - displayed as ct/kWh - `EUR` (Euro) - displayed as ct/kWh
- `NOK` (Norwegian Krone) - displayed as øre/kWh - `NOK` (Norwegian Krone) - displayed as øre/kWh
- `SEK` (Swedish Krona) - displayed as öre/kWh - `SEK` (Swedish Krona) - displayed as öre/kWh
@ -133,52 +128,42 @@ Supported currencies:
### Common Error Responses ### Common Error Responses
**Invalid Token:** **Invalid Token:**
```json ```json
{ {
"errors": [ "errors": [{
{ "message": "Unauthorized",
"message": "Unauthorized", "extensions": {
"extensions": { "code": "UNAUTHENTICATED"
"code": "UNAUTHENTICATED" }
} }]
}
]
} }
``` ```
**Rate Limit Exceeded:** **Rate Limit Exceeded:**
```json ```json
{ {
"errors": [ "errors": [{
{ "message": "Too Many Requests",
"message": "Too Many Requests", "extensions": {
"extensions": { "code": "RATE_LIMIT_EXCEEDED"
"code": "RATE_LIMIT_EXCEEDED" }
} }]
}
]
} }
``` ```
**Home Not Found:** **Home Not Found:**
```json ```json
{ {
"errors": [ "errors": [{
{ "message": "Home not found",
"message": "Home not found", "extensions": {
"extensions": { "code": "NOT_FOUND"
"code": "NOT_FOUND" }
} }]
}
]
} }
``` ```
Integration handles these with: Integration handles these with:
- Exponential backoff retry (3 attempts) - Exponential backoff retry (3 attempts)
- ConfigEntryAuthFailed for auth errors - ConfigEntryAuthFailed for auth errors
- ConfigEntryNotReady for temporary failures - ConfigEntryNotReady for temporary failures
@ -186,7 +171,6 @@ Integration handles these with:
## Data Transformation ## Data Transformation
Raw API data is enriched with: Raw API data is enriched with:
- **Trailing 24h average** - Calculated from previous intervals - **Trailing 24h average** - Calculated from previous intervals
- **Leading 24h average** - Calculated from future intervals - **Leading 24h average** - Calculated from future intervals
- **Price difference %** - Deviation from average - **Price difference %** - Deviation from average
@ -197,7 +181,6 @@ See `utils/price.py` for enrichment logic.
--- ---
💡 **External Resources:** 💡 **External Resources:**
- [Tibber API Documentation](https://developer.tibber.com/docs/overview) - [Tibber API Documentation](https://developer.tibber.com/docs/overview)
- [GraphQL Explorer](https://developer.tibber.com/explorer) - [GraphQL Explorer](https://developer.tibber.com/explorer)
- [Get API Token](https://developer.tibber.com/settings/access-token) - [Get API Token](https://developer.tibber.com/settings/access-token)

View file

@ -100,43 +100,43 @@ flowchart TB
### Flow Description ### Flow Description
1. **Setup** (`__init__.py`) 1. **Setup** (`__init__.py`)
- Integration loads, creates coordinator instance - Integration loads, creates coordinator instance
- Registers entity platforms (sensor, binary_sensor) - Registers entity platforms (sensor, binary_sensor)
- Sets up custom services - Sets up custom services
2. **Data Fetch** (every 15 minutes) 2. **Data Fetch** (every 15 minutes)
- Coordinator triggers update via `api.py` - Coordinator triggers update via `api.py`
- API client checks **persistent cache** first (`coordinator/cache.py`) - API client checks **persistent cache** first (`coordinator/cache.py`)
- If cache valid → return cached data - If cache valid → return cached data
- If cache stale → query Tibber GraphQL API - If cache stale → query Tibber GraphQL API
- Store fresh data in persistent cache (survives HA restart) - Store fresh data in persistent cache (survives HA restart)
3. **Price Enrichment** 3. **Price Enrichment**
- Coordinator passes raw prices to `DataTransformer` - Coordinator passes raw prices to `DataTransformer`
- Transformer checks **transformation cache** (memory) - Transformer checks **transformation cache** (memory)
- If cache valid → return enriched data - If cache valid → return enriched data
- If cache invalid → enrich via `price_utils.py` + `average_utils.py` - If cache invalid → enrich via `price_utils.py` + `average_utils.py`
- Calculate 24h trailing/leading averages - Calculate 24h trailing/leading averages
- Calculate price differences (% from average) - Calculate price differences (% from average)
- Assign rating levels (LOW/NORMAL/HIGH) - Assign rating levels (LOW/NORMAL/HIGH)
- Store enriched data in transformation cache - Store enriched data in transformation cache
4. **Period Calculation** 4. **Period Calculation**
- Coordinator passes enriched data to `PeriodCalculator` - Coordinator passes enriched data to `PeriodCalculator`
- Calculator computes **hash** from prices + config - Calculator computes **hash** from prices + config
- If hash matches cache → return cached periods - If hash matches cache → return cached periods
- If hash differs → recalculate best/peak price periods - If hash differs → recalculate best/peak price periods
- Store periods with new hash - Store periods with new hash
5. **Entity Updates** 5. **Entity Updates**
- Coordinator provides complete data (prices + periods) - Coordinator provides complete data (prices + periods)
- Sensors read values via unified handlers - Sensors read values via unified handlers
- Binary sensors evaluate period states - Binary sensors evaluate period states
- Entities update on quarter-hour boundaries (00/15/30/45) - Entities update on quarter-hour boundaries (00/15/30/45)
6. **Service Calls** 6. **Service Calls**
- Custom services access coordinator data directly - Custom services access coordinator data directly
- Return formatted responses (JSON, ApexCharts format) - Return formatted responses (JSON, ApexCharts format)
--- ---
@ -146,13 +146,13 @@ flowchart TB
The integration uses **5 independent caching layers** for optimal performance: The integration uses **5 independent caching layers** for optimal performance:
| Layer | Location | Lifetime | Invalidation | Memory | | Layer | Location | Lifetime | Invalidation | Memory |
| ------------------------ | ------------------------------------ | -------------------------------------- | ------------ | ------ | |-------|----------|----------|--------------|--------|
| **API Cache** | `coordinator/cache.py` | 24h (user)<br/>Until midnight (prices) | Automatic | 50KB | | **API Cache** | `coordinator/cache.py` | 24h (user)<br/>Until midnight (prices) | Automatic | 50KB |
| **Translation Cache** | `const.py` | Until HA restart | Never | 5KB | | **Translation Cache** | `const.py` | Until HA restart | Never | 5KB |
| **Config Cache** | `coordinator/*` | Until config change | Explicit | 1KB | | **Config Cache** | `coordinator/*` | Until config change | Explicit | 1KB |
| **Period Cache** | `coordinator/periods.py` | Until data/config change | Hash-based | 10KB | | **Period Cache** | `coordinator/periods.py` | Until data/config change | Hash-based | 10KB |
| **Transformation Cache** | `coordinator/data_transformation.py` | Until midnight/config | Automatic | 60KB | | **Transformation Cache** | `coordinator/data_transformation.py` | Until midnight/config | Automatic | 60KB |
**Total cache overhead:** ~126KB per coordinator instance (main entry + subentries) **Total cache overhead:** ~126KB per coordinator instance (main entry + subentries)
@ -195,31 +195,30 @@ For detailed cache behavior, see [Caching Strategy](./caching-strategy.md).
### Core Components ### Core Components
| Component | File | Responsibility | | Component | File | Responsibility |
| --------------------- | ------------------------------------ | ---------------------------------------------------------------------------------------- | |-----------|------|----------------|
| **API Client** | `api.py` | GraphQL queries to Tibber, retry logic, error handling | | **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 | | **Coordinator** | `coordinator.py` | Update orchestration, cache management, absolute-time scheduling with boundary tolerance |
| **Data Transformer** | `coordinator/data_transformation.py` | Price enrichment (averages, ratings, differences) | | **Data Transformer** | `coordinator/data_transformation.py` | Price enrichment (averages, ratings, differences) |
| **Period Calculator** | `coordinator/periods.py` | Best/peak price period calculation with relaxation | | **Period Calculator** | `coordinator/periods.py` | Best/peak price period calculation with relaxation |
| **Sensors** | `sensor/` | 80+ entities for prices, levels, ratings, statistics | | **Sensors** | `sensor/` | 80+ entities for prices, levels, ratings, statistics |
| **Binary Sensors** | `binary_sensor/` | Period indicators (best/peak price active) | | **Binary Sensors** | `binary_sensor/` | Period indicators (best/peak price active) |
| **Services** | `services/` | Custom service endpoints (get_chartdata, get_apexcharts_yaml, refresh_user_data) | | **Services** | `services/` | Custom service endpoints (get_chartdata, get_apexcharts_yaml, refresh_user_data) |
### Sensor Architecture (Calculator Pattern) ### Sensor Architecture (Calculator Pattern)
The sensor platform uses **Calculator Pattern** for clean separation of concerns (refactored Nov 2025): The sensor platform uses **Calculator Pattern** for clean separation of concerns (refactored Nov 2025):
| Component | Files | Lines | Responsibility | | Component | Files | Lines | Responsibility |
| ---------------- | ------------------------- | ----- | ------------------------------------------------------- | |-----------|-------|-------|----------------|
| **Entity Class** | `sensor/core.py` | 909 | Entity lifecycle, coordinator, delegates to calculators | | **Entity Class** | `sensor/core.py` | 909 | Entity lifecycle, coordinator, delegates to calculators |
| **Calculators** | `sensor/calculators/` | 1,838 | Business logic (8 specialized calculators) | | **Calculators** | `sensor/calculators/` | 1,838 | Business logic (8 specialized calculators) |
| **Attributes** | `sensor/attributes/` | 1,209 | State presentation (8 specialized modules) | | **Attributes** | `sensor/attributes/` | 1,209 | State presentation (8 specialized modules) |
| **Routing** | `sensor/value_getters.py` | 276 | Centralized sensor → calculator mapping | | **Routing** | `sensor/value_getters.py` | 276 | Centralized sensor → calculator mapping |
| **Chart Export** | `sensor/chart_data.py` | 144 | Service call handling, YAML parsing | | **Chart Export** | `sensor/chart_data.py` | 144 | Service call handling, YAML parsing |
| **Helpers** | `sensor/helpers.py` | 188 | Aggregation functions, utilities | | **Helpers** | `sensor/helpers.py` | 188 | Aggregation functions, utilities |
**Calculator Package** (`sensor/calculators/`): **Calculator Package** (`sensor/calculators/`):
- `base.py` - Abstract BaseCalculator with coordinator access - `base.py` - Abstract BaseCalculator with coordinator access
- `interval.py` - Single interval calculations (current/next/previous) - `interval.py` - Single interval calculations (current/next/previous)
- `rolling_hour.py` - 5-interval rolling windows - `rolling_hour.py` - 5-interval rolling windows
@ -231,7 +230,6 @@ The sensor platform uses **Calculator Pattern** for clean separation of concerns
- `metadata.py` - Home/metering metadata - `metadata.py` - Home/metering metadata
**Benefits:** **Benefits:**
- 58% reduction in core.py (2,170 → 909 lines) - 58% reduction in core.py (2,170 → 909 lines)
- Clear separation: Calculators (logic) vs Attributes (presentation) - Clear separation: Calculators (logic) vs Attributes (presentation)
- Independent testability for each calculator - Independent testability for each calculator
@ -239,12 +237,12 @@ The sensor platform uses **Calculator Pattern** for clean separation of concerns
### Helper Utilities ### Helper Utilities
| Utility | File | Purpose | | Utility | File | Purpose |
| ----------------- | ------------------ | ------------------------------------------------- | |---------|------|---------|
| **Price Utils** | `utils/price.py` | Rating calculation, enrichment, level aggregation | | **Price Utils** | `utils/price.py` | Rating calculation, enrichment, level aggregation |
| **Average Utils** | `utils/average.py` | Trailing/leading 24h average calculations | | **Average Utils** | `utils/average.py` | Trailing/leading 24h average calculations |
| **Entity Utils** | `entity_utils/` | Shared icon/color/attribute logic | | **Entity Utils** | `entity_utils/` | Shared icon/color/attribute logic |
| **Translations** | `const.py` | Translation loading and caching | | **Translations** | `const.py` | Translation loading and caching |
--- ---
@ -285,12 +283,12 @@ All quarter-hourly price intervals get augmented via `utils/price.py`:
- **API polling**: Every 15 minutes (coordinator fetch cycle) - **API polling**: Every 15 minutes (coordinator fetch cycle)
- **Entity updates**: On 00/15/30/45-minute boundaries via `coordinator/listeners.py` - **Entity updates**: On 00/15/30/45-minute boundaries via `coordinator/listeners.py`
- **Timer scheduling**: Uses `async_track_utc_time_change(minute=[0, 15, 30, 45], second=0)` - **Timer scheduling**: Uses `async_track_utc_time_change(minute=[0, 15, 30, 45], second=0)`
- HA may trigger ±few milliseconds before/after exact boundary - HA may trigger ±few milliseconds before/after exact boundary
- Smart boundary tolerance (±2 seconds) handles scheduling jitter in `sensor/helpers.py` - Smart boundary tolerance (±2 seconds) handles scheduling jitter in `sensor/helpers.py`
- If HA schedules at 14:59:58 → rounds to 15:00:00 (shows new interval data) - If HA schedules at 14:59:58 → rounds to 15:00:00 (shows new interval data)
- If HA restarts at 14:59:30 → stays at 14:45:00 (shows current interval data) - If HA restarts at 14:59:30 → stays at 14:45:00 (shows current interval data)
- **Absolute time tracking**: Timer plans for **all future boundaries** (not relative delays) - **Absolute time tracking**: Timer plans for **all future boundaries** (not relative delays)
- Prevents double-updates (if triggered at 14:59:58, next trigger is 15:15:00, not 15:00:00) - Prevents double-updates (if triggered at 14:59:58, next trigger is 15:15:00, not 15:00:00)
- **Result**: Current price sensors update without waiting for next API poll - **Result**: Current price sensors update without waiting for next API poll
### 4. Calculator Pattern (Sensor Platform) ### 4. Calculator Pattern (Sensor Platform)
@ -298,31 +296,26 @@ All quarter-hourly price intervals get augmented via `utils/price.py`:
Sensors organized by **calculation method** (refactored Nov 2025): Sensors organized by **calculation method** (refactored Nov 2025):
**Unified Handler Methods** (`sensor/core.py`): **Unified Handler Methods** (`sensor/core.py`):
- `_get_interval_value(offset, type)` - current/next/previous intervals - `_get_interval_value(offset, type)` - current/next/previous intervals
- `_get_rolling_hour_value(offset, type)` - 5-interval rolling windows - `_get_rolling_hour_value(offset, type)` - 5-interval rolling windows
- `_get_daily_stat_value(day, stat_func)` - calendar day min/max/avg - `_get_daily_stat_value(day, stat_func)` - calendar day min/max/avg
- `_get_24h_window_value(stat_func)` - trailing/leading statistics - `_get_24h_window_value(stat_func)` - trailing/leading statistics
**Routing** (`sensor/value_getters.py`): **Routing** (`sensor/value_getters.py`):
- Single source of truth mapping 80+ entity keys to calculator methods - Single source of truth mapping 80+ entity keys to calculator methods
- Organized by calculation type (Interval, Rolling Hour, Daily Stats, etc.) - Organized by calculation type (Interval, Rolling Hour, Daily Stats, etc.)
**Calculators** (`sensor/calculators/`): **Calculators** (`sensor/calculators/`):
- Each calculator inherits from `BaseCalculator` with coordinator access - Each calculator inherits from `BaseCalculator` with coordinator access
- Focused responsibility: `IntervalCalculator`, `TrendCalculator`, etc. - Focused responsibility: `IntervalCalculator`, `TrendCalculator`, etc.
- Complex logic isolated (e.g., `TrendCalculator` has internal caching) - Complex logic isolated (e.g., `TrendCalculator` has internal caching)
**Attributes** (`sensor/attributes/`): **Attributes** (`sensor/attributes/`):
- Separate from business logic, handles state presentation - Separate from business logic, handles state presentation
- Builds extra_state_attributes dicts for entity classes - Builds extra_state_attributes dicts for entity classes
- Unified builders: `build_sensor_attributes()`, `build_extra_state_attributes()` - Unified builders: `build_sensor_attributes()`, `build_extra_state_attributes()`
**Benefits:** **Benefits:**
- Minimal code duplication across 80+ sensors - Minimal code duplication across 80+ sensors
- Clear separation of concerns (calculation vs presentation) - Clear separation of concerns (calculation vs presentation)
- Easy to extend: Add sensor → choose pattern → add to routing - Easy to extend: Add sensor → choose pattern → add to routing
@ -340,12 +333,12 @@ Sensors organized by **calculation method** (refactored Nov 2025):
### CPU Optimization ### CPU Optimization
| Optimization | Location | Savings | | Optimization | Location | Savings |
| ------------------- | ------------------------ | ---------------------------- | |--------------|----------|---------|
| Config caching | `coordinator/*` | ~50% on config checks | | Config caching | `coordinator/*` | ~50% on config checks |
| Period caching | `coordinator/periods.py` | ~70% on period recalculation | | Period caching | `coordinator/periods.py` | ~70% on period recalculation |
| Lazy logging | Throughout | ~15% on log-heavy operations | | Lazy logging | Throughout | ~15% on log-heavy operations |
| Import optimization | Module structure | ~20% faster loading | | Import optimization | Module structure | ~20% faster loading |
### Memory Usage ### Memory Usage

View file

@ -24,13 +24,11 @@ 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. **Purpose:** Reduce API calls to Tibber by caching user data and price data between HA restarts.
**What is cached:** **What is cached:**
- **Price data** (`price_data`): Day before yesterday/yesterday/today/tomorrow price intervals with enriched fields (384 intervals total) - **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 - **User data** (`user_data`): Homes, subscriptions, features from Tibber GraphQL `viewer` query
- **Timestamps**: Last update times for validation - **Timestamps**: Last update times for validation
**Lifetime:** **Lifetime:**
- **Price data**: Until midnight turnover (cleared daily at 00:00 local time) - **Price data**: Until midnight turnover (cleared daily at 00:00 local time)
- **User data**: 24 hours (refreshed daily) - **User data**: 24 hours (refreshed daily)
- **Survives**: HA restarts via persistent Storage - **Survives**: HA restarts via persistent Storage
@ -38,31 +36,29 @@ The integration uses **4 distinct caching layers** with different purposes and l
**Invalidation triggers:** **Invalidation triggers:**
1. **Midnight turnover** (Timer #2 in coordinator): 1. **Midnight turnover** (Timer #2 in coordinator):
```python
```python # coordinator/day_transitions.py
# coordinator/day_transitions.py def _handle_midnight_turnover() -> None:
def _handle_midnight_turnover() -> None: self._cached_price_data = None # Force fresh fetch for new day
self._cached_price_data = None # Force fresh fetch for new day self._last_price_update = None
self._last_price_update = None await self.store_cache()
await self.store_cache() ```
```
2. **Cache validation on load**: 2. **Cache validation on load**:
```python
```python # coordinator/cache.py
# coordinator/cache.py def is_cache_valid(cache_data: CacheData) -> bool:
def is_cache_valid(cache_data: CacheData) -> bool: # Checks if price data is from a previous day
# Checks if price data is from a previous day if today_date < local_now.date(): # Yesterday's data
if today_date < local_now.date(): # Yesterday's data return False
return False ```
```
3. **Tomorrow data check** (after 13:00): 3. **Tomorrow data check** (after 13:00):
```python ```python
# coordinator/data_fetching.py # coordinator/data_fetching.py
if tomorrow_missing or tomorrow_invalid: if tomorrow_missing or tomorrow_invalid:
return "tomorrow_check" # Update needed return "tomorrow_check" # Update needed
``` ```
**Why this cache matters:** Reduces API load on Tibber (~192 intervals per fetch), speeds up HA restarts, enables offline operation until cache expires. **Why this cache matters:** Reduces API load on Tibber (~192 intervals per fetch), speeds up HA restarts, enables offline operation until cache expires.
@ -75,22 +71,18 @@ 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. **Purpose:** Avoid repeated file I/O when accessing entity descriptions, UI strings, etc.
**What is cached:** **What is cached:**
- **Standard translations** (`/translations/*.json`): Config flow, selector options, entity names - **Standard translations** (`/translations/*.json`): Config flow, selector options, entity names
- **Custom translations** (`/custom_translations/*.json`): Entity descriptions, usage tips, long descriptions - **Custom translations** (`/custom_translations/*.json`): Entity descriptions, usage tips, long descriptions
**Lifetime:** **Lifetime:**
- **Forever** (until HA restart) - **Forever** (until HA restart)
- No invalidation during runtime - No invalidation during runtime
**When populated:** **When populated:**
- At integration setup: `async_load_translations(hass, "en")` in `__init__.py` - At integration setup: `async_load_translations(hass, "en")` in `__init__.py`
- Lazy loading: If translation missing, attempts file load once - Lazy loading: If translation missing, attempts file load once
**Access pattern:** **Access pattern:**
```python ```python
# Non-blocking synchronous access from cached data # Non-blocking synchronous access from cached data
description = get_translation("binary_sensor.best_price_period.description", "en") description = get_translation("binary_sensor.best_price_period.description", "en")
@ -109,7 +101,6 @@ description = get_translation("binary_sensor.best_price_period.description", "en
**What is cached:** **What is cached:**
### DataTransformer Config Cache ### DataTransformer Config Cache
```python ```python
{ {
"thresholds": {"low": 15, "high": 35}, "thresholds": {"low": 15, "high": 35},
@ -119,7 +110,6 @@ description = get_translation("binary_sensor.best_price_period.description", "en
``` ```
### PeriodCalculator Config Cache ### PeriodCalculator Config Cache
```python ```python
{ {
"best": {"flex": 0.15, "min_distance_from_avg": 5.0, "min_period_length": 60}, "best": {"flex": 0.15, "min_distance_from_avg": 5.0, "min_period_length": 60},
@ -128,23 +118,20 @@ description = get_translation("binary_sensor.best_price_period.description", "en
``` ```
**Lifetime:** **Lifetime:**
- Until `invalidate_config_cache()` is called - Until `invalidate_config_cache()` is called
- Built once on first use per coordinator update cycle - Built once on first use per coordinator update cycle
**Invalidation trigger:** **Invalidation trigger:**
- **Options change** (user reconfigures integration): - **Options change** (user reconfigures integration):
```python ```python
# coordinator/core.py # coordinator/core.py
async def _handle_options_update(...) -> None: async def _handle_options_update(...) -> None:
self._data_transformer.invalidate_config_cache() self._data_transformer.invalidate_config_cache()
self._period_calculator.invalidate_config_cache() self._period_calculator.invalidate_config_cache()
await self.async_request_refresh() await self.async_request_refresh()
``` ```
**Performance impact:** **Performance impact:**
- **Before:** ~30 dict lookups + type conversions per update = ~50μs - **Before:** ~30 dict lookups + type conversions per update = ~50μs
- **After:** 1 cache check = ~1μs - **After:** 1 cache check = ~1μs
- **Savings:** ~98% (50μs → 1μs per update) - **Savings:** ~98% (50μs → 1μs per update)
@ -160,7 +147,6 @@ 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. **Purpose:** Avoid expensive period calculations (~100-500ms) when price data and config haven't changed.
**What is cached:** **What is cached:**
```python ```python
{ {
"best_price": { "best_price": {
@ -175,7 +161,6 @@ description = get_translation("binary_sensor.best_price_period.description", "en
``` ```
**Cache key:** Hash of relevant inputs **Cache key:** Hash of relevant inputs
```python ```python
hash_data = ( hash_data = (
today_signature, # (startsAt, rating_level) for each interval today_signature, # (startsAt, rating_level) for each interval
@ -187,7 +172,6 @@ hash_data = (
``` ```
**Lifetime:** **Lifetime:**
- Until price data changes (today's intervals modified) - Until price data changes (today's intervals modified)
- Until config changes (flex, thresholds, filters) - Until config changes (flex, thresholds, filters)
- Recalculated at midnight (new today data) - Recalculated at midnight (new today data)
@ -195,27 +179,24 @@ hash_data = (
**Invalidation triggers:** **Invalidation triggers:**
1. **Config change** (explicit): 1. **Config change** (explicit):
```python
```python def invalidate_config_cache() -> None:
def invalidate_config_cache() -> None: self._cached_periods = None
self._cached_periods = None self._last_periods_hash = None
self._last_periods_hash = None ```
```
2. **Price data change** (automatic via hash mismatch): 2. **Price data change** (automatic via hash mismatch):
```python ```python
current_hash = self._compute_periods_hash(price_info) current_hash = self._compute_periods_hash(price_info)
if self._last_periods_hash != current_hash: if self._last_periods_hash != current_hash:
# Cache miss - recalculate # Cache miss - recalculate
``` ```
**Cache hit rate:** **Cache hit rate:**
- **High:** During normal operation (coordinator updates every 15min, price data unchanged) - **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) - **Low:** After midnight (new today data) or when tomorrow data arrives (~13:00-14:00)
**Performance impact:** **Performance impact:**
- **Period calculation:** ~100-500ms (depends on interval count, relaxation attempts) - **Period calculation:** ~100-500ms (depends on interval count, relaxation attempts)
- **Cache hit:** `<`1ms (hash comparison + dict lookup) - **Cache hit:** `<`1ms (hash comparison + dict lookup)
- **Savings:** ~70% of calculation time (most updates hit cache) - **Savings:** ~70% of calculation time (most updates hit cache)
@ -231,7 +212,6 @@ hash_data = (
**Status:** ✅ **Clean separation** - enrichment only, no redundancy **Status:** ✅ **Clean separation** - enrichment only, no redundancy
**What is cached:** **What is cached:**
```python ```python
{ {
"timestamp": ..., "timestamp": ...,
@ -244,16 +224,14 @@ hash_data = (
**Purpose:** Avoid re-enriching price data when config unchanged between midnight checks. **Purpose:** Avoid re-enriching price data when config unchanged between midnight checks.
**Current behavior:** **Current behavior:**
- Caches **only enriched price data** (price + statistics) - Caches **only enriched price data** (price + statistics)
- **Does NOT cache periods** (handled by Period Calculation Cache) - **Does NOT cache periods** (handled by Period Calculation Cache)
- Invalidated when: - Invalidated when:
- Config changes (thresholds affect enrichment) - Config changes (thresholds affect enrichment)
- Midnight turnover detected - Midnight turnover detected
- New update cycle begins - New update cycle begins
**Architecture:** **Architecture:**
- DataTransformer: Handles price enrichment only - DataTransformer: Handles price enrichment only
- PeriodCalculator: Handles period calculation only (with hash-based cache) - PeriodCalculator: Handles period calculation only (with hash-based cache)
- Coordinator: Assembles final data on-demand from both caches - Coordinator: Assembles final data on-demand from both caches
@ -265,7 +243,6 @@ hash_data = (
## Cache Invalidation Flow ## Cache Invalidation Flow
### User Changes Options (Config Flow) ### User Changes Options (Config Flow)
``` ```
User saves options User saves options
@ -290,7 +267,6 @@ Fresh data fetch with new config
``` ```
### Midnight Turnover (Day Transition) ### Midnight Turnover (Day Transition)
``` ```
Timer #2 fires at 00:00 Timer #2 fires at 00:00
@ -310,7 +286,6 @@ Fresh API fetch for new day
``` ```
### Tomorrow Data Arrives (~13:00) ### Tomorrow Data Arrives (~13:00)
``` ```
Coordinator update cycle Coordinator update cycle
@ -352,14 +327,12 @@ API Data Cache (price_data, user_data)
``` ```
**No cache invalidation cascades:** **No cache invalidation cascades:**
- Config cache invalidation is **explicit** (on options update) - Config cache invalidation is **explicit** (on options update)
- Period cache invalidation is **automatic** (via hash mismatch) - Period cache invalidation is **automatic** (via hash mismatch)
- Transformation cache invalidation is **automatic** (on midnight/config change) - Transformation cache invalidation is **automatic** (on midnight/config change)
- Translation cache is **never invalidated** (read-only after load) - Translation cache is **never invalidated** (read-only after load)
**Thread safety:** **Thread safety:**
- All caches are accessed from `MainThread` only (Home Assistant event loop) - All caches are accessed from `MainThread` only (Home Assistant event loop)
- No locking needed (single-threaded execution model) - No locking needed (single-threaded execution model)
@ -368,7 +341,6 @@ API Data Cache (price_data, user_data)
## Performance Characteristics ## Performance Characteristics
### Typical Operation (No Changes) ### Typical Operation (No Changes)
``` ```
Coordinator Update (every 15 min) Coordinator Update (every 15 min)
├─> API fetch: SKIP (cache valid) ├─> API fetch: SKIP (cache valid)
@ -381,7 +353,6 @@ Total: ~16ms (down from ~600ms without caching)
``` ```
### After Midnight Turnover ### After Midnight Turnover
``` ```
Coordinator Update (00:00) Coordinator Update (00:00)
├─> API fetch: ~500ms (cache cleared, fetch new day) ├─> API fetch: ~500ms (cache cleared, fetch new day)
@ -394,7 +365,6 @@ Total: ~755ms (expected once per day)
``` ```
### After Config Change ### After Config Change
``` ```
Options Update Options Update
├─> Cache invalidation: `<`1ms ├─> Cache invalidation: `<`1ms
@ -411,25 +381,23 @@ Options Update
## Summary Table ## Summary Table
| Cache Type | Lifetime | Size | Invalidation | Purpose | | Cache Type | Lifetime | Size | Invalidation | Purpose |
| ---------------------- | ---------------------------- | ------ | ------------------------- | ------------------------------- | |------------|----------|------|--------------|---------|
| **API Data** | Hours to 1 day | ~50KB | Midnight, validation | Reduce API calls | | **API Data** | Hours to 1 day | ~50KB | Midnight, validation | Reduce API calls |
| **Translations** | Forever (until HA restart) | ~5KB | Never | Avoid file I/O | | **Translations** | Forever (until HA restart) | ~5KB | Never | Avoid file I/O |
| **Config Dicts** | Until options change | `<`1KB | Explicit (options update) | Avoid dict lookups | | **Config Dicts** | Until options change | `<`1KB | Explicit (options update) | Avoid dict lookups |
| **Period Calculation** | Until data/config change | ~10KB | Auto (hash mismatch) | Avoid CPU-intensive calculation | | **Period Calculation** | Until data/config change | ~10KB | Auto (hash mismatch) | Avoid CPU-intensive calculation |
| **Transformation** | Until midnight/config change | ~50KB | Auto (midnight/config) | Avoid re-enrichment | | **Transformation** | Until midnight/config change | ~50KB | Auto (midnight/config) | Avoid re-enrichment |
**Total memory overhead:** ~116KB per coordinator instance (main + subentries) **Total memory overhead:** ~116KB per coordinator instance (main + subentries)
**Benefits:** **Benefits:**
- 97% reduction in API calls (from every 15min to once per day) - 97% reduction in API calls (from every 15min to once per day)
- 70% reduction in period calculation time (cache hits during normal operation) - 70% reduction in period calculation time (cache hits during normal operation)
- 98% reduction in config access time (30+ lookups → 1 cache check) - 98% reduction in config access time (30+ lookups → 1 cache check)
- Zero file I/O during runtime (translations cached at startup) - Zero file I/O during runtime (translations cached at startup)
**Trade-offs:** **Trade-offs:**
- Memory usage: ~116KB per home (negligible for modern systems) - Memory usage: ~116KB per home (negligible for modern systems)
- Code complexity: 5 cache invalidation points (well-tested, documented) - Code complexity: 5 cache invalidation points (well-tested, documented)
- Debugging: Must understand cache lifetime when investigating stale data issues - Debugging: Must understand cache lifetime when investigating stale data issues
@ -439,9 +407,7 @@ Options Update
## Debugging Cache Issues ## Debugging Cache Issues
### Symptom: Stale data after config change ### Symptom: Stale data after config change
**Check:** **Check:**
1. Is `_handle_options_update()` called? (should see "Options updated" log) 1. Is `_handle_options_update()` called? (should see "Options updated" log)
2. Are `invalidate_config_cache()` methods executed? 2. Are `invalidate_config_cache()` methods executed?
3. Does `async_request_refresh()` trigger? 3. Does `async_request_refresh()` trigger?
@ -449,9 +415,7 @@ Options Update
**Fix:** Ensure `config_entry.add_update_listener()` is registered in coordinator init. **Fix:** Ensure `config_entry.add_update_listener()` is registered in coordinator init.
### Symptom: Period calculation not updating ### Symptom: Period calculation not updating
**Check:** **Check:**
1. Verify hash changes when data changes: `_compute_periods_hash()` 1. Verify hash changes when data changes: `_compute_periods_hash()`
2. Check `_last_periods_hash` vs `current_hash` 2. Check `_last_periods_hash` vs `current_hash`
3. Look for "Using cached period calculation" vs "Calculating periods" logs 3. Look for "Using cached period calculation" vs "Calculating periods" logs
@ -459,9 +423,7 @@ Options Update
**Fix:** Hash function may not include all relevant data. Review `_compute_periods_hash()` inputs. **Fix:** Hash function may not include all relevant data. Review `_compute_periods_hash()` inputs.
### Symptom: Yesterday's prices shown as today ### Symptom: Yesterday's prices shown as today
**Check:** **Check:**
1. `is_cache_valid()` logic in `coordinator/cache.py` 1. `is_cache_valid()` logic in `coordinator/cache.py`
2. Midnight turnover execution (Timer #2) 2. Midnight turnover execution (Timer #2)
3. Cache clear confirmation in logs 3. Cache clear confirmation in logs
@ -469,9 +431,7 @@ Options Update
**Fix:** Timer may not be firing. Check `_schedule_midnight_turnover()` registration. **Fix:** Timer may not be firing. Check `_schedule_midnight_turnover()` registration.
### Symptom: Missing translations ### Symptom: Missing translations
**Check:** **Check:**
1. `async_load_translations()` called at startup? 1. `async_load_translations()` called at startup?
2. Translation files exist in `/translations/` and `/custom_translations/`? 2. Translation files exist in `/translations/` and `/custom_translations/`?
3. Cache population: `_TRANSLATIONS_CACHE` keys 3. Cache population: `_TRANSLATIONS_CACHE` keys

View file

@ -8,10 +8,10 @@ comments: false
## Code Style ## Code Style
- **Formatter/Linter**: Ruff (replaces Black, Flake8, isort) - **Formatter/Linter**: Ruff (replaces Black, Flake8, isort)
- **Max line length**: 120 characters - **Max line length**: 120 characters
- **Max complexity**: 25 (McCabe) - **Max complexity**: 25 (McCabe)
- **Target**: Python 3.13 - **Target**: Python 3.13
Run before committing: Run before committing:
@ -41,14 +41,12 @@ class TimeService:
``` ```
**When prefix is required:** **When prefix is required:**
- Public classes used across multiple modules - Public classes used across multiple modules
- All exception classes - All exception classes
- All coordinator and entity classes - All coordinator and entity classes
- Data classes (dataclasses, NamedTuples) used as public APIs - Data classes (dataclasses, NamedTuples) used as public APIs
**When prefix can be omitted:** **When prefix can be omitted:**
- Private helper classes within a single module (prefix with `_` underscore) - Private helper classes within a single module (prefix with `_` underscore)
- Type aliases and callbacks (e.g., `TimeServiceCallback`) - Type aliases and callbacks (e.g., `TimeServiceCallback`)
- Small internal NamedTuples for function returns - Small internal NamedTuples for function returns
@ -73,7 +71,6 @@ class DataFetcher: # Should be TibberPricesDataFetcher
**Current Technical Debt:** **Current Technical Debt:**
Many existing classes lack the `TibberPrices` prefix. Before refactoring: Many existing classes lack the `TibberPrices` prefix. Before refactoring:
1. Document the plan in `/planning/class-naming-refactoring.md` 1. Document the plan in `/planning/class-naming-refactoring.md`
2. Use `multi_replace_string_in_file` for bulk renames 2. Use `multi_replace_string_in_file` for bulk renames
3. Test thoroughly after each module 3. Test thoroughly after each module

View file

@ -14,10 +14,10 @@ Welcome! This guide helps you contribute to the Tibber Prices integration.
1. Fork the repository on GitHub 1. Fork the repository on GitHub
2. Clone your fork: 2. Clone your fork:
```bash ```bash
git clone https://github.com/YOUR_USERNAME/hass.tibber_prices.git git clone https://github.com/YOUR_USERNAME/hass.tibber_prices.git
cd hass.tibber_prices cd hass.tibber_prices
``` ```
3. Open in VS Code 3. Open in VS Code
4. Click "Reopen in Container" when prompted 4. Click "Reopen in Container" when prompted
@ -34,7 +34,6 @@ git checkout -b fix/issue-123-description
``` ```
**Branch naming:** **Branch naming:**
- `feature/` - New features - `feature/` - New features
- `fix/` - Bug fixes - `fix/` - Bug fixes
- `docs/` - Documentation only - `docs/` - Documentation only
@ -46,7 +45,6 @@ git checkout -b fix/issue-123-description
Edit code, following [Coding Guidelines](coding-guidelines.md). Edit code, following [Coding Guidelines](coding-guidelines.md).
**Run checks frequently:** **Run checks frequently:**
```bash ```bash
./scripts/type-check # Pyright type checking ./scripts/type-check # Pyright type checking
./scripts/lint # Ruff linting (auto-fix) ./scripts/lint # Ruff linting (auto-fix)
@ -80,7 +78,6 @@ async def test_your_feature(hass, coordinator):
``` ```
Run your test: Run your test:
```bash ```bash
./scripts/test tests/test_your_feature.py -v ./scripts/test tests/test_your_feature.py -v
``` ```
@ -100,7 +97,6 @@ Impact: Users can predict when prices will stabilize or continue fluctuating."
``` ```
**Commit types:** **Commit types:**
- `feat:` - New feature - `feat:` - New feature
- `fix:` - Bug fix - `fix:` - Bug fix
- `docs:` - Documentation - `docs:` - Documentation
@ -109,7 +105,6 @@ Impact: Users can predict when prices will stabilize or continue fluctuating."
- `chore:` - Maintenance - `chore:` - Maintenance
**Add scope when relevant:** **Add scope when relevant:**
- `feat(sensors):` - Sensor platform - `feat(sensors):` - Sensor platform
- `fix(coordinator):` - Data coordinator - `fix(coordinator):` - Data coordinator
- `docs(user):` - User documentation - `docs(user):` - User documentation
@ -129,40 +124,32 @@ Then open Pull Request on GitHub.
Title: Short, descriptive (50 chars max) Title: Short, descriptive (50 chars max)
Description should include: Description should include:
```markdown ```markdown
## What ## What
Brief description of changes Brief description of changes
## Why ## Why
Problem being solved or feature rationale Problem being solved or feature rationale
## How ## How
Implementation approach Implementation approach
## Testing ## Testing
- [ ] Manual testing in Home Assistant - [ ] Manual testing in Home Assistant
- [ ] Unit tests added/updated - [ ] Unit tests added/updated
- [ ] Type checking passes - [ ] Type checking passes
- [ ] Linting passes - [ ] Linting passes
## Breaking Changes ## Breaking Changes
(If any - describe migration path) (If any - describe migration path)
## Related Issues ## Related Issues
Closes #123 Closes #123
``` ```
### PR Checklist ### PR Checklist
Before submitting: Before submitting:
- [ ] Code follows [Coding Guidelines](coding-guidelines.md) - [ ] Code follows [Coding Guidelines](coding-guidelines.md)
- [ ] All tests pass (`./scripts/test`) - [ ] All tests pass (`./scripts/test`)
- [ ] Type checking passes (`./scripts/type-check`) - [ ] Type checking passes (`./scripts/type-check`)
@ -183,7 +170,6 @@ Before submitting:
### What Reviewers Look For ### What Reviewers Look For
✅ **Good:** ✅ **Good:**
- Clear, self-explanatory code - Clear, self-explanatory code
- Appropriate comments for complex logic - Appropriate comments for complex logic
- Tests covering edge cases - Tests covering edge cases
@ -191,7 +177,6 @@ Before submitting:
- Follows existing patterns - Follows existing patterns
❌ **Avoid:** ❌ **Avoid:**
- Large PRs (>500 lines) - split into smaller ones - Large PRs (>500 lines) - split into smaller ones
- Mixing unrelated changes - Mixing unrelated changes
- Missing tests for new features - Missing tests for new features
@ -208,7 +193,6 @@ Before submitting:
## Finding Issues to Work On ## Finding Issues to Work On
Good first issues are labeled: Good first issues are labeled:
- `good first issue` - Beginner-friendly - `good first issue` - Beginner-friendly
- `help wanted` - Maintainers welcome contributions - `help wanted` - Maintainers welcome contributions
- `documentation` - Docs improvements - `documentation` - Docs improvements
@ -226,7 +210,6 @@ Be respectful, constructive, and patient. We're all volunteers! 🙏
--- ---
💡 **Related:** 💡 **Related:**
- [Setup Guide](setup.md) - DevContainer setup - [Setup Guide](setup.md) - DevContainer setup
- [Coding Guidelines](coding-guidelines.md) - Style guide - [Coding Guidelines](coding-guidelines.md) - Style guide
- [Testing](testing.md) - Writing tests - [Testing](testing.md) - Writing tests

Some files were not shown because too many files have changed in this diff Show more