mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-07-28 09:36:49 +00:00
Compare commits
21 commits
779e22a84e
...
adf85792d5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
adf85792d5 | ||
|
|
1706bd7c0e | ||
|
|
b1e0245a60 | ||
|
|
51a62d712f | ||
|
|
dd59c687e3 | ||
|
|
3ba8e91958 | ||
|
|
a2fe572dc2 | ||
|
|
aa9a1200b8 | ||
|
|
e163a47d57 | ||
|
|
a93ad1ac96 | ||
|
|
a957334990 | ||
|
|
0ca52f8d3c | ||
|
|
7b477cd4c7 | ||
|
|
6f5261785b | ||
|
|
c89248d493 | ||
|
|
32b080d178 | ||
|
|
6e990564b9 | ||
|
|
b2d63c2b6d | ||
|
|
3fda932442 | ||
|
|
a240393911 | ||
|
|
1d3c55097d |
404 changed files with 67394 additions and 60049 deletions
|
|
@ -1,6 +1,4 @@
|
|||
{
|
||||
"recommendations": [],
|
||||
"unwantedRecommendations": [
|
||||
"ms-python.pylint"
|
||||
]
|
||||
"recommendations": [],
|
||||
"unwantedRecommendations": ["ms-python.pylint"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,174 +1,169 @@
|
|||
{
|
||||
"name": "jpawlowski/hass.tibber_prices",
|
||||
"image": "mcr.microsoft.com/devcontainers/python:3.14",
|
||||
"postCreateCommand": "bash .devcontainer/setup-git.sh && scripts/setup/setup",
|
||||
"postStartCommand": "scripts/motd",
|
||||
"containerEnv": {
|
||||
"PYTHONASYNCIODEBUG": "1",
|
||||
"TIBBER_PRICES_DEV": "1"
|
||||
"name": "jpawlowski/hass.tibber_prices",
|
||||
"image": "mcr.microsoft.com/devcontainers/python:3.14",
|
||||
"postCreateCommand": "bash .devcontainer/setup-git.sh && scripts/setup/setup",
|
||||
"postStartCommand": "scripts/motd",
|
||||
"containerEnv": {
|
||||
"PYTHONASYNCIODEBUG": "1",
|
||||
"TIBBER_PRICES_DEV": "1"
|
||||
},
|
||||
"forwardPorts": [8123, 3000, 3001],
|
||||
"portsAttributes": {
|
||||
"8123": {
|
||||
"label": "Home Assistant",
|
||||
"onAutoForward": "notify"
|
||||
},
|
||||
"forwardPorts": [
|
||||
8123,
|
||||
3000,
|
||||
3001
|
||||
],
|
||||
"portsAttributes": {
|
||||
"8123": {
|
||||
"label": "Home Assistant",
|
||||
"onAutoForward": "notify"
|
||||
},
|
||||
"3000": {
|
||||
"label": "Docusaurus User Docs",
|
||||
"onAutoForward": "notify"
|
||||
},
|
||||
"3001": {
|
||||
"label": "Docusaurus Developer Docs",
|
||||
"onAutoForward": "notify"
|
||||
}
|
||||
"3000": {
|
||||
"label": "Docusaurus User Docs",
|
||||
"onAutoForward": "notify"
|
||||
},
|
||||
"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.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"
|
||||
]
|
||||
}
|
||||
"3001": {
|
||||
"label": "Docusaurus Developer Docs",
|
||||
"onAutoForward": "notify"
|
||||
}
|
||||
},
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,15 +51,15 @@ if grep -q '^\[alias\]' ~/.gitconfig.host; then
|
|||
|
||||
# First, collect all aliases from host config
|
||||
TEMP_ALIASES=$(mktemp)
|
||||
sed -n '/^\[alias\]/,/^\[/p' ~/.gitconfig.host | \
|
||||
grep -v '^\[' | \
|
||||
grep -v '^$' | \
|
||||
sed -n '/^\[alias\]/,/^\[/p' ~/.gitconfig.host |
|
||||
grep -v '^\[' |
|
||||
grep -v '^$' |
|
||||
while IFS= read -r line; do
|
||||
# Skip aliases with macOS-specific paths
|
||||
if echo "$line" | grep -q -E '/(Applications|usr/local)'; then
|
||||
continue
|
||||
fi
|
||||
echo "$line" >> "$TEMP_ALIASES"
|
||||
echo "$line" >>"$TEMP_ALIASES"
|
||||
done
|
||||
|
||||
# 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_VALUE=$(echo "$line" | sed "s/^$ALIAS_NAME = //")
|
||||
git config --global "alias.$ALIAS_NAME" "$ALIAS_VALUE" 2>/dev/null || true
|
||||
done < "$TEMP_ALIASES"
|
||||
echo " Synced $(wc -l < "$TEMP_ALIASES") aliases"
|
||||
done <"$TEMP_ALIASES"
|
||||
echo " Synced $(wc -l <"$TEMP_ALIASES") aliases"
|
||||
fi
|
||||
|
||||
rm -f "$TEMP_ALIASES"
|
||||
|
|
|
|||
95
.github/instructions/commit-messages.instructions.md
vendored
Normal file
95
.github/instructions/commit-messages.instructions.md
vendored
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
---
|
||||
description: "Use when writing or suggesting git commit messages, deciding commit type/scope, or preparing release-note-relevant commit trailers."
|
||||
---
|
||||
|
||||
# Commit Message Rules (Release-Notes Aware)
|
||||
|
||||
Use these rules whenever you generate or suggest commit messages.
|
||||
|
||||
## Primary Goal
|
||||
|
||||
Write technically correct Conventional Commit messages while ensuring release notes only include user-relevant changes.
|
||||
|
||||
## Required Format
|
||||
|
||||
Use this structure:
|
||||
|
||||
<type>(<scope>): <short summary>
|
||||
|
||||
<body>
|
||||
|
||||
Impact: <user-facing outcome>
|
||||
|
||||
### Notes
|
||||
|
||||
- Keep summary imperative and concise.
|
||||
- Keep body technical (what changed and why).
|
||||
- Keep Impact user-facing (what users notice).
|
||||
|
||||
## Type Selection
|
||||
|
||||
- Use feat for new user-visible capability.
|
||||
- Use fix only for user-visible bug fixes.
|
||||
- Use perf for user-visible reliability/performance improvements.
|
||||
- Use docs, test, refactor, chore, ci, build for non-user-facing work.
|
||||
|
||||
## Critical Rule: Internal/Unreleased Fixes
|
||||
|
||||
If a fix addresses code that was not released to users yet, DO NOT treat it as a user-facing fix.
|
||||
|
||||
In that case:
|
||||
|
||||
- Prefer chore(...) or refactor(...) instead of fix(...), and/or
|
||||
- Add an explicit trailer in the commit body:
|
||||
- Release-Notes: skip
|
||||
- User-Impact: none
|
||||
- Released-Bug: no
|
||||
|
||||
Any one of these trailers is enough.
|
||||
|
||||
## How To Decide Released vs Unreleased
|
||||
|
||||
When uncertain whether users were affected, check if the introducing commit was part of a release tag:
|
||||
|
||||
./scripts/release/check-if-released <commit-hash>
|
||||
|
||||
Interpretation:
|
||||
|
||||
- NOT RELEASED -> treat as internal/non-user-facing.
|
||||
- ALREADY RELEASED -> user-facing fix is possible.
|
||||
|
||||
## Release Notes Alignment
|
||||
|
||||
This repository's release notes generator excludes commits with any of these trailers:
|
||||
|
||||
- Release-Notes: skip
|
||||
- User-Impact: none
|
||||
- Released-Bug: no
|
||||
|
||||
Therefore, add one of them whenever you intentionally want to exclude a commit from release notes.
|
||||
|
||||
## Examples
|
||||
|
||||
### User-facing fix
|
||||
|
||||
fix(config_flow): prevent setup failure on invalid home selection
|
||||
|
||||
Validate home selection before entry creation to avoid runtime errors when stale API data is returned.
|
||||
|
||||
Impact: Setup wizard no longer fails for users when home data changes during configuration.
|
||||
|
||||
### Internal-only fix for unreleased code
|
||||
|
||||
chore(periods): adjust extension guard for new geometric matcher
|
||||
|
||||
Tune guard conditions in the new matcher implementation to avoid edge-case misclassification during development.
|
||||
|
||||
User-Impact: none
|
||||
|
||||
### Alternative with explicit skip marker
|
||||
|
||||
fix(periods): correct follow-up edge case in unreleased geometric matcher
|
||||
|
||||
Adjust comparison threshold in iterative matcher pass.
|
||||
|
||||
Release-Notes: skip
|
||||
25
.github/workflows/auto-assign.yml
vendored
Normal file
25
.github/workflows/auto-assign.yml
vendored
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
---
|
||||
name: Auto-assign
|
||||
|
||||
on:
|
||||
issues:
|
||||
types:
|
||||
- opened
|
||||
|
||||
jobs:
|
||||
auto-assign:
|
||||
name: Assign to owner
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Assign issue to owner
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
await github.rest.issues.addAssignees({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
assignees: [context.repo.owner],
|
||||
});
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
{
|
||||
"default": true,
|
||||
"MD013": false,
|
||||
"MD033": false,
|
||||
"MD041": false,
|
||||
"no-inline-html": false,
|
||||
"line-length": false,
|
||||
"first-line-heading": false
|
||||
"default": true,
|
||||
"MD013": false,
|
||||
"MD033": false,
|
||||
"MD041": false,
|
||||
"no-inline-html": false,
|
||||
"line-length": false,
|
||||
"first-line-heading": false
|
||||
}
|
||||
|
|
|
|||
11
CODEOWNERS
Normal file
11
CODEOWNERS
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# CODEOWNERS
|
||||
#
|
||||
# This file defines code owners for this repository.
|
||||
# Code owners are automatically requested for review when a pull request
|
||||
# modifies files they own.
|
||||
#
|
||||
# See: https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
|
||||
#
|
||||
# NOTE: This file is updated automatically by initialize.sh when using the blueprint.
|
||||
|
||||
* @jpawlowski
|
||||
|
|
@ -18,14 +18,14 @@ For detailed developer documentation, see [docs/development/](docs/development/)
|
|||
|
||||
1. **Fork the repository** on GitHub
|
||||
2. **Clone your fork**:
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/hass.tibber_prices.git
|
||||
cd hass.tibber_prices
|
||||
```
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/hass.tibber_prices.git
|
||||
cd hass.tibber_prices
|
||||
```
|
||||
3. **Open in DevContainer** (recommended):
|
||||
- Open in VS Code
|
||||
- Click "Reopen in Container" when prompted
|
||||
- Or manually: `Ctrl+Shift+P` → "Dev Containers: Reopen in Container"
|
||||
- Open in VS Code
|
||||
- Click "Reopen in Container" when prompted
|
||||
- Or manually: `Ctrl+Shift+P` → "Dev Containers: Reopen in Container"
|
||||
|
||||
See [Development Setup](docs/development/setup.md) for detailed instructions.
|
||||
|
||||
|
|
@ -72,7 +72,18 @@ Impact: <user-visible effects>
|
|||
|
||||
**Types:** `feat`, `fix`, `docs`, `refactor`, `chore`, `test`
|
||||
|
||||
For full commit-message rules (including release-note skip trailers for internal/unreleased fixes), see:
|
||||
|
||||
- `.github/instructions/commit-messages.instructions.md`
|
||||
|
||||
Important trailers for commits that should NOT appear in release notes:
|
||||
|
||||
- `Release-Notes: skip`
|
||||
- `User-Impact: none`
|
||||
- `Released-Bug: no`
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
git commit -m "feat(sensors): add daily average price sensor
|
||||
|
||||
|
|
@ -81,7 +92,7 @@ Added new sensor that calculates average price for the entire day.
|
|||
Impact: Users can now track daily average prices for cost analysis."
|
||||
```
|
||||
|
||||
See [`AGENTS.md`](AGENTS.md) section "Git Workflow Guidance" for detailed guidelines.
|
||||
See `.github/instructions/commit-messages.instructions.md` for detailed commit-message guidelines.
|
||||
|
||||
## Submitting Changes
|
||||
|
||||
|
|
@ -89,9 +100,9 @@ See [`AGENTS.md`](AGENTS.md) section "Git Workflow Guidance" for detailed guidel
|
|||
|
||||
1. **Push your branch** to your fork
|
||||
2. **Create a Pull Request** on GitHub with:
|
||||
- Clear title describing the change
|
||||
- Detailed description with context
|
||||
- Reference related issues (`Fixes #123`)
|
||||
- Clear title describing the change
|
||||
- Detailed description with context
|
||||
- Reference related issues (`Fixes #123`)
|
||||
3. **Wait for review** and address feedback
|
||||
|
||||
### PR Requirements
|
||||
|
|
@ -111,6 +122,7 @@ See [`AGENTS.md`](AGENTS.md) section "Git Workflow Guidance" for detailed guidel
|
|||
- **Python version**: 3.13+
|
||||
|
||||
Always run before committing:
|
||||
|
||||
```bash
|
||||
./scripts/lint
|
||||
```
|
||||
|
|
@ -129,13 +141,14 @@ See [Coding Guidelines](docs/developer/docs/coding-guidelines.md) for complete d
|
|||
Documentation is organized in two Docusaurus sites:
|
||||
|
||||
- **User docs** (`docs/user/`): Installation, configuration, usage guides
|
||||
- Markdown files in `docs/user/docs/*.md`
|
||||
- Navigation via `docs/user/sidebars.ts`
|
||||
- Markdown files in `docs/user/docs/*.md`
|
||||
- Navigation via `docs/user/sidebars.ts`
|
||||
- **Developer docs** (`docs/developer/`): Architecture, patterns, contribution guides
|
||||
- Markdown files in `docs/developer/docs/*.md`
|
||||
- Navigation via `docs/developer/sidebars.ts`
|
||||
- Markdown files in `docs/developer/docs/*.md`
|
||||
- Navigation via `docs/developer/sidebars.ts`
|
||||
|
||||
**When adding new documentation:**
|
||||
|
||||
1. Place file in appropriate `docs/*/docs/` directory
|
||||
2. Add to corresponding `sidebars.ts` for navigation
|
||||
3. Update translations when changing `translations/en.json` (update ALL language files)
|
||||
|
|
@ -145,6 +158,7 @@ Documentation is organized in two Docusaurus sites:
|
|||
Report bugs via [GitHub Issues](../../issues/new/choose).
|
||||
|
||||
**Great bug reports include:**
|
||||
|
||||
- Quick summary and background
|
||||
- Steps to reproduce (be specific!)
|
||||
- Expected vs. actual behavior
|
||||
|
|
|
|||
88
README.md
88
README.md
|
|
@ -22,8 +22,8 @@
|
|||
|
||||
**[📚 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
|
||||
- **[🔧 Developer Documentation](https://jpawlowski.github.io/hass.tibber_prices/developer/)** — Architecture, contributing, development
|
||||
- **[👤 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
|
||||
|
||||
**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)
|
||||
|
|
@ -34,39 +34,39 @@ Most Tibber integrations give you a single price sensor. This one gives you a **
|
|||
|
||||
### 🔮 Know What's Coming
|
||||
|
||||
- **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
|
||||
- **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 outlook** — Instantly see if the next hours will be cheaper or more expensive than now
|
||||
- **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
|
||||
- **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 outlook** — Instantly see if the next hours will be cheaper or more expensive than now
|
||||
|
||||
### ⚡ 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))
|
||||
- **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
|
||||
- **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
|
||||
- **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
|
||||
- **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
|
||||
- **3-level price ratings** — LOW, NORMAL, HIGH based on 24h trailing average comparison
|
||||
|
||||
### 📊 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))
|
||||
- **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
|
||||
- **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))
|
||||
- **Chart data export** — Flexible data API with filtering, resolution control, and multiple output formats for any visualization card
|
||||
|
||||
### 📈 Understand Your Market
|
||||
|
||||
- **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
|
||||
- **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)
|
||||
- **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
|
||||
- **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)
|
||||
|
||||
### 🛡️ Built for Reliability
|
||||
|
||||
- **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
|
||||
- **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.
|
||||
- **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
|
||||
- **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.
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
|
|
@ -91,9 +91,9 @@ Or manually: **Settings** → **Devices & Services** → **+ Add Integration**
|
|||
|
||||
### Step 3: Done!
|
||||
|
||||
- **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**
|
||||
- Start building automations, dashboards, and energy-saving workflows
|
||||
- **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**
|
||||
- Start building automations, dashboards, and energy-saving workflows
|
||||
|
||||
📖 **[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">
|
||||
|
||||
| Category | Highlights | Count |
|
||||
|----------|-----------|-------|
|
||||
| **💰 Prices** | Current, next & previous interval price + rolling hour averages | 6+ |
|
||||
| **📊 Statistics** | Daily min/max/avg for today & tomorrow, 24h trailing & leading windows | 12+ |
|
||||
| **🔮 Forecasts** | Next 1h–12h average prices, price outlook & trajectory sensors | 20+ |
|
||||
| **📈 Trends** | Current trend direction, next trend change time & countdown | 3 |
|
||||
| **📉 Volatility** | Today, tomorrow, next 24h & combined volatility levels | 4 |
|
||||
| **🏷️ 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+ |
|
||||
| **🔌 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 |
|
||||
| **🔧 Diagnostics** | Data lifecycle status, home metadata, grid info, subscription status | 15+ |
|
||||
| Category | Highlights | Count |
|
||||
| ----------------------- | ----------------------------------------------------------------------------- | ----- |
|
||||
| **💰 Prices** | Current, next & previous interval price + rolling hour averages | 6+ |
|
||||
| **📊 Statistics** | Daily min/max/avg for today & tomorrow, 24h trailing & leading windows | 12+ |
|
||||
| **🔮 Forecasts** | Next 1h–12h average prices, price outlook & trajectory sensors | 20+ |
|
||||
| **📈 Trends** | Current trend direction, next trend change time & countdown | 3 |
|
||||
| **📉 Volatility** | Today, tomorrow, next 24h & combined volatility levels | 4 |
|
||||
| **🏷️ 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+ |
|
||||
| **🔌 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 |
|
||||
| **🔧 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.
|
||||
|
||||
|
|
@ -168,17 +168,17 @@ Generate beautiful price charts with a single action call — dynamic Y-axis, co
|
|||
|
||||
## ❓ Help & Support
|
||||
|
||||
- 📖 **[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
|
||||
- 🐛 **[Report an Issue](https://github.com/jpawlowski/hass.tibber_prices/issues/new)** — Found a bug? Let us know
|
||||
- 📖 **[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
|
||||
- 🐛 **[Report an Issue](https://github.com/jpawlowski/hass.tibber_prices/issues/new)** — Found a bug? Let us know
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
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
|
||||
- **[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
|
||||
- **[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
|
||||
- **[Release Management](https://jpawlowski.github.io/hass.tibber_prices/developer/release-management)** — Release process and versioning
|
||||
|
||||
## 🤖 Development Note
|
||||
|
||||
|
|
|
|||
57
cliff.toml
57
cliff.toml
|
|
@ -5,13 +5,19 @@
|
|||
# Template for the changelog body
|
||||
header = ""
|
||||
body = """
|
||||
{% for group, commits in commits | group_by(attribute="group") %}
|
||||
### {{ group | striptags | trim | upper_first }}
|
||||
{% for commit in commits %}
|
||||
- {% if commit.scope %}**{{ commit.scope }}**: {% endif %}{{ commit.message | upper_first }}\
|
||||
{% if commit.breaking %} [**BREAKING**]{% endif %} \
|
||||
([{{ commit.id | truncate(length=7, end="") }}](https://github.com/jpawlowski/hass.tibber_prices/commit/{{ commit.id }}))
|
||||
{% endfor %}
|
||||
{% for group, commits in commits | group_by(attribute="group") -%}
|
||||
### {{ group | striptags | trim | upper_first }}
|
||||
{% for commit in commits -%}
|
||||
{% set impact_text = "" -%}
|
||||
{% set footers = commit.footers | default(value=[]) -%}
|
||||
{% for footer in footers -%}
|
||||
{% if footer.token == "Impact" -%}
|
||||
{% set impact_text = footer.value -%}
|
||||
{% endif -%}
|
||||
{% endfor -%}
|
||||
- {% if impact_text %}{{ impact_text | trim | upper_first }}{% else %}{% if commit.scope %}**{{ commit.scope }}**: {% endif %}{{ commit.message | upper_first }}{% endif %}{% if commit.breaking %} [**BREAKING**]{% endif %} ([{{ commit.id | truncate(length=7, end="") }}](https://github.com/jpawlowski/hass.tibber_prices/commit/{{ commit.id }}))
|
||||
{% endfor %}
|
||||
|
||||
{% endfor %}
|
||||
|
||||
---
|
||||
|
|
@ -25,7 +31,8 @@ trim = true
|
|||
[git]
|
||||
# Parse conventional commits
|
||||
conventional_commits = true
|
||||
# Include all commits (even non-conventional)
|
||||
# Keep unconventional commits in parsing pipeline; parser rules decide what to skip.
|
||||
# This avoids noisy parse-error warnings on older commit history.
|
||||
filter_unconventional = false
|
||||
split_commits = false
|
||||
|
||||
|
|
@ -33,22 +40,28 @@ split_commits = false
|
|||
commit_parsers = [
|
||||
# Skip manifest.json version bumps (release housekeeping)
|
||||
{ message = "^chore\\(release\\): bump version", skip = true },
|
||||
# Skip explicit revert commits; final net state should drive release notes
|
||||
{ message = "^revert", skip = true },
|
||||
# Skip development environment changes (not user-relevant)
|
||||
{ message = "^(feat|fix|chore|refactor)\\((devcontainer|vscode|scripts|dev-env|environment)\\):", skip = true },
|
||||
# Skip CI/CD infrastructure changes (not user-relevant)
|
||||
{ message = "^(feat|fix|chore|ci)\\((ci|workflow|actions|github-actions)\\):", skip = true },
|
||||
# Keep dependency updates - these ARE relevant for users
|
||||
{ message = "^chore\\(deps\\):", group = "📦 Dependencies" },
|
||||
# Regular commit types
|
||||
{ message = "^feat", group = "🎉 New Features" },
|
||||
{ message = "^fix", group = "🐛 Bug Fixes" },
|
||||
{ message = "^docs?", group = "📚 Documentation" },
|
||||
{ message = "^perf", group = "⚡ Performance" },
|
||||
{ message = "^refactor", group = "🔧 Maintenance & Refactoring" },
|
||||
{ message = "^style", group = "🎨 Styling" },
|
||||
{ message = "^test", group = "🧪 Testing" },
|
||||
{ message = "^chore", group = "🔧 Maintenance & Refactoring" },
|
||||
{ message = "^build", group = "📦 Build" },
|
||||
# Skip non-user-facing fix scopes
|
||||
{ message = "^fix\\((docs|lint|types|tests?|ci|workflow|scripts|devcontainer|vscode|build|release)\\):", skip = true },
|
||||
# User-facing categories aligned with AI output style
|
||||
{ message = "^feat", group = "🎉 What's New" },
|
||||
{ message = "^fix", group = "🐛 Fixed" },
|
||||
{ message = "^perf", group = "⚡ More Reliable" },
|
||||
{ message = "^chore\\(deps\\):", group = "📦 Updated Dependencies" },
|
||||
# Skip mostly developer-facing categories
|
||||
{ message = "^docs?", skip = true },
|
||||
{ message = "^refactor", skip = true },
|
||||
{ message = "^style", skip = true },
|
||||
{ message = "^test", skip = true },
|
||||
{ message = "^build", skip = true },
|
||||
{ message = "^chore", skip = true },
|
||||
# Final fallback to avoid ungrouped commits
|
||||
{ message = ".*", skip = true },
|
||||
]
|
||||
|
||||
# Protect breaking changes
|
||||
|
|
@ -56,5 +69,5 @@ commit_preprocessors = [
|
|||
{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](https://github.com/jpawlowski/hass.tibber_prices/issues/${2}))" },
|
||||
]
|
||||
|
||||
# Filter out commits
|
||||
filter_commits = false
|
||||
# Apply commit parser filtering rules
|
||||
filter_commits = true
|
||||
|
|
|
|||
|
|
@ -1,23 +1,58 @@
|
|||
# Development-friendly config that excludes go2rtc which has compatibility issues
|
||||
# yaml-language-server: $schema=../schemas/yaml/configuration_schema.yaml
|
||||
# Development-friendly Home Assistant configuration
|
||||
#
|
||||
# We don't use default_config to avoid HA OS-specific integrations like go2rtc
|
||||
# that expect specific container environments. Instead, we explicitly load
|
||||
# the integrations useful for custom component development.
|
||||
|
||||
# https://www.home-assistant.io/integrations/homeassistant/
|
||||
homeassistant:
|
||||
debug: true
|
||||
|
||||
# Disable analytics, diagnostics and error reporting for development instance
|
||||
# Debugging integration
|
||||
# https://www.home-assistant.io/integrations/debugpy/
|
||||
debugpy:
|
||||
|
||||
# Privacy & analytics settings
|
||||
# https://www.home-assistant.io/integrations/analytics/
|
||||
analytics:
|
||||
# Disable usage analytics to prevent skewing production statistics
|
||||
# https://analytics.home-assistant.io should only reflect real user installations
|
||||
# Analytics are disabled to prevent development instances from skewing
|
||||
# production statistics at https://analytics.home-assistant.io
|
||||
|
||||
# System monitoring
|
||||
# https://www.home-assistant.io/integrations/system_health/
|
||||
system_health:
|
||||
# Provides system health information in Settings > System > Repairs
|
||||
# Safe for development - only shows local system status
|
||||
|
||||
# https://www.home-assistant.io/integrations/diagnostics/
|
||||
# Note: Diagnostics integration cannot be disabled, but without analytics
|
||||
# and with internal_url set, no data is sent externally
|
||||
# Note: The diagnostics integration is always loaded and cannot be disabled.
|
||||
# With analytics disabled, diagnostic data stays local and isn't sent anywhere.
|
||||
|
||||
# Core integrations needed for development
|
||||
# Core integrations
|
||||
http:
|
||||
# Development server settings for Codespaces/DevContainer
|
||||
server_host: "0.0.0.0"
|
||||
# Disable IP banning for development to avoid lockouts
|
||||
ip_ban_enabled: false
|
||||
# Allow access from Codespaces reverse proxy
|
||||
use_x_forwarded_for: true
|
||||
trusted_proxies:
|
||||
- 127.0.0.0/8
|
||||
- ::1
|
||||
- 192.168.0.0/16
|
||||
- 172.16.0.0/12
|
||||
- 10.0.0.0/8
|
||||
# CORS for development
|
||||
cors_allowed_origins:
|
||||
- "*"
|
||||
|
||||
# Config UI integration - useful for development
|
||||
config:
|
||||
|
||||
# Frontend - required for UI
|
||||
frontend:
|
||||
# Optional: Enable custom themes
|
||||
# themes: !include_dir_merge_named themes
|
||||
|
||||
automation:
|
||||
|
||||
|
|
@ -25,13 +60,106 @@ script:
|
|||
|
||||
scene:
|
||||
|
||||
# Useful default_config integrations for development
|
||||
# https://www.home-assistant.io/integrations/history/
|
||||
history:
|
||||
|
||||
# https://www.home-assistant.io/integrations/logbook/
|
||||
logbook:
|
||||
|
||||
# https://www.home-assistant.io/integrations/conversation/
|
||||
# conversation:
|
||||
# Note: Uncomment to enable voice assistant/conversation features
|
||||
# Dependencies (hassil, home-assistant-intents) are pre-installed in bootstrap
|
||||
|
||||
# https://www.home-assistant.io/integrations/webhook/
|
||||
webhook:
|
||||
|
||||
# https://www.home-assistant.io/integrations/my/
|
||||
my:
|
||||
|
||||
# https://www.home-assistant.io/integrations/recorder/
|
||||
recorder:
|
||||
# Development-friendly database settings
|
||||
# Reduce database size and improve performance
|
||||
purge_keep_days: 2
|
||||
commit_interval: 30
|
||||
# Exclude entities you don't need history for
|
||||
exclude:
|
||||
domains:
|
||||
# Sun position changes constantly, rarely needed in dev
|
||||
- sun
|
||||
# Backups don't need history
|
||||
- backup
|
||||
# Updates don't need full history tracking
|
||||
- update
|
||||
entity_globs:
|
||||
# Time sensors change every second/minute
|
||||
- sensor.time*
|
||||
- sensor.date*
|
||||
# Uptime sensors not interesting for development
|
||||
- sensor.*uptime*
|
||||
- sensor.*last_boot*
|
||||
# Memory/CPU sensors create a lot of data
|
||||
- sensor.*memory*
|
||||
- sensor.*cpu*
|
||||
event_types:
|
||||
# Very frequent, rarely needed in development
|
||||
- call_service
|
||||
# System events create lots of noise
|
||||
- system_log_event
|
||||
# Component loading events
|
||||
- component_loaded
|
||||
|
||||
energy:
|
||||
|
||||
# https://www.home-assistant.io/integrations/logger/
|
||||
logger:
|
||||
default: info
|
||||
logs:
|
||||
# Main integration logger - applies to ALL sub-loggers by default
|
||||
# Reduce noise from chatty components
|
||||
homeassistant.components.recorder: warning
|
||||
homeassistant.components.recorder.util: warning
|
||||
homeassistant.components.websocket_api: warning
|
||||
homeassistant.components.http.ban: warning
|
||||
homeassistant.components.zeroconf: warning
|
||||
homeassistant.components.ssdp: warning
|
||||
homeassistant.components.bluetooth: warning
|
||||
# Conversation can be noisy with hassil
|
||||
homeassistant.components.conversation: warning
|
||||
# Analytics/metrics are not interesting during development
|
||||
homeassistant.components.analytics: error
|
||||
|
||||
# Hide platform setup messages (scene, binary_sensor, sensor, etc.)
|
||||
homeassistant.components.scene: warning
|
||||
homeassistant.components.binary_sensor: warning
|
||||
homeassistant.components.sensor: warning
|
||||
homeassistant.components.event: warning
|
||||
homeassistant.components.switch: warning
|
||||
|
||||
# HTTP/network
|
||||
homeassistant.components.http: warning
|
||||
|
||||
# Keep loader at warning level to see real issues with our integration
|
||||
homeassistant.loader: warning
|
||||
|
||||
# Hide the verbose "Setting up X" messages during startup
|
||||
# but keep warnings/errors visible
|
||||
homeassistant.bootstrap: warning
|
||||
homeassistant.setup: warning
|
||||
|
||||
# Core system - keep visible for important messages
|
||||
homeassistant.core: info
|
||||
|
||||
# IMPORTANT for custom integration development:
|
||||
# Coordinator issues (API calls, update failures)
|
||||
homeassistant.helpers.update_coordinator: info
|
||||
# Entity registration problems
|
||||
homeassistant.helpers.entity_registry: info
|
||||
# Config flow debugging (setup, options)
|
||||
homeassistant.config_entries: info
|
||||
|
||||
# Your integration debug logging - shows EVERYTHING from your integration
|
||||
custom_components.tibber_prices: debug
|
||||
|
||||
# Reduce verbosity for details loggers (change to 'debug' for deep debugging)
|
||||
|
|
|
|||
|
|
@ -127,7 +127,7 @@ def get_price_intervals_attributes(
|
|||
| {
|
||||
"period_position": i,
|
||||
"period_count_total": total_filtered,
|
||||
"periods_remaining": total_filtered - i,
|
||||
"period_count_remaining": total_filtered - i,
|
||||
}
|
||||
for i, period in enumerate(filtered_periods, 1)
|
||||
]
|
||||
|
|
@ -266,8 +266,8 @@ def add_detail_attributes(attributes: dict, current_period: dict) -> None:
|
|||
attributes["period_position"] = current_period["period_position"]
|
||||
if "period_count_total" in current_period:
|
||||
attributes["period_count_total"] = current_period["period_count_total"]
|
||||
if "periods_remaining" in current_period:
|
||||
attributes["periods_remaining"] = current_period["periods_remaining"]
|
||||
if "period_count_remaining" in current_period:
|
||||
attributes["period_count_remaining"] = current_period["period_count_remaining"]
|
||||
|
||||
|
||||
def add_period_count_attributes(
|
||||
|
|
@ -421,7 +421,7 @@ def build_final_attributes_simple(
|
|||
2. Core decision attributes (level, rating_level, rating_difference_%)
|
||||
3. Price statistics (price_mean, price_median, price_min, price_max, price_spread, volatility)
|
||||
4. Price differences (period_price_diff_from_daily_min, period_price_diff_from_daily_min_%)
|
||||
5. Detail information (period_interval_count, period_position, period_count_total, periods_remaining)
|
||||
5. Detail information (period_interval_count, period_position, period_count_total, period_count_remaining)
|
||||
6. Relaxation information (relaxation_active, relaxation_level, relaxation_threshold_original_%,
|
||||
relaxation_threshold_applied_%) - only if current period was relaxed
|
||||
7. Calculation summary (min_periods_configured, flat_days_detected,
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity, RestoreEn
|
|||
"period_price_diff_from_daily_min",
|
||||
"period_price_diff_from_daily_min_%",
|
||||
"period_count_total",
|
||||
"periods_remaining",
|
||||
"period_count_remaining",
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ class PeriodSummary(TypedDict, total=False):
|
|||
period_interval_count: int # Number of intervals in period
|
||||
period_position: int # Period position (1-based)
|
||||
period_count_total: int # Total number of periods
|
||||
periods_remaining: int # Remaining periods after this one
|
||||
period_count_remaining: int # Remaining periods after this one
|
||||
|
||||
# Relaxation information (priority 6 - only if period was relaxed)
|
||||
relaxation_active: bool # Whether this period was found via relaxation
|
||||
|
|
@ -125,7 +125,7 @@ class PeriodAttributes(BaseAttributes, total=False):
|
|||
2. Core decision attributes (level, rating_level, rating_difference_%)
|
||||
3. Price statistics (price_mean, price_median, price_min, price_max, price_spread, volatility)
|
||||
4. Price comparison (period_price_diff_from_daily_min, period_price_diff_from_daily_min_%)
|
||||
5. Detail information (period_interval_count, period_position, period_count_total, periods_remaining)
|
||||
5. Detail information (period_interval_count, period_position, period_count_total, period_count_remaining)
|
||||
6. Relaxation information (only if period was relaxed)
|
||||
7. Meta information (periods list)
|
||||
"""
|
||||
|
|
@ -156,7 +156,7 @@ class PeriodAttributes(BaseAttributes, total=False):
|
|||
period_interval_count: int # Number of intervals in current/next period
|
||||
period_position: int # Period position (1-based)
|
||||
period_count_total: int # Total number of periods found
|
||||
periods_remaining: int # Remaining periods after current/next one
|
||||
period_count_remaining: int # Remaining periods after current/next one
|
||||
|
||||
# Relaxation information (priority 6 - only if period was relaxed)
|
||||
relaxation_active: bool # Whether current/next period was found via relaxation
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ def recalculate_period_metadata(periods: list[dict], *, time: TibberPricesTimeSe
|
|||
"""
|
||||
Recalculate period metadata after merging periods.
|
||||
|
||||
Updates period_position, period_count_total, and periods_remaining for all periods
|
||||
Updates period_position, period_count_total, and period_count_remaining for all periods
|
||||
based on chronological order.
|
||||
|
||||
This must be called after resolve_period_overlaps() to ensure metadata
|
||||
|
|
@ -79,7 +79,7 @@ def recalculate_period_metadata(periods: list[dict], *, time: TibberPricesTimeSe
|
|||
for position, period in enumerate(periods, 1):
|
||||
period["period_position"] = position
|
||||
period["period_count_total"] = total_periods
|
||||
period["periods_remaining"] = total_periods - position
|
||||
period["period_count_remaining"] = total_periods - position
|
||||
|
||||
|
||||
def merge_adjacent_periods(period1: dict, period2: dict) -> dict:
|
||||
|
|
|
|||
|
|
@ -179,7 +179,7 @@ def build_period_summary_dict(
|
|||
"period_interval_count": period_data.period_length,
|
||||
"period_position": period_data.period_idx,
|
||||
"period_count_total": period_data.total_periods,
|
||||
"periods_remaining": period_data.total_periods - period_data.period_idx,
|
||||
"period_count_remaining": period_data.total_periods - period_data.period_idx,
|
||||
}
|
||||
|
||||
# Add period price difference attributes based on sensor type (step 4)
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ if TYPE_CHECKING:
|
|||
|
||||
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
|
||||
|
||||
from custom_components.tibber_prices.utils.price import calculate_coefficient_of_variation
|
||||
from custom_components.tibber_prices.utils.price import calculate_coefficient_of_variation, calculate_iqr_stats
|
||||
|
||||
from .period_overlap import (
|
||||
recalculate_period_metadata,
|
||||
|
|
@ -51,7 +51,10 @@ FLEX_WARNING_VSHAPE_RATIO = 0.5 # span/ref_price ratio below which a day is con
|
|||
# On flat price days (low variation), it is unrealistic to require multiple distinct
|
||||
# best/peak price periods. Requiring 2+ periods would force relaxation to create
|
||||
# artificial periods that don't represent genuine price structure.
|
||||
LOW_CV_FLAT_DAY_THRESHOLD = 10.0 # %: days with CV ≤ this need only 1 period
|
||||
LOW_CV_FLAT_DAY_THRESHOLD = 10.0 # %: fallback when IQR% not available (near-zero or negative median)
|
||||
# IQR% ≤ 15% ≈ CV ≤ 10% for clean data, but also catches "flat + isolated spike" days correctly:
|
||||
# a single spike inflates CV to 15-25% while leaving IQR% near 0-5%.
|
||||
LOW_IQR_PCT_FLAT_DAY_THRESHOLD = 15.0 # %: days with IQR% ≤ this need only 1 period
|
||||
|
||||
|
||||
def _check_period_quality(
|
||||
|
|
@ -448,11 +451,14 @@ def _compute_day_effective_min(
|
|||
"""
|
||||
Compute per-day effective min_periods with flat-day adaptation.
|
||||
|
||||
On days with very low price variation (CV ≤ LOW_CV_FLAT_DAY_THRESHOLD),
|
||||
On days with very low price variation (IQR% ≤ LOW_IQR_PCT_FLAT_DAY_THRESHOLD),
|
||||
requiring multiple distinct cheapest/peak periods is unrealistic. Finding
|
||||
ONE period is sufficient because there is no meaningful price structure that
|
||||
would create natural multiple periods.
|
||||
|
||||
Uses IQR% as primary metric (robust to isolated price spikes) with CV as
|
||||
fallback when IQR% is undefined (near-zero or negative median prices).
|
||||
|
||||
This applies ONLY to BEST PRICE periods (reverse_sort=False). For PEAK PRICE
|
||||
periods, full relaxation should run even on flat days because identifying the
|
||||
genuinely most expensive window requires the complete filter evaluation.
|
||||
|
|
@ -471,7 +477,6 @@ def _compute_day_effective_min(
|
|||
"""
|
||||
day_effective_min = {}
|
||||
flat_day_count = 0
|
||||
min_prices_for_cv = 2 # Need at least 2 prices to calculate CV
|
||||
|
||||
for day, day_prices in prices_by_day.items():
|
||||
if not enable_relaxation or min_periods <= 1 or reverse_sort:
|
||||
|
|
@ -481,30 +486,46 @@ def _compute_day_effective_min(
|
|||
|
||||
price_values = [float(p["total"]) for p in day_prices if p.get("total") is not None]
|
||||
|
||||
if len(price_values) < min_prices_for_cv:
|
||||
if len(price_values) < 2: # noqa: PLR2004 - need at least 2 prices for any metric
|
||||
day_effective_min[day] = min_periods
|
||||
continue
|
||||
|
||||
day_cv = calculate_coefficient_of_variation(price_values)
|
||||
# Primary flat-day metric: IQR% is robust to isolated price spikes.
|
||||
# A single spike inflates CV to 15-25% while leaving IQR% near 0-5%,
|
||||
# so IQR correctly identifies "flat core + spike" days as flat.
|
||||
iqr_stats = calculate_iqr_stats(price_values)
|
||||
iqr_pct = iqr_stats["iqr_pct"] if iqr_stats else None
|
||||
|
||||
if day_cv is not None and day_cv <= LOW_CV_FLAT_DAY_THRESHOLD:
|
||||
is_flat = False
|
||||
flat_metric = ""
|
||||
|
||||
if iqr_pct is not None:
|
||||
is_flat = iqr_pct <= LOW_IQR_PCT_FLAT_DAY_THRESHOLD
|
||||
flat_metric = f"IQR%={iqr_pct:.1f}% ≤ {LOW_IQR_PCT_FLAT_DAY_THRESHOLD:.0f}%"
|
||||
else:
|
||||
# IQR% undefined (near-zero or negative median): fall back to CV
|
||||
day_cv = calculate_coefficient_of_variation(price_values)
|
||||
if day_cv is not None:
|
||||
is_flat = day_cv <= LOW_CV_FLAT_DAY_THRESHOLD
|
||||
flat_metric = f"CV={day_cv:.1f}% ≤ {LOW_CV_FLAT_DAY_THRESHOLD:.0f}% (IQR% N/A)"
|
||||
|
||||
if is_flat:
|
||||
day_effective_min[day] = 1
|
||||
flat_day_count += 1
|
||||
_LOGGER_DETAILS.debug(
|
||||
"%sDay %s: flat price profile (CV=%.1f%% ≤ %.1f%%) → min_periods relaxed to 1",
|
||||
"%sDay %s: flat price profile (%s) → min_periods relaxed to 1",
|
||||
INDENT_L1,
|
||||
day,
|
||||
day_cv,
|
||||
LOW_CV_FLAT_DAY_THRESHOLD,
|
||||
flat_metric,
|
||||
)
|
||||
else:
|
||||
day_effective_min[day] = min_periods
|
||||
|
||||
if flat_day_count > 0:
|
||||
_LOGGER.info(
|
||||
"Adaptive min_periods: %d flat day(s) (CV ≤ %.0f%%) need only 1 period instead of %d",
|
||||
"Adaptive min_periods: %d flat day(s) (IQR%% ≤ %.0f%%) need only 1 period instead of %d",
|
||||
flat_day_count,
|
||||
LOW_CV_FLAT_DAY_THRESHOLD,
|
||||
LOW_IQR_PCT_FLAT_DAY_THRESHOLD,
|
||||
min_periods,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,17 @@
|
|||
"""
|
||||
Shape-based period extension: extend periods into adjacent VERY_CHEAP/VERY_EXPENSIVE intervals.
|
||||
Shape-based period extension: extend periods into adjacent cheap/expensive intervals.
|
||||
|
||||
After periods are identified by the core algorithm, this module optionally extends
|
||||
each period's boundaries to include any directly-adjacent intervals that carry the
|
||||
most extreme price level relevant to the period type:
|
||||
each period's boundaries to include any directly-adjacent intervals that carry a
|
||||
favourable price level relevant to the period type:
|
||||
|
||||
- Best price periods → extend into VERY_CHEAP neighbouring intervals
|
||||
- Peak price periods → extend into VERY_EXPENSIVE neighbouring intervals
|
||||
- Best price periods → extend into VERY_CHEAP neighbours; fall back to CHEAP
|
||||
on each side where no VERY_CHEAP neighbour exists.
|
||||
- Peak price periods → extend into VERY_EXPENSIVE neighbours; fall back to
|
||||
EXPENSIVE on each side where no VERY_EXPENSIVE exists.
|
||||
|
||||
The fallback is evaluated **per side independently**: one side may extend via
|
||||
VERY_CHEAP while the other side falls back to CHEAP.
|
||||
|
||||
Extension is purely additive and opt-in (disabled by default). It does not affect
|
||||
the core period-finding logic; periods that would not normally be found are not
|
||||
|
|
@ -20,6 +25,8 @@ from datetime import timedelta
|
|||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from custom_components.tibber_prices.const import (
|
||||
PRICE_LEVEL_CHEAP,
|
||||
PRICE_LEVEL_EXPENSIVE,
|
||||
PRICE_LEVEL_VERY_CHEAP,
|
||||
PRICE_LEVEL_VERY_EXPENSIVE,
|
||||
)
|
||||
|
|
@ -55,13 +62,17 @@ def extend_periods_for_shape( # noqa: PLR0913 - Extension requires all context
|
|||
time: TibberPricesTimeService,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Extend each period into adjacent VERY_CHEAP or VERY_EXPENSIVE intervals.
|
||||
Extend each period into adjacent cheap/expensive intervals.
|
||||
|
||||
For best price periods (reverse_sort=False): extend into VERY_CHEAP neighbours.
|
||||
For peak price periods (reverse_sort=True): extend into VERY_EXPENSIVE neighbours.
|
||||
For best price periods (reverse_sort=False):
|
||||
Primary: extend into VERY_CHEAP neighbours.
|
||||
Fallback: extend into CHEAP neighbours (per side, only if no VERY_CHEAP found).
|
||||
For peak price periods (reverse_sort=True):
|
||||
Primary: extend into VERY_EXPENSIVE neighbours.
|
||||
Fallback: extend into EXPENSIVE neighbours (per side, only if no VERY_EXPENSIVE found).
|
||||
|
||||
Only intervals that are directly contiguous with the period and carry the
|
||||
target level are added. At most *max_extension_intervals* are consumed on
|
||||
required level are added. At most *max_extension_intervals* are consumed on
|
||||
each side independently. Period statistics are fully recalculated after
|
||||
any extension.
|
||||
|
||||
|
|
@ -82,7 +93,12 @@ def extend_periods_for_shape( # noqa: PLR0913 - Extension requires all context
|
|||
if not periods or max_extension_intervals <= 0:
|
||||
return periods
|
||||
|
||||
target_level = PRICE_LEVEL_VERY_EXPENSIVE if reverse_sort else PRICE_LEVEL_VERY_CHEAP
|
||||
if reverse_sort:
|
||||
primary_level = PRICE_LEVEL_VERY_EXPENSIVE
|
||||
fallback_level = PRICE_LEVEL_EXPENSIVE
|
||||
else:
|
||||
primary_level = PRICE_LEVEL_VERY_CHEAP
|
||||
fallback_level = PRICE_LEVEL_CHEAP
|
||||
|
||||
# Build a lookup dict: local datetime → full interval dict
|
||||
interval_index: dict[datetime, dict[str, Any]] = {}
|
||||
|
|
@ -95,7 +111,8 @@ def extend_periods_for_shape( # noqa: PLR0913 - Extension requires all context
|
|||
_extend_period_edges(
|
||||
period,
|
||||
interval_index,
|
||||
target_level=target_level,
|
||||
primary_level=primary_level,
|
||||
fallback_level=fallback_level,
|
||||
max_intervals=max_extension_intervals,
|
||||
thresholds=thresholds,
|
||||
price_context=price_context,
|
||||
|
|
@ -107,25 +124,72 @@ def extend_periods_for_shape( # noqa: PLR0913 - Extension requires all context
|
|||
# ── private helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _extend_period_edges( # noqa: PLR0913, PLR0912, PLR0915 - Period edge extension requires many args, branches, and statements
|
||||
def _walk_contiguous(
|
||||
interval_index: dict[datetime, dict[str, Any]],
|
||||
start_cursor: datetime,
|
||||
step: timedelta,
|
||||
target_level: str,
|
||||
max_intervals: int,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Walk contiguously from *start_cursor* in direction *step*, collecting intervals.
|
||||
|
||||
Stops when the next interval is missing from the index, does not carry
|
||||
*target_level*, or the *max_intervals* cap is reached.
|
||||
|
||||
Args:
|
||||
interval_index: Lookup map of ``{starts_at_datetime: interval_dict}``.
|
||||
start_cursor: First position to check (already offset from the period edge).
|
||||
step: ``+_INTERVAL_DURATION`` for rightward, ``-_INTERVAL_DURATION`` for leftward.
|
||||
target_level: Required ``level`` value (e.g. ``"VERY_CHEAP"``).
|
||||
max_intervals: Maximum intervals to collect.
|
||||
|
||||
Returns:
|
||||
Collected intervals in chronological order (reversed for leftward walks).
|
||||
|
||||
"""
|
||||
additions: list[dict[str, Any]] = []
|
||||
cursor = start_cursor
|
||||
for _ in range(max_intervals):
|
||||
iv = interval_index.get(cursor)
|
||||
if iv is None or iv.get("level") != target_level:
|
||||
break
|
||||
additions.append(iv)
|
||||
cursor += step
|
||||
|
||||
# For leftward walks the list was built newest-first; reverse to chronological
|
||||
if step < timedelta(0):
|
||||
additions.reverse()
|
||||
|
||||
return additions
|
||||
|
||||
|
||||
def _extend_period_edges( # noqa: PLR0913 - Period edge extension requires many args
|
||||
period: dict[str, Any],
|
||||
interval_index: dict[datetime, dict[str, Any]],
|
||||
*,
|
||||
target_level: str,
|
||||
primary_level: str,
|
||||
fallback_level: str,
|
||||
max_intervals: int,
|
||||
thresholds: TibberPricesThresholdConfig,
|
||||
price_context: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Consume adjacent target-level intervals on both edges of a period.
|
||||
Consume adjacent intervals on both edges of a period.
|
||||
|
||||
Each side is evaluated independently:
|
||||
1. Try extending into *primary_level* neighbours (VERY_CHEAP / VERY_EXPENSIVE).
|
||||
2. If no primary-level neighbours were found on that side, fall back to
|
||||
*fallback_level* neighbours (CHEAP / EXPENSIVE).
|
||||
|
||||
The original period dict is never mutated; a new dict is returned.
|
||||
If no extension is possible, the original dict is returned unchanged.
|
||||
If no extension is possible on either side, the original dict is returned.
|
||||
|
||||
Args:
|
||||
period: Period summary dict with ``start`` and ``end`` datetime keys.
|
||||
interval_index: Lookup map of ``{starts_at_datetime: interval_dict}``.
|
||||
target_level: ``"VERY_CHEAP"`` or ``"VERY_EXPENSIVE"``.
|
||||
primary_level: Preferred level (``"VERY_CHEAP"`` or ``"VERY_EXPENSIVE"``).
|
||||
fallback_level: Fallback level (``"CHEAP"`` or ``"EXPENSIVE"``).
|
||||
max_intervals: Maximum intervals that may be added on each side.
|
||||
thresholds: Threshold config for aggregation helpers.
|
||||
price_context: Reference prices / averages per calendar day.
|
||||
|
|
@ -139,25 +203,21 @@ def _extend_period_edges( # noqa: PLR0913, PLR0912, PLR0915 - Period edge exten
|
|||
# ``end`` is the exclusive boundary: the last included interval starts at
|
||||
# ``end - _INTERVAL_DURATION``.
|
||||
|
||||
backward_step = -_INTERVAL_DURATION
|
||||
forward_step = _INTERVAL_DURATION
|
||||
|
||||
# ── walk LEFT (earlier than period start) ─────────────────────────────────
|
||||
left_additions: list[dict[str, Any]] = []
|
||||
cursor = start - _INTERVAL_DURATION
|
||||
for _ in range(max_intervals):
|
||||
iv = interval_index.get(cursor)
|
||||
if iv is None or iv.get("level") != target_level:
|
||||
break
|
||||
left_additions.insert(0, iv)
|
||||
cursor -= _INTERVAL_DURATION
|
||||
left_cursor = start - _INTERVAL_DURATION
|
||||
left_additions = _walk_contiguous(interval_index, left_cursor, backward_step, primary_level, max_intervals)
|
||||
if not left_additions:
|
||||
# Fallback: no primary-level neighbours on this side → try fallback level
|
||||
left_additions = _walk_contiguous(interval_index, left_cursor, backward_step, fallback_level, max_intervals)
|
||||
|
||||
# ── walk RIGHT (later than period end) ────────────────────────────────────
|
||||
right_additions: list[dict[str, Any]] = []
|
||||
cursor = end # first interval AFTER the period
|
||||
for _ in range(max_intervals):
|
||||
iv = interval_index.get(cursor)
|
||||
if iv is None or iv.get("level") != target_level:
|
||||
break
|
||||
right_additions.append(iv)
|
||||
cursor += _INTERVAL_DURATION
|
||||
right_additions = _walk_contiguous(interval_index, end, forward_step, primary_level, max_intervals)
|
||||
if not right_additions:
|
||||
# Fallback: no primary-level neighbours on this side → try fallback level
|
||||
right_additions = _walk_contiguous(interval_index, end, forward_step, fallback_level, max_intervals)
|
||||
|
||||
total_added = len(left_additions) + len(right_additions)
|
||||
if total_added == 0:
|
||||
|
|
@ -199,7 +259,7 @@ def _extend_period_edges( # noqa: PLR0913, PLR0912, PLR0915 - Period edge exten
|
|||
cv_pct = round(statistics.stdev(prices_for_vol) / mean_p * 100, 1)
|
||||
|
||||
# ── assemble updated period dict (keep structural fields, update statistics) ─
|
||||
reverse_sort = target_level == PRICE_LEVEL_VERY_EXPENSIVE
|
||||
reverse_sort = primary_level == PRICE_LEVEL_VERY_EXPENSIVE
|
||||
updated: dict[str, Any] = {
|
||||
**period,
|
||||
# Time fields
|
||||
|
|
|
|||
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
|
|
@ -1,79 +1,79 @@
|
|||
{
|
||||
"services": {
|
||||
"get_price": {
|
||||
"service": "mdi:table-search"
|
||||
},
|
||||
"get_chartdata": {
|
||||
"service": "mdi:chart-bar",
|
||||
"sections": {
|
||||
"general": "mdi:identifier",
|
||||
"selection": "mdi:calendar-range",
|
||||
"filters": "mdi:filter-variant",
|
||||
"transformation": "mdi:tune",
|
||||
"format": "mdi:file-table",
|
||||
"arrays_of_objects": "mdi:code-json",
|
||||
"arrays_of_arrays": "mdi:code-brackets"
|
||||
}
|
||||
},
|
||||
"get_apexcharts_yaml": {
|
||||
"service": "mdi:chart-line",
|
||||
"sections": {
|
||||
"entry_id": "mdi:identifier",
|
||||
"day": "mdi:calendar-range",
|
||||
"level_type": "mdi:format-list-bulleted-type",
|
||||
"resolution": "mdi:timer-sand",
|
||||
"highlight_best_price": "mdi:battery-charging-low",
|
||||
"highlight_peak_price": "mdi:battery-alert"
|
||||
}
|
||||
},
|
||||
"refresh_user_data": {
|
||||
"service": "mdi:refresh"
|
||||
},
|
||||
"find_cheapest_block": {
|
||||
"service": "mdi:washing-machine",
|
||||
"sections": {
|
||||
"search_range": "mdi:calendar-search",
|
||||
"time_alternatives": "mdi:clock-time-eight-outline",
|
||||
"price_filter": "mdi:filter-variant",
|
||||
"output": "mdi:tune-variant"
|
||||
}
|
||||
},
|
||||
"find_most_expensive_block": {
|
||||
"service": "mdi:lightning-bolt-circle",
|
||||
"sections": {
|
||||
"search_range": "mdi:calendar-search",
|
||||
"time_alternatives": "mdi:clock-time-eight-outline",
|
||||
"price_filter": "mdi:filter-variant",
|
||||
"output": "mdi:tune-variant"
|
||||
}
|
||||
},
|
||||
"find_cheapest_hours": {
|
||||
"service": "mdi:ev-station",
|
||||
"sections": {
|
||||
"search_range": "mdi:calendar-search",
|
||||
"time_alternatives": "mdi:clock-time-eight-outline",
|
||||
"price_filter": "mdi:filter-variant",
|
||||
"output": "mdi:tune-variant"
|
||||
}
|
||||
},
|
||||
"find_most_expensive_hours": {
|
||||
"service": "mdi:flash-alert",
|
||||
"sections": {
|
||||
"search_range": "mdi:calendar-search",
|
||||
"time_alternatives": "mdi:clock-time-eight-outline",
|
||||
"price_filter": "mdi:filter-variant",
|
||||
"output": "mdi:tune-variant"
|
||||
}
|
||||
},
|
||||
"find_cheapest_schedule": {
|
||||
"service": "mdi:calendar-check",
|
||||
"sections": {
|
||||
"scheduling_options": "mdi:format-list-numbered",
|
||||
"search_range": "mdi:calendar-search",
|
||||
"time_alternatives": "mdi:clock-time-eight-outline",
|
||||
"price_filter": "mdi:filter-variant",
|
||||
"output": "mdi:tune-variant"
|
||||
}
|
||||
}
|
||||
"services": {
|
||||
"get_price": {
|
||||
"service": "mdi:table-search"
|
||||
},
|
||||
"get_chartdata": {
|
||||
"service": "mdi:chart-bar",
|
||||
"sections": {
|
||||
"general": "mdi:identifier",
|
||||
"selection": "mdi:calendar-range",
|
||||
"filters": "mdi:filter-variant",
|
||||
"transformation": "mdi:tune",
|
||||
"format": "mdi:file-table",
|
||||
"arrays_of_objects": "mdi:code-json",
|
||||
"arrays_of_arrays": "mdi:code-brackets"
|
||||
}
|
||||
},
|
||||
"get_apexcharts_yaml": {
|
||||
"service": "mdi:chart-line",
|
||||
"sections": {
|
||||
"entry_id": "mdi:identifier",
|
||||
"day": "mdi:calendar-range",
|
||||
"level_type": "mdi:format-list-bulleted-type",
|
||||
"resolution": "mdi:timer-sand",
|
||||
"highlight_best_price": "mdi:battery-charging-low",
|
||||
"highlight_peak_price": "mdi:battery-alert"
|
||||
}
|
||||
},
|
||||
"refresh_user_data": {
|
||||
"service": "mdi:refresh"
|
||||
},
|
||||
"find_cheapest_block": {
|
||||
"service": "mdi:washing-machine",
|
||||
"sections": {
|
||||
"search_range": "mdi:calendar-search",
|
||||
"time_alternatives": "mdi:clock-time-eight-outline",
|
||||
"price_filter": "mdi:filter-variant",
|
||||
"output": "mdi:tune-variant"
|
||||
}
|
||||
},
|
||||
"find_most_expensive_block": {
|
||||
"service": "mdi:lightning-bolt-circle",
|
||||
"sections": {
|
||||
"search_range": "mdi:calendar-search",
|
||||
"time_alternatives": "mdi:clock-time-eight-outline",
|
||||
"price_filter": "mdi:filter-variant",
|
||||
"output": "mdi:tune-variant"
|
||||
}
|
||||
},
|
||||
"find_cheapest_hours": {
|
||||
"service": "mdi:ev-station",
|
||||
"sections": {
|
||||
"search_range": "mdi:calendar-search",
|
||||
"time_alternatives": "mdi:clock-time-eight-outline",
|
||||
"price_filter": "mdi:filter-variant",
|
||||
"output": "mdi:tune-variant"
|
||||
}
|
||||
},
|
||||
"find_most_expensive_hours": {
|
||||
"service": "mdi:flash-alert",
|
||||
"sections": {
|
||||
"search_range": "mdi:calendar-search",
|
||||
"time_alternatives": "mdi:clock-time-eight-outline",
|
||||
"price_filter": "mdi:filter-variant",
|
||||
"output": "mdi:tune-variant"
|
||||
}
|
||||
},
|
||||
"find_cheapest_schedule": {
|
||||
"service": "mdi:calendar-check",
|
||||
"sections": {
|
||||
"scheduling_options": "mdi:format-list-numbered",
|
||||
"search_range": "mdi:calendar-search",
|
||||
"time_alternatives": "mdi:clock-time-eight-outline",
|
||||
"price_filter": "mdi:filter-variant",
|
||||
"output": "mdi:tune-variant"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,11 @@
|
|||
{
|
||||
"domain": "tibber_prices",
|
||||
"name": "Tibber Price Information & Ratings",
|
||||
"codeowners": [
|
||||
"@jpawlowski"
|
||||
],
|
||||
"codeowners": ["@jpawlowski"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://github.com/jpawlowski/hass.tibber_prices",
|
||||
"iot_class": "cloud_polling",
|
||||
"issue_tracker": "https://github.com/jpawlowski/hass.tibber_prices/issues",
|
||||
"requirements": [
|
||||
"aiofiles>=23.2.1"
|
||||
],
|
||||
"requirements": ["aiofiles>=23.2.1"],
|
||||
"version": "0.30.0"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ from .lifecycle import build_lifecycle_attributes
|
|||
from .metadata import get_day_pattern_attributes
|
||||
from .timing import _is_timing_or_volatility_sensor
|
||||
from .trend import _add_cached_trend_attributes, _add_timing_or_volatility_attributes
|
||||
from .volatility import add_volatility_type_attributes, get_prices_for_volatility
|
||||
from .volatility import add_percentile_rank_attributes, add_volatility_type_attributes, get_prices_for_volatility
|
||||
from .window_24h import add_average_price_attributes
|
||||
|
||||
__all__ = [
|
||||
|
|
@ -65,6 +65,7 @@ __all__ = [
|
|||
"TrendAttributes",
|
||||
"VolatilityAttributes",
|
||||
"Window24hAttributes",
|
||||
"add_percentile_rank_attributes",
|
||||
"add_volatility_type_attributes",
|
||||
"build_extra_state_attributes",
|
||||
"build_sensor_attributes",
|
||||
|
|
@ -190,6 +191,9 @@ def build_sensor_attributes( # noqa: PLR0912
|
|||
elif _is_timing_or_volatility_sensor(key):
|
||||
_add_timing_or_volatility_attributes(attributes, key, cached_data, native_value, time=time)
|
||||
|
||||
elif "_price_rank_" in key:
|
||||
add_percentile_rank_attributes(attributes, cached_data, time=time)
|
||||
|
||||
elif key in ("day_pattern_yesterday", "day_pattern_today", "day_pattern_tomorrow"):
|
||||
day = key.removeprefix("day_pattern_")
|
||||
day_attrs = get_day_pattern_attributes(coordinator, day)
|
||||
|
|
|
|||
|
|
@ -164,3 +164,54 @@ def add_volatility_type_attributes(
|
|||
# Add time window info
|
||||
now = time.now()
|
||||
volatility_attributes["timestamp"] = now
|
||||
|
||||
|
||||
def add_percentile_rank_attributes(
|
||||
attributes: dict,
|
||||
cached_data: dict,
|
||||
*,
|
||||
time: TibberPricesTimeService,
|
||||
) -> None:
|
||||
"""
|
||||
Add attributes for percentile rank sensors.
|
||||
|
||||
Sets the timestamp based on the percentile type stored in cached_data:
|
||||
- "today" / "today_tomorrow": today's first interval start (midnight context)
|
||||
- "tomorrow": tomorrow's first interval start
|
||||
|
||||
Args:
|
||||
attributes: Dictionary to add attributes to
|
||||
cached_data: Dictionary containing cached sensor data (percentile_rank_attributes,
|
||||
percentile_rank_type, coordinator_data)
|
||||
time: TibberPricesTimeService instance (required)
|
||||
|
||||
"""
|
||||
from datetime import timedelta # noqa: PLC0415 - local import to avoid circular
|
||||
|
||||
rank_attrs = cached_data.get("percentile_rank_attributes")
|
||||
if rank_attrs:
|
||||
attributes.update(rank_attrs)
|
||||
|
||||
# Set timestamp based on period type
|
||||
percentile_type = cached_data.get("percentile_rank_type", "today")
|
||||
coordinator_data = cached_data.get("coordinator_data")
|
||||
|
||||
if coordinator_data:
|
||||
from custom_components.tibber_prices.coordinator.helpers import ( # noqa: PLC0415
|
||||
get_intervals_for_day_offsets,
|
||||
)
|
||||
|
||||
all_intervals = get_intervals_for_day_offsets(coordinator_data, [-1, 0, 1])
|
||||
now = time.now()
|
||||
today_date = now.date()
|
||||
tomorrow_date = (now + timedelta(days=1)).date()
|
||||
|
||||
if percentile_type == "tomorrow":
|
||||
tomorrow_data = [p for p in all_intervals if p.get("startsAt") and p["startsAt"].date() == tomorrow_date]
|
||||
if tomorrow_data:
|
||||
attributes["timestamp"] = tomorrow_data[0].get("startsAt")
|
||||
else:
|
||||
# today / today_tomorrow → use today's midnight
|
||||
today_data = [p for p in all_intervals if p.get("startsAt") and p["startsAt"].date() == today_date]
|
||||
if today_data:
|
||||
attributes["timestamp"] = today_data[0].get("startsAt")
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import bisect
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from custom_components.tibber_prices.const import (
|
||||
|
|
@ -13,13 +14,18 @@ from custom_components.tibber_prices.const import (
|
|||
DEFAULT_VOLATILITY_THRESHOLD_VERY_HIGH,
|
||||
get_display_unit_factor,
|
||||
)
|
||||
from custom_components.tibber_prices.entity_utils import add_icon_color_attribute
|
||||
from custom_components.tibber_prices.coordinator.helpers import get_intervals_for_day_offsets
|
||||
from custom_components.tibber_prices.entity_utils import add_icon_color_attribute, find_rolling_hour_center_index
|
||||
from custom_components.tibber_prices.sensor.attributes import (
|
||||
add_volatility_type_attributes,
|
||||
get_prices_for_volatility,
|
||||
)
|
||||
from custom_components.tibber_prices.utils.average import calculate_mean
|
||||
from custom_components.tibber_prices.utils.price import calculate_volatility_with_cv
|
||||
from custom_components.tibber_prices.utils.price import (
|
||||
calculate_iqr_stats,
|
||||
calculate_percentile_rank,
|
||||
calculate_volatility_with_cv,
|
||||
)
|
||||
|
||||
from .base import TibberPricesBaseCalculator
|
||||
|
||||
|
|
@ -46,6 +52,7 @@ class TibberPricesVolatilityCalculator(TibberPricesBaseCalculator):
|
|||
"""
|
||||
super().__init__(*args, **kwargs)
|
||||
self._last_volatility_attributes: dict[str, Any] = {}
|
||||
self._last_percentile_rank_attributes: dict[str, Any] = {}
|
||||
|
||||
def get_volatility_value(self, *, volatility_type: str) -> str | None:
|
||||
"""
|
||||
|
|
@ -101,17 +108,33 @@ class TibberPricesVolatilityCalculator(TibberPricesBaseCalculator):
|
|||
# Calculate volatility level AND coefficient of variation
|
||||
volatility, cv = calculate_volatility_with_cv(prices_to_analyze, **thresholds)
|
||||
|
||||
# Calculate IQR statistics (robust to outliers)
|
||||
iqr_stats = calculate_iqr_stats(prices_to_analyze)
|
||||
|
||||
# Store attributes for this sensor
|
||||
self._last_volatility_attributes = {
|
||||
"price_spread": round(spread_display, 2),
|
||||
"price_coefficient_variation_%": round(cv, 2) if cv is not None else None,
|
||||
# Build attributes with all price_* together, interval_count last
|
||||
attrs: dict[str, Any] = {
|
||||
"price_volatility": volatility.lower(),
|
||||
"price_coefficient_variation_%": round(cv, 2) if cv is not None else None,
|
||||
"price_spread": round(spread_display, 2),
|
||||
"price_min": round(price_min * factor, 2),
|
||||
"price_max": round(price_max * factor, 2),
|
||||
"price_mean": round(price_mean * factor, 2),
|
||||
"interval_count": len(prices_to_analyze),
|
||||
}
|
||||
|
||||
# Add IQR attributes when enough data is available (stay in price_* group)
|
||||
if iqr_stats is not None:
|
||||
attrs["price_median"] = round(iqr_stats["median"] * factor, 2)
|
||||
attrs["price_q25"] = round(iqr_stats["q25"] * factor, 2)
|
||||
attrs["price_q75"] = round(iqr_stats["q75"] * factor, 2)
|
||||
attrs["price_typical_spread"] = round(iqr_stats["iqr"] * factor, 2)
|
||||
if iqr_stats["iqr_pct"] is not None:
|
||||
attrs["price_typical_spread_%"] = round(iqr_stats["iqr_pct"], 2)
|
||||
attrs["price_spike_count"] = iqr_stats["outlier_count"]
|
||||
|
||||
attrs["interval_count"] = len(prices_to_analyze)
|
||||
self._last_volatility_attributes = attrs
|
||||
|
||||
# Add icon_color for dynamic styling
|
||||
add_icon_color_attribute(self._last_volatility_attributes, key="volatility", state_value=volatility)
|
||||
|
||||
|
|
@ -136,3 +159,146 @@ class TibberPricesVolatilityCalculator(TibberPricesBaseCalculator):
|
|||
|
||||
"""
|
||||
return self._last_volatility_attributes
|
||||
|
||||
def get_percentile_rank_value(
|
||||
self,
|
||||
*,
|
||||
percentile_type: str,
|
||||
subject: str = "current_interval",
|
||||
) -> float | None:
|
||||
"""
|
||||
Calculate the percentile rank of a subject price within a reference set.
|
||||
|
||||
The result is 0-100: percentage of reference prices strictly cheaper than
|
||||
the subject price. 0% = cheapest, ~99% = most expensive.
|
||||
|
||||
Also stores detailed attributes in self._last_percentile_rank_attributes
|
||||
for use in extra_state_attributes.
|
||||
|
||||
Args:
|
||||
percentile_type: Reference window - one of "today", "tomorrow", "today_tomorrow".
|
||||
subject: Price to rank - one of "current_interval" (default), "next_interval",
|
||||
"previous_interval", "current_hour", "next_hour".
|
||||
|
||||
Returns:
|
||||
Percentile rank (0.0-100.0) or None if unavailable.
|
||||
|
||||
"""
|
||||
if not self.has_data():
|
||||
return None
|
||||
|
||||
# Get the price of the subject to rank
|
||||
subject_price = self._get_subject_price(subject)
|
||||
if subject_price is None:
|
||||
return None
|
||||
|
||||
# Get reference prices for this type (reuse volatility helper)
|
||||
reference_prices = get_prices_for_volatility(
|
||||
percentile_type,
|
||||
self.coordinator.data,
|
||||
time=self.coordinator.time,
|
||||
)
|
||||
if not reference_prices:
|
||||
return None
|
||||
|
||||
# Calculate percentile rank
|
||||
rank = calculate_percentile_rank(subject_price, reference_prices)
|
||||
if rank is None:
|
||||
return None
|
||||
|
||||
# Convert to display units for attribute storage
|
||||
factor = get_display_unit_factor(self.config_entry)
|
||||
price_attr_key = self._get_subject_price_attr_key(subject)
|
||||
|
||||
self._last_percentile_rank_attributes = {
|
||||
price_attr_key: round(subject_price * factor, 2),
|
||||
"prices_below_count": bisect.bisect_left(sorted(reference_prices), subject_price),
|
||||
"interval_count": len(reference_prices),
|
||||
"reference_min": round(min(reference_prices) * factor, 2),
|
||||
"reference_max": round(max(reference_prices) * factor, 2),
|
||||
"reference_mean": round(calculate_mean(reference_prices) * factor, 2),
|
||||
}
|
||||
|
||||
return rank
|
||||
|
||||
def _get_subject_price(self, subject: str) -> float | None:
|
||||
"""
|
||||
Get the price of the subject to rank.
|
||||
|
||||
Args:
|
||||
subject: One of "current_interval", "next_interval", "previous_interval",
|
||||
"current_hour", "next_hour".
|
||||
|
||||
Returns:
|
||||
Price as float or None if unavailable.
|
||||
|
||||
"""
|
||||
if subject == "current_interval":
|
||||
interval = self.find_interval_at_offset(0)
|
||||
elif subject == "next_interval":
|
||||
interval = self.find_interval_at_offset(1)
|
||||
elif subject == "previous_interval":
|
||||
interval = self.find_interval_at_offset(-1)
|
||||
elif subject in ("current_hour", "next_hour"):
|
||||
hour_offset = 0 if subject == "current_hour" else 1
|
||||
return self._get_rolling_hour_avg_price(hour_offset)
|
||||
else:
|
||||
return None
|
||||
|
||||
if interval is None:
|
||||
return None
|
||||
raw = interval.get("total")
|
||||
return float(raw) if raw is not None else None
|
||||
|
||||
def _get_subject_price_attr_key(self, subject: str) -> str:
|
||||
"""Return the attribute key name for the subject's price."""
|
||||
return {
|
||||
"current_interval": "current_price",
|
||||
"next_interval": "next_price",
|
||||
"previous_interval": "previous_price",
|
||||
"current_hour": "current_hour_avg_price",
|
||||
"next_hour": "next_hour_avg_price",
|
||||
}.get(subject, "ranked_price")
|
||||
|
||||
def _get_rolling_hour_avg_price(self, hour_offset: int) -> float | None:
|
||||
"""
|
||||
Get the rolling 1h average price for the given hour offset.
|
||||
|
||||
Uses the same 5-interval window as current_hour_average_price.
|
||||
|
||||
Args:
|
||||
hour_offset: 0 for current hour, 1 for next hour.
|
||||
|
||||
Returns:
|
||||
Average price as float or None if unavailable.
|
||||
|
||||
"""
|
||||
all_prices = get_intervals_for_day_offsets(self.coordinator_data, [-1, 0, 1])
|
||||
if not all_prices:
|
||||
return None
|
||||
|
||||
time = self.coordinator.time
|
||||
now = time.now()
|
||||
center_idx = find_rolling_hour_center_index(all_prices, now, hour_offset, time=time)
|
||||
if center_idx is None:
|
||||
return None
|
||||
|
||||
window: list[float] = []
|
||||
for offset in range(-2, 3):
|
||||
idx = center_idx + offset
|
||||
if 0 <= idx < len(all_prices):
|
||||
raw = all_prices[idx].get("total")
|
||||
if raw is not None:
|
||||
window.append(float(raw))
|
||||
|
||||
return calculate_mean(window) if window else None
|
||||
|
||||
def get_percentile_rank_attributes(self) -> dict[str, Any]:
|
||||
"""
|
||||
Get stored percentile rank attributes from last calculation.
|
||||
|
||||
Returns:
|
||||
Dictionary of percentile rank attributes, or empty dict if no calculation yet.
|
||||
|
||||
"""
|
||||
return self._last_percentile_rank_attributes
|
||||
|
|
|
|||
|
|
@ -100,6 +100,22 @@ MIN_HOURS_FOR_LATER_HALF = 3 # Minimum hours needed to calculate later half ave
|
|||
_SENTINEL = object()
|
||||
|
||||
|
||||
def _extract_percentile_rank_type(key: str) -> str | None:
|
||||
"""
|
||||
Extract the reference-window type from a price rank sensor key.
|
||||
|
||||
Returns "today_tomorrow", "tomorrow", or "today" based on the key suffix.
|
||||
Returns None if the key is not a price rank sensor key.
|
||||
"""
|
||||
if "_rank_today_tomorrow" in key:
|
||||
return "today_tomorrow"
|
||||
if "_rank_tomorrow" in key:
|
||||
return "tomorrow"
|
||||
if "_rank_today" in key:
|
||||
return "today"
|
||||
return None
|
||||
|
||||
|
||||
class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
|
||||
"""tibber_prices Sensor class with state restoration."""
|
||||
|
||||
|
|
@ -173,7 +189,7 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
|
|||
"period_price_diff_from_daily_min",
|
||||
"period_price_diff_from_daily_min_%",
|
||||
"period_count_total",
|
||||
"periods_remaining",
|
||||
"period_count_remaining",
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -1164,6 +1180,9 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
|
|||
"current_trend_attributes": self._trend_calculator.get_current_trend_attributes(),
|
||||
"trend_change_attributes": self._trend_calculator.get_trend_change_attributes(),
|
||||
"volatility_attributes": self._volatility_calculator.get_volatility_attributes(),
|
||||
"percentile_rank_attributes": self._volatility_calculator.get_percentile_rank_attributes(),
|
||||
"percentile_rank_type": _extract_percentile_rank_type(key),
|
||||
"coordinator_data": self.coordinator.data,
|
||||
"last_extreme_interval": self._daily_stat_calculator.get_last_extreme_interval(),
|
||||
"last_energy_tax_averages": self._daily_stat_calculator.get_last_energy_tax_averages(),
|
||||
"last_price_level": self._interval_calculator.get_last_price_level(),
|
||||
|
|
|
|||
|
|
@ -736,6 +736,139 @@ VOLATILITY_SENSORS = (
|
|||
),
|
||||
)
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# 6b. PRICE PERCENTILE RANK SENSORS
|
||||
# ----------------------------------------------------------------------------
|
||||
# These sensors show where the current price ranks within a reference period.
|
||||
# The state (0-100%) answers: "What percentage of reference prices are cheaper
|
||||
# than the current price?"
|
||||
#
|
||||
# 0% = current price is the cheapest in the reference period
|
||||
# 50% = half the prices are cheaper (current price at median level)
|
||||
# ~99% = almost everything is cheaper (current price near the maximum)
|
||||
#
|
||||
# Reference periods:
|
||||
# - today: 96 intervals of today (local calendar day)
|
||||
# - tomorrow: 96 intervals of tomorrow (once data is available)
|
||||
# - today_tomorrow: 192 combined intervals when tomorrow is available
|
||||
#
|
||||
# Use case: "Is now the right time to run a large appliance?"
|
||||
# - current_interval_price_rank_today < 25 → bottom quartile, great time to use energy
|
||||
# - current_interval_price_rank_today > 75 → top quartile, consider delaying consumption
|
||||
|
||||
PERCENTILE_RANK_SENSORS = (
|
||||
# ----------------------------------------------------------------
|
||||
# Current interval rank sensors
|
||||
# ----------------------------------------------------------------
|
||||
SensorEntityDescription(
|
||||
key="current_interval_price_rank_today",
|
||||
translation_key="current_interval_price_rank_today",
|
||||
icon="mdi:percent",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=None, # Position metric: no statistics
|
||||
suggested_display_precision=0,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="current_interval_price_rank_tomorrow",
|
||||
translation_key="current_interval_price_rank_tomorrow",
|
||||
icon="mdi:percent",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=None, # Position metric: no statistics
|
||||
suggested_display_precision=0,
|
||||
entity_registry_enabled_default=False, # Available once tomorrow's data arrives
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="current_interval_price_rank_today_tomorrow",
|
||||
translation_key="current_interval_price_rank_today_tomorrow",
|
||||
icon="mdi:percent",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=None, # Position metric: no statistics
|
||||
suggested_display_precision=0,
|
||||
entity_registry_enabled_default=False, # Advanced overview use case
|
||||
),
|
||||
# ----------------------------------------------------------------
|
||||
# Next interval rank sensors
|
||||
# ----------------------------------------------------------------
|
||||
SensorEntityDescription(
|
||||
key="next_interval_price_rank_today",
|
||||
translation_key="next_interval_price_rank_today",
|
||||
icon="mdi:percent",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=None,
|
||||
suggested_display_precision=0,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="next_interval_price_rank_today_tomorrow",
|
||||
translation_key="next_interval_price_rank_today_tomorrow",
|
||||
icon="mdi:percent",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=None,
|
||||
suggested_display_precision=0,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
# ----------------------------------------------------------------
|
||||
# Previous interval rank sensors
|
||||
# ----------------------------------------------------------------
|
||||
SensorEntityDescription(
|
||||
key="previous_interval_price_rank_today",
|
||||
translation_key="previous_interval_price_rank_today",
|
||||
icon="mdi:percent",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=None,
|
||||
suggested_display_precision=0,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="previous_interval_price_rank_today_tomorrow",
|
||||
translation_key="previous_interval_price_rank_today_tomorrow",
|
||||
icon="mdi:percent",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=None,
|
||||
suggested_display_precision=0,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
# ----------------------------------------------------------------
|
||||
# Rolling-hour rank sensors (rank of 1h rolling average)
|
||||
# ----------------------------------------------------------------
|
||||
SensorEntityDescription(
|
||||
key="current_hour_price_rank_today",
|
||||
translation_key="current_hour_price_rank_today",
|
||||
icon="mdi:percent",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=None,
|
||||
suggested_display_precision=0,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="current_hour_price_rank_today_tomorrow",
|
||||
translation_key="current_hour_price_rank_today_tomorrow",
|
||||
icon="mdi:percent",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=None,
|
||||
suggested_display_precision=0,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="next_hour_price_rank_today",
|
||||
translation_key="next_hour_price_rank_today",
|
||||
icon="mdi:percent",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=None,
|
||||
suggested_display_precision=0,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="next_hour_price_rank_today_tomorrow",
|
||||
translation_key="next_hour_price_rank_today_tomorrow",
|
||||
icon="mdi:percent",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=None,
|
||||
suggested_display_precision=0,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
)
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# 7. BEST/PEAK PRICE TIMING SENSORS (period-based time tracking)
|
||||
# ----------------------------------------------------------------------------
|
||||
|
|
@ -1116,6 +1249,7 @@ ENTITY_DESCRIPTIONS = (
|
|||
*FUTURE_TREND_SENSORS,
|
||||
*PRICE_TRAJECTORY_SENSORS,
|
||||
*VOLATILITY_SENSORS,
|
||||
*PERCENTILE_RANK_SENSORS,
|
||||
*BEST_PRICE_TIMING_SENSORS,
|
||||
*PEAK_PRICE_TIMING_SENSORS,
|
||||
*DAY_PATTERN_SENSORS,
|
||||
|
|
|
|||
|
|
@ -249,6 +249,44 @@ def get_value_getter_mapping( # noqa: PLR0913 - needs all calculators as parame
|
|||
"today_tomorrow_volatility": lambda: volatility_calculator.get_volatility_value(
|
||||
volatility_type="today_tomorrow"
|
||||
),
|
||||
# Price rank sensors (via VolatilityCalculator - reuses same price extraction)
|
||||
# Current interval rank
|
||||
"current_interval_price_rank_today": lambda: volatility_calculator.get_percentile_rank_value(
|
||||
subject="current_interval", percentile_type="today"
|
||||
),
|
||||
"current_interval_price_rank_tomorrow": lambda: volatility_calculator.get_percentile_rank_value(
|
||||
subject="current_interval", percentile_type="tomorrow"
|
||||
),
|
||||
"current_interval_price_rank_today_tomorrow": lambda: volatility_calculator.get_percentile_rank_value(
|
||||
subject="current_interval", percentile_type="today_tomorrow"
|
||||
),
|
||||
# Next interval rank
|
||||
"next_interval_price_rank_today": lambda: volatility_calculator.get_percentile_rank_value(
|
||||
subject="next_interval", percentile_type="today"
|
||||
),
|
||||
"next_interval_price_rank_today_tomorrow": lambda: volatility_calculator.get_percentile_rank_value(
|
||||
subject="next_interval", percentile_type="today_tomorrow"
|
||||
),
|
||||
# Previous interval rank
|
||||
"previous_interval_price_rank_today": lambda: volatility_calculator.get_percentile_rank_value(
|
||||
subject="previous_interval", percentile_type="today"
|
||||
),
|
||||
"previous_interval_price_rank_today_tomorrow": lambda: volatility_calculator.get_percentile_rank_value(
|
||||
subject="previous_interval", percentile_type="today_tomorrow"
|
||||
),
|
||||
# Rolling-hour rank (1h average)
|
||||
"current_hour_price_rank_today": lambda: volatility_calculator.get_percentile_rank_value(
|
||||
subject="current_hour", percentile_type="today"
|
||||
),
|
||||
"current_hour_price_rank_today_tomorrow": lambda: volatility_calculator.get_percentile_rank_value(
|
||||
subject="current_hour", percentile_type="today_tomorrow"
|
||||
),
|
||||
"next_hour_price_rank_today": lambda: volatility_calculator.get_percentile_rank_value(
|
||||
subject="next_hour", percentile_type="today"
|
||||
),
|
||||
"next_hour_price_rank_today_tomorrow": lambda: volatility_calculator.get_percentile_rank_value(
|
||||
subject="next_hour", percentile_type="today_tomorrow"
|
||||
),
|
||||
# ================================================================
|
||||
# BEST/PEAK PRICE TIMING SENSORS - via TimingCalculator
|
||||
# ================================================================
|
||||
|
|
|
|||
|
|
@ -930,6 +930,11 @@ find_cheapest_schedule:
|
|||
output:
|
||||
collapsed: true
|
||||
fields:
|
||||
include_comparison_details:
|
||||
required: false
|
||||
default: false
|
||||
selector:
|
||||
boolean:
|
||||
use_base_unit:
|
||||
required: false
|
||||
default: false
|
||||
|
|
|
|||
|
|
@ -126,6 +126,23 @@ def _compute_price_comparison(
|
|||
return result
|
||||
|
||||
|
||||
def _determine_no_window_reason(
|
||||
price_info: list[dict],
|
||||
filtered_price_info: list[dict],
|
||||
duration_intervals: int,
|
||||
*,
|
||||
level_filter_active: bool,
|
||||
) -> str:
|
||||
"""Classify why no block window could be found."""
|
||||
if not price_info:
|
||||
return "no_data_in_range"
|
||||
if level_filter_active and not filtered_price_info:
|
||||
return "no_intervals_matching_level_filter"
|
||||
if len(filtered_price_info) < duration_intervals:
|
||||
return "insufficient_intervals_after_filter"
|
||||
return "insufficient_contiguous_window"
|
||||
|
||||
|
||||
async def _handle_find_block( # noqa: PLR0915
|
||||
call: ServiceCall,
|
||||
*,
|
||||
|
|
@ -146,6 +163,7 @@ async def _handle_find_block( # noqa: PLR0915
|
|||
min_price_level: str | None = call.data.get("min_price_level")
|
||||
include_comparison_details: bool = call.data.get("include_comparison_details", False)
|
||||
power_profile: list[int] | None = call.data.get("power_profile")
|
||||
level_filter_active = min_price_level is not None or max_price_level is not None
|
||||
|
||||
duration_minutes_requested = int(duration_td.total_seconds() / 60)
|
||||
# Round up to nearest quarter-hour interval
|
||||
|
|
@ -217,9 +235,16 @@ async def _handle_find_block( # noqa: PLR0915
|
|||
result = find_cheapest_contiguous_window(filtered_price_info, duration_intervals, reverse=reverse)
|
||||
|
||||
if result is None:
|
||||
reason = _determine_no_window_reason(
|
||||
price_info,
|
||||
filtered_price_info,
|
||||
duration_intervals,
|
||||
level_filter_active=level_filter_active,
|
||||
)
|
||||
_LOGGER.info(
|
||||
"%s: no window found (need %d intervals, have %d after level filter)",
|
||||
"%s: no window found (reason=%s, need %d intervals, have %d after level filter)",
|
||||
service_label,
|
||||
reason,
|
||||
duration_intervals,
|
||||
len(filtered_price_info),
|
||||
)
|
||||
|
|
@ -232,6 +257,7 @@ async def _handle_find_block( # noqa: PLR0915
|
|||
"currency": currency,
|
||||
"price_unit": price_unit,
|
||||
"window_found": False,
|
||||
"reason": reason,
|
||||
"window": None,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -84,6 +84,23 @@ _COMMON_HOURS_SCHEMA = {
|
|||
FIND_CHEAPEST_HOURS_SERVICE_SCHEMA = vol.Schema(_COMMON_HOURS_SCHEMA)
|
||||
|
||||
|
||||
def _determine_no_intervals_reason(
|
||||
price_info: list[dict],
|
||||
filtered_price_info: list[dict],
|
||||
total_intervals: int,
|
||||
*,
|
||||
level_filter_active: bool,
|
||||
) -> str:
|
||||
"""Classify why no interval selection could be found."""
|
||||
if not price_info:
|
||||
return "no_data_in_range"
|
||||
if level_filter_active and not filtered_price_info:
|
||||
return "no_intervals_matching_level_filter"
|
||||
if len(filtered_price_info) < total_intervals:
|
||||
return "insufficient_intervals_after_filter"
|
||||
return "insufficient_intervals_for_constraints"
|
||||
|
||||
|
||||
def _build_found_response( # noqa: PLR0913
|
||||
*,
|
||||
result: dict,
|
||||
|
|
@ -207,6 +224,7 @@ async def _handle_find_hours(
|
|||
min_price_level: str | None = call.data.get("min_price_level")
|
||||
include_comparison_details: bool = call.data.get("include_comparison_details", False)
|
||||
power_profile: list[int] | None = call.data.get("power_profile")
|
||||
level_filter_active = min_price_level is not None or max_price_level is not None
|
||||
|
||||
total_minutes_requested = int(duration_td.total_seconds() / 60)
|
||||
min_segment_minutes_requested = int(min_segment_td.total_seconds() / 60) if min_segment_td else INTERVAL_MINUTES
|
||||
|
|
@ -283,9 +301,16 @@ async def _handle_find_hours(
|
|||
result = find_cheapest_n_intervals(filtered_price_info, total_intervals, min_segment_intervals, reverse=reverse)
|
||||
|
||||
if result is None:
|
||||
reason = _determine_no_intervals_reason(
|
||||
price_info,
|
||||
filtered_price_info,
|
||||
total_intervals,
|
||||
level_filter_active=level_filter_active,
|
||||
)
|
||||
_LOGGER.info(
|
||||
"%s: not enough intervals (need %d, have %d after level filter)",
|
||||
"%s: no interval selection found (reason=%s, need %d, have %d after level filter)",
|
||||
service_label,
|
||||
reason,
|
||||
total_intervals,
|
||||
len(filtered_price_info),
|
||||
)
|
||||
|
|
@ -300,6 +325,7 @@ async def _handle_find_hours(
|
|||
"currency": currency,
|
||||
"price_unit": price_unit,
|
||||
"intervals_found": False,
|
||||
"reason": reason,
|
||||
"schedule": None,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ from custom_components.tibber_prices.const import (
|
|||
)
|
||||
from custom_components.tibber_prices.utils.price_window import (
|
||||
calculate_window_statistics,
|
||||
find_cheapest_contiguous_window,
|
||||
)
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
|
@ -83,11 +84,77 @@ FIND_CHEAPEST_SCHEDULE_SERVICE_SCHEMA = vol.Schema(
|
|||
vol.Optional("search_scope"): vol.In(VALID_SEARCH_SCOPES),
|
||||
vol.Optional("max_price_level"): vol.In([lvl.lower() for lvl in PRICE_LEVEL_ORDER]),
|
||||
vol.Optional("min_price_level"): vol.In([lvl.lower() for lvl in PRICE_LEVEL_ORDER]),
|
||||
vol.Optional("include_comparison_details", default=False): cv.boolean,
|
||||
vol.Optional("use_base_unit", default=False): cv.boolean,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _compute_task_price_comparison(
|
||||
task_intervals: list[dict[str, Any]],
|
||||
full_price_info: list[dict[str, Any]],
|
||||
unit_factor: int,
|
||||
*,
|
||||
include_details: bool,
|
||||
) -> dict[str, float | str | None] | None:
|
||||
"""Compute per-task comparison against most expensive window of same duration."""
|
||||
duration_intervals = len(task_intervals)
|
||||
comparison_result = find_cheapest_contiguous_window(full_price_info, duration_intervals, reverse=True)
|
||||
if comparison_result is None:
|
||||
return None
|
||||
|
||||
task_stats = calculate_window_statistics(task_intervals, unit_factor=unit_factor, round_decimals=4)
|
||||
comparison_stats = calculate_window_statistics(
|
||||
comparison_result["intervals"], unit_factor=unit_factor, round_decimals=4
|
||||
)
|
||||
task_mean = task_stats.get("price_mean")
|
||||
comparison_mean = comparison_stats.get("price_mean")
|
||||
if task_mean is None or comparison_mean is None:
|
||||
return None
|
||||
|
||||
comparison_window_start = comparison_result["intervals"][0]["startsAt"]
|
||||
if not isinstance(comparison_window_start, str):
|
||||
comparison_window_start = comparison_window_start.isoformat()
|
||||
|
||||
result: dict[str, float | str | None] = {
|
||||
"comparison_price_mean": comparison_mean,
|
||||
"price_difference": abs(round(float(comparison_mean) - float(task_mean), 4)),
|
||||
"comparison_window_start": comparison_window_start,
|
||||
}
|
||||
|
||||
if include_details:
|
||||
result["comparison_price_min"] = comparison_stats.get("price_min")
|
||||
result["comparison_price_max"] = comparison_stats.get("price_max")
|
||||
last_start = comparison_result["intervals"][-1]["startsAt"]
|
||||
if not isinstance(last_start, str):
|
||||
last_start = last_start.isoformat()
|
||||
result["comparison_window_end"] = (
|
||||
datetime.fromisoformat(last_start) + timedelta(minutes=INTERVAL_MINUTES)
|
||||
).isoformat()
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _determine_schedule_reason(
|
||||
*,
|
||||
all_tasks_scheduled: bool,
|
||||
assignments_count: int,
|
||||
price_info: list[dict[str, Any]],
|
||||
filtered_price_info: list[dict[str, Any]],
|
||||
level_filter_active: bool,
|
||||
) -> str | None:
|
||||
"""Classify schedule outcome reason for automation-friendly no-result handling."""
|
||||
if all_tasks_scheduled:
|
||||
return None
|
||||
if not price_info:
|
||||
return "no_data_in_range"
|
||||
if level_filter_active and not filtered_price_info:
|
||||
return "no_intervals_matching_level_filter"
|
||||
if assignments_count == 0:
|
||||
return "insufficient_contiguous_window"
|
||||
return "insufficient_contiguous_window_for_some_tasks"
|
||||
|
||||
|
||||
def _find_cheapest_window_in_pool(
|
||||
pool: list[dict[str, Any]],
|
||||
duration_intervals: int,
|
||||
|
|
@ -156,6 +223,8 @@ async def handle_find_cheapest_schedule(call: ServiceCall) -> ServiceResponse:
|
|||
use_base_unit: bool = call.data.get("use_base_unit", False)
|
||||
max_price_level: str | None = call.data.get("max_price_level")
|
||||
min_price_level: str | None = call.data.get("min_price_level")
|
||||
include_comparison_details: bool = call.data.get("include_comparison_details", False)
|
||||
level_filter_active = min_price_level is not None or max_price_level is not None
|
||||
|
||||
# Round gap up to nearest quarter interval
|
||||
gap_intervals = math.ceil(gap_minutes / INTERVAL_MINUTES) if gap_minutes > 0 else 0
|
||||
|
|
@ -236,6 +305,13 @@ async def handle_find_cheapest_schedule(call: ServiceCall) -> ServiceResponse:
|
|||
filtered_price_info = filter_intervals_by_price_level(price_info, min_price_level, max_price_level)
|
||||
|
||||
if not filtered_price_info:
|
||||
reason = _determine_schedule_reason(
|
||||
all_tasks_scheduled=False,
|
||||
assignments_count=0,
|
||||
price_info=price_info,
|
||||
filtered_price_info=filtered_price_info,
|
||||
level_filter_active=level_filter_active,
|
||||
)
|
||||
return {
|
||||
"home_id": home_id,
|
||||
"search_start": search_start.isoformat(),
|
||||
|
|
@ -243,6 +319,7 @@ async def handle_find_cheapest_schedule(call: ServiceCall) -> ServiceResponse:
|
|||
"currency": currency,
|
||||
"price_unit": price_unit,
|
||||
"all_tasks_scheduled": False,
|
||||
"reason": reason,
|
||||
"tasks": [],
|
||||
"total_estimated_cost": None,
|
||||
}
|
||||
|
|
@ -295,6 +372,12 @@ async def handle_find_cheapest_schedule(call: ServiceCall) -> ServiceResponse:
|
|||
"duration_minutes": task["duration_minutes"],
|
||||
**stats,
|
||||
"intervals": task_response_intervals,
|
||||
"price_comparison": _compute_task_price_comparison(
|
||||
task_intervals,
|
||||
price_info,
|
||||
unit_factor,
|
||||
include_details=include_comparison_details,
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -308,6 +391,13 @@ async def handle_find_cheapest_schedule(call: ServiceCall) -> ServiceResponse:
|
|||
total_estimated_cost = round(sum(total_cost_values), 4) if total_cost_values else None
|
||||
|
||||
all_scheduled = len(unscheduled) == 0
|
||||
reason = _determine_schedule_reason(
|
||||
all_tasks_scheduled=all_scheduled,
|
||||
assignments_count=len(assignments),
|
||||
price_info=price_info,
|
||||
filtered_price_info=filtered_price_info,
|
||||
level_filter_active=level_filter_active,
|
||||
)
|
||||
|
||||
_LOGGER.info(
|
||||
"%s: scheduled %d/%d tasks, total_cost=%s",
|
||||
|
|
@ -324,6 +414,7 @@ async def handle_find_cheapest_schedule(call: ServiceCall) -> ServiceResponse:
|
|||
"currency": currency,
|
||||
"price_unit": price_unit,
|
||||
"all_tasks_scheduled": all_scheduled,
|
||||
"reason": reason,
|
||||
"unscheduled_tasks": unscheduled or None,
|
||||
"tasks": assignments,
|
||||
"total_estimated_cost": total_estimated_cost,
|
||||
|
|
|
|||
|
|
@ -1027,6 +1027,39 @@
|
|||
"ready": "Bereit",
|
||||
"error": "Fehler"
|
||||
}
|
||||
},
|
||||
"current_interval_price_rank_today": {
|
||||
"name": "Aktueller Preisrang (heute)"
|
||||
},
|
||||
"current_interval_price_rank_tomorrow": {
|
||||
"name": "Aktueller Preisrang (morgen)"
|
||||
},
|
||||
"current_interval_price_rank_today_tomorrow": {
|
||||
"name": "Aktueller Preisrang (heute+morgen)"
|
||||
},
|
||||
"next_interval_price_rank_today": {
|
||||
"name": "Nächster Preisrang (heute)"
|
||||
},
|
||||
"next_interval_price_rank_today_tomorrow": {
|
||||
"name": "Nächster Preisrang (heute+morgen)"
|
||||
},
|
||||
"previous_interval_price_rank_today": {
|
||||
"name": "Letzter Preisrang (heute)"
|
||||
},
|
||||
"previous_interval_price_rank_today_tomorrow": {
|
||||
"name": "Letzter Preisrang (heute+morgen)"
|
||||
},
|
||||
"current_hour_price_rank_today": {
|
||||
"name": "⌀ Stündlicher Preisrang Aktuell (heute)"
|
||||
},
|
||||
"current_hour_price_rank_today_tomorrow": {
|
||||
"name": "⌀ Stündlicher Preisrang Aktuell (heute+morgen)"
|
||||
},
|
||||
"next_hour_price_rank_today": {
|
||||
"name": "⌀ Stündlicher Preisrang Nächste (heute)"
|
||||
},
|
||||
"next_hour_price_rank_today_tomorrow": {
|
||||
"name": "⌀ Stündlicher Preisrang Nächste (heute+morgen)"
|
||||
}
|
||||
},
|
||||
"binary_sensor": {
|
||||
|
|
@ -1847,6 +1880,10 @@
|
|||
"name": "Minimale Preisstufe",
|
||||
"description": "Nur Intervalle ab dieser Tibber-Preisstufe beruecksichtigen. Nuetzlich fuer find_most_expensive, um wirklich teure Intervalle zu fokussieren."
|
||||
},
|
||||
"include_comparison_details": {
|
||||
"name": "Vergleichsdetails einbeziehen",
|
||||
"description": "Fuegt pro Aufgabe zusaetzliche price_comparison-Details hinzu (comparison_price_min, comparison_price_max, comparison_window_end), um das gefundene Zeitfenster mit dem gegenteiligen Extremfenster gleicher Dauer zu vergleichen."
|
||||
},
|
||||
"use_base_unit": {
|
||||
"name": "Basiswährung verwenden",
|
||||
"description": "Preise in Basiswährung (EUR, NOK) statt der konfigurierten Anzeigeeinheit (ct, øre) erzwingen. Nützlich für Berechnungen."
|
||||
|
|
|
|||
|
|
@ -1027,6 +1027,39 @@
|
|||
"ready": "Ready",
|
||||
"error": "Error"
|
||||
}
|
||||
},
|
||||
"current_interval_price_rank_today": {
|
||||
"name": "Current Price Rank (Today)"
|
||||
},
|
||||
"current_interval_price_rank_tomorrow": {
|
||||
"name": "Current Price Rank (Tomorrow)"
|
||||
},
|
||||
"current_interval_price_rank_today_tomorrow": {
|
||||
"name": "Current Price Rank (Today+Tomorrow)"
|
||||
},
|
||||
"next_interval_price_rank_today": {
|
||||
"name": "Next Price Rank (Today)"
|
||||
},
|
||||
"next_interval_price_rank_today_tomorrow": {
|
||||
"name": "Next Price Rank (Today+Tomorrow)"
|
||||
},
|
||||
"previous_interval_price_rank_today": {
|
||||
"name": "Last Price Rank (Today)"
|
||||
},
|
||||
"previous_interval_price_rank_today_tomorrow": {
|
||||
"name": "Last Price Rank (Today+Tomorrow)"
|
||||
},
|
||||
"current_hour_price_rank_today": {
|
||||
"name": "⌀ Hourly Price Current Rank (Today)"
|
||||
},
|
||||
"current_hour_price_rank_today_tomorrow": {
|
||||
"name": "⌀ Hourly Price Current Rank (Today+Tomorrow)"
|
||||
},
|
||||
"next_hour_price_rank_today": {
|
||||
"name": "⌀ Hourly Price Next Rank (Today)"
|
||||
},
|
||||
"next_hour_price_rank_today_tomorrow": {
|
||||
"name": "⌀ Hourly Price Next Rank (Today+Tomorrow)"
|
||||
}
|
||||
},
|
||||
"binary_sensor": {
|
||||
|
|
@ -1387,7 +1420,7 @@
|
|||
},
|
||||
"find_cheapest_block": {
|
||||
"name": "Find Cheapest Block",
|
||||
"description": "Finds the cheapest contiguous time window of a given duration. Designed for appliance scheduling: dishwasher, washing machine, dryer, etc. Returns the single cheapest window with start/end times and price statistics.",
|
||||
"description": "Finds the cheapest contiguous time window of a given duration. Designed for appliance scheduling: dishwasher, washing machine, dryer, etc. Returns the single cheapest window with start/end times and price statistics. If no window is found, the response includes a stable reason code in the reason field (for example: no_data_in_range, no_intervals_matching_level_filter, insufficient_intervals_after_filter, insufficient_contiguous_window).",
|
||||
"sections": {
|
||||
"search_range": {
|
||||
"name": "Search Range",
|
||||
|
|
@ -1571,7 +1604,7 @@
|
|||
},
|
||||
"find_cheapest_hours": {
|
||||
"name": "Find Cheapest Hours",
|
||||
"description": "Finds the cheapest intervals totaling a given duration, not necessarily contiguous. Designed for flexible loads: battery charging, EV, water heater. Returns a schedule of intervals grouped into contiguous segments.",
|
||||
"description": "Finds the cheapest intervals totaling a given duration, not necessarily contiguous. Designed for flexible loads: battery charging, EV, water heater. Returns a schedule of intervals grouped into contiguous segments. If no schedule is found, the response includes a stable reason code in the reason field (for example: no_data_in_range, no_intervals_matching_level_filter, insufficient_intervals_after_filter, insufficient_intervals_for_constraints).",
|
||||
"sections": {
|
||||
"search_range": {
|
||||
"name": "Search Range",
|
||||
|
|
@ -1763,7 +1796,7 @@
|
|||
},
|
||||
"find_cheapest_schedule": {
|
||||
"name": "Find Cheapest Schedule",
|
||||
"description": "Schedules multiple appliances optimally without time overlap. Each task gets the cheapest available contiguous window; tasks are placed greedily in ascending cost order. Returns a per-task schedule with start/end times and price stats.",
|
||||
"description": "Schedules multiple appliances optimally without time overlap. Each task gets the cheapest available contiguous window; tasks are placed greedily in ascending cost order. Returns a per-task schedule with start/end times and price stats. If scheduling is incomplete, the response includes a stable reason code in the reason field (for example: no_data_in_range, no_intervals_matching_level_filter, insufficient_contiguous_window, insufficient_contiguous_window_for_some_tasks).",
|
||||
"sections": {
|
||||
"scheduling_options": {
|
||||
"name": "Scheduling Options",
|
||||
|
|
@ -1847,6 +1880,10 @@
|
|||
"name": "Minimum Price Level",
|
||||
"description": "Only consider intervals at or above this Tibber price level. Useful for find_most_expensive to focus on truly expensive intervals."
|
||||
},
|
||||
"include_comparison_details": {
|
||||
"name": "Include Comparison Details",
|
||||
"description": "Add per-task price_comparison details (comparison_price_min, comparison_price_max, comparison_window_end) to compare each selected task window against the opposite extreme window of the same duration."
|
||||
},
|
||||
"use_base_unit": {
|
||||
"name": "Use Base Currency Unit",
|
||||
"description": "Force prices in base currency (EUR, NOK) instead of the configured display unit (ct, øre). Useful for calculations."
|
||||
|
|
|
|||
|
|
@ -1027,6 +1027,39 @@
|
|||
"ready": "Klar",
|
||||
"error": "Feil"
|
||||
}
|
||||
},
|
||||
"current_interval_price_rank_today": {
|
||||
"name": "Aktuell prisrang (i dag)"
|
||||
},
|
||||
"current_interval_price_rank_tomorrow": {
|
||||
"name": "Aktuell prisrang (i morgen)"
|
||||
},
|
||||
"current_interval_price_rank_today_tomorrow": {
|
||||
"name": "Aktuell prisrang (i dag+i morgen)"
|
||||
},
|
||||
"next_interval_price_rank_today": {
|
||||
"name": "Neste prisrang (i dag)"
|
||||
},
|
||||
"next_interval_price_rank_today_tomorrow": {
|
||||
"name": "Neste prisrang (i dag+i morgen)"
|
||||
},
|
||||
"previous_interval_price_rank_today": {
|
||||
"name": "Forrige prisrang (i dag)"
|
||||
},
|
||||
"previous_interval_price_rank_today_tomorrow": {
|
||||
"name": "Forrige prisrang (i dag+i morgen)"
|
||||
},
|
||||
"current_hour_price_rank_today": {
|
||||
"name": "⌀ Timesprisrang nå (i dag)"
|
||||
},
|
||||
"current_hour_price_rank_today_tomorrow": {
|
||||
"name": "⌀ Timesprisrang nå (i dag+i morgen)"
|
||||
},
|
||||
"next_hour_price_rank_today": {
|
||||
"name": "⌀ Timesprisrang neste (i dag)"
|
||||
},
|
||||
"next_hour_price_rank_today_tomorrow": {
|
||||
"name": "⌀ Timesprisrang neste (i dag+i morgen)"
|
||||
}
|
||||
},
|
||||
"binary_sensor": {
|
||||
|
|
@ -1847,6 +1880,10 @@
|
|||
"name": "Minimalt prisnivaae",
|
||||
"description": "Ta bare med intervaller paa eller over dette Tibber-prisnivaeet. Nyttig for find_most_expensive for aa fokusere paa virkelig dyre intervaller."
|
||||
},
|
||||
"include_comparison_details": {
|
||||
"name": "Inkluder sammenligningsdetaljer",
|
||||
"description": "Legger til ekstra price_comparison-detaljer per oppgave (comparison_price_min, comparison_price_max, comparison_window_end) for aa sammenligne valgt vindu med motsatt ekstremvindu med samme varighet."
|
||||
},
|
||||
"use_base_unit": {
|
||||
"name": "Bruk basisvaluta",
|
||||
"description": "Tving priser i basisvaluta (EUR, NOK) i stedet for konfigurert visningsenhet (ct, øre). Nyttig for beregninger."
|
||||
|
|
|
|||
|
|
@ -1027,6 +1027,39 @@
|
|||
"ready": "Gereed",
|
||||
"error": "Fout"
|
||||
}
|
||||
},
|
||||
"current_interval_price_rank_today": {
|
||||
"name": "Huidige prijsrang (vandaag)"
|
||||
},
|
||||
"current_interval_price_rank_tomorrow": {
|
||||
"name": "Huidige prijsrang (morgen)"
|
||||
},
|
||||
"current_interval_price_rank_today_tomorrow": {
|
||||
"name": "Huidige prijsrang (vandaag+morgen)"
|
||||
},
|
||||
"next_interval_price_rank_today": {
|
||||
"name": "Volgende prijsrang (vandaag)"
|
||||
},
|
||||
"next_interval_price_rank_today_tomorrow": {
|
||||
"name": "Volgende prijsrang (vandaag+morgen)"
|
||||
},
|
||||
"previous_interval_price_rank_today": {
|
||||
"name": "Vorige prijsrang (vandaag)"
|
||||
},
|
||||
"previous_interval_price_rank_today_tomorrow": {
|
||||
"name": "Vorige prijsrang (vandaag+morgen)"
|
||||
},
|
||||
"current_hour_price_rank_today": {
|
||||
"name": "⌀ Uurlijkse prijsrang huidig (vandaag)"
|
||||
},
|
||||
"current_hour_price_rank_today_tomorrow": {
|
||||
"name": "⌀ Uurlijkse prijsrang huidig (vandaag+morgen)"
|
||||
},
|
||||
"next_hour_price_rank_today": {
|
||||
"name": "⌀ Uurlijkse prijsrang volgende (vandaag)"
|
||||
},
|
||||
"next_hour_price_rank_today_tomorrow": {
|
||||
"name": "⌀ Uurlijkse prijsrang volgende (vandaag+morgen)"
|
||||
}
|
||||
},
|
||||
"binary_sensor": {
|
||||
|
|
@ -1847,6 +1880,10 @@
|
|||
"name": "Minimaal prijsniveau",
|
||||
"description": "Overweeg alleen intervallen op of boven dit Tibber-prijsniveau. Nuttig voor find_most_expensive om te focussen op echt dure intervallen."
|
||||
},
|
||||
"include_comparison_details": {
|
||||
"name": "Vergelijkingsdetails opnemen",
|
||||
"description": "Voegt per taak extra price_comparison-details toe (comparison_price_min, comparison_price_max, comparison_window_end) om het gekozen venster te vergelijken met het tegenovergestelde extreme venster met dezelfde duur."
|
||||
},
|
||||
"use_base_unit": {
|
||||
"name": "Basisvaluta gebruiken",
|
||||
"description": "Forceer prijzen in basisvaluta (EUR, NOK) in plaats van de geconfigureerde weergave-eenheid (ct, øre). Handig voor berekeningen."
|
||||
|
|
|
|||
|
|
@ -1027,6 +1027,39 @@
|
|||
"ready": "Redo",
|
||||
"error": "Fel"
|
||||
}
|
||||
},
|
||||
"current_interval_price_rank_today": {
|
||||
"name": "Aktuellt prisrang (idag)"
|
||||
},
|
||||
"current_interval_price_rank_tomorrow": {
|
||||
"name": "Aktuellt prisrang (imorgon)"
|
||||
},
|
||||
"current_interval_price_rank_today_tomorrow": {
|
||||
"name": "Aktuellt prisrang (idag+imorgon)"
|
||||
},
|
||||
"next_interval_price_rank_today": {
|
||||
"name": "Nästa prisrang (idag)"
|
||||
},
|
||||
"next_interval_price_rank_today_tomorrow": {
|
||||
"name": "Nästa prisrang (idag+imorgon)"
|
||||
},
|
||||
"previous_interval_price_rank_today": {
|
||||
"name": "Förra prisrang (idag)"
|
||||
},
|
||||
"previous_interval_price_rank_today_tomorrow": {
|
||||
"name": "Förra prisrang (idag+imorgon)"
|
||||
},
|
||||
"current_hour_price_rank_today": {
|
||||
"name": "⌀ Timprisrang aktuell (idag)"
|
||||
},
|
||||
"current_hour_price_rank_today_tomorrow": {
|
||||
"name": "⌀ Timprisrang aktuell (idag+imorgon)"
|
||||
},
|
||||
"next_hour_price_rank_today": {
|
||||
"name": "⌀ Timprisrang nästa (idag)"
|
||||
},
|
||||
"next_hour_price_rank_today_tomorrow": {
|
||||
"name": "⌀ Timprisrang nästa (idag+imorgon)"
|
||||
}
|
||||
},
|
||||
"binary_sensor": {
|
||||
|
|
@ -1847,6 +1880,10 @@
|
|||
"name": "Minimal prisnivaae",
|
||||
"description": "Ta bara med intervall paa eller oever denna Tibber-prisnivaae. Anvaendbart foer find_most_expensive foer att fokusera paa verkligt dyra intervall."
|
||||
},
|
||||
"include_comparison_details": {
|
||||
"name": "Inkludera jaemfoerelsedetaljer",
|
||||
"description": "Laegger till extra price_comparison-detaljer per uppgift (comparison_price_min, comparison_price_max, comparison_window_end) foer att jaemfoera valt foenster med motsatt extremfoenster med samma laengd."
|
||||
},
|
||||
"use_base_unit": {
|
||||
"name": "Använd basvaluta",
|
||||
"description": "Tvinga priser i basvaluta (EUR, NOK) istället för konfigurerad visningsenhet (ct, öre). Användbart för beräkningar."
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import bisect
|
||||
import logging
|
||||
import statistics
|
||||
from datetime import datetime, timedelta
|
||||
|
|
@ -176,6 +177,104 @@ def calculate_volatility_level(
|
|||
return level
|
||||
|
||||
|
||||
MIN_PRICES_FOR_IQR = 4 # Minimum price values needed for meaningful IQR calculation
|
||||
|
||||
|
||||
def calculate_iqr_stats(prices: list[float]) -> dict[str, Any] | None:
|
||||
"""
|
||||
Calculate Interquartile Range (IQR) statistics from a price list.
|
||||
|
||||
IQR = Q75 - Q25, representing the spread of the central 50% of prices.
|
||||
This is more robust to outliers than coefficient of variation because
|
||||
extreme values (price spikes or negative prices) don't distort the result.
|
||||
|
||||
Args:
|
||||
prices: List of price values (in any unit, e.g. EUR or NOK per kWh)
|
||||
|
||||
Returns:
|
||||
Dict with keys:
|
||||
- q25: 25th percentile (lower quartile)
|
||||
- median: 50th percentile (median)
|
||||
- q75: 75th percentile (upper quartile)
|
||||
- iqr: Interquartile range (q75 - q25)
|
||||
- iqr_pct: Relative IQR as percentage of median (None if median is 0)
|
||||
- outlier_count: Intervals outside Tukey fences [Q25 - 1.5xIQR, Q75 + 1.5xIQR]
|
||||
Returns None if fewer than MIN_PRICES_FOR_IQR prices are provided.
|
||||
|
||||
Examples:
|
||||
- iqr_pct ~5%: Very tight price band, stability similar to CV
|
||||
- iqr_pct ~20%: Moderate spread in the core price range
|
||||
- iqr_pct ~50%: Wide core spread, significant optimization potential
|
||||
- outlier_count > 0: Isolated price spikes/dips exist (CV-heavy days)
|
||||
|
||||
"""
|
||||
if len(prices) < MIN_PRICES_FOR_IQR:
|
||||
return None
|
||||
|
||||
quartiles = statistics.quantiles(prices, n=4) # Returns [Q25, Q50, Q75]
|
||||
q25 = quartiles[0]
|
||||
median = quartiles[1]
|
||||
q75 = quartiles[2]
|
||||
iqr = q75 - q25
|
||||
|
||||
# Relative IQR: normalized by median for cross-price-level comparison
|
||||
iqr_pct = (iqr / abs(median) * 100) if median != 0 else None
|
||||
|
||||
# Tukey fence outlier detection (standard method)
|
||||
lower_fence = q25 - 1.5 * iqr
|
||||
upper_fence = q75 + 1.5 * iqr
|
||||
outlier_count = sum(1 for p in prices if p < lower_fence or p > upper_fence)
|
||||
|
||||
return {
|
||||
"q25": q25,
|
||||
"median": median,
|
||||
"q75": q75,
|
||||
"iqr": iqr,
|
||||
"iqr_pct": iqr_pct,
|
||||
"outlier_count": outlier_count,
|
||||
}
|
||||
|
||||
|
||||
def calculate_percentile_rank(current_price: float, prices: list[float]) -> float | None:
|
||||
"""
|
||||
Calculate where the current price ranks among a reference price set.
|
||||
|
||||
Returns the percentage of prices in the reference set that are strictly
|
||||
cheaper than current_price. A value of 0% means the current price is at
|
||||
or below the cheapest reference price; ~99% means nearly everything is
|
||||
cheaper (current price near the maximum).
|
||||
|
||||
The current interval's own price is included in today's reference set,
|
||||
so the cheapest interval of the day always returns 0%.
|
||||
|
||||
Args:
|
||||
current_price: The price to rank (any unit, must match prices unit)
|
||||
prices: Reference price list to rank against
|
||||
|
||||
Returns:
|
||||
Percentile rank as float 0.0-100.0 (1 decimal precision), or None if
|
||||
reference list is empty.
|
||||
|
||||
Examples (8 intervals: [8, 10, 12, 15, 15, 18, 20, 22]):
|
||||
- current=8: 0/8 x 100 = 0.0% (cheapest)
|
||||
- current=15: 3/8 x 100 = 37.5% (above 3 cheaper intervals)
|
||||
- current=22: 7/8 x 100 = 87.5% (most expensive)
|
||||
|
||||
Note:
|
||||
Equal prices: All duplicate prices at the current level are counted as
|
||||
"not below" the current price (bisect_left semantics). This matches
|
||||
automation logic: "is now cheap?" returns False if current == minimum
|
||||
is debatable but ensures 0% always means strictly cheapest.
|
||||
|
||||
"""
|
||||
if not prices:
|
||||
return None
|
||||
|
||||
sorted_prices = sorted(prices)
|
||||
count_below = bisect.bisect_left(sorted_prices, current_price)
|
||||
return round(count_below / len(sorted_prices) * 100, 1)
|
||||
|
||||
|
||||
def calculate_trailing_average_for_interval(
|
||||
interval_start: datetime,
|
||||
all_prices: list[dict[str, Any]],
|
||||
|
|
|
|||
|
|
@ -22,30 +22,30 @@ Fetches home information and metadata:
|
|||
|
||||
```graphql
|
||||
query {
|
||||
viewer {
|
||||
homes {
|
||||
id
|
||||
appNickname
|
||||
address {
|
||||
address1
|
||||
postalCode
|
||||
city
|
||||
country
|
||||
}
|
||||
timeZone
|
||||
currentSubscription {
|
||||
priceInfo {
|
||||
current {
|
||||
currency
|
||||
}
|
||||
viewer {
|
||||
homes {
|
||||
id
|
||||
appNickname
|
||||
address {
|
||||
address1
|
||||
postalCode
|
||||
city
|
||||
country
|
||||
}
|
||||
timeZone
|
||||
currentSubscription {
|
||||
priceInfo {
|
||||
current {
|
||||
currency
|
||||
}
|
||||
}
|
||||
}
|
||||
meteringPointData {
|
||||
consumptionEan
|
||||
gridAreaCode
|
||||
}
|
||||
}
|
||||
}
|
||||
meteringPointData {
|
||||
consumptionEan
|
||||
gridAreaCode
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -56,26 +56,27 @@ query {
|
|||
Fetches quarter-hourly prices:
|
||||
|
||||
```graphql
|
||||
query($homeId: ID!) {
|
||||
viewer {
|
||||
home(id: $homeId) {
|
||||
currentSubscription {
|
||||
priceInfo {
|
||||
range(resolution: QUARTER_HOURLY, first: 384) {
|
||||
nodes {
|
||||
total
|
||||
startsAt
|
||||
level
|
||||
query ($homeId: ID!) {
|
||||
viewer {
|
||||
home(id: $homeId) {
|
||||
currentSubscription {
|
||||
priceInfo {
|
||||
range(resolution: QUARTER_HOURLY, first: 384) {
|
||||
nodes {
|
||||
total
|
||||
startsAt
|
||||
level
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `homeId`: Tibber home identifier
|
||||
- `resolution`: Always `QUARTER_HOURLY`
|
||||
- `first`: 384 intervals (4 days of data)
|
||||
|
|
@ -85,10 +86,12 @@ query($homeId: ID!) {
|
|||
## Rate Limits
|
||||
|
||||
Tibber API rate limits (as of 2024):
|
||||
|
||||
- **5000 requests per hour** per token
|
||||
- **Burst limit:** 100 requests per minute
|
||||
|
||||
Integration stays well below these limits:
|
||||
|
||||
- Polls every 15 minutes = 96 requests/day
|
||||
- User data cached for 24h = 1 request/day
|
||||
- **Total:** ~100 requests/day per home
|
||||
|
|
@ -99,13 +102,14 @@ Integration stays well below these limits:
|
|||
|
||||
```json
|
||||
{
|
||||
"total": 0.2456,
|
||||
"startsAt": "2024-12-06T14:00:00.000+01:00",
|
||||
"level": "NORMAL"
|
||||
"total": 0.2456,
|
||||
"startsAt": "2024-12-06T14:00:00.000+01:00",
|
||||
"level": "NORMAL"
|
||||
}
|
||||
```
|
||||
|
||||
**Fields:**
|
||||
|
||||
- `total`: Price including VAT and fees (currency's major unit, e.g., EUR)
|
||||
- `startsAt`: ISO 8601 timestamp with timezone
|
||||
- `level`: Tibber's own classification (VERY_CHEAP, CHEAP, NORMAL, EXPENSIVE, VERY_EXPENSIVE)
|
||||
|
|
@ -114,11 +118,12 @@ Integration stays well below these limits:
|
|||
|
||||
```json
|
||||
{
|
||||
"currency": "EUR"
|
||||
"currency": "EUR"
|
||||
}
|
||||
```
|
||||
|
||||
Supported currencies:
|
||||
|
||||
- `EUR` (Euro) - displayed as ct/kWh
|
||||
- `NOK` (Norwegian Krone) - displayed as øre/kWh
|
||||
- `SEK` (Swedish Krona) - displayed as öre/kWh
|
||||
|
|
@ -128,42 +133,52 @@ Supported currencies:
|
|||
### Common Error Responses
|
||||
|
||||
**Invalid Token:**
|
||||
|
||||
```json
|
||||
{
|
||||
"errors": [{
|
||||
"message": "Unauthorized",
|
||||
"extensions": {
|
||||
"code": "UNAUTHENTICATED"
|
||||
}
|
||||
}]
|
||||
"errors": [
|
||||
{
|
||||
"message": "Unauthorized",
|
||||
"extensions": {
|
||||
"code": "UNAUTHENTICATED"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Rate Limit Exceeded:**
|
||||
|
||||
```json
|
||||
{
|
||||
"errors": [{
|
||||
"message": "Too Many Requests",
|
||||
"extensions": {
|
||||
"code": "RATE_LIMIT_EXCEEDED"
|
||||
}
|
||||
}]
|
||||
"errors": [
|
||||
{
|
||||
"message": "Too Many Requests",
|
||||
"extensions": {
|
||||
"code": "RATE_LIMIT_EXCEEDED"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Home Not Found:**
|
||||
|
||||
```json
|
||||
{
|
||||
"errors": [{
|
||||
"message": "Home not found",
|
||||
"extensions": {
|
||||
"code": "NOT_FOUND"
|
||||
}
|
||||
}]
|
||||
"errors": [
|
||||
{
|
||||
"message": "Home not found",
|
||||
"extensions": {
|
||||
"code": "NOT_FOUND"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Integration handles these with:
|
||||
|
||||
- Exponential backoff retry (3 attempts)
|
||||
- ConfigEntryAuthFailed for auth errors
|
||||
- ConfigEntryNotReady for temporary failures
|
||||
|
|
@ -171,6 +186,7 @@ Integration handles these with:
|
|||
## Data Transformation
|
||||
|
||||
Raw API data is enriched with:
|
||||
|
||||
- **Trailing 24h average** - Calculated from previous intervals
|
||||
- **Leading 24h average** - Calculated from future intervals
|
||||
- **Price difference %** - Deviation from average
|
||||
|
|
@ -181,6 +197,7 @@ See `utils/price.py` for enrichment logic.
|
|||
---
|
||||
|
||||
💡 **External Resources:**
|
||||
|
||||
- [Tibber API Documentation](https://developer.tibber.com/docs/overview)
|
||||
- [GraphQL Explorer](https://developer.tibber.com/explorer)
|
||||
- [Get API Token](https://developer.tibber.com/settings/access-token)
|
||||
|
|
|
|||
|
|
@ -100,43 +100,43 @@ flowchart TB
|
|||
### Flow Description
|
||||
|
||||
1. **Setup** (`__init__.py`)
|
||||
- Integration loads, creates coordinator instance
|
||||
- Registers entity platforms (sensor, binary_sensor)
|
||||
- Sets up custom services
|
||||
- Integration loads, creates coordinator instance
|
||||
- Registers entity platforms (sensor, binary_sensor)
|
||||
- Sets up custom services
|
||||
|
||||
2. **Data Fetch** (every 15 minutes)
|
||||
- Coordinator triggers update via `api.py`
|
||||
- API client checks **persistent cache** first (`coordinator/cache.py`)
|
||||
- If cache valid → return cached data
|
||||
- If cache stale → query Tibber GraphQL API
|
||||
- Store fresh data in persistent cache (survives HA restart)
|
||||
- Coordinator triggers update via `api.py`
|
||||
- API client checks **persistent cache** first (`coordinator/cache.py`)
|
||||
- If cache valid → return cached data
|
||||
- If cache stale → query Tibber GraphQL API
|
||||
- Store fresh data in persistent cache (survives HA restart)
|
||||
|
||||
3. **Price Enrichment**
|
||||
- Coordinator passes raw prices to `DataTransformer`
|
||||
- Transformer checks **transformation cache** (memory)
|
||||
- If cache valid → return enriched data
|
||||
- If cache invalid → enrich via `price_utils.py` + `average_utils.py`
|
||||
- Calculate 24h trailing/leading averages
|
||||
- Calculate price differences (% from average)
|
||||
- Assign rating levels (LOW/NORMAL/HIGH)
|
||||
- Store enriched data in transformation cache
|
||||
- Coordinator passes raw prices to `DataTransformer`
|
||||
- Transformer checks **transformation cache** (memory)
|
||||
- If cache valid → return enriched data
|
||||
- If cache invalid → enrich via `price_utils.py` + `average_utils.py`
|
||||
- Calculate 24h trailing/leading averages
|
||||
- Calculate price differences (% from average)
|
||||
- Assign rating levels (LOW/NORMAL/HIGH)
|
||||
- Store enriched data in transformation cache
|
||||
|
||||
4. **Period Calculation**
|
||||
- Coordinator passes enriched data to `PeriodCalculator`
|
||||
- Calculator computes **hash** from prices + config
|
||||
- If hash matches cache → return cached periods
|
||||
- If hash differs → recalculate best/peak price periods
|
||||
- Store periods with new hash
|
||||
- Coordinator passes enriched data to `PeriodCalculator`
|
||||
- Calculator computes **hash** from prices + config
|
||||
- If hash matches cache → return cached periods
|
||||
- If hash differs → recalculate best/peak price periods
|
||||
- Store periods with new hash
|
||||
|
||||
5. **Entity Updates**
|
||||
- Coordinator provides complete data (prices + periods)
|
||||
- Sensors read values via unified handlers
|
||||
- Binary sensors evaluate period states
|
||||
- Entities update on quarter-hour boundaries (00/15/30/45)
|
||||
- Coordinator provides complete data (prices + periods)
|
||||
- Sensors read values via unified handlers
|
||||
- Binary sensors evaluate period states
|
||||
- Entities update on quarter-hour boundaries (00/15/30/45)
|
||||
|
||||
6. **Service Calls**
|
||||
- Custom services access coordinator data directly
|
||||
- Return formatted responses (JSON, ApexCharts format)
|
||||
- Custom services access coordinator data directly
|
||||
- Return formatted responses (JSON, ApexCharts format)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -146,13 +146,13 @@ flowchart TB
|
|||
|
||||
The integration uses **5 independent caching layers** for optimal performance:
|
||||
|
||||
| Layer | Location | Lifetime | Invalidation | Memory |
|
||||
|-------|----------|----------|--------------|--------|
|
||||
| **API Cache** | `coordinator/cache.py` | 24h (user)<br/>Until midnight (prices) | Automatic | 50KB |
|
||||
| **Translation Cache** | `const.py` | Until HA restart | Never | 5KB |
|
||||
| **Config Cache** | `coordinator/*` | Until config change | Explicit | 1KB |
|
||||
| **Period Cache** | `coordinator/periods.py` | Until data/config change | Hash-based | 10KB |
|
||||
| **Transformation Cache** | `coordinator/data_transformation.py` | Until midnight/config | Automatic | 60KB |
|
||||
| Layer | Location | Lifetime | Invalidation | Memory |
|
||||
| ------------------------ | ------------------------------------ | -------------------------------------- | ------------ | ------ |
|
||||
| **API Cache** | `coordinator/cache.py` | 24h (user)<br/>Until midnight (prices) | Automatic | 50KB |
|
||||
| **Translation Cache** | `const.py` | Until HA restart | Never | 5KB |
|
||||
| **Config Cache** | `coordinator/*` | Until config change | Explicit | 1KB |
|
||||
| **Period Cache** | `coordinator/periods.py` | Until data/config change | Hash-based | 10KB |
|
||||
| **Transformation Cache** | `coordinator/data_transformation.py` | Until midnight/config | Automatic | 60KB |
|
||||
|
||||
**Total cache overhead:** ~126KB per coordinator instance (main entry + subentries)
|
||||
|
||||
|
|
@ -195,30 +195,31 @@ For detailed cache behavior, see [Caching Strategy](./caching-strategy.md).
|
|||
|
||||
### Core Components
|
||||
|
||||
| Component | File | Responsibility |
|
||||
|-----------|------|----------------|
|
||||
| **API Client** | `api.py` | GraphQL queries to Tibber, retry logic, error handling |
|
||||
| **Coordinator** | `coordinator.py` | Update orchestration, cache management, absolute-time scheduling with boundary tolerance |
|
||||
| **Data Transformer** | `coordinator/data_transformation.py` | Price enrichment (averages, ratings, differences) |
|
||||
| **Period Calculator** | `coordinator/periods.py` | Best/peak price period calculation with relaxation |
|
||||
| **Sensors** | `sensor/` | 80+ entities for prices, levels, ratings, statistics |
|
||||
| **Binary Sensors** | `binary_sensor/` | Period indicators (best/peak price active) |
|
||||
| **Services** | `services/` | Custom service endpoints (get_chartdata, get_apexcharts_yaml, refresh_user_data) |
|
||||
| Component | File | Responsibility |
|
||||
| --------------------- | ------------------------------------ | ---------------------------------------------------------------------------------------- |
|
||||
| **API Client** | `api.py` | GraphQL queries to Tibber, retry logic, error handling |
|
||||
| **Coordinator** | `coordinator.py` | Update orchestration, cache management, absolute-time scheduling with boundary tolerance |
|
||||
| **Data Transformer** | `coordinator/data_transformation.py` | Price enrichment (averages, ratings, differences) |
|
||||
| **Period Calculator** | `coordinator/periods.py` | Best/peak price period calculation with relaxation |
|
||||
| **Sensors** | `sensor/` | 80+ entities for prices, levels, ratings, statistics |
|
||||
| **Binary Sensors** | `binary_sensor/` | Period indicators (best/peak price active) |
|
||||
| **Services** | `services/` | Custom service endpoints (get_chartdata, get_apexcharts_yaml, refresh_user_data) |
|
||||
|
||||
### Sensor Architecture (Calculator Pattern)
|
||||
|
||||
The sensor platform uses **Calculator Pattern** for clean separation of concerns (refactored Nov 2025):
|
||||
|
||||
| Component | Files | Lines | Responsibility |
|
||||
|-----------|-------|-------|----------------|
|
||||
| **Entity Class** | `sensor/core.py` | 909 | Entity lifecycle, coordinator, delegates to calculators |
|
||||
| **Calculators** | `sensor/calculators/` | 1,838 | Business logic (8 specialized calculators) |
|
||||
| **Attributes** | `sensor/attributes/` | 1,209 | State presentation (8 specialized modules) |
|
||||
| **Routing** | `sensor/value_getters.py` | 276 | Centralized sensor → calculator mapping |
|
||||
| **Chart Export** | `sensor/chart_data.py` | 144 | Service call handling, YAML parsing |
|
||||
| **Helpers** | `sensor/helpers.py` | 188 | Aggregation functions, utilities |
|
||||
| Component | Files | Lines | Responsibility |
|
||||
| ---------------- | ------------------------- | ----- | ------------------------------------------------------- |
|
||||
| **Entity Class** | `sensor/core.py` | 909 | Entity lifecycle, coordinator, delegates to calculators |
|
||||
| **Calculators** | `sensor/calculators/` | 1,838 | Business logic (8 specialized calculators) |
|
||||
| **Attributes** | `sensor/attributes/` | 1,209 | State presentation (8 specialized modules) |
|
||||
| **Routing** | `sensor/value_getters.py` | 276 | Centralized sensor → calculator mapping |
|
||||
| **Chart Export** | `sensor/chart_data.py` | 144 | Service call handling, YAML parsing |
|
||||
| **Helpers** | `sensor/helpers.py` | 188 | Aggregation functions, utilities |
|
||||
|
||||
**Calculator Package** (`sensor/calculators/`):
|
||||
|
||||
- `base.py` - Abstract BaseCalculator with coordinator access
|
||||
- `interval.py` - Single interval calculations (current/next/previous)
|
||||
- `rolling_hour.py` - 5-interval rolling windows
|
||||
|
|
@ -230,6 +231,7 @@ The sensor platform uses **Calculator Pattern** for clean separation of concerns
|
|||
- `metadata.py` - Home/metering metadata
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- 58% reduction in core.py (2,170 → 909 lines)
|
||||
- Clear separation: Calculators (logic) vs Attributes (presentation)
|
||||
- Independent testability for each calculator
|
||||
|
|
@ -237,12 +239,12 @@ The sensor platform uses **Calculator Pattern** for clean separation of concerns
|
|||
|
||||
### Helper Utilities
|
||||
|
||||
| Utility | File | Purpose |
|
||||
|---------|------|---------|
|
||||
| **Price Utils** | `utils/price.py` | Rating calculation, enrichment, level aggregation |
|
||||
| **Average Utils** | `utils/average.py` | Trailing/leading 24h average calculations |
|
||||
| **Entity Utils** | `entity_utils/` | Shared icon/color/attribute logic |
|
||||
| **Translations** | `const.py` | Translation loading and caching |
|
||||
| Utility | File | Purpose |
|
||||
| ----------------- | ------------------ | ------------------------------------------------- |
|
||||
| **Price Utils** | `utils/price.py` | Rating calculation, enrichment, level aggregation |
|
||||
| **Average Utils** | `utils/average.py` | Trailing/leading 24h average calculations |
|
||||
| **Entity Utils** | `entity_utils/` | Shared icon/color/attribute logic |
|
||||
| **Translations** | `const.py` | Translation loading and caching |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -283,12 +285,12 @@ All quarter-hourly price intervals get augmented via `utils/price.py`:
|
|||
- **API polling**: Every 15 minutes (coordinator fetch cycle)
|
||||
- **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)`
|
||||
- HA may trigger ±few milliseconds before/after exact boundary
|
||||
- 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 restarts at 14:59:30 → stays at 14:45:00 (shows current interval data)
|
||||
- HA may trigger ±few milliseconds before/after exact boundary
|
||||
- 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 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)
|
||||
- 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
|
||||
|
||||
### 4. Calculator Pattern (Sensor Platform)
|
||||
|
|
@ -296,26 +298,31 @@ All quarter-hourly price intervals get augmented via `utils/price.py`:
|
|||
Sensors organized by **calculation method** (refactored Nov 2025):
|
||||
|
||||
**Unified Handler Methods** (`sensor/core.py`):
|
||||
|
||||
- `_get_interval_value(offset, type)` - current/next/previous intervals
|
||||
- `_get_rolling_hour_value(offset, type)` - 5-interval rolling windows
|
||||
- `_get_daily_stat_value(day, stat_func)` - calendar day min/max/avg
|
||||
- `_get_24h_window_value(stat_func)` - trailing/leading statistics
|
||||
|
||||
**Routing** (`sensor/value_getters.py`):
|
||||
|
||||
- Single source of truth mapping 80+ entity keys to calculator methods
|
||||
- Organized by calculation type (Interval, Rolling Hour, Daily Stats, etc.)
|
||||
|
||||
**Calculators** (`sensor/calculators/`):
|
||||
|
||||
- Each calculator inherits from `BaseCalculator` with coordinator access
|
||||
- Focused responsibility: `IntervalCalculator`, `TrendCalculator`, etc.
|
||||
- Complex logic isolated (e.g., `TrendCalculator` has internal caching)
|
||||
|
||||
**Attributes** (`sensor/attributes/`):
|
||||
|
||||
- Separate from business logic, handles state presentation
|
||||
- Builds extra_state_attributes dicts for entity classes
|
||||
- Unified builders: `build_sensor_attributes()`, `build_extra_state_attributes()`
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- Minimal code duplication across 80+ sensors
|
||||
- Clear separation of concerns (calculation vs presentation)
|
||||
- Easy to extend: Add sensor → choose pattern → add to routing
|
||||
|
|
@ -333,12 +340,12 @@ Sensors organized by **calculation method** (refactored Nov 2025):
|
|||
|
||||
### CPU Optimization
|
||||
|
||||
| Optimization | Location | Savings |
|
||||
|--------------|----------|---------|
|
||||
| Config caching | `coordinator/*` | ~50% on config checks |
|
||||
| Period caching | `coordinator/periods.py` | ~70% on period recalculation |
|
||||
| Lazy logging | Throughout | ~15% on log-heavy operations |
|
||||
| Import optimization | Module structure | ~20% faster loading |
|
||||
| Optimization | Location | Savings |
|
||||
| ------------------- | ------------------------ | ---------------------------- |
|
||||
| Config caching | `coordinator/*` | ~50% on config checks |
|
||||
| Period caching | `coordinator/periods.py` | ~70% on period recalculation |
|
||||
| Lazy logging | Throughout | ~15% on log-heavy operations |
|
||||
| Import optimization | Module structure | ~20% faster loading |
|
||||
|
||||
### Memory Usage
|
||||
|
||||
|
|
|
|||
|
|
@ -24,11 +24,13 @@ The integration uses **4 distinct caching layers** with different purposes and l
|
|||
**Purpose:** Reduce API calls to Tibber by caching user data and price data between HA restarts.
|
||||
|
||||
**What is cached:**
|
||||
|
||||
- **Price data** (`price_data`): Day before yesterday/yesterday/today/tomorrow price intervals with enriched fields (384 intervals total)
|
||||
- **User data** (`user_data`): Homes, subscriptions, features from Tibber GraphQL `viewer` query
|
||||
- **Timestamps**: Last update times for validation
|
||||
|
||||
**Lifetime:**
|
||||
|
||||
- **Price data**: Until midnight turnover (cleared daily at 00:00 local time)
|
||||
- **User data**: 24 hours (refreshed daily)
|
||||
- **Survives**: HA restarts via persistent Storage
|
||||
|
|
@ -36,29 +38,31 @@ The integration uses **4 distinct caching layers** with different purposes and l
|
|||
**Invalidation triggers:**
|
||||
|
||||
1. **Midnight turnover** (Timer #2 in coordinator):
|
||||
```python
|
||||
# coordinator/day_transitions.py
|
||||
def _handle_midnight_turnover() -> None:
|
||||
self._cached_price_data = None # Force fresh fetch for new day
|
||||
self._last_price_update = None
|
||||
await self.store_cache()
|
||||
```
|
||||
|
||||
```python
|
||||
# coordinator/day_transitions.py
|
||||
def _handle_midnight_turnover() -> None:
|
||||
self._cached_price_data = None # Force fresh fetch for new day
|
||||
self._last_price_update = None
|
||||
await self.store_cache()
|
||||
```
|
||||
|
||||
2. **Cache validation on load**:
|
||||
```python
|
||||
# coordinator/cache.py
|
||||
def is_cache_valid(cache_data: CacheData) -> bool:
|
||||
# Checks if price data is from a previous day
|
||||
if today_date < local_now.date(): # Yesterday's data
|
||||
return False
|
||||
```
|
||||
|
||||
```python
|
||||
# coordinator/cache.py
|
||||
def is_cache_valid(cache_data: CacheData) -> bool:
|
||||
# Checks if price data is from a previous day
|
||||
if today_date < local_now.date(): # Yesterday's data
|
||||
return False
|
||||
```
|
||||
|
||||
3. **Tomorrow data check** (after 13:00):
|
||||
```python
|
||||
# coordinator/data_fetching.py
|
||||
if tomorrow_missing or tomorrow_invalid:
|
||||
return "tomorrow_check" # Update needed
|
||||
```
|
||||
```python
|
||||
# coordinator/data_fetching.py
|
||||
if tomorrow_missing or tomorrow_invalid:
|
||||
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.
|
||||
|
||||
|
|
@ -71,18 +75,22 @@ The integration uses **4 distinct caching layers** with different purposes and l
|
|||
**Purpose:** Avoid repeated file I/O when accessing entity descriptions, UI strings, etc.
|
||||
|
||||
**What is cached:**
|
||||
|
||||
- **Standard translations** (`/translations/*.json`): Config flow, selector options, entity names
|
||||
- **Custom translations** (`/custom_translations/*.json`): Entity descriptions, usage tips, long descriptions
|
||||
|
||||
**Lifetime:**
|
||||
|
||||
- **Forever** (until HA restart)
|
||||
- No invalidation during runtime
|
||||
|
||||
**When populated:**
|
||||
|
||||
- At integration setup: `async_load_translations(hass, "en")` in `__init__.py`
|
||||
- Lazy loading: If translation missing, attempts file load once
|
||||
|
||||
**Access pattern:**
|
||||
|
||||
```python
|
||||
# Non-blocking synchronous access from cached data
|
||||
description = get_translation("binary_sensor.best_price_period.description", "en")
|
||||
|
|
@ -101,6 +109,7 @@ description = get_translation("binary_sensor.best_price_period.description", "en
|
|||
**What is cached:**
|
||||
|
||||
### DataTransformer Config Cache
|
||||
|
||||
```python
|
||||
{
|
||||
"thresholds": {"low": 15, "high": 35},
|
||||
|
|
@ -110,6 +119,7 @@ description = get_translation("binary_sensor.best_price_period.description", "en
|
|||
```
|
||||
|
||||
### PeriodCalculator Config Cache
|
||||
|
||||
```python
|
||||
{
|
||||
"best": {"flex": 0.15, "min_distance_from_avg": 5.0, "min_period_length": 60},
|
||||
|
|
@ -118,20 +128,23 @@ description = get_translation("binary_sensor.best_price_period.description", "en
|
|||
```
|
||||
|
||||
**Lifetime:**
|
||||
|
||||
- Until `invalidate_config_cache()` is called
|
||||
- Built once on first use per coordinator update cycle
|
||||
|
||||
**Invalidation trigger:**
|
||||
|
||||
- **Options change** (user reconfigures integration):
|
||||
```python
|
||||
# coordinator/core.py
|
||||
async def _handle_options_update(...) -> None:
|
||||
self._data_transformer.invalidate_config_cache()
|
||||
self._period_calculator.invalidate_config_cache()
|
||||
await self.async_request_refresh()
|
||||
```
|
||||
```python
|
||||
# coordinator/core.py
|
||||
async def _handle_options_update(...) -> None:
|
||||
self._data_transformer.invalidate_config_cache()
|
||||
self._period_calculator.invalidate_config_cache()
|
||||
await self.async_request_refresh()
|
||||
```
|
||||
|
||||
**Performance impact:**
|
||||
|
||||
- **Before:** ~30 dict lookups + type conversions per update = ~50μs
|
||||
- **After:** 1 cache check = ~1μs
|
||||
- **Savings:** ~98% (50μs → 1μs per update)
|
||||
|
|
@ -147,6 +160,7 @@ description = get_translation("binary_sensor.best_price_period.description", "en
|
|||
**Purpose:** Avoid expensive period calculations (~100-500ms) when price data and config haven't changed.
|
||||
|
||||
**What is cached:**
|
||||
|
||||
```python
|
||||
{
|
||||
"best_price": {
|
||||
|
|
@ -161,6 +175,7 @@ description = get_translation("binary_sensor.best_price_period.description", "en
|
|||
```
|
||||
|
||||
**Cache key:** Hash of relevant inputs
|
||||
|
||||
```python
|
||||
hash_data = (
|
||||
today_signature, # (startsAt, rating_level) for each interval
|
||||
|
|
@ -172,6 +187,7 @@ hash_data = (
|
|||
```
|
||||
|
||||
**Lifetime:**
|
||||
|
||||
- Until price data changes (today's intervals modified)
|
||||
- Until config changes (flex, thresholds, filters)
|
||||
- Recalculated at midnight (new today data)
|
||||
|
|
@ -179,24 +195,27 @@ hash_data = (
|
|||
**Invalidation triggers:**
|
||||
|
||||
1. **Config change** (explicit):
|
||||
```python
|
||||
def invalidate_config_cache() -> None:
|
||||
self._cached_periods = None
|
||||
self._last_periods_hash = None
|
||||
```
|
||||
|
||||
```python
|
||||
def invalidate_config_cache() -> None:
|
||||
self._cached_periods = None
|
||||
self._last_periods_hash = None
|
||||
```
|
||||
|
||||
2. **Price data change** (automatic via hash mismatch):
|
||||
```python
|
||||
current_hash = self._compute_periods_hash(price_info)
|
||||
if self._last_periods_hash != current_hash:
|
||||
# Cache miss - recalculate
|
||||
```
|
||||
```python
|
||||
current_hash = self._compute_periods_hash(price_info)
|
||||
if self._last_periods_hash != current_hash:
|
||||
# Cache miss - recalculate
|
||||
```
|
||||
|
||||
**Cache hit rate:**
|
||||
|
||||
- **High:** During normal operation (coordinator updates every 15min, price data unchanged)
|
||||
- **Low:** After midnight (new today data) or when tomorrow data arrives (~13:00-14:00)
|
||||
|
||||
**Performance impact:**
|
||||
|
||||
- **Period calculation:** ~100-500ms (depends on interval count, relaxation attempts)
|
||||
- **Cache hit:** `<`1ms (hash comparison + dict lookup)
|
||||
- **Savings:** ~70% of calculation time (most updates hit cache)
|
||||
|
|
@ -212,6 +231,7 @@ hash_data = (
|
|||
**Status:** ✅ **Clean separation** - enrichment only, no redundancy
|
||||
|
||||
**What is cached:**
|
||||
|
||||
```python
|
||||
{
|
||||
"timestamp": ...,
|
||||
|
|
@ -224,14 +244,16 @@ hash_data = (
|
|||
**Purpose:** Avoid re-enriching price data when config unchanged between midnight checks.
|
||||
|
||||
**Current behavior:**
|
||||
|
||||
- Caches **only enriched price data** (price + statistics)
|
||||
- **Does NOT cache periods** (handled by Period Calculation Cache)
|
||||
- Invalidated when:
|
||||
- Config changes (thresholds affect enrichment)
|
||||
- Midnight turnover detected
|
||||
- New update cycle begins
|
||||
- Config changes (thresholds affect enrichment)
|
||||
- Midnight turnover detected
|
||||
- New update cycle begins
|
||||
|
||||
**Architecture:**
|
||||
|
||||
- DataTransformer: Handles price enrichment only
|
||||
- PeriodCalculator: Handles period calculation only (with hash-based cache)
|
||||
- Coordinator: Assembles final data on-demand from both caches
|
||||
|
|
@ -243,6 +265,7 @@ hash_data = (
|
|||
## Cache Invalidation Flow
|
||||
|
||||
### User Changes Options (Config Flow)
|
||||
|
||||
```
|
||||
User saves options
|
||||
↓
|
||||
|
|
@ -267,6 +290,7 @@ Fresh data fetch with new config
|
|||
```
|
||||
|
||||
### Midnight Turnover (Day Transition)
|
||||
|
||||
```
|
||||
Timer #2 fires at 00:00
|
||||
↓
|
||||
|
|
@ -286,6 +310,7 @@ Fresh API fetch for new day
|
|||
```
|
||||
|
||||
### Tomorrow Data Arrives (~13:00)
|
||||
|
||||
```
|
||||
Coordinator update cycle
|
||||
↓
|
||||
|
|
@ -327,12 +352,14 @@ API Data Cache (price_data, user_data)
|
|||
```
|
||||
|
||||
**No cache invalidation cascades:**
|
||||
|
||||
- Config cache invalidation is **explicit** (on options update)
|
||||
- Period cache invalidation is **automatic** (via hash mismatch)
|
||||
- Transformation cache invalidation is **automatic** (on midnight/config change)
|
||||
- Translation cache is **never invalidated** (read-only after load)
|
||||
|
||||
**Thread safety:**
|
||||
|
||||
- All caches are accessed from `MainThread` only (Home Assistant event loop)
|
||||
- No locking needed (single-threaded execution model)
|
||||
|
||||
|
|
@ -341,6 +368,7 @@ API Data Cache (price_data, user_data)
|
|||
## Performance Characteristics
|
||||
|
||||
### Typical Operation (No Changes)
|
||||
|
||||
```
|
||||
Coordinator Update (every 15 min)
|
||||
├─> API fetch: SKIP (cache valid)
|
||||
|
|
@ -353,6 +381,7 @@ Total: ~16ms (down from ~600ms without caching)
|
|||
```
|
||||
|
||||
### After Midnight Turnover
|
||||
|
||||
```
|
||||
Coordinator Update (00:00)
|
||||
├─> API fetch: ~500ms (cache cleared, fetch new day)
|
||||
|
|
@ -365,6 +394,7 @@ Total: ~755ms (expected once per day)
|
|||
```
|
||||
|
||||
### After Config Change
|
||||
|
||||
```
|
||||
Options Update
|
||||
├─> Cache invalidation: `<`1ms
|
||||
|
|
@ -381,23 +411,25 @@ Options Update
|
|||
|
||||
## Summary Table
|
||||
|
||||
| Cache Type | Lifetime | Size | Invalidation | Purpose |
|
||||
|------------|----------|------|--------------|---------|
|
||||
| **API Data** | Hours to 1 day | ~50KB | Midnight, validation | Reduce API calls |
|
||||
| **Translations** | Forever (until HA restart) | ~5KB | Never | Avoid file I/O |
|
||||
| **Config Dicts** | Until options change | `<`1KB | Explicit (options update) | Avoid dict lookups |
|
||||
| **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 |
|
||||
| Cache Type | Lifetime | Size | Invalidation | Purpose |
|
||||
| ---------------------- | ---------------------------- | ------ | ------------------------- | ------------------------------- |
|
||||
| **API Data** | Hours to 1 day | ~50KB | Midnight, validation | Reduce API calls |
|
||||
| **Translations** | Forever (until HA restart) | ~5KB | Never | Avoid file I/O |
|
||||
| **Config Dicts** | Until options change | `<`1KB | Explicit (options update) | Avoid dict lookups |
|
||||
| **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 |
|
||||
|
||||
**Total memory overhead:** ~116KB per coordinator instance (main + subentries)
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- 97% reduction in API calls (from every 15min to once per day)
|
||||
- 70% reduction in period calculation time (cache hits during normal operation)
|
||||
- 98% reduction in config access time (30+ lookups → 1 cache check)
|
||||
- Zero file I/O during runtime (translations cached at startup)
|
||||
|
||||
**Trade-offs:**
|
||||
|
||||
- Memory usage: ~116KB per home (negligible for modern systems)
|
||||
- Code complexity: 5 cache invalidation points (well-tested, documented)
|
||||
- Debugging: Must understand cache lifetime when investigating stale data issues
|
||||
|
|
@ -407,7 +439,9 @@ Options Update
|
|||
## Debugging Cache Issues
|
||||
|
||||
### Symptom: Stale data after config change
|
||||
|
||||
**Check:**
|
||||
|
||||
1. Is `_handle_options_update()` called? (should see "Options updated" log)
|
||||
2. Are `invalidate_config_cache()` methods executed?
|
||||
3. Does `async_request_refresh()` trigger?
|
||||
|
|
@ -415,7 +449,9 @@ Options Update
|
|||
**Fix:** Ensure `config_entry.add_update_listener()` is registered in coordinator init.
|
||||
|
||||
### Symptom: Period calculation not updating
|
||||
|
||||
**Check:**
|
||||
|
||||
1. Verify hash changes when data changes: `_compute_periods_hash()`
|
||||
2. Check `_last_periods_hash` vs `current_hash`
|
||||
3. Look for "Using cached period calculation" vs "Calculating periods" logs
|
||||
|
|
@ -423,7 +459,9 @@ Options Update
|
|||
**Fix:** Hash function may not include all relevant data. Review `_compute_periods_hash()` inputs.
|
||||
|
||||
### Symptom: Yesterday's prices shown as today
|
||||
|
||||
**Check:**
|
||||
|
||||
1. `is_cache_valid()` logic in `coordinator/cache.py`
|
||||
2. Midnight turnover execution (Timer #2)
|
||||
3. Cache clear confirmation in logs
|
||||
|
|
@ -431,7 +469,9 @@ Options Update
|
|||
**Fix:** Timer may not be firing. Check `_schedule_midnight_turnover()` registration.
|
||||
|
||||
### Symptom: Missing translations
|
||||
|
||||
**Check:**
|
||||
|
||||
1. `async_load_translations()` called at startup?
|
||||
2. Translation files exist in `/translations/` and `/custom_translations/`?
|
||||
3. Cache population: `_TRANSLATIONS_CACHE` keys
|
||||
|
|
|
|||
|
|
@ -8,10 +8,10 @@ comments: false
|
|||
|
||||
## Code Style
|
||||
|
||||
- **Formatter/Linter**: Ruff (replaces Black, Flake8, isort)
|
||||
- **Max line length**: 120 characters
|
||||
- **Max complexity**: 25 (McCabe)
|
||||
- **Target**: Python 3.13
|
||||
- **Formatter/Linter**: Ruff (replaces Black, Flake8, isort)
|
||||
- **Max line length**: 120 characters
|
||||
- **Max complexity**: 25 (McCabe)
|
||||
- **Target**: Python 3.13
|
||||
|
||||
Run before committing:
|
||||
|
||||
|
|
@ -41,12 +41,14 @@ class TimeService:
|
|||
```
|
||||
|
||||
**When prefix is required:**
|
||||
|
||||
- Public classes used across multiple modules
|
||||
- All exception classes
|
||||
- All coordinator and entity classes
|
||||
- Data classes (dataclasses, NamedTuples) used as public APIs
|
||||
|
||||
**When prefix can be omitted:**
|
||||
|
||||
- Private helper classes within a single module (prefix with `_` underscore)
|
||||
- Type aliases and callbacks (e.g., `TimeServiceCallback`)
|
||||
- Small internal NamedTuples for function returns
|
||||
|
|
@ -71,6 +73,7 @@ class DataFetcher: # Should be TibberPricesDataFetcher
|
|||
**Current Technical Debt:**
|
||||
|
||||
Many existing classes lack the `TibberPrices` prefix. Before refactoring:
|
||||
|
||||
1. Document the plan in `/planning/class-naming-refactoring.md`
|
||||
2. Use `multi_replace_string_in_file` for bulk renames
|
||||
3. Test thoroughly after each module
|
||||
|
|
|
|||
|
|
@ -14,10 +14,10 @@ Welcome! This guide helps you contribute to the Tibber Prices integration.
|
|||
|
||||
1. Fork the repository on GitHub
|
||||
2. Clone your fork:
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/hass.tibber_prices.git
|
||||
cd hass.tibber_prices
|
||||
```
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/hass.tibber_prices.git
|
||||
cd hass.tibber_prices
|
||||
```
|
||||
3. Open in VS Code
|
||||
4. Click "Reopen in Container" when prompted
|
||||
|
||||
|
|
@ -34,6 +34,7 @@ git checkout -b fix/issue-123-description
|
|||
```
|
||||
|
||||
**Branch naming:**
|
||||
|
||||
- `feature/` - New features
|
||||
- `fix/` - Bug fixes
|
||||
- `docs/` - Documentation only
|
||||
|
|
@ -45,6 +46,7 @@ git checkout -b fix/issue-123-description
|
|||
Edit code, following [Coding Guidelines](coding-guidelines.md).
|
||||
|
||||
**Run checks frequently:**
|
||||
|
||||
```bash
|
||||
./scripts/type-check # Pyright type checking
|
||||
./scripts/lint # Ruff linting (auto-fix)
|
||||
|
|
@ -78,6 +80,7 @@ async def test_your_feature(hass, coordinator):
|
|||
```
|
||||
|
||||
Run your test:
|
||||
|
||||
```bash
|
||||
./scripts/test tests/test_your_feature.py -v
|
||||
```
|
||||
|
|
@ -97,6 +100,7 @@ Impact: Users can predict when prices will stabilize or continue fluctuating."
|
|||
```
|
||||
|
||||
**Commit types:**
|
||||
|
||||
- `feat:` - New feature
|
||||
- `fix:` - Bug fix
|
||||
- `docs:` - Documentation
|
||||
|
|
@ -105,6 +109,7 @@ Impact: Users can predict when prices will stabilize or continue fluctuating."
|
|||
- `chore:` - Maintenance
|
||||
|
||||
**Add scope when relevant:**
|
||||
|
||||
- `feat(sensors):` - Sensor platform
|
||||
- `fix(coordinator):` - Data coordinator
|
||||
- `docs(user):` - User documentation
|
||||
|
|
@ -124,32 +129,40 @@ Then open Pull Request on GitHub.
|
|||
Title: Short, descriptive (50 chars max)
|
||||
|
||||
Description should include:
|
||||
|
||||
```markdown
|
||||
## What
|
||||
|
||||
Brief description of changes
|
||||
|
||||
## Why
|
||||
|
||||
Problem being solved or feature rationale
|
||||
|
||||
## How
|
||||
|
||||
Implementation approach
|
||||
|
||||
## Testing
|
||||
|
||||
- [ ] Manual testing in Home Assistant
|
||||
- [ ] Unit tests added/updated
|
||||
- [ ] Type checking passes
|
||||
- [ ] Linting passes
|
||||
|
||||
## Breaking Changes
|
||||
|
||||
(If any - describe migration path)
|
||||
|
||||
## Related Issues
|
||||
|
||||
Closes #123
|
||||
```
|
||||
|
||||
### PR Checklist
|
||||
|
||||
Before submitting:
|
||||
|
||||
- [ ] Code follows [Coding Guidelines](coding-guidelines.md)
|
||||
- [ ] All tests pass (`./scripts/test`)
|
||||
- [ ] Type checking passes (`./scripts/type-check`)
|
||||
|
|
@ -170,6 +183,7 @@ Before submitting:
|
|||
### What Reviewers Look For
|
||||
|
||||
✅ **Good:**
|
||||
|
||||
- Clear, self-explanatory code
|
||||
- Appropriate comments for complex logic
|
||||
- Tests covering edge cases
|
||||
|
|
@ -177,6 +191,7 @@ Before submitting:
|
|||
- Follows existing patterns
|
||||
|
||||
❌ **Avoid:**
|
||||
|
||||
- Large PRs (>500 lines) - split into smaller ones
|
||||
- Mixing unrelated changes
|
||||
- Missing tests for new features
|
||||
|
|
@ -193,6 +208,7 @@ Before submitting:
|
|||
## Finding Issues to Work On
|
||||
|
||||
Good first issues are labeled:
|
||||
|
||||
- `good first issue` - Beginner-friendly
|
||||
- `help wanted` - Maintainers welcome contributions
|
||||
- `documentation` - Docs improvements
|
||||
|
|
@ -210,6 +226,7 @@ Be respectful, constructive, and patient. We're all volunteers! 🙏
|
|||
---
|
||||
|
||||
💡 **Related:**
|
||||
|
||||
- [Setup Guide](setup.md) - DevContainer setup
|
||||
- [Coding Guidelines](coding-guidelines.md) - Style guide
|
||||
- [Testing](testing.md) - Writing tests
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ comments: false
|
|||
## 🎯 Why Are These Tests Critical?
|
||||
|
||||
Home Assistant integrations run **continuously** in the background. Resource leaks lead to:
|
||||
|
||||
- **Memory Leaks**: RAM usage grows over days/weeks until HA becomes unstable
|
||||
- **Callback Leaks**: Listeners remain registered after entity removal → CPU load increases
|
||||
- **Timer Leaks**: Timers continue running after unload → unnecessary background tasks
|
||||
|
|
@ -26,6 +27,7 @@ Home Assistant integrations run **continuously** in the background. Resource lea
|
|||
#### 1.1 Listener Cleanup ✅
|
||||
|
||||
**What is tested:**
|
||||
|
||||
- Time-sensitive listeners are correctly removed (`async_add_time_sensitive_listener()`)
|
||||
- Minute-update listeners are correctly removed (`async_add_minute_update_listener()`)
|
||||
- Lifecycle callbacks are correctly unregistered (`register_lifecycle_callback()`)
|
||||
|
|
@ -33,11 +35,13 @@ Home Assistant integrations run **continuously** in the background. Resource lea
|
|||
- Binary sensor cleanup removes ALL registered listeners
|
||||
|
||||
**Why critical:**
|
||||
|
||||
- Each registered listener holds references to Entity + Coordinator
|
||||
- Without cleanup: Entities are not freed by GC → Memory Leak
|
||||
- With 80+ sensors × 3 listener types = 240+ callbacks that must be cleanly removed
|
||||
|
||||
**Code Locations:**
|
||||
|
||||
- `coordinator/listeners.py` → `async_add_time_sensitive_listener()`, `async_add_minute_update_listener()`
|
||||
- `coordinator/core.py` → `register_lifecycle_callback()`
|
||||
- `sensor/core.py` → `async_will_remove_from_hass()`
|
||||
|
|
@ -46,32 +50,38 @@ Home Assistant integrations run **continuously** in the background. Resource lea
|
|||
#### 1.2 Timer Cleanup ✅
|
||||
|
||||
**What is tested:**
|
||||
|
||||
- Quarter-hour timer is cancelled and reference cleared
|
||||
- Minute timer is cancelled and reference cleared
|
||||
- Both timers are cancelled together
|
||||
- Cleanup works even when timers are `None`
|
||||
|
||||
**Why critical:**
|
||||
|
||||
- Uncancelled timers continue running after integration unload
|
||||
- HA's `async_track_utc_time_change()` creates persistent callbacks
|
||||
- Without cleanup: Timers keep firing → CPU load + unnecessary coordinator updates
|
||||
|
||||
**Code Locations:**
|
||||
|
||||
- `coordinator/listeners.py` → `cancel_timers()`
|
||||
- `coordinator/core.py` → `async_shutdown()`
|
||||
|
||||
#### 1.3 Config Entry Cleanup ✅
|
||||
|
||||
**What is tested:**
|
||||
|
||||
- Options update listener is registered via `async_on_unload()`
|
||||
- Cleanup function is correctly passed to `async_on_unload()`
|
||||
|
||||
**Why critical:**
|
||||
|
||||
- `entry.add_update_listener()` registers permanent callback
|
||||
- Without `async_on_unload()`: Listener remains active after reload → duplicate updates
|
||||
- Pattern: `entry.async_on_unload(entry.add_update_listener(handler))`
|
||||
|
||||
**Code Locations:**
|
||||
|
||||
- `coordinator/core.py` → `__init__()` (listener registration)
|
||||
- `__init__.py` → `async_unload_entry()`
|
||||
|
||||
|
|
@ -82,16 +92,19 @@ Home Assistant integrations run **continuously** in the background. Resource lea
|
|||
#### 2.1 Config Cache Invalidation
|
||||
|
||||
**What is tested:**
|
||||
|
||||
- DataTransformer config cache is invalidated on options change
|
||||
- PeriodCalculator config + period cache is invalidated
|
||||
- Trend calculator cache is cleared on coordinator update
|
||||
|
||||
**Why critical:**
|
||||
|
||||
- Stale config → Sensors use old user settings
|
||||
- Stale period cache → Incorrect best/peak price periods
|
||||
- Stale trend cache → Outdated trend analysis
|
||||
|
||||
**Code Locations:**
|
||||
|
||||
- `coordinator/data_transformation.py` → `invalidate_config_cache()`
|
||||
- `coordinator/periods.py` → `invalidate_config_cache()`
|
||||
- `sensor/calculators/trend.py` → `clear_trend_cache()`
|
||||
|
|
@ -103,15 +116,18 @@ Home Assistant integrations run **continuously** in the background. Resource lea
|
|||
#### 3.1 Persistent Storage Removal
|
||||
|
||||
**What is tested:**
|
||||
|
||||
- Storage file is deleted on config entry removal
|
||||
- Cache is saved on shutdown (no data loss)
|
||||
|
||||
**Why critical:**
|
||||
|
||||
- Without storage removal: Old files remain after uninstallation
|
||||
- Without cache save on shutdown: Data loss on HA restart
|
||||
- Storage path: `.storage/tibber_prices.{entry_id}`
|
||||
|
||||
**Code Locations:**
|
||||
|
||||
- `__init__.py` → `async_remove_entry()`
|
||||
- `coordinator/core.py` → `async_shutdown()`
|
||||
|
||||
|
|
@ -120,12 +136,14 @@ Home Assistant integrations run **continuously** in the background. Resource lea
|
|||
**File:** `tests/test_timer_scheduling.py`
|
||||
|
||||
**What is tested:**
|
||||
|
||||
- Quarter-hour timer is registered with correct parameters
|
||||
- Minute timer is registered with correct parameters
|
||||
- Timers can be re-scheduled (override old timer)
|
||||
- Midnight turnover detection works correctly
|
||||
|
||||
**Why critical:**
|
||||
|
||||
- Wrong timer parameters → Entities update at wrong times
|
||||
- Without timer override on re-schedule → Multiple parallel timers → Performance problem
|
||||
|
||||
|
|
@ -134,12 +152,14 @@ Home Assistant integrations run **continuously** in the background. Resource lea
|
|||
**File:** `tests/test_sensor_timer_assignment.py`
|
||||
|
||||
**What is tested:**
|
||||
|
||||
- All `TIME_SENSITIVE_ENTITY_KEYS` are valid entity keys
|
||||
- All `MINUTE_UPDATE_ENTITY_KEYS` are valid entity keys
|
||||
- Both lists are disjoint (no overlap)
|
||||
- Sensor and binary sensor platforms are checked
|
||||
|
||||
**Why critical:**
|
||||
|
||||
- Wrong timer assignment → Sensors update at wrong times
|
||||
- Overlap → Duplicate updates → Performance problem
|
||||
|
||||
|
|
@ -150,10 +170,12 @@ These patterns were analyzed and classified as **not critical**:
|
|||
### 6. Async Task Management
|
||||
|
||||
**Current Status:** Fire-and-forget pattern for short tasks
|
||||
|
||||
- `sensor/core.py` → Chart data refresh (short-lived, max 1-2 seconds)
|
||||
- `coordinator/core.py` → Cache storage (short-lived, max 100ms)
|
||||
|
||||
**Why no tests needed:**
|
||||
|
||||
- No long-running tasks (all < 2 seconds)
|
||||
- HA's event loop handles short tasks automatically
|
||||
- Task exceptions are already logged
|
||||
|
|
@ -163,6 +185,7 @@ These patterns were analyzed and classified as **not critical**:
|
|||
### 7. API Session Cleanup
|
||||
|
||||
**Current Status:** ✅ Correctly implemented
|
||||
|
||||
- `async_get_clientsession(hass)` is used (shared session)
|
||||
- No new sessions are created
|
||||
- HA manages session lifecycle automatically
|
||||
|
|
@ -172,6 +195,7 @@ These patterns were analyzed and classified as **not critical**:
|
|||
### 8. Translation Cache Memory
|
||||
|
||||
**Current Status:** ✅ Bounded cache
|
||||
|
||||
- Max ~5-10 languages × 5KB = 50KB total
|
||||
- Module-level cache without re-loading
|
||||
- Practically no memory issue
|
||||
|
|
@ -181,11 +205,13 @@ These patterns were analyzed and classified as **not critical**:
|
|||
### 9. Coordinator Data Structure Integrity
|
||||
|
||||
**Current Status:** Manually tested via `./scripts/develop`
|
||||
|
||||
- Midnight turnover works correctly (observed over several days)
|
||||
- Missing keys are handled via `.get()` with defaults
|
||||
- 80+ sensors access `coordinator.data` without errors
|
||||
|
||||
**Structure:**
|
||||
|
||||
```python
|
||||
coordinator.data = {
|
||||
"user_data": {...},
|
||||
|
|
@ -197,6 +223,7 @@ coordinator.data = {
|
|||
### 10. Service Response Memory
|
||||
|
||||
**Current Status:** HA's response lifecycle
|
||||
|
||||
- HA automatically frees service responses after return
|
||||
- ApexCharts ~20KB response is one-time per call
|
||||
- No response accumulation in integration code
|
||||
|
|
@ -207,29 +234,30 @@ coordinator.data = {
|
|||
|
||||
### ✅ Implemented Tests (41 total)
|
||||
|
||||
| Category | Status | Tests | File | Coverage |
|
||||
|----------|--------|-------|------|----------|
|
||||
| Listener Cleanup | ✅ | 5 | `test_resource_cleanup.py` | 100% |
|
||||
| Timer Cleanup | ✅ | 4 | `test_resource_cleanup.py` | 100% |
|
||||
| Config Entry Cleanup | ✅ | 1 | `test_resource_cleanup.py` | 100% |
|
||||
| Cache Invalidation | ✅ | 3 | `test_resource_cleanup.py` | 100% |
|
||||
| Storage Cleanup | ✅ | 1 | `test_resource_cleanup.py` | 100% |
|
||||
| Storage Persistence | ✅ | 2 | `test_coordinator_shutdown.py` | 100% |
|
||||
| Timer Scheduling | ✅ | 8 | `test_timer_scheduling.py` | 100% |
|
||||
| Sensor-Timer Assignment | ✅ | 17 | `test_sensor_timer_assignment.py` | 100% |
|
||||
| **TOTAL** | **✅** | **41** | | **100% (critical)** |
|
||||
| Category | Status | Tests | File | Coverage |
|
||||
| ----------------------- | ------ | ------ | --------------------------------- | ------------------- |
|
||||
| Listener Cleanup | ✅ | 5 | `test_resource_cleanup.py` | 100% |
|
||||
| Timer Cleanup | ✅ | 4 | `test_resource_cleanup.py` | 100% |
|
||||
| Config Entry Cleanup | ✅ | 1 | `test_resource_cleanup.py` | 100% |
|
||||
| Cache Invalidation | ✅ | 3 | `test_resource_cleanup.py` | 100% |
|
||||
| Storage Cleanup | ✅ | 1 | `test_resource_cleanup.py` | 100% |
|
||||
| Storage Persistence | ✅ | 2 | `test_coordinator_shutdown.py` | 100% |
|
||||
| Timer Scheduling | ✅ | 8 | `test_timer_scheduling.py` | 100% |
|
||||
| Sensor-Timer Assignment | ✅ | 17 | `test_sensor_timer_assignment.py` | 100% |
|
||||
| **TOTAL** | **✅** | **41** | | **100% (critical)** |
|
||||
|
||||
### 📋 Analyzed but Not Implemented (Nice-to-Have)
|
||||
|
||||
| Category | Status | Rationale |
|
||||
|----------|--------|-----------|
|
||||
| Async Task Management | 📋 | Fire-and-forget pattern used (no long-running tasks) |
|
||||
| API Session Cleanup | ✅ | Pattern correct (`async_get_clientsession` used) |
|
||||
| Translation Cache | ✅ | Cache size bounded (~50KB max for 10 languages) |
|
||||
| Data Structure Integrity | 📋 | Would add test time without finding real issues |
|
||||
| Service Response Memory | 📋 | HA automatically frees service responses |
|
||||
| Category | Status | Rationale |
|
||||
| ------------------------ | ------ | ---------------------------------------------------- |
|
||||
| Async Task Management | 📋 | Fire-and-forget pattern used (no long-running tasks) |
|
||||
| API Session Cleanup | ✅ | Pattern correct (`async_get_clientsession` used) |
|
||||
| Translation Cache | ✅ | Cache size bounded (~50KB max for 10 languages) |
|
||||
| Data Structure Integrity | 📋 | Would add test time without finding real issues |
|
||||
| Service Response Memory | 📋 | HA automatically frees service responses |
|
||||
|
||||
**Legend:**
|
||||
|
||||
- ✅ = Fully tested or pattern verified correct
|
||||
- 📋 = Analyzed, low priority for testing (no known issues)
|
||||
|
||||
|
|
@ -238,6 +266,7 @@ coordinator.data = {
|
|||
### ✅ All Critical Patterns Tested
|
||||
|
||||
All essential memory leak prevention patterns are covered by 41 tests:
|
||||
|
||||
- ✅ Listeners are correctly removed (no callback leaks)
|
||||
- ✅ Timers are cancelled (no background task leaks)
|
||||
- ✅ Config entry cleanup works (no dangling listeners)
|
||||
|
|
|
|||
|
|
@ -10,9 +10,9 @@ Add to `configuration.yaml`:
|
|||
|
||||
```yaml
|
||||
logger:
|
||||
default: info
|
||||
logs:
|
||||
custom_components.tibber_prices: debug
|
||||
default: info
|
||||
logs:
|
||||
custom_components.tibber_prices: debug
|
||||
```
|
||||
|
||||
Restart Home Assistant to apply.
|
||||
|
|
@ -20,6 +20,7 @@ Restart Home Assistant to apply.
|
|||
### Key Log Messages
|
||||
|
||||
**Coordinator Updates:**
|
||||
|
||||
```
|
||||
[custom_components.tibber_prices.coordinator] Successfully fetched price data
|
||||
[custom_components.tibber_prices.coordinator] Cache valid, using cached data
|
||||
|
|
@ -27,6 +28,7 @@ Restart Home Assistant to apply.
|
|||
```
|
||||
|
||||
**Period Calculation:**
|
||||
|
||||
```
|
||||
[custom_components.tibber_prices.coordinator.periods] Calculating BEST PRICE periods: flex=15.0%
|
||||
[custom_components.tibber_prices.coordinator.periods] Day 2024-12-06: Found 2 periods
|
||||
|
|
@ -34,6 +36,7 @@ Restart Home Assistant to apply.
|
|||
```
|
||||
|
||||
**API Errors:**
|
||||
|
||||
```
|
||||
[custom_components.tibber_prices.api] API request failed: Unauthorized
|
||||
[custom_components.tibber_prices.api] Retrying (attempt 2/3) after 2.0s
|
||||
|
|
@ -47,26 +50,27 @@ Restart Home Assistant to apply.
|
|||
|
||||
```json
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Home Assistant",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"module": "homeassistant",
|
||||
"args": ["-c", "config", "--debug"],
|
||||
"justMyCode": false,
|
||||
"env": {
|
||||
"PYTHONPATH": "${workspaceFolder}/.venv/lib/python3.13/site-packages"
|
||||
}
|
||||
}
|
||||
]
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Home Assistant",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"module": "homeassistant",
|
||||
"args": ["-c", "config", "--debug"],
|
||||
"justMyCode": false,
|
||||
"env": {
|
||||
"PYTHONPATH": "${workspaceFolder}/.venv/lib/python3.13/site-packages"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Set Breakpoints
|
||||
|
||||
**Coordinator update:**
|
||||
|
||||
```python
|
||||
# coordinator/core.py
|
||||
async def _async_update_data(self) -> dict:
|
||||
|
|
@ -75,6 +79,7 @@ async def _async_update_data(self) -> dict:
|
|||
```
|
||||
|
||||
**Period calculation:**
|
||||
|
||||
```python
|
||||
# coordinator/period_handlers/core.py
|
||||
def calculate_periods(...) -> list[dict]:
|
||||
|
|
@ -91,6 +96,7 @@ def calculate_periods(...) -> list[dict]:
|
|||
```
|
||||
|
||||
**Flags:**
|
||||
|
||||
- `-v` - Verbose output
|
||||
- `-s` - Show print statements
|
||||
- `-k pattern` - Run tests matching pattern
|
||||
|
|
@ -102,6 +108,7 @@ Set breakpoint in test file, use "Debug Test" CodeLens.
|
|||
### Useful Test Patterns
|
||||
|
||||
**Print coordinator data:**
|
||||
|
||||
```python
|
||||
def test_something(coordinator):
|
||||
print(f"Coordinator data: {coordinator.data}")
|
||||
|
|
@ -109,6 +116,7 @@ def test_something(coordinator):
|
|||
```
|
||||
|
||||
**Inspect period attributes:**
|
||||
|
||||
```python
|
||||
def test_periods(hass, coordinator):
|
||||
periods = coordinator.data.get('best_price_periods', [])
|
||||
|
|
@ -122,11 +130,13 @@ def test_periods(hass, coordinator):
|
|||
### Integration Not Loading
|
||||
|
||||
**Check:**
|
||||
|
||||
```bash
|
||||
grep "tibber_prices" config/home-assistant.log
|
||||
```
|
||||
|
||||
**Common causes:**
|
||||
|
||||
- Syntax error in Python code → Check logs for traceback
|
||||
- Missing dependency → Run `uv sync`
|
||||
- Wrong file permissions → `chmod +x scripts/*`
|
||||
|
|
@ -134,12 +144,14 @@ grep "tibber_prices" config/home-assistant.log
|
|||
### Sensors Not Updating
|
||||
|
||||
**Check coordinator state:**
|
||||
|
||||
```python
|
||||
# In Developer Tools > Template
|
||||
{{ states.sensor.tibber_home_current_interval_price.last_updated }}
|
||||
```
|
||||
|
||||
**Debug in code:**
|
||||
|
||||
```python
|
||||
# Add logging in sensor/core.py
|
||||
_LOGGER.debug("Updating sensor %s: old=%s new=%s",
|
||||
|
|
@ -149,6 +161,7 @@ _LOGGER.debug("Updating sensor %s: old=%s new=%s",
|
|||
### Period Calculation Wrong
|
||||
|
||||
**Enable detailed period logs:**
|
||||
|
||||
```python
|
||||
# coordinator/period_handlers/period_building.py
|
||||
_LOGGER.debug("Candidate intervals: %s",
|
||||
|
|
@ -156,6 +169,7 @@ _LOGGER.debug("Candidate intervals: %s",
|
|||
```
|
||||
|
||||
**Check filter statistics:**
|
||||
|
||||
```
|
||||
[period_building] Flex filter blocked: 45 intervals
|
||||
[period_building] Min distance blocked: 12 intervals
|
||||
|
|
@ -200,6 +214,7 @@ python -m pstats profile.stats
|
|||
### Remote Debugging with debugpy
|
||||
|
||||
Add to coordinator code:
|
||||
|
||||
```python
|
||||
import debugpy
|
||||
debugpy.listen(5678)
|
||||
|
|
@ -212,11 +227,13 @@ Connect from VS Code with remote attach configuration.
|
|||
### IPython REPL
|
||||
|
||||
Install in container:
|
||||
|
||||
```bash
|
||||
uv pip install ipython
|
||||
```
|
||||
|
||||
Add breakpoint:
|
||||
|
||||
```python
|
||||
from IPython import embed
|
||||
embed() # Drops into interactive shell
|
||||
|
|
@ -225,6 +242,7 @@ embed() # Drops into interactive shell
|
|||
---
|
||||
|
||||
💡 **Related:**
|
||||
|
||||
- [Testing Guide](testing.md) - Writing and running tests
|
||||
- [Setup Guide](setup.md) - Development environment
|
||||
- [Architecture](architecture.md) - Code structure
|
||||
|
|
|
|||
|
|
@ -8,25 +8,25 @@ This is an independent, community-maintained custom integration for Home Assista
|
|||
|
||||
## 📚 Developer Guides
|
||||
|
||||
- **[Setup](setup.md)** - DevContainer, environment setup, and dependencies
|
||||
- **[Architecture](architecture.md)** - Code structure, patterns, and conventions
|
||||
- **[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)
|
||||
- **[Caching Strategy](caching-strategy.md)** - Cache layers, invalidation, debugging
|
||||
- **[Testing](testing.md)** - How to run tests and write new test cases
|
||||
- **[Release Management](release-management.md)** - Release workflow and versioning process
|
||||
- **[Coding Guidelines](coding-guidelines.md)** - Style guide, linting, and best practices
|
||||
- **[Refactoring Guide](refactoring-guide.md)** - How to plan and execute major refactorings
|
||||
- **[Setup](setup.md)** - DevContainer, environment setup, and dependencies
|
||||
- **[Architecture](architecture.md)** - Code structure, patterns, and conventions
|
||||
- **[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)
|
||||
- **[Caching Strategy](caching-strategy.md)** - Cache layers, invalidation, debugging
|
||||
- **[Testing](testing.md)** - How to run tests and write new test cases
|
||||
- **[Release Management](release-management.md)** - Release workflow and versioning process
|
||||
- **[Coding Guidelines](coding-guidelines.md)** - Style guide, linting, and best practices
|
||||
- **[Refactoring Guide](refactoring-guide.md)** - How to plan and execute major refactorings
|
||||
|
||||
## 🤖 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:
|
||||
|
||||
- Detailed architectural patterns
|
||||
- Code quality rules and conventions
|
||||
- Development workflow guidance
|
||||
- Common pitfalls and anti-patterns
|
||||
- Project-specific patterns and utilities
|
||||
- Detailed architectural patterns
|
||||
- Code quality rules and conventions
|
||||
- Development workflow guidance
|
||||
- Common pitfalls and anti-patterns
|
||||
- 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.
|
||||
|
||||
|
|
@ -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:
|
||||
|
||||
- **Pattern Recognition**: Understanding and applying Home Assistant best practices
|
||||
- **Code Generation**: Implementing features with proper type hints, error handling, and documentation
|
||||
- **Refactoring**: Maintaining consistency across the codebase during structural changes
|
||||
- **Translation Management**: Keeping 5 language files synchronized
|
||||
- **Documentation**: Generating and maintaining comprehensive documentation
|
||||
- **Pattern Recognition**: Understanding and applying Home Assistant best practices
|
||||
- **Code Generation**: Implementing features with proper type hints, error handling, and documentation
|
||||
- **Refactoring**: Maintaining consistency across the codebase during structural changes
|
||||
- **Translation Management**: Keeping 5 language files synchronized
|
||||
- **Documentation**: Generating and maintaining comprehensive documentation
|
||||
|
||||
**Quality Assurance:**
|
||||
|
||||
- Automated linting with Ruff (120-char line length, max complexity 25)
|
||||
- Home Assistant's type checking and validation
|
||||
- Real-world testing in development environment
|
||||
- Code review by maintainer before merging
|
||||
- Automated linting with Ruff (120-char line length, max complexity 25)
|
||||
- Home Assistant's type checking and validation
|
||||
- Real-world testing in development environment
|
||||
- Code review by maintainer before merging
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- Rapid feature development while maintaining quality
|
||||
- Consistent code patterns across all modules
|
||||
- Comprehensive documentation maintained alongside code
|
||||
- Quick bug fixes with proper understanding of context
|
||||
- Rapid feature development while maintaining quality
|
||||
- Consistent code patterns across all modules
|
||||
- Comprehensive documentation maintained alongside code
|
||||
- Quick bug fixes with proper understanding of context
|
||||
|
||||
**Limitations:**
|
||||
|
||||
- AI may occasionally miss edge cases or subtle bugs
|
||||
- Some complex Home Assistant patterns may need human review
|
||||
- Translation quality depends on AI's understanding of target language
|
||||
- User feedback is crucial for discovering real-world issues
|
||||
- AI may occasionally miss edge cases or subtle bugs
|
||||
- Some complex Home Assistant patterns may need human review
|
||||
- Translation quality depends on AI's understanding of target language
|
||||
- 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.
|
||||
|
||||
|
|
@ -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/`:
|
||||
|
||||
- `bootstrap` - Initial setup of dependencies
|
||||
- `develop` - Start Home Assistant in debug mode (auto-cleans .egg-info)
|
||||
- `clean` - Remove build artifacts and caches
|
||||
- `lint` - Auto-fix code issues with ruff
|
||||
- `lint-check` - Check code without modifications (CI mode)
|
||||
- `hassfest` - Validate integration structure (JSON, Python syntax, required files)
|
||||
- `setup` - Install development tools (git-cliff, @github/copilot)
|
||||
- `prepare-release` - Prepare a new release (bump version, create tag)
|
||||
- `generate-release-notes` - Generate release notes from commits
|
||||
- `bootstrap` - Initial setup of dependencies
|
||||
- `develop` - Start Home Assistant in debug mode (auto-cleans .egg-info)
|
||||
- `clean` - Remove build artifacts and caches
|
||||
- `lint` - Auto-fix code issues with ruff
|
||||
- `lint-check` - Check code without modifications (CI mode)
|
||||
- `hassfest` - Validate integration structure (JSON, Python syntax, required files)
|
||||
- `setup` - Install development tools (git-cliff, @github/copilot)
|
||||
- `prepare-release` - Prepare a new release (bump version, create tag)
|
||||
- `generate-release-notes` - Generate release notes from commits
|
||||
|
||||
## 📦 Project Structure
|
||||
|
||||
|
|
@ -121,23 +121,23 @@ custom_components/tibber_prices/
|
|||
|
||||
**DataUpdateCoordinator Pattern:**
|
||||
|
||||
- Centralized data fetching and caching
|
||||
- Automatic entity updates on data changes
|
||||
- Persistent storage via `Store`
|
||||
- Quarter-hour boundary refresh scheduling
|
||||
- Centralized data fetching and caching
|
||||
- Automatic entity updates on data changes
|
||||
- Persistent storage via `Store`
|
||||
- Quarter-hour boundary refresh scheduling
|
||||
|
||||
**Price Data Enrichment:**
|
||||
|
||||
- Raw API data is enriched with statistical analysis
|
||||
- Trailing/leading 24h averages calculated per interval
|
||||
- Price differences and ratings added
|
||||
- All via pure functions in `price_utils.py`
|
||||
- Raw API data is enriched with statistical analysis
|
||||
- Trailing/leading 24h averages calculated per interval
|
||||
- Price differences and ratings added
|
||||
- All via pure functions in `price_utils.py`
|
||||
|
||||
**Translation System:**
|
||||
|
||||
- Dual system: `/translations/` (HA schema) + `/custom_translations/` (extended)
|
||||
- Both must stay in sync across all languages (de, en, nb, nl, sv)
|
||||
- Async loading at integration setup
|
||||
- Dual system: `/translations/` (HA schema) + `/custom_translations/` (extended)
|
||||
- Both must stay in sync across all languages (de, en, nb, nl, sv)
|
||||
- Async loading at integration setup
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
|
|
@ -159,18 +159,19 @@ pytest --cov=custom_components.tibber_prices tests/
|
|||
|
||||
Documentation is organized in two Docusaurus sites:
|
||||
|
||||
- **User docs** (`docs/user/`): Installation, configuration, usage guides
|
||||
- Markdown files in `docs/user/docs/*.md`
|
||||
- Navigation managed via `docs/user/sidebars.ts`
|
||||
- **Developer docs** (`docs/developer/`): Architecture, patterns, contribution guides
|
||||
- Markdown files in `docs/developer/docs/*.md`
|
||||
- Navigation managed via `docs/developer/sidebars.ts`
|
||||
- **AI guidance**: `AGENTS.md` (patterns, conventions, long-term memory)
|
||||
- **User docs** (`docs/user/`): Installation, configuration, usage guides
|
||||
- Markdown files in `docs/user/docs/*.md`
|
||||
- Navigation managed via `docs/user/sidebars.ts`
|
||||
- **Developer docs** (`docs/developer/`): Architecture, patterns, contribution guides
|
||||
- Markdown files in `docs/developer/docs/*.md`
|
||||
- Navigation managed via `docs/developer/sidebars.ts`
|
||||
- **AI guidance**: `AGENTS.md` (patterns, conventions, long-term memory)
|
||||
|
||||
**Best practices:**
|
||||
- Use clear examples and code snippets
|
||||
- Keep docs up-to-date with code changes
|
||||
- Add new pages to appropriate `sidebars.ts` for navigation
|
||||
|
||||
- Use clear examples and code snippets
|
||||
- Keep docs up-to-date with code changes
|
||||
- Add new pages to appropriate `sidebars.ts` for navigation
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ Guidelines for maintaining and improving integration performance.
|
|||
## Performance Goals
|
||||
|
||||
Target metrics:
|
||||
|
||||
- **Coordinator update**: <500ms (typical: 200-300ms)
|
||||
- **Sensor update**: <10ms per sensor
|
||||
- **Period calculation**: <100ms (typical: 20-50ms)
|
||||
|
|
@ -64,6 +65,7 @@ python -m aioprof homeassistant -c config
|
|||
### Caching
|
||||
|
||||
**1. Persistent Cache** (API data):
|
||||
|
||||
```python
|
||||
# Already implemented in coordinator/cache.py
|
||||
store = Store(hass, STORAGE_VERSION, STORAGE_KEY)
|
||||
|
|
@ -71,6 +73,7 @@ data = await store.async_load()
|
|||
```
|
||||
|
||||
**2. Translation Cache** (in-memory):
|
||||
|
||||
```python
|
||||
# Already implemented in const.py
|
||||
_TRANSLATION_CACHE: dict[str, dict] = {}
|
||||
|
|
@ -83,6 +86,7 @@ def get_translation(path: str, language: str) -> dict:
|
|||
```
|
||||
|
||||
**3. Config Cache** (invalidated on options change):
|
||||
|
||||
```python
|
||||
class DataTransformer:
|
||||
def __init__(self):
|
||||
|
|
@ -100,6 +104,7 @@ class DataTransformer:
|
|||
### Lazy Loading
|
||||
|
||||
**Load data only when needed:**
|
||||
|
||||
```python
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict | None:
|
||||
|
|
@ -113,6 +118,7 @@ def extra_state_attributes(self) -> dict | None:
|
|||
### Bulk Operations
|
||||
|
||||
**Process multiple items at once:**
|
||||
|
||||
```python
|
||||
# ❌ Slow - loop with individual operations
|
||||
for interval in intervals:
|
||||
|
|
@ -126,6 +132,7 @@ results = enrich_intervals_bulk(intervals)
|
|||
### Async Best Practices
|
||||
|
||||
**1. Concurrent API calls:**
|
||||
|
||||
```python
|
||||
# ❌ Sequential (slow)
|
||||
user_data = await fetch_user_data()
|
||||
|
|
@ -139,6 +146,7 @@ user_data, price_data = await asyncio.gather(
|
|||
```
|
||||
|
||||
**2. Don't block event loop:**
|
||||
|
||||
```python
|
||||
# ❌ Blocking
|
||||
result = heavy_computation() # Blocks for seconds
|
||||
|
|
@ -152,6 +160,7 @@ result = await hass.async_add_executor_job(heavy_computation)
|
|||
### Avoid Memory Leaks
|
||||
|
||||
**1. Clear references:**
|
||||
|
||||
```python
|
||||
class Coordinator:
|
||||
async def async_shutdown(self):
|
||||
|
|
@ -162,6 +171,7 @@ class Coordinator:
|
|||
```
|
||||
|
||||
**2. Use weak references for callbacks:**
|
||||
|
||||
```python
|
||||
import weakref
|
||||
|
||||
|
|
@ -176,6 +186,7 @@ class Manager:
|
|||
### Efficient Data Structures
|
||||
|
||||
**Use appropriate types:**
|
||||
|
||||
```python
|
||||
# ❌ List for lookups (O(n))
|
||||
if timestamp in timestamp_list:
|
||||
|
|
@ -197,11 +208,13 @@ results = (x for x in items if condition(x))
|
|||
### Minimize API Calls
|
||||
|
||||
**Already implemented:**
|
||||
|
||||
- Cache valid until midnight
|
||||
- User data cached for 24h
|
||||
- Only poll when tomorrow data expected
|
||||
|
||||
**Monitor API usage:**
|
||||
|
||||
```python
|
||||
_LOGGER.debug("API call: %s (cache_age=%s)",
|
||||
endpoint, cache_age)
|
||||
|
|
@ -210,6 +223,7 @@ _LOGGER.debug("API call: %s (cache_age=%s)",
|
|||
### Smart Updates
|
||||
|
||||
**Only update when needed:**
|
||||
|
||||
```python
|
||||
async def _async_update_data(self) -> dict:
|
||||
"""Fetch data from API."""
|
||||
|
|
@ -226,6 +240,7 @@ async def _async_update_data(self) -> dict:
|
|||
### State Class Selection
|
||||
|
||||
**Affects long-term statistics storage:**
|
||||
|
||||
```python
|
||||
# ❌ MEASUREMENT for prices (stores every change)
|
||||
state_class=SensorStateClass.MEASUREMENT # ~35K records/year
|
||||
|
|
@ -240,6 +255,7 @@ state_class=SensorStateClass.TOTAL # For cumulative values
|
|||
### Attribute Size
|
||||
|
||||
**Keep attributes minimal:**
|
||||
|
||||
```python
|
||||
# ❌ Large nested structures (KB per update)
|
||||
attributes = {
|
||||
|
|
@ -317,6 +333,7 @@ _LOGGER.debug("Current memory usage: %.2f MB", memory_mb)
|
|||
---
|
||||
|
||||
💡 **Related:**
|
||||
|
||||
- [Caching Strategy](caching-strategy.md) - Cache layers
|
||||
- [Architecture](architecture.md) - System design
|
||||
- [Debugging](debugging.md) - Profiling tools
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -29,6 +29,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
```
|
||||
|
||||
**Key Points:**
|
||||
|
||||
- Must be a **class attribute** (not instance attribute)
|
||||
- Use `frozenset` for immutability and performance
|
||||
- Applied automatically by Home Assistant's Recorder component
|
||||
|
|
@ -40,6 +41,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
**Attributes:** `description`, `usage_tips`
|
||||
|
||||
**Reason:** Static, large text strings (100-500 chars each) that:
|
||||
|
||||
- Never change or change very rarely
|
||||
- Don't provide analytical value in history
|
||||
- Consume significant database space when recorded every state change
|
||||
|
|
@ -50,6 +52,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
### 2. Large Nested Structures
|
||||
|
||||
**Attributes:**
|
||||
|
||||
- `periods` (binary_sensor) - Array of all period summaries
|
||||
- `data` (chart_data_export) - Complete price data arrays
|
||||
- `trend_attributes` - Detailed trend analysis
|
||||
|
|
@ -58,6 +61,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
- `volatility_attributes` - Detailed volatility breakdown
|
||||
|
||||
**Reason:** Complex nested data structures that are:
|
||||
|
||||
- Serialized to JSON for storage (expensive)
|
||||
- Create large database rows (2-20 KB each)
|
||||
- Slow down history queries
|
||||
|
|
@ -66,20 +70,21 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
**Impact:** ~10-30 KB saved per state change for affected sensors
|
||||
|
||||
**Example - periods array:**
|
||||
|
||||
```json
|
||||
{
|
||||
"periods": [
|
||||
{
|
||||
"start": "2025-12-07T06:00:00+01:00",
|
||||
"end": "2025-12-07T08:00:00+01:00",
|
||||
"duration_minutes": 120,
|
||||
"price_mean": 18.5,
|
||||
"price_median": 18.3,
|
||||
"price_min": 17.2,
|
||||
"price_max": 19.8,
|
||||
// ... 10+ more attributes × 10-20 periods
|
||||
}
|
||||
]
|
||||
"periods": [
|
||||
{
|
||||
"start": "2025-12-07T06:00:00+01:00",
|
||||
"end": "2025-12-07T08:00:00+01:00",
|
||||
"duration_minutes": 120,
|
||||
"price_mean": 18.5,
|
||||
"price_median": 18.3,
|
||||
"price_min": 17.2,
|
||||
"price_max": 19.8
|
||||
// ... 10+ more attributes × 10-20 periods
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -88,6 +93,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
**Attributes:** `icon_color`, `cache_age`, `cache_validity`, `data_completeness`, `data_status`
|
||||
|
||||
**Reason:**
|
||||
|
||||
- Change every update cycle (every 15 minutes or more frequently)
|
||||
- Don't provide long-term analytical value
|
||||
- Create state changes even when core values haven't changed
|
||||
|
|
@ -103,6 +109,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
**Attributes:** `tomorrow_expected_after`, `level_value`, `rating_value`, `level_id`, `rating_id`, `currency`, `resolution`, `yaxis_min`, `yaxis_max`
|
||||
|
||||
**Reason:**
|
||||
|
||||
- Configuration values that rarely change
|
||||
- Wastes space when recorded repeatedly
|
||||
- Can be derived from other attributes or from entity state
|
||||
|
|
@ -114,6 +121,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
**Attributes:** `timestamp`, `next_api_poll`, `next_midnight_turnover`, `last_api_fetch`, `last_cache_update`, `last_turnover`, `last_error`, `error`
|
||||
|
||||
**Reason:**
|
||||
|
||||
- `timestamp` is the rounded-quarter reference time used at the moment of the state write — it's stale as soon as the next update fires and has no analytical value in history
|
||||
- `next_api_poll`, `next_midnight_turnover` etc. are only relevant at the moment of reading; they're superseded by the next update
|
||||
- Similar to `entity_picture` in HA core image entities
|
||||
|
|
@ -129,6 +137,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
**Attributes:** `relaxation_level`, `relaxation_threshold_original_%`, `relaxation_threshold_applied_%`
|
||||
|
||||
**Reason:**
|
||||
|
||||
- Detailed technical information not needed for historical analysis
|
||||
- Only useful for debugging during active development
|
||||
- Boolean `relaxation_active` is kept for high-level analysis
|
||||
|
|
@ -137,39 +146,45 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
|
||||
### 7. Redundant/Derived Data
|
||||
|
||||
**Attributes:** `price_spread`, `volatility`, `diff_%`, `rating_difference_%`, `period_price_diff_from_daily_min`, `period_price_diff_from_daily_min_%`, `period_count_total`, `periods_remaining`
|
||||
**Attributes:** `price_spread`, `volatility`, `diff_%`, `rating_difference_%`, `period_price_diff_from_daily_min`, `period_price_diff_from_daily_min_%`, `period_count_total`, `period_count_remaining`
|
||||
|
||||
**Reason:**
|
||||
|
||||
- Can be calculated from other attributes
|
||||
- Redundant information
|
||||
- Doesn't add analytical value to history
|
||||
|
||||
**Impact:** ~100-200 bytes saved per state change
|
||||
|
||||
**Example:** `price_spread = price_max - price_min` (both are recorded, so spread can be calculated). `periods_remaining = period_count_total - period_position` (both components are recorded).
|
||||
**Example:** `price_spread = price_max - price_min` (both are recorded, so spread can be calculated). `period_count_remaining = period_count_total - period_position` (both components are recorded).
|
||||
|
||||
## Attributes That ARE Recorded
|
||||
|
||||
These attributes **remain in history** because they provide essential analytical value:
|
||||
|
||||
### Time-Series Core
|
||||
|
||||
- All price values - Core sensor states (the entity's `native_value` is always recorded separately)
|
||||
|
||||
### Diagnostics & Tracking
|
||||
|
||||
- `cache_age_minutes` - Numeric value for diagnostics tracking over time
|
||||
- `updates_today` - Tracking API usage patterns
|
||||
|
||||
### Data Completeness
|
||||
|
||||
- `interval_count`, `intervals_available` - Data completeness metrics
|
||||
- `yesterday_available`, `today_available`, `tomorrow_available` - Boolean status
|
||||
|
||||
### Period Data
|
||||
|
||||
- `start`, `end`, `duration_minutes` - Core period timing
|
||||
- `price_mean`, `price_median`, `price_min`, `price_max` - Core price statistics
|
||||
- `period_position` - Position of current period in the day's sequence
|
||||
- `period_count_today`, `period_count_tomorrow` - How many periods per day (useful in automations)
|
||||
|
||||
### High-Level Status
|
||||
|
||||
- `relaxation_active` - Whether relaxation was used (boolean, useful for analyzing when periods needed relaxation)
|
||||
|
||||
## Expected Database Impact
|
||||
|
|
@ -177,6 +192,7 @@ These attributes **remain in history** because they provide essential analytical
|
|||
### Space Savings
|
||||
|
||||
**Per state change:**
|
||||
|
||||
- Before: ~3-8 KB average
|
||||
- After: ~0.5-1.5 KB average
|
||||
- **Reduction: 60-85%**
|
||||
|
|
@ -198,6 +214,7 @@ These attributes **remain in history** because they provide essential analytical
|
|||
### Real-World Impact
|
||||
|
||||
For a typical installation with:
|
||||
|
||||
- 80+ sensors
|
||||
- Updates every 15 minutes
|
||||
- ~10 sensors updating every minute
|
||||
|
|
@ -209,14 +226,14 @@ For a typical installation with:
|
|||
## Implementation Files
|
||||
|
||||
- **Sensor Platform**: `custom_components/tibber_prices/sensor/core.py`
|
||||
- Class: `TibberPricesSensor`
|
||||
- 46 attributes excluded
|
||||
- Class: `TibberPricesSensor`
|
||||
- 46 attributes excluded
|
||||
|
||||
- **Binary Sensor Platform**: `custom_components/tibber_prices/binary_sensor/core.py`
|
||||
- Class: `TibberPricesBinarySensor`
|
||||
- 29 attributes excluded
|
||||
- Class: `TibberPricesBinarySensor`
|
||||
- 29 attributes excluded
|
||||
|
||||
## When to Update _unrecorded_attributes
|
||||
## When to Update \_unrecorded_attributes
|
||||
|
||||
### Add to Exclusion List When:
|
||||
|
||||
|
|
@ -238,24 +255,24 @@ For a typical installation with:
|
|||
When adding a new attribute, ask:
|
||||
|
||||
1. **Will this be useful in history queries 1 week from now?**
|
||||
- No → Exclude
|
||||
- Yes → Keep
|
||||
- No → Exclude
|
||||
- Yes → Keep
|
||||
|
||||
2. **Can this be calculated from other recorded attributes?**
|
||||
- Yes → Exclude
|
||||
- No → Keep
|
||||
- Yes → Exclude
|
||||
- No → Keep
|
||||
|
||||
3. **Is this primarily for current UI display?**
|
||||
- Yes → Exclude
|
||||
- No → Keep
|
||||
- Yes → Exclude
|
||||
- No → Keep
|
||||
|
||||
4. **Does this change frequently without indicating state change?**
|
||||
- Yes → Exclude
|
||||
- No → Keep
|
||||
- Yes → Exclude
|
||||
- No → Keep
|
||||
|
||||
5. **Is this larger than 100 bytes and not essential for analysis?**
|
||||
- Yes → Exclude
|
||||
- No → Keep
|
||||
- Yes → Exclude
|
||||
- No → Keep
|
||||
|
||||
## Testing
|
||||
|
||||
|
|
@ -267,6 +284,7 @@ After modifying `_unrecorded_attributes`:
|
|||
4. **Confirm excluded attributes** don't appear in new state writes
|
||||
|
||||
**SQL Query to check attribute presence:**
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
state_id,
|
||||
|
|
@ -300,11 +318,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:
|
||||
|
||||
| `state_class` | Statistics written | Frontend effect |
|
||||
|---|---|---|
|
||||
| `TOTAL` | ✅ Yes — unbounded growth | Statistics line-chart on entity detail page |
|
||||
| `None` | ❌ No | States timeline only (History panel, "Show More") |
|
||||
| `MEASUREMENT` | ❌ Blocked by hassfest | — |
|
||||
| `state_class` | Statistics written | Frontend effect |
|
||||
| ------------- | ------------------------- | ------------------------------------------------- |
|
||||
| `TOTAL` | ✅ Yes — unbounded growth | Statistics line-chart on entity detail page |
|
||||
| `None` | ❌ No | States timeline only (History panel, "Show More") |
|
||||
| `MEASUREMENT` | ❌ Blocked by hassfest | — |
|
||||
|
||||
`MEASUREMENT` causes a hassfest validation error for MONETARY sensors, leaving only `TOTAL` or `None`.
|
||||
|
||||
|
|
@ -312,19 +330,21 @@ For sensors with `device_class=SensorDeviceClass.MONETARY`, only two `state_clas
|
|||
|
||||
Only 3 of 26 MONETARY sensors keep `state_class=TOTAL` — those where long-term history is genuinely useful:
|
||||
|
||||
| Sensor | Reason |
|
||||
|---|---|
|
||||
| `current_interval_price` | Long-term price trend (weeks/months) |
|
||||
| `current_interval_price_base` | Required for Energy Dashboard |
|
||||
| `average_price_today` | Seasonal daily average tracking |
|
||||
| Sensor | Reason |
|
||||
| ----------------------------- | ------------------------------------ |
|
||||
| `current_interval_price` | Long-term price trend (weeks/months) |
|
||||
| `current_interval_price_base` | Required for Energy Dashboard |
|
||||
| `average_price_today` | Seasonal daily average tracking |
|
||||
|
||||
All other 23 MONETARY sensors use `state_class=None`:
|
||||
|
||||
- Forecast/future sensors (`next_avg_*h`)
|
||||
- Daily snapshots (`lowest/highest_price_today/tomorrow`)
|
||||
- Rolling windows (`trailing/leading_24h_*`)
|
||||
- Next/previous interval sensors
|
||||
|
||||
**Effect of `state_class=None`:**
|
||||
|
||||
- ✅ Short-term state history (States timeline, ~10 days) still works normally
|
||||
- ✅ Templates, automations, and attributes are unaffected
|
||||
- ❌ Statistics line-chart removed from entity detail page for these sensors
|
||||
|
|
@ -333,6 +353,7 @@ All other 23 MONETARY sensors use `state_class=None`:
|
|||
### Expected Impact
|
||||
|
||||
Going from 26 → 3 sensors writing to the statistics tables:
|
||||
|
||||
- **~88% reduction** in statistics table writes
|
||||
- Prevents the primary cause of long-term database bloat
|
||||
- Existing statistics data is retained (only new writes stop)
|
||||
|
|
@ -341,10 +362,10 @@ Going from 26 → 3 sensors writing to the statistics tables:
|
|||
|
||||
These are two independent mechanisms targeting different tables:
|
||||
|
||||
| Mechanism | Table affected | Purged? | Controls |
|
||||
|---|---|---|---|
|
||||
| `_unrecorded_attributes` | `state_attributes` | ✅ ~10 days | Which attributes are stored per state write |
|
||||
| `state_class=None` | `statistics`, `statistics_short_term` | ❌ Never | Whether long-term statistics are written at all |
|
||||
| Mechanism | Table affected | Purged? | Controls |
|
||||
| ------------------------ | ------------------------------------- | ----------- | ----------------------------------------------- |
|
||||
| `_unrecorded_attributes` | `state_attributes` | ✅ ~10 days | Which attributes are stored per state write |
|
||||
| `state_class=None` | `statistics`, `statistics_short_term` | ❌ Never | Whether long-term statistics are written at all |
|
||||
|
||||
Both optimizations work together. `_unrecorded_attributes` reduces the size of each state write; `state_class=None` eliminates an entire category of unbounded writes.
|
||||
|
||||
|
|
|
|||
|
|
@ -8,22 +8,22 @@ Not every code change needs a detailed plan. Create a refactoring plan when:
|
|||
|
||||
🔴 **Major changes requiring planning:**
|
||||
|
||||
- Splitting modules into packages (>5 files affected, >500 lines moved)
|
||||
- Architectural changes (new packages, module restructuring)
|
||||
- Breaking changes (API changes, config format migrations)
|
||||
- Splitting modules into packages (>5 files affected, >500 lines moved)
|
||||
- Architectural changes (new packages, module restructuring)
|
||||
- Breaking changes (API changes, config format migrations)
|
||||
|
||||
🟡 **Medium changes that might benefit from planning:**
|
||||
|
||||
- Complex features with multiple moving parts
|
||||
- Changes affecting many files (>3 files, unclear best approach)
|
||||
- Refactorings with unclear scope
|
||||
- Complex features with multiple moving parts
|
||||
- Changes affecting many files (>3 files, unclear best approach)
|
||||
- Refactorings with unclear scope
|
||||
|
||||
🟢 **Small changes - no planning needed:**
|
||||
|
||||
- Bug fixes (straightforward, `<`100 lines)
|
||||
- Small features (`<`3 files, clear approach)
|
||||
- Documentation updates
|
||||
- Cosmetic changes (formatting, renaming)
|
||||
- Bug fixes (straightforward, `<`100 lines)
|
||||
- Small features (`<`3 files, clear approach)
|
||||
- Documentation updates
|
||||
- Cosmetic changes (formatting, renaming)
|
||||
|
||||
## The Planning Process
|
||||
|
||||
|
|
@ -51,34 +51,34 @@ Every planning document should include:
|
|||
|
||||
## Problem Statement
|
||||
|
||||
- What's the issue?
|
||||
- Why does it need fixing?
|
||||
- Current pain points
|
||||
- What's the issue?
|
||||
- Why does it need fixing?
|
||||
- Current pain points
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
- High-level approach
|
||||
- File structure (before/after)
|
||||
- Module responsibilities
|
||||
- High-level approach
|
||||
- File structure (before/after)
|
||||
- Module responsibilities
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
- Phase-by-phase breakdown
|
||||
- File lifecycle (CREATE/MODIFY/DELETE/RENAME)
|
||||
- Dependencies between phases
|
||||
- Testing checkpoints
|
||||
- Phase-by-phase breakdown
|
||||
- File lifecycle (CREATE/MODIFY/DELETE/RENAME)
|
||||
- Dependencies between phases
|
||||
- Testing checkpoints
|
||||
|
||||
## Risks & Mitigation
|
||||
|
||||
- What could go wrong?
|
||||
- How to prevent it?
|
||||
- Rollback strategy
|
||||
- What could go wrong?
|
||||
- How to prevent it?
|
||||
- Rollback strategy
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- Measurable improvements
|
||||
- Testing requirements
|
||||
- Verification steps
|
||||
- Measurable improvements
|
||||
- Testing requirements
|
||||
- Verification steps
|
||||
```
|
||||
|
||||
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:
|
||||
|
||||
- Draft multiple versions
|
||||
- Get AI assistance without commit pressure
|
||||
- Refine until the plan is solid
|
||||
- No need to clean up intermediate versions
|
||||
- Draft multiple versions
|
||||
- Get AI assistance without commit pressure
|
||||
- Refine until the plan is solid
|
||||
- No need to clean up intermediate versions
|
||||
|
||||
### 4. Implementation Phase
|
||||
|
||||
Once plan is approved:
|
||||
|
||||
- Follow the phases defined in the plan
|
||||
- Test after each phase (don't skip!)
|
||||
- Update plan if issues discovered
|
||||
- Track progress through phase status
|
||||
- Follow the phases defined in the plan
|
||||
- Test after each phase (don't skip!)
|
||||
- Update plan if issues discovered
|
||||
- Track progress through phase status
|
||||
|
||||
### 5. After Completion
|
||||
|
||||
|
|
@ -134,13 +134,13 @@ The **sensor/ package refactoring** (Nov 2025) is a successful example:
|
|||
|
||||
**Before:**
|
||||
|
||||
- `sensor.py` - 2,574 lines, hard to navigate
|
||||
- `sensor.py` - 2,574 lines, hard to navigate
|
||||
|
||||
**After:**
|
||||
|
||||
- `sensor/` package with 5 focused modules
|
||||
- Each module `<`800 lines
|
||||
- Clear separation of concerns
|
||||
- `sensor/` package with 5 focused modules
|
||||
- Each module `<`800 lines
|
||||
- Clear separation of concerns
|
||||
|
||||
**Process:**
|
||||
|
||||
|
|
@ -153,10 +153,10 @@ The **sensor/ package refactoring** (Nov 2025) is a successful example:
|
|||
|
||||
**Key learnings:**
|
||||
|
||||
- Temporary `_impl.py` files avoid Python package conflicts
|
||||
- Test after EVERY phase (don't accumulate changes)
|
||||
- Clear file lifecycle (CREATE/MODIFY/DELETE/RENAME)
|
||||
- Phase-by-phase approach enables safe rollback
|
||||
- Temporary `_impl.py` files avoid Python package conflicts
|
||||
- Test after EVERY phase (don't accumulate changes)
|
||||
- Clear file lifecycle (CREATE/MODIFY/DELETE/RENAME)
|
||||
- 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.
|
||||
|
||||
|
|
@ -166,11 +166,11 @@ The **sensor/ package refactoring** (Nov 2025) is a successful example:
|
|||
|
||||
Breaking refactorings into phases:
|
||||
|
||||
- ✅ Enables testing after each change (catch bugs early)
|
||||
- ✅ Allows rollback to last good state
|
||||
- ✅ Makes progress visible
|
||||
- ✅ Reduces cognitive load (focus on one thing)
|
||||
- ❌ Takes more time (but worth it!)
|
||||
- ✅ Enables testing after each change (catch bugs early)
|
||||
- ✅ Allows rollback to last good state
|
||||
- ✅ Makes progress visible
|
||||
- ✅ Reduces cognitive load (focus on one thing)
|
||||
- ❌ Takes more time (but worth it!)
|
||||
|
||||
### Phase Structure
|
||||
|
||||
|
|
@ -191,8 +191,8 @@ Each phase should:
|
|||
|
||||
**File Lifecycle**:
|
||||
|
||||
- ✨ CREATE `sensor/helpers.py` (utility functions)
|
||||
- ✏️ MODIFY `sensor/core.py` (import from helpers.py)
|
||||
- ✨ CREATE `sensor/helpers.py` (utility functions)
|
||||
- ✏️ MODIFY `sensor/core.py` (import from helpers.py)
|
||||
|
||||
**Steps**:
|
||||
|
||||
|
|
@ -205,10 +205,10 @@ Each phase should:
|
|||
|
||||
**Success criteria**:
|
||||
|
||||
- ✅ All pure functions moved
|
||||
- ✅ `./scripts/lint-check` passes
|
||||
- ✅ HA starts successfully
|
||||
- ✅ All entities work correctly
|
||||
- ✅ All pure functions moved
|
||||
- ✅ `./scripts/lint-check` passes
|
||||
- ✅ HA starts successfully
|
||||
- ✅ All entities work correctly
|
||||
```
|
||||
|
||||
## Testing Strategy
|
||||
|
|
@ -238,13 +238,13 @@ Minimum testing checklist:
|
|||
|
||||
After completing all phases:
|
||||
|
||||
- Test all entities (sensors, binary sensors)
|
||||
- Test configuration flow (add/modify/remove)
|
||||
- Test options flow (change settings)
|
||||
- Test services (custom service calls)
|
||||
- Test error handling (disconnect API, invalid data)
|
||||
- Test caching (restart HA, verify cache loads)
|
||||
- Test time-based updates (quarter-hour refresh)
|
||||
- Test all entities (sensors, binary sensors)
|
||||
- Test configuration flow (add/modify/remove)
|
||||
- Test options flow (change settings)
|
||||
- Test services (custom service calls)
|
||||
- Test error handling (disconnect API, invalid data)
|
||||
- Test caching (restart HA, verify cache loads)
|
||||
- Test time-based updates (quarter-hour refresh)
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
|
|
@ -286,21 +286,21 @@ This project uses AI heavily (GitHub Copilot, Claude). The planning process supp
|
|||
|
||||
**AI reads from:**
|
||||
|
||||
- `AGENTS.md` - Long-term memory, patterns, conventions (AI-focused)
|
||||
- `docs/development/` - Human-readable guides (human-focused)
|
||||
- `planning/` - Active refactoring plans (shared context)
|
||||
- `AGENTS.md` - Long-term memory, patterns, conventions (AI-focused)
|
||||
- `docs/development/` - Human-readable guides (human-focused)
|
||||
- `planning/` - Active refactoring plans (shared context)
|
||||
|
||||
**AI updates:**
|
||||
|
||||
- `AGENTS.md` - When patterns change
|
||||
- `planning/*.md` - During refactoring implementation
|
||||
- `docs/development/` - After successful completion
|
||||
- `AGENTS.md` - When patterns change
|
||||
- `planning/*.md` - During refactoring implementation
|
||||
- `docs/development/` - After successful completion
|
||||
|
||||
**Why separate AGENTS.md and docs/development/?**
|
||||
|
||||
- `AGENTS.md`: Technical, comprehensive, AI-optimized
|
||||
- `docs/development/`: Practical, focused, human-optimized
|
||||
- Both stay in sync but serve different audiences
|
||||
- `AGENTS.md`: Technical, comprehensive, AI-optimized
|
||||
- `docs/development/`: Practical, focused, human-optimized
|
||||
- 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.
|
||||
|
||||
|
|
@ -308,16 +308,16 @@ See [AGENTS.md](https://github.com/jpawlowski/hass.tibber_prices/blob/main/AGENT
|
|||
|
||||
### Planning Directory
|
||||
|
||||
- `planning/` - Git-ignored workspace for drafts
|
||||
- `planning/README.md` - Detailed planning documentation
|
||||
- `planning/*.md` - Active refactoring plans
|
||||
- `planning/` - Git-ignored workspace for drafts
|
||||
- `planning/README.md` - Detailed planning documentation
|
||||
- `planning/*.md` - Active refactoring plans
|
||||
|
||||
### Example Plans
|
||||
|
||||
- `docs/development/module-splitting-plan.md` - ✅ Completed, archived
|
||||
- `planning/config-flow-refactoring-plan.md` - 🔄 Planned (1013 lines → 4 modules)
|
||||
- `planning/binary-sensor-refactoring-plan.md` - 🔄 Planned (644 lines → 4 modules)
|
||||
- `planning/coordinator-refactoring-plan.md` - 🔄 Planned (1446 lines, high complexity)
|
||||
- `docs/development/module-splitting-plan.md` - ✅ Completed, archived
|
||||
- `planning/config-flow-refactoring-plan.md` - 🔄 Planned (1013 lines → 4 modules)
|
||||
- `planning/binary-sensor-refactoring-plan.md` - 🔄 Planned (644 lines → 4 modules)
|
||||
- `planning/coordinator-refactoring-plan.md` - 🔄 Planned (1446 lines, high complexity)
|
||||
|
||||
### 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:
|
||||
|
||||
- Lists all files affected (CREATE/MODIFY/DELETE)
|
||||
- Defines phases with clear boundaries
|
||||
- Includes testing strategy
|
||||
- Estimates time per phase
|
||||
- Lists all files affected (CREATE/MODIFY/DELETE)
|
||||
- Defines phases with clear boundaries
|
||||
- Includes testing strategy
|
||||
- Estimates time per phase
|
||||
|
||||
Too detailed:
|
||||
|
||||
- Exact code snippets for every change
|
||||
- Line-by-line instructions
|
||||
- Exact code snippets for every change
|
||||
- Line-by-line instructions
|
||||
|
||||
Too vague:
|
||||
|
||||
- "Refactor sensor.py to be better"
|
||||
- No phase breakdown
|
||||
- No testing strategy
|
||||
- "Refactor sensor.py to be better"
|
||||
- No phase breakdown
|
||||
- No testing strategy
|
||||
|
||||
### Q: What if the plan changes during implementation?
|
||||
|
||||
|
|
@ -363,9 +363,9 @@ Too vague:
|
|||
|
||||
If you discover:
|
||||
|
||||
- Better approach → Update "Proposed Solution"
|
||||
- More phases needed → Add to "Migration Strategy"
|
||||
- New risks → Update "Risks & Mitigation"
|
||||
- Better approach → Update "Proposed Solution"
|
||||
- More phases needed → Add to "Migration Strategy"
|
||||
- New risks → Update "Risks & Mitigation"
|
||||
|
||||
Document WHY the plan changed (helps future refactorings).
|
||||
|
||||
|
|
@ -373,9 +373,9 @@ Document WHY the plan changed (helps future refactorings).
|
|||
|
||||
**A:** No! Use judgment:
|
||||
|
||||
- **Small changes (`<`100 lines, clear approach)**: Just do it, no plan needed
|
||||
- **Medium changes (unclear scope)**: Write rough outline, refine if needed
|
||||
- **Large changes (>500 lines, >5 files)**: Full planning process
|
||||
- **Small changes (`<`100 lines, clear approach)**: Just do it, no plan needed
|
||||
- **Medium changes (unclear scope)**: Write rough outline, refine if needed
|
||||
- **Large changes (>500 lines, >5 files)**: Full planning process
|
||||
|
||||
### Q: How do I know when a refactoring is successful?
|
||||
|
||||
|
|
@ -383,12 +383,12 @@ Document WHY the plan changed (helps future refactorings).
|
|||
|
||||
Typical criteria:
|
||||
|
||||
- ✅ All linting checks pass
|
||||
- ✅ HA starts without errors
|
||||
- ✅ All entities functional
|
||||
- ✅ No regressions (existing features work)
|
||||
- ✅ Code easier to understand/modify
|
||||
- ✅ Documentation updated
|
||||
- ✅ All linting checks pass
|
||||
- ✅ HA starts without errors
|
||||
- ✅ All entities functional
|
||||
- ✅ No regressions (existing features work)
|
||||
- ✅ Code easier to understand/modify
|
||||
- ✅ Documentation updated
|
||||
|
||||
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:**
|
||||
|
||||
- Read `planning/README.md` for detailed template
|
||||
- Check `docs/development/module-splitting-plan.md` for real example
|
||||
- Browse `planning/` for active refactoring plans
|
||||
- Read `planning/README.md` for detailed template
|
||||
- Check `docs/development/module-splitting-plan.md` for real example
|
||||
- Browse `planning/` for active refactoring plans
|
||||
|
|
|
|||
|
|
@ -112,6 +112,7 @@ In CI/CD (`$CI` or `$GITHUB_ACTIONS`), AI is automatically disabled.
|
|||
**In DevContainer (automatic):**
|
||||
|
||||
git-cliff is automatically installed when the DevContainer is built:
|
||||
|
||||
- **Rust toolchain**: Installed via `ghcr.io/devcontainers/features/rust:1` (minimal profile)
|
||||
- **git-cliff**: Installed via cargo in `scripts/setup/setup`
|
||||
|
||||
|
|
@ -120,6 +121,7 @@ Simply rebuild the container (VS Code: "Dev Containers: Rebuild Container") and
|
|||
**Manual installation (outside DevContainer):**
|
||||
|
||||
**git-cliff** (template-based):
|
||||
|
||||
```bash
|
||||
# See: https://git-cliff.org/docs/installation
|
||||
|
||||
|
|
@ -190,13 +192,13 @@ All methods produce GitHub-flavored Markdown with emoji categories:
|
|||
|
||||
## 🎯 When to Use Which
|
||||
|
||||
| Method | Use Case | Pros | Cons |
|
||||
|--------|----------|------|------|
|
||||
| **Helper Script** | Normal releases | Foolproof, automatic | Requires script |
|
||||
| **Auto-Tag Workflow** | Forgot script | Safety net, automatic tagging | Still need manifest bump |
|
||||
| **GitHub Button** | Manual quick release | Easy, no script | Limited categorization |
|
||||
| **Local Script** | Testing release notes | Preview before release | Manual process |
|
||||
| **CI/CD** | After tag push | Fully automatic | Needs tag first |
|
||||
| Method | Use Case | Pros | Cons |
|
||||
| --------------------- | --------------------- | ----------------------------- | ------------------------ |
|
||||
| **Helper Script** | Normal releases | Foolproof, automatic | Requires script |
|
||||
| **Auto-Tag Workflow** | Forgot script | Safety net, automatic tagging | Still need manifest bump |
|
||||
| **GitHub Button** | Manual quick release | Easy, no script | Limited categorization |
|
||||
| **Local Script** | Testing release notes | Preview before release | Manual process |
|
||||
| **CI/CD** | After tag push | Fully automatic | Needs tag first |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -219,6 +221,7 @@ git push origin main v0.3.0
|
|||
```
|
||||
|
||||
**What happens:**
|
||||
|
||||
1. Script bumps manifest.json → commits → creates tag locally
|
||||
2. You push commit + tag together
|
||||
3. Release workflow sees tag → generates notes → creates release
|
||||
|
|
@ -242,6 +245,7 @@ git push
|
|||
```
|
||||
|
||||
**What happens:**
|
||||
|
||||
1. You push manifest.json change
|
||||
2. Auto-Tag workflow detects change → creates tag automatically
|
||||
3. Release workflow sees new tag → creates release
|
||||
|
|
@ -263,6 +267,7 @@ git push origin main v0.3.0
|
|||
```
|
||||
|
||||
**What happens:**
|
||||
|
||||
1. You create and push tag manually
|
||||
2. Release workflow creates release
|
||||
3. Auto-Tag workflow skips (tag already exists)
|
||||
|
|
@ -282,19 +287,24 @@ git push origin main v0.3.0
|
|||
## 🛡️ Safety Features
|
||||
|
||||
### 1. **Version Validation**
|
||||
|
||||
Both helper script and auto-tag workflow validate version format (X.Y.Z).
|
||||
|
||||
### 2. **No Duplicate Tags**
|
||||
|
||||
- Helper script checks if tag exists (local + remote)
|
||||
- Auto-tag workflow checks if tag exists before creating
|
||||
|
||||
### 3. **Atomic Operations**
|
||||
|
||||
Helper script creates commit + tag locally. You decide when to push.
|
||||
|
||||
### 4. **Version Bumps Filtered**
|
||||
|
||||
Release notes automatically exclude `chore(release): bump version` commits.
|
||||
|
||||
### 5. **Rollback Instructions**
|
||||
|
||||
Helper script shows how to undo if you change your mind.
|
||||
|
||||
---
|
||||
|
|
@ -330,6 +340,7 @@ git push -f origin main v0.3.0
|
|||
**Auto-tag didn't create tag:**
|
||||
|
||||
Check workflow runs in GitHub Actions. Common causes:
|
||||
|
||||
- Tag already exists remotely
|
||||
- Invalid version format in manifest.json
|
||||
- manifest.json not in the commit that was pushed
|
||||
|
|
@ -348,13 +359,14 @@ Check workflow runs in GitHub Actions. Common causes:
|
|||
## 💡 Tips
|
||||
|
||||
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
|
||||
|
||||
Impact: Users can now do X and Y.
|
||||
```
|
||||
Detailed description of what changed.
|
||||
|
||||
Impact: Users can now do X and Y.
|
||||
```
|
||||
|
||||
2. **Impact Section:** Add `Impact:` in commit body for user-friendly descriptions
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ The Tibber Prices integration includes a proactive repair notification system th
|
|||
The repairs system is implemented in `coordinator/repairs.py` via the `TibberPricesRepairManager` class, which is instantiated in the coordinator and integrated into the update cycle.
|
||||
|
||||
**Design Principles:**
|
||||
|
||||
- **Proactive**: Detect issues before they become critical
|
||||
- **User-friendly**: Clear explanations with actionable guidance
|
||||
- **Auto-clearing**: Repairs automatically disappear when conditions resolve
|
||||
|
|
@ -19,10 +20,12 @@ The repairs system is implemented in `coordinator/repairs.py` via the `TibberPri
|
|||
**Issue ID:** `tomorrow_data_missing_{entry_id}`
|
||||
|
||||
**When triggered:**
|
||||
|
||||
- Current time is after 18:00 (configurable via `TOMORROW_DATA_WARNING_HOUR`)
|
||||
- Tomorrow's electricity price data is still not available
|
||||
|
||||
**When cleared:**
|
||||
|
||||
- Tomorrow's data becomes available
|
||||
- Automatically checks on every successful API update
|
||||
|
||||
|
|
@ -30,6 +33,7 @@ The repairs system is implemented in `coordinator/repairs.py` via the `TibberPri
|
|||
Users cannot plan ahead for tomorrow's electricity usage optimization. Automations relying on tomorrow's prices will not work.
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```python
|
||||
# In coordinator update cycle
|
||||
has_tomorrow_data = self._data_fetcher.has_tomorrow_data(result["priceInfo"])
|
||||
|
|
@ -40,6 +44,7 @@ await self._repair_manager.check_tomorrow_data_availability(
|
|||
```
|
||||
|
||||
**Translation placeholders:**
|
||||
|
||||
- `home_name`: Name of the affected home
|
||||
- `warning_hour`: Hour after which warning appears (default: 18)
|
||||
|
||||
|
|
@ -48,10 +53,12 @@ await self._repair_manager.check_tomorrow_data_availability(
|
|||
**Issue ID:** `rate_limit_exceeded_{entry_id}`
|
||||
|
||||
**When triggered:**
|
||||
|
||||
- Integration encounters 3 or more consecutive rate limit errors (HTTP 429)
|
||||
- Threshold configurable via `RATE_LIMIT_WARNING_THRESHOLD`
|
||||
|
||||
**When cleared:**
|
||||
|
||||
- Successful API call completes (no rate limit error)
|
||||
- Error counter resets to 0
|
||||
|
||||
|
|
@ -59,6 +66,7 @@ await self._repair_manager.check_tomorrow_data_availability(
|
|||
API requests are being throttled, causing stale data. Updates may be delayed until rate limit expires.
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```python
|
||||
# In error handler
|
||||
is_rate_limit = (
|
||||
|
|
@ -74,6 +82,7 @@ await self._repair_manager.clear_rate_limit_tracking()
|
|||
```
|
||||
|
||||
**Translation placeholders:**
|
||||
|
||||
- `home_name`: Name of the affected home
|
||||
- `error_count`: Number of consecutive rate limit errors
|
||||
|
||||
|
|
@ -82,10 +91,12 @@ await self._repair_manager.clear_rate_limit_tracking()
|
|||
**Issue ID:** `home_not_found_{entry_id}`
|
||||
|
||||
**When triggered:**
|
||||
|
||||
- Home configured in this integration is no longer present in Tibber account
|
||||
- Detected during user data refresh (daily check)
|
||||
|
||||
**When cleared:**
|
||||
|
||||
- Home reappears in Tibber account (unlikely - manual cleanup expected)
|
||||
- Integration entry is removed (shutdown cleanup)
|
||||
|
||||
|
|
@ -93,6 +104,7 @@ await self._repair_manager.clear_rate_limit_tracking()
|
|||
Integration cannot fetch data for a non-existent home. User must remove the config entry and re-add if needed.
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```python
|
||||
# After user data update
|
||||
home_exists = self._data_fetcher._check_home_exists(home_id)
|
||||
|
|
@ -103,6 +115,7 @@ else:
|
|||
```
|
||||
|
||||
**Translation placeholders:**
|
||||
|
||||
- `home_name`: Name of the missing home
|
||||
- `entry_id`: Config entry ID for reference
|
||||
|
||||
|
|
@ -153,6 +166,7 @@ Each repair type maintains internal state to avoid redundant operations:
|
|||
### Lifecycle Integration
|
||||
|
||||
**Coordinator Initialization:**
|
||||
|
||||
```python
|
||||
self._repair_manager = TibberPricesRepairManager(
|
||||
hass=hass,
|
||||
|
|
@ -162,6 +176,7 @@ self._repair_manager = TibberPricesRepairManager(
|
|||
```
|
||||
|
||||
**Update Cycle Integration:**
|
||||
|
||||
```python
|
||||
# Success path - check conditions
|
||||
if result and "priceInfo" in result:
|
||||
|
|
@ -178,6 +193,7 @@ if is_rate_limit:
|
|||
```
|
||||
|
||||
**Shutdown Cleanup:**
|
||||
|
||||
```python
|
||||
async def async_shutdown(self) -> None:
|
||||
"""Shut down coordinator and clean up."""
|
||||
|
|
@ -196,24 +212,27 @@ Repairs use Home Assistant's standard translation system. Translations are defin
|
|||
- `/translations/sv.json`
|
||||
|
||||
**Structure:**
|
||||
|
||||
```json
|
||||
{
|
||||
"issues": {
|
||||
"tomorrow_data_missing": {
|
||||
"title": "Tomorrow's price data missing for {home_name}",
|
||||
"description": "Detailed explanation with multiple paragraphs...\n\nPossible causes:\n- Cause 1\n- Cause 2"
|
||||
"issues": {
|
||||
"tomorrow_data_missing": {
|
||||
"title": "Tomorrow's price data missing for {home_name}",
|
||||
"description": "Detailed explanation with multiple paragraphs...\n\nPossible causes:\n- Cause 1\n- Cause 2"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Home Assistant Integration
|
||||
|
||||
Repairs appear in:
|
||||
|
||||
- **Settings → System → Repairs** (main repairs panel)
|
||||
- **Notifications** (bell icon in UI shows repair count)
|
||||
|
||||
Repair properties:
|
||||
|
||||
- **`is_fixable=False`**: No automated fix available (user action required)
|
||||
- **`severity=IssueSeverity.WARNING`**: Yellow warning level (not critical)
|
||||
- **`translation_key`**: References `issues.{key}` in translation files
|
||||
|
|
@ -228,6 +247,7 @@ Repair properties:
|
|||
4. When tomorrow data arrives (next API fetch), repair clears
|
||||
|
||||
**Manual trigger:**
|
||||
|
||||
```python
|
||||
# Temporarily set warning hour to current hour for testing
|
||||
TOMORROW_DATA_WARNING_HOUR = datetime.now().hour
|
||||
|
|
@ -240,6 +260,7 @@ TOMORROW_DATA_WARNING_HOUR = datetime.now().hour
|
|||
3. Successful API call clears the repair
|
||||
|
||||
**Manual test:**
|
||||
|
||||
- Reduce API polling interval to trigger rate limiting
|
||||
- Or temporarily return HTTP 429 in API client
|
||||
|
||||
|
|
@ -263,6 +284,7 @@ To add a new repair type:
|
|||
7. **Document** in this file
|
||||
|
||||
**Example template:**
|
||||
|
||||
```python
|
||||
async def check_new_condition(self, *, param: bool) -> None:
|
||||
"""Check new condition and create/clear repair."""
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@
|
|||
|
||||
## Prerequisites
|
||||
|
||||
- VS Code with Dev Container support
|
||||
- Docker installed and running
|
||||
- GitHub account (for Tibber API token)
|
||||
- VS Code with Dev Container support
|
||||
- Docker installed and running
|
||||
- GitHub account (for Tibber API token)
|
||||
|
||||
## Quick Setup
|
||||
|
||||
|
|
@ -26,11 +26,11 @@ code .
|
|||
|
||||
The DevContainer includes:
|
||||
|
||||
- Python 3.13 with `.venv` at `/home/vscode/.venv/`
|
||||
- `uv` package manager (fast, modern Python tooling)
|
||||
- Home Assistant development dependencies
|
||||
- Ruff linter/formatter
|
||||
- Git, GitHub CLI, Node.js, Rust toolchain
|
||||
- Python 3.13 with `.venv` at `/home/vscode/.venv/`
|
||||
- `uv` package manager (fast, modern Python tooling)
|
||||
- Home Assistant development dependencies
|
||||
- Ruff linter/formatter
|
||||
- Git, GitHub CLI, Node.js, Rust toolchain
|
||||
|
||||
## Running the Integration
|
||||
|
||||
|
|
|
|||
|
|
@ -13,10 +13,10 @@ Before running tests or committing changes, validate the integration structure:
|
|||
|
||||
This lightweight script checks:
|
||||
|
||||
- ✓ `config_flow.py` exists
|
||||
- ✓ `manifest.json` is valid JSON with required fields
|
||||
- ✓ Translation files have valid JSON syntax
|
||||
- ✓ All Python files compile without syntax errors
|
||||
- ✓ `config_flow.py` exists
|
||||
- ✓ `manifest.json` is valid JSON with required fields
|
||||
- ✓ Translation files have valid JSON syntax
|
||||
- ✓ All Python files compile without syntax errors
|
||||
|
||||
**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:
|
||||
|
||||
- Configuration flow
|
||||
- Sensor states and attributes
|
||||
- Services
|
||||
- Translation strings
|
||||
- Configuration flow
|
||||
- Sensor states and attributes
|
||||
- Services
|
||||
- Translation strings
|
||||
|
||||
## Test Guidelines
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
| Timer | Type | Interval | Purpose | Trigger Method |
|
||||
|-------|------|----------|---------|----------------|
|
||||
| **Timer #1** | HA built-in | 15 minutes | API data updates | `DataUpdateCoordinator` |
|
||||
| **Timer #2** | Custom | :00, :15, :30, :45 | Entity state refresh | `async_track_utc_time_change()` |
|
||||
| **Timer #3** | Custom | Every minute | Countdown/progress | `async_track_utc_time_change()` |
|
||||
| Timer | Type | Interval | Purpose | Trigger Method |
|
||||
| ------------ | ----------- | ------------------ | -------------------- | ------------------------------- |
|
||||
| **Timer #1** | HA built-in | 15 minutes | API data updates | `DataUpdateCoordinator` |
|
||||
| **Timer #2** | Custom | :00, :15, :30, :45 | Entity state refresh | `async_track_utc_time_change()` |
|
||||
| **Timer #3** | Custom | Every minute | Countdown/progress | `async_track_utc_time_change()` |
|
||||
|
||||
**Key principle:** Timer #1 (HA) controls **data fetching**, Timer #2 controls **entity updates**, Timer #3 controls **timing displays**.
|
||||
|
||||
|
|
@ -27,6 +27,7 @@ The integration uses **three independent timer mechanisms** for different purpos
|
|||
**Type:** Home Assistant's built-in `DataUpdateCoordinator` with `UPDATE_INTERVAL = 15 minutes`
|
||||
|
||||
**What it is:**
|
||||
|
||||
- HA provides this timer system automatically when you inherit from `DataUpdateCoordinator`
|
||||
- Triggers `_async_update_data()` method every 15 minutes
|
||||
- **Not** synchronized to clock boundaries (each installation has different start time)
|
||||
|
|
@ -53,16 +54,19 @@ async def _async_update_data(self) -> TibberPricesData:
|
|||
```
|
||||
|
||||
**Load Distribution:**
|
||||
|
||||
- Each HA installation starts Timer #1 at different times → natural distribution
|
||||
- Tomorrow data check adds 0-30s random delay → prevents "thundering herd" on Tibber API
|
||||
- Result: API load spread over ~30 minutes instead of all at once
|
||||
|
||||
**Midnight Coordination:**
|
||||
|
||||
- Atomic check: `_check_midnight_turnover_needed(now)` compares dates only (no side effects)
|
||||
- If midnight turnover needed → performs it and returns early
|
||||
- Timer #2 will see turnover already done and skip gracefully
|
||||
|
||||
**Why we use HA's timer:**
|
||||
|
||||
- Automatic restart after HA restart
|
||||
- Built-in retry logic for temporary failures
|
||||
- Standard HA integration pattern
|
||||
|
|
@ -79,6 +83,7 @@ async def _async_update_data(self) -> TibberPricesData:
|
|||
**Purpose:** Update time-sensitive entity states at interval boundaries **without waiting for API poll**
|
||||
|
||||
**Problem it solves:**
|
||||
|
||||
- Timer #1 runs every 15 minutes but NOT synchronized to clock (:03, :18, :33, :48)
|
||||
- Current price changes at :00, :15, :30, :45 → entities would show stale data for up to 15 minutes
|
||||
- Example: 14:00 new price, but Timer #1 ran at 13:58 → next update at 14:13 → users see old price until 14:13
|
||||
|
|
@ -100,22 +105,26 @@ async def _handle_quarter_hour_refresh(self, now: datetime) -> None:
|
|||
```
|
||||
|
||||
**Smart Boundary Tolerance:**
|
||||
|
||||
- Uses `round_to_nearest_quarter_hour()` with ±2 second tolerance
|
||||
- HA may schedule timer at 14:59:58 → rounds to 15:00:00 (shows new interval)
|
||||
- HA restart at 14:59:30 → stays at 14:45:00 (shows current interval)
|
||||
- See [Architecture](./architecture.md#3-quarter-hour-precision) for details
|
||||
|
||||
**Absolute Time Scheduling:**
|
||||
|
||||
- `async_track_utc_time_change()` plans for **all future boundaries** (15:00, 15:15, 15:30, ...)
|
||||
- NOT relative delays ("in 15 minutes")
|
||||
- If triggered at 14:59:58 → next trigger is 15:15:00, NOT 15:00:00 (prevents double updates)
|
||||
|
||||
**Which entities listen:**
|
||||
|
||||
- All sensors that depend on "current interval" (e.g., `current_interval_price`, `next_interval_price`)
|
||||
- Binary sensors that check "is now in period?" (e.g., `best_price_period_active`)
|
||||
- ~50-60 entities out of 120+ total
|
||||
|
||||
**Why custom timer:**
|
||||
|
||||
- HA's built-in coordinator doesn't support exact boundary timing
|
||||
- We need **absolute time** triggers, not periodic intervals
|
||||
- Allows fast entity updates without expensive data transformation
|
||||
|
|
@ -140,6 +149,7 @@ async def _handle_minute_refresh(self, now: datetime) -> None:
|
|||
```
|
||||
|
||||
**Which entities listen:**
|
||||
|
||||
- `best_price_remaining_minutes` - Countdown timer
|
||||
- `peak_price_remaining_minutes` - Countdown timer
|
||||
- `best_price_progress` - Progress bar (0-100%)
|
||||
|
|
@ -147,11 +157,13 @@ async def _handle_minute_refresh(self, now: datetime) -> None:
|
|||
- ~10 entities total
|
||||
|
||||
**Why custom timer:**
|
||||
|
||||
- Users want smooth countdowns (not jumping 15 minutes at a time)
|
||||
- Progress bars need minute-by-minute updates
|
||||
- Very lightweight (no data processing, just state recalculation)
|
||||
|
||||
**Why NOT every second:**
|
||||
|
||||
- Minute precision sufficient for countdown UX
|
||||
- Reduces CPU load (60× fewer updates than seconds)
|
||||
- Home Assistant best practice (avoid sub-minute updates)
|
||||
|
|
@ -194,6 +206,7 @@ class ListenerManager:
|
|||
```
|
||||
|
||||
**Why this pattern:**
|
||||
|
||||
- Decouples timer logic from entity logic
|
||||
- One timer can notify many entities efficiently
|
||||
- Entities can unregister when removed (cleanup)
|
||||
|
|
@ -279,11 +292,13 @@ class ListenerManager:
|
|||
### Reason 1: Load Distribution on Tibber API
|
||||
|
||||
If all installations used synchronized timers:
|
||||
|
||||
- ❌ Everyone fetches at 13:00:00 → Tibber API overload
|
||||
- ❌ Everyone fetches at 14:00:00 → Tibber API overload
|
||||
- ❌ "Thundering herd" problem
|
||||
|
||||
With HA's unsynchronized timer:
|
||||
|
||||
- ✅ Installation A: 13:03:12, 13:18:12, 13:33:12, ...
|
||||
- ✅ Installation B: 13:07:45, 13:22:45, 13:37:45, ...
|
||||
- ✅ Installation C: 13:11:28, 13:26:28, 13:41:28, ...
|
||||
|
|
@ -316,6 +331,7 @@ def _should_update_price_data(self) -> str:
|
|||
**Most Timer #1 cycles:** Fast path (~2ms), no API call, just returns cached data.
|
||||
|
||||
**API fetch only when:**
|
||||
|
||||
- Tomorrow data missing/invalid (after 13:00)
|
||||
- Cache expired (midnight turnover)
|
||||
- Explicit user refresh
|
||||
|
|
@ -339,6 +355,7 @@ def _should_update_price_data(self) -> str:
|
|||
## Performance Characteristics
|
||||
|
||||
### Timer #1 (DataUpdateCoordinator)
|
||||
|
||||
- **Triggers:** Every 15 minutes (unsynchronized)
|
||||
- **Fast path:** ~2ms (cache check, return existing data)
|
||||
- **Slow path:** ~600ms (API fetch + transform + calculate)
|
||||
|
|
@ -346,12 +363,14 @@ def _should_update_price_data(self) -> str:
|
|||
- **API calls:** ~1-2 times/day (cached otherwise)
|
||||
|
||||
### Timer #2 (Quarter-Hour Refresh)
|
||||
|
||||
- **Triggers:** 96 times/day (exact boundaries)
|
||||
- **Processing:** ~5ms (notify 60 entities)
|
||||
- **No API calls:** Uses cached/transformed data
|
||||
- **No transformation:** Just entity state updates
|
||||
|
||||
### Timer #3 (Minute Refresh)
|
||||
|
||||
- **Triggers:** 1440 times/day (every minute)
|
||||
- **Processing:** ~1ms (notify 10 entities)
|
||||
- **No API calls:** No data processing at all
|
||||
|
|
@ -393,16 +412,16 @@ _LOGGER.setLevel(logging.DEBUG)
|
|||
### Common Issues
|
||||
|
||||
1. **Timer #2 not triggering:**
|
||||
- Check: `schedule_quarter_hour_refresh()` called in `__init__`?
|
||||
- Check: `_quarter_hour_timer_cancel` properly stored?
|
||||
- Check: `schedule_quarter_hour_refresh()` called in `__init__`?
|
||||
- Check: `_quarter_hour_timer_cancel` properly stored?
|
||||
|
||||
2. **Double updates at midnight:**
|
||||
- Should NOT happen (atomic coordination)
|
||||
- Check: Both timers use same date comparison logic?
|
||||
- Should NOT happen (atomic coordination)
|
||||
- Check: Both timers use same date comparison logic?
|
||||
|
||||
3. **API overload:**
|
||||
- Check: Random delay working? (0-30s jitter on tomorrow check)
|
||||
- Check: Cache validation logic correct?
|
||||
- Check: Random delay working? (0-30s jitter on tomorrow check)
|
||||
- Check: Cache validation logic correct?
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -417,17 +436,20 @@ _LOGGER.setLevel(logging.DEBUG)
|
|||
## Summary
|
||||
|
||||
**Three independent timers:**
|
||||
|
||||
1. **Timer #1** (HA built-in, 15 min, unsynchronized) → Data fetching (when needed)
|
||||
2. **Timer #2** (Custom, :00/:15/:30/:45) → Entity state updates (always)
|
||||
3. **Timer #3** (Custom, every minute) → Countdown/progress (always)
|
||||
|
||||
**Key insights:**
|
||||
|
||||
- Timer #1 unsynchronized = good (load distribution on API)
|
||||
- Timer #2 synchronized = good (user sees correct data immediately)
|
||||
- Timer #3 synchronized = good (smooth countdown UX)
|
||||
- All three coordinate gracefully (atomic midnight checks, no conflicts)
|
||||
|
||||
**"Listener" terminology:**
|
||||
|
||||
- Timer = mechanism that triggers
|
||||
- Listener = callback that gets called
|
||||
- Observer pattern = entities register, coordinator notifies
|
||||
|
|
|
|||
42280
docs/developer/package-lock.json
generated
42280
docs/developer/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,50 +1,50 @@
|
|||
{
|
||||
"name": "docs-split-developer",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"docusaurus": "docusaurus",
|
||||
"start": "docusaurus start",
|
||||
"build": "docusaurus build",
|
||||
"swizzle": "docusaurus swizzle",
|
||||
"deploy": "docusaurus deploy",
|
||||
"clear": "docusaurus clear",
|
||||
"serve": "docusaurus serve",
|
||||
"write-translations": "docusaurus write-translations",
|
||||
"write-heading-ids": "docusaurus write-heading-ids",
|
||||
"typecheck": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@docusaurus/core": "^3.10.0",
|
||||
"@docusaurus/faster": "^3.10.0",
|
||||
"@docusaurus/preset-classic": "^3.10.0",
|
||||
"@docusaurus/theme-mermaid": "^3.10.0",
|
||||
"@mdx-js/react": "^3.0.0",
|
||||
"clsx": "^2.0.0",
|
||||
"docusaurus-lunr-search": "^3.6.0",
|
||||
"prism-react-renderer": "^2.3.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@docusaurus/module-type-aliases": "^3.10.0",
|
||||
"@docusaurus/tsconfig": "^3.10.0",
|
||||
"@docusaurus/types": "^3.10.0",
|
||||
"typescript": "~6.0.2"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.5%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 3 chrome version",
|
||||
"last 3 firefox version",
|
||||
"last 5 safari version"
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0"
|
||||
}
|
||||
"name": "docs-split-developer",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"docusaurus": "docusaurus",
|
||||
"start": "docusaurus start",
|
||||
"build": "docusaurus build",
|
||||
"swizzle": "docusaurus swizzle",
|
||||
"deploy": "docusaurus deploy",
|
||||
"clear": "docusaurus clear",
|
||||
"serve": "docusaurus serve",
|
||||
"write-translations": "docusaurus write-translations",
|
||||
"write-heading-ids": "docusaurus write-heading-ids",
|
||||
"typecheck": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@docusaurus/core": "^3.10.0",
|
||||
"@docusaurus/faster": "^3.10.0",
|
||||
"@docusaurus/preset-classic": "^3.10.0",
|
||||
"@docusaurus/theme-mermaid": "^3.10.0",
|
||||
"@mdx-js/react": "^3.0.0",
|
||||
"clsx": "^2.0.0",
|
||||
"docusaurus-lunr-search": "^3.6.0",
|
||||
"prism-react-renderer": "^2.3.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@docusaurus/module-type-aliases": "^3.10.0",
|
||||
"@docusaurus/tsconfig": "^3.10.0",
|
||||
"@docusaurus/types": "^3.10.0",
|
||||
"typescript": "~6.0.2"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.5%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 3 chrome version",
|
||||
"last 3 firefox version",
|
||||
"last 5 safari version"
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,30 +22,30 @@ Fetches home information and metadata:
|
|||
|
||||
```graphql
|
||||
query {
|
||||
viewer {
|
||||
homes {
|
||||
id
|
||||
appNickname
|
||||
address {
|
||||
address1
|
||||
postalCode
|
||||
city
|
||||
country
|
||||
}
|
||||
timeZone
|
||||
currentSubscription {
|
||||
priceInfo {
|
||||
current {
|
||||
currency
|
||||
}
|
||||
viewer {
|
||||
homes {
|
||||
id
|
||||
appNickname
|
||||
address {
|
||||
address1
|
||||
postalCode
|
||||
city
|
||||
country
|
||||
}
|
||||
timeZone
|
||||
currentSubscription {
|
||||
priceInfo {
|
||||
current {
|
||||
currency
|
||||
}
|
||||
}
|
||||
}
|
||||
meteringPointData {
|
||||
consumptionEan
|
||||
gridAreaCode
|
||||
}
|
||||
}
|
||||
}
|
||||
meteringPointData {
|
||||
consumptionEan
|
||||
gridAreaCode
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -56,26 +56,27 @@ query {
|
|||
Fetches quarter-hourly prices:
|
||||
|
||||
```graphql
|
||||
query($homeId: ID!) {
|
||||
viewer {
|
||||
home(id: $homeId) {
|
||||
currentSubscription {
|
||||
priceInfo {
|
||||
range(resolution: QUARTER_HOURLY, first: 384) {
|
||||
nodes {
|
||||
total
|
||||
startsAt
|
||||
level
|
||||
query ($homeId: ID!) {
|
||||
viewer {
|
||||
home(id: $homeId) {
|
||||
currentSubscription {
|
||||
priceInfo {
|
||||
range(resolution: QUARTER_HOURLY, first: 384) {
|
||||
nodes {
|
||||
total
|
||||
startsAt
|
||||
level
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `homeId`: Tibber home identifier
|
||||
- `resolution`: Always `QUARTER_HOURLY`
|
||||
- `first`: 384 intervals (4 days of data)
|
||||
|
|
@ -85,10 +86,12 @@ query($homeId: ID!) {
|
|||
## Rate Limits
|
||||
|
||||
Tibber API rate limits (as of 2024):
|
||||
|
||||
- **5000 requests per hour** per token
|
||||
- **Burst limit:** 100 requests per minute
|
||||
|
||||
Integration stays well below these limits:
|
||||
|
||||
- Polls every 15 minutes = 96 requests/day
|
||||
- User data cached for 24h = 1 request/day
|
||||
- **Total:** ~100 requests/day per home
|
||||
|
|
@ -99,13 +102,14 @@ Integration stays well below these limits:
|
|||
|
||||
```json
|
||||
{
|
||||
"total": 0.2456,
|
||||
"startsAt": "2024-12-06T14:00:00.000+01:00",
|
||||
"level": "NORMAL"
|
||||
"total": 0.2456,
|
||||
"startsAt": "2024-12-06T14:00:00.000+01:00",
|
||||
"level": "NORMAL"
|
||||
}
|
||||
```
|
||||
|
||||
**Fields:**
|
||||
|
||||
- `total`: Price including VAT and fees (currency's major unit, e.g., EUR)
|
||||
- `startsAt`: ISO 8601 timestamp with timezone
|
||||
- `level`: Tibber's own classification (VERY_CHEAP, CHEAP, NORMAL, EXPENSIVE, VERY_EXPENSIVE)
|
||||
|
|
@ -114,11 +118,12 @@ Integration stays well below these limits:
|
|||
|
||||
```json
|
||||
{
|
||||
"currency": "EUR"
|
||||
"currency": "EUR"
|
||||
}
|
||||
```
|
||||
|
||||
Supported currencies:
|
||||
|
||||
- `EUR` (Euro) - displayed as ct/kWh
|
||||
- `NOK` (Norwegian Krone) - displayed as øre/kWh
|
||||
- `SEK` (Swedish Krona) - displayed as öre/kWh
|
||||
|
|
@ -128,42 +133,52 @@ Supported currencies:
|
|||
### Common Error Responses
|
||||
|
||||
**Invalid Token:**
|
||||
|
||||
```json
|
||||
{
|
||||
"errors": [{
|
||||
"message": "Unauthorized",
|
||||
"extensions": {
|
||||
"code": "UNAUTHENTICATED"
|
||||
}
|
||||
}]
|
||||
"errors": [
|
||||
{
|
||||
"message": "Unauthorized",
|
||||
"extensions": {
|
||||
"code": "UNAUTHENTICATED"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Rate Limit Exceeded:**
|
||||
|
||||
```json
|
||||
{
|
||||
"errors": [{
|
||||
"message": "Too Many Requests",
|
||||
"extensions": {
|
||||
"code": "RATE_LIMIT_EXCEEDED"
|
||||
}
|
||||
}]
|
||||
"errors": [
|
||||
{
|
||||
"message": "Too Many Requests",
|
||||
"extensions": {
|
||||
"code": "RATE_LIMIT_EXCEEDED"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Home Not Found:**
|
||||
|
||||
```json
|
||||
{
|
||||
"errors": [{
|
||||
"message": "Home not found",
|
||||
"extensions": {
|
||||
"code": "NOT_FOUND"
|
||||
}
|
||||
}]
|
||||
"errors": [
|
||||
{
|
||||
"message": "Home not found",
|
||||
"extensions": {
|
||||
"code": "NOT_FOUND"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Integration handles these with:
|
||||
|
||||
- Exponential backoff retry (3 attempts)
|
||||
- ConfigEntryAuthFailed for auth errors
|
||||
- ConfigEntryNotReady for temporary failures
|
||||
|
|
@ -171,6 +186,7 @@ Integration handles these with:
|
|||
## Data Transformation
|
||||
|
||||
Raw API data is enriched with:
|
||||
|
||||
- **Trailing 24h average** - Calculated from previous intervals
|
||||
- **Leading 24h average** - Calculated from future intervals
|
||||
- **Price difference %** - Deviation from average
|
||||
|
|
@ -181,6 +197,7 @@ See `utils/price.py` for enrichment logic.
|
|||
---
|
||||
|
||||
💡 **External Resources:**
|
||||
|
||||
- [Tibber API Documentation](https://developer.tibber.com/docs/overview)
|
||||
- [GraphQL Explorer](https://developer.tibber.com/explorer)
|
||||
- [Get API Token](https://developer.tibber.com/settings/access-token)
|
||||
|
|
|
|||
|
|
@ -100,43 +100,43 @@ flowchart TB
|
|||
### Flow Description
|
||||
|
||||
1. **Setup** (`__init__.py`)
|
||||
- Integration loads, creates coordinator instance
|
||||
- Registers entity platforms (sensor, binary_sensor)
|
||||
- Sets up custom services
|
||||
- Integration loads, creates coordinator instance
|
||||
- Registers entity platforms (sensor, binary_sensor)
|
||||
- Sets up custom services
|
||||
|
||||
2. **Data Fetch** (every 15 minutes)
|
||||
- Coordinator triggers update via `api.py`
|
||||
- API client checks **persistent cache** first (`coordinator/cache.py`)
|
||||
- If cache valid → return cached data
|
||||
- If cache stale → query Tibber GraphQL API
|
||||
- Store fresh data in persistent cache (survives HA restart)
|
||||
- Coordinator triggers update via `api.py`
|
||||
- API client checks **persistent cache** first (`coordinator/cache.py`)
|
||||
- If cache valid → return cached data
|
||||
- If cache stale → query Tibber GraphQL API
|
||||
- Store fresh data in persistent cache (survives HA restart)
|
||||
|
||||
3. **Price Enrichment**
|
||||
- Coordinator passes raw prices to `DataTransformer`
|
||||
- Transformer checks **transformation cache** (memory)
|
||||
- If cache valid → return enriched data
|
||||
- If cache invalid → enrich via `price_utils.py` + `average_utils.py`
|
||||
- Calculate 24h trailing/leading averages
|
||||
- Calculate price differences (% from average)
|
||||
- Assign rating levels (LOW/NORMAL/HIGH)
|
||||
- Store enriched data in transformation cache
|
||||
- Coordinator passes raw prices to `DataTransformer`
|
||||
- Transformer checks **transformation cache** (memory)
|
||||
- If cache valid → return enriched data
|
||||
- If cache invalid → enrich via `price_utils.py` + `average_utils.py`
|
||||
- Calculate 24h trailing/leading averages
|
||||
- Calculate price differences (% from average)
|
||||
- Assign rating levels (LOW/NORMAL/HIGH)
|
||||
- Store enriched data in transformation cache
|
||||
|
||||
4. **Period Calculation**
|
||||
- Coordinator passes enriched data to `PeriodCalculator`
|
||||
- Calculator computes **hash** from prices + config
|
||||
- If hash matches cache → return cached periods
|
||||
- If hash differs → recalculate best/peak price periods
|
||||
- Store periods with new hash
|
||||
- Coordinator passes enriched data to `PeriodCalculator`
|
||||
- Calculator computes **hash** from prices + config
|
||||
- If hash matches cache → return cached periods
|
||||
- If hash differs → recalculate best/peak price periods
|
||||
- Store periods with new hash
|
||||
|
||||
5. **Entity Updates**
|
||||
- Coordinator provides complete data (prices + periods)
|
||||
- Sensors read values via unified handlers
|
||||
- Binary sensors evaluate period states
|
||||
- Entities update on quarter-hour boundaries (00/15/30/45)
|
||||
- Coordinator provides complete data (prices + periods)
|
||||
- Sensors read values via unified handlers
|
||||
- Binary sensors evaluate period states
|
||||
- Entities update on quarter-hour boundaries (00/15/30/45)
|
||||
|
||||
6. **Service Calls**
|
||||
- Custom services access coordinator data directly
|
||||
- Return formatted responses (JSON, ApexCharts format)
|
||||
- Custom services access coordinator data directly
|
||||
- Return formatted responses (JSON, ApexCharts format)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -146,13 +146,13 @@ flowchart TB
|
|||
|
||||
The integration uses **5 independent caching layers** for optimal performance:
|
||||
|
||||
| Layer | Location | Lifetime | Invalidation | Memory |
|
||||
|-------|----------|----------|--------------|--------|
|
||||
| **API Cache** | `coordinator/cache.py` | 24h (user)<br/>Until midnight (prices) | Automatic | 50KB |
|
||||
| **Translation Cache** | `const.py` | Until HA restart | Never | 5KB |
|
||||
| **Config Cache** | `coordinator/*` | Until config change | Explicit | 1KB |
|
||||
| **Period Cache** | `coordinator/periods.py` | Until data/config change | Hash-based | 10KB |
|
||||
| **Transformation Cache** | `coordinator/data_transformation.py` | Until midnight/config | Automatic | 60KB |
|
||||
| Layer | Location | Lifetime | Invalidation | Memory |
|
||||
| ------------------------ | ------------------------------------ | -------------------------------------- | ------------ | ------ |
|
||||
| **API Cache** | `coordinator/cache.py` | 24h (user)<br/>Until midnight (prices) | Automatic | 50KB |
|
||||
| **Translation Cache** | `const.py` | Until HA restart | Never | 5KB |
|
||||
| **Config Cache** | `coordinator/*` | Until config change | Explicit | 1KB |
|
||||
| **Period Cache** | `coordinator/periods.py` | Until data/config change | Hash-based | 10KB |
|
||||
| **Transformation Cache** | `coordinator/data_transformation.py` | Until midnight/config | Automatic | 60KB |
|
||||
|
||||
**Total cache overhead:** ~126KB per coordinator instance (main entry + subentries)
|
||||
|
||||
|
|
@ -195,30 +195,31 @@ For detailed cache behavior, see [Caching Strategy](./caching-strategy.md).
|
|||
|
||||
### Core Components
|
||||
|
||||
| Component | File | Responsibility |
|
||||
|-----------|------|----------------|
|
||||
| **API Client** | `api.py` | GraphQL queries to Tibber, retry logic, error handling |
|
||||
| **Coordinator** | `coordinator.py` | Update orchestration, cache management, absolute-time scheduling with boundary tolerance |
|
||||
| **Data Transformer** | `coordinator/data_transformation.py` | Price enrichment (averages, ratings, differences) |
|
||||
| **Period Calculator** | `coordinator/periods.py` | Best/peak price period calculation with relaxation |
|
||||
| **Sensors** | `sensor/` | 80+ entities for prices, levels, ratings, statistics |
|
||||
| **Binary Sensors** | `binary_sensor/` | Period indicators (best/peak price active) |
|
||||
| **Services** | `services/` | Custom service endpoints (get_chartdata, get_apexcharts_yaml, refresh_user_data) |
|
||||
| Component | File | Responsibility |
|
||||
| --------------------- | ------------------------------------ | ---------------------------------------------------------------------------------------- |
|
||||
| **API Client** | `api.py` | GraphQL queries to Tibber, retry logic, error handling |
|
||||
| **Coordinator** | `coordinator.py` | Update orchestration, cache management, absolute-time scheduling with boundary tolerance |
|
||||
| **Data Transformer** | `coordinator/data_transformation.py` | Price enrichment (averages, ratings, differences) |
|
||||
| **Period Calculator** | `coordinator/periods.py` | Best/peak price period calculation with relaxation |
|
||||
| **Sensors** | `sensor/` | 80+ entities for prices, levels, ratings, statistics |
|
||||
| **Binary Sensors** | `binary_sensor/` | Period indicators (best/peak price active) |
|
||||
| **Services** | `services/` | Custom service endpoints (get_chartdata, get_apexcharts_yaml, refresh_user_data) |
|
||||
|
||||
### Sensor Architecture (Calculator Pattern)
|
||||
|
||||
The sensor platform uses **Calculator Pattern** for clean separation of concerns (refactored Nov 2025):
|
||||
|
||||
| Component | Files | Lines | Responsibility |
|
||||
|-----------|-------|-------|----------------|
|
||||
| **Entity Class** | `sensor/core.py` | 909 | Entity lifecycle, coordinator, delegates to calculators |
|
||||
| **Calculators** | `sensor/calculators/` | 1,838 | Business logic (8 specialized calculators) |
|
||||
| **Attributes** | `sensor/attributes/` | 1,209 | State presentation (8 specialized modules) |
|
||||
| **Routing** | `sensor/value_getters.py` | 276 | Centralized sensor → calculator mapping |
|
||||
| **Chart Export** | `sensor/chart_data.py` | 144 | Service call handling, YAML parsing |
|
||||
| **Helpers** | `sensor/helpers.py` | 188 | Aggregation functions, utilities |
|
||||
| Component | Files | Lines | Responsibility |
|
||||
| ---------------- | ------------------------- | ----- | ------------------------------------------------------- |
|
||||
| **Entity Class** | `sensor/core.py` | 909 | Entity lifecycle, coordinator, delegates to calculators |
|
||||
| **Calculators** | `sensor/calculators/` | 1,838 | Business logic (8 specialized calculators) |
|
||||
| **Attributes** | `sensor/attributes/` | 1,209 | State presentation (8 specialized modules) |
|
||||
| **Routing** | `sensor/value_getters.py` | 276 | Centralized sensor → calculator mapping |
|
||||
| **Chart Export** | `sensor/chart_data.py` | 144 | Service call handling, YAML parsing |
|
||||
| **Helpers** | `sensor/helpers.py` | 188 | Aggregation functions, utilities |
|
||||
|
||||
**Calculator Package** (`sensor/calculators/`):
|
||||
|
||||
- `base.py` - Abstract BaseCalculator with coordinator access
|
||||
- `interval.py` - Single interval calculations (current/next/previous)
|
||||
- `rolling_hour.py` - 5-interval rolling windows
|
||||
|
|
@ -230,6 +231,7 @@ The sensor platform uses **Calculator Pattern** for clean separation of concerns
|
|||
- `metadata.py` - Home/metering metadata
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- 58% reduction in core.py (2,170 → 909 lines)
|
||||
- Clear separation: Calculators (logic) vs Attributes (presentation)
|
||||
- Independent testability for each calculator
|
||||
|
|
@ -237,12 +239,12 @@ The sensor platform uses **Calculator Pattern** for clean separation of concerns
|
|||
|
||||
### Helper Utilities
|
||||
|
||||
| Utility | File | Purpose |
|
||||
|---------|------|---------|
|
||||
| **Price Utils** | `utils/price.py` | Rating calculation, enrichment, level aggregation |
|
||||
| **Average Utils** | `utils/average.py` | Trailing/leading 24h average calculations |
|
||||
| **Entity Utils** | `entity_utils/` | Shared icon/color/attribute logic |
|
||||
| **Translations** | `const.py` | Translation loading and caching |
|
||||
| Utility | File | Purpose |
|
||||
| ----------------- | ------------------ | ------------------------------------------------- |
|
||||
| **Price Utils** | `utils/price.py` | Rating calculation, enrichment, level aggregation |
|
||||
| **Average Utils** | `utils/average.py` | Trailing/leading 24h average calculations |
|
||||
| **Entity Utils** | `entity_utils/` | Shared icon/color/attribute logic |
|
||||
| **Translations** | `const.py` | Translation loading and caching |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -283,12 +285,12 @@ All quarter-hourly price intervals get augmented via `utils/price.py`:
|
|||
- **API polling**: Every 15 minutes (coordinator fetch cycle)
|
||||
- **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)`
|
||||
- HA may trigger ±few milliseconds before/after exact boundary
|
||||
- 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 restarts at 14:59:30 → stays at 14:45:00 (shows current interval data)
|
||||
- HA may trigger ±few milliseconds before/after exact boundary
|
||||
- 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 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)
|
||||
- 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
|
||||
|
||||
### 4. Calculator Pattern (Sensor Platform)
|
||||
|
|
@ -296,26 +298,31 @@ All quarter-hourly price intervals get augmented via `utils/price.py`:
|
|||
Sensors organized by **calculation method** (refactored Nov 2025):
|
||||
|
||||
**Unified Handler Methods** (`sensor/core.py`):
|
||||
|
||||
- `_get_interval_value(offset, type)` - current/next/previous intervals
|
||||
- `_get_rolling_hour_value(offset, type)` - 5-interval rolling windows
|
||||
- `_get_daily_stat_value(day, stat_func)` - calendar day min/max/avg
|
||||
- `_get_24h_window_value(stat_func)` - trailing/leading statistics
|
||||
|
||||
**Routing** (`sensor/value_getters.py`):
|
||||
|
||||
- Single source of truth mapping 80+ entity keys to calculator methods
|
||||
- Organized by calculation type (Interval, Rolling Hour, Daily Stats, etc.)
|
||||
|
||||
**Calculators** (`sensor/calculators/`):
|
||||
|
||||
- Each calculator inherits from `BaseCalculator` with coordinator access
|
||||
- Focused responsibility: `IntervalCalculator`, `TrendCalculator`, etc.
|
||||
- Complex logic isolated (e.g., `TrendCalculator` has internal caching)
|
||||
|
||||
**Attributes** (`sensor/attributes/`):
|
||||
|
||||
- Separate from business logic, handles state presentation
|
||||
- Builds extra_state_attributes dicts for entity classes
|
||||
- Unified builders: `build_sensor_attributes()`, `build_extra_state_attributes()`
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- Minimal code duplication across 80+ sensors
|
||||
- Clear separation of concerns (calculation vs presentation)
|
||||
- Easy to extend: Add sensor → choose pattern → add to routing
|
||||
|
|
@ -333,12 +340,12 @@ Sensors organized by **calculation method** (refactored Nov 2025):
|
|||
|
||||
### CPU Optimization
|
||||
|
||||
| Optimization | Location | Savings |
|
||||
|--------------|----------|---------|
|
||||
| Config caching | `coordinator/*` | ~50% on config checks |
|
||||
| Period caching | `coordinator/periods.py` | ~70% on period recalculation |
|
||||
| Lazy logging | Throughout | ~15% on log-heavy operations |
|
||||
| Import optimization | Module structure | ~20% faster loading |
|
||||
| Optimization | Location | Savings |
|
||||
| ------------------- | ------------------------ | ---------------------------- |
|
||||
| Config caching | `coordinator/*` | ~50% on config checks |
|
||||
| Period caching | `coordinator/periods.py` | ~70% on period recalculation |
|
||||
| Lazy logging | Throughout | ~15% on log-heavy operations |
|
||||
| Import optimization | Module structure | ~20% faster loading |
|
||||
|
||||
### Memory Usage
|
||||
|
||||
|
|
|
|||
|
|
@ -24,11 +24,13 @@ The integration uses **4 distinct caching layers** with different purposes and l
|
|||
**Purpose:** Reduce API calls to Tibber by caching user data and price data between HA restarts.
|
||||
|
||||
**What is cached:**
|
||||
|
||||
- **Price data** (`price_data`): Day before yesterday/yesterday/today/tomorrow price intervals with enriched fields (384 intervals total)
|
||||
- **User data** (`user_data`): Homes, subscriptions, features from Tibber GraphQL `viewer` query
|
||||
- **Timestamps**: Last update times for validation
|
||||
|
||||
**Lifetime:**
|
||||
|
||||
- **Price data**: Until midnight turnover (cleared daily at 00:00 local time)
|
||||
- **User data**: 24 hours (refreshed daily)
|
||||
- **Survives**: HA restarts via persistent Storage
|
||||
|
|
@ -36,29 +38,31 @@ The integration uses **4 distinct caching layers** with different purposes and l
|
|||
**Invalidation triggers:**
|
||||
|
||||
1. **Midnight turnover** (Timer #2 in coordinator):
|
||||
```python
|
||||
# coordinator/day_transitions.py
|
||||
def _handle_midnight_turnover() -> None:
|
||||
self._cached_price_data = None # Force fresh fetch for new day
|
||||
self._last_price_update = None
|
||||
await self.store_cache()
|
||||
```
|
||||
|
||||
```python
|
||||
# coordinator/day_transitions.py
|
||||
def _handle_midnight_turnover() -> None:
|
||||
self._cached_price_data = None # Force fresh fetch for new day
|
||||
self._last_price_update = None
|
||||
await self.store_cache()
|
||||
```
|
||||
|
||||
2. **Cache validation on load**:
|
||||
```python
|
||||
# coordinator/cache.py
|
||||
def is_cache_valid(cache_data: CacheData) -> bool:
|
||||
# Checks if price data is from a previous day
|
||||
if today_date < local_now.date(): # Yesterday's data
|
||||
return False
|
||||
```
|
||||
|
||||
```python
|
||||
# coordinator/cache.py
|
||||
def is_cache_valid(cache_data: CacheData) -> bool:
|
||||
# Checks if price data is from a previous day
|
||||
if today_date < local_now.date(): # Yesterday's data
|
||||
return False
|
||||
```
|
||||
|
||||
3. **Tomorrow data check** (after 13:00):
|
||||
```python
|
||||
# coordinator/data_fetching.py
|
||||
if tomorrow_missing or tomorrow_invalid:
|
||||
return "tomorrow_check" # Update needed
|
||||
```
|
||||
```python
|
||||
# coordinator/data_fetching.py
|
||||
if tomorrow_missing or tomorrow_invalid:
|
||||
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.
|
||||
|
||||
|
|
@ -71,18 +75,22 @@ The integration uses **4 distinct caching layers** with different purposes and l
|
|||
**Purpose:** Avoid repeated file I/O when accessing entity descriptions, UI strings, etc.
|
||||
|
||||
**What is cached:**
|
||||
|
||||
- **Standard translations** (`/translations/*.json`): Config flow, selector options, entity names
|
||||
- **Custom translations** (`/custom_translations/*.json`): Entity descriptions, usage tips, long descriptions
|
||||
|
||||
**Lifetime:**
|
||||
|
||||
- **Forever** (until HA restart)
|
||||
- No invalidation during runtime
|
||||
|
||||
**When populated:**
|
||||
|
||||
- At integration setup: `async_load_translations(hass, "en")` in `__init__.py`
|
||||
- Lazy loading: If translation missing, attempts file load once
|
||||
|
||||
**Access pattern:**
|
||||
|
||||
```python
|
||||
# Non-blocking synchronous access from cached data
|
||||
description = get_translation("binary_sensor.best_price_period.description", "en")
|
||||
|
|
@ -101,6 +109,7 @@ description = get_translation("binary_sensor.best_price_period.description", "en
|
|||
**What is cached:**
|
||||
|
||||
### DataTransformer Config Cache
|
||||
|
||||
```python
|
||||
{
|
||||
"thresholds": {"low": 15, "high": 35},
|
||||
|
|
@ -110,6 +119,7 @@ description = get_translation("binary_sensor.best_price_period.description", "en
|
|||
```
|
||||
|
||||
### PeriodCalculator Config Cache
|
||||
|
||||
```python
|
||||
{
|
||||
"best": {"flex": 0.15, "min_distance_from_avg": 5.0, "min_period_length": 60},
|
||||
|
|
@ -118,20 +128,23 @@ description = get_translation("binary_sensor.best_price_period.description", "en
|
|||
```
|
||||
|
||||
**Lifetime:**
|
||||
|
||||
- Until `invalidate_config_cache()` is called
|
||||
- Built once on first use per coordinator update cycle
|
||||
|
||||
**Invalidation trigger:**
|
||||
|
||||
- **Options change** (user reconfigures integration):
|
||||
```python
|
||||
# coordinator/core.py
|
||||
async def _handle_options_update(...) -> None:
|
||||
self._data_transformer.invalidate_config_cache()
|
||||
self._period_calculator.invalidate_config_cache()
|
||||
await self.async_request_refresh()
|
||||
```
|
||||
```python
|
||||
# coordinator/core.py
|
||||
async def _handle_options_update(...) -> None:
|
||||
self._data_transformer.invalidate_config_cache()
|
||||
self._period_calculator.invalidate_config_cache()
|
||||
await self.async_request_refresh()
|
||||
```
|
||||
|
||||
**Performance impact:**
|
||||
|
||||
- **Before:** ~30 dict lookups + type conversions per update = ~50μs
|
||||
- **After:** 1 cache check = ~1μs
|
||||
- **Savings:** ~98% (50μs → 1μs per update)
|
||||
|
|
@ -147,6 +160,7 @@ description = get_translation("binary_sensor.best_price_period.description", "en
|
|||
**Purpose:** Avoid expensive period calculations (~100-500ms) when price data and config haven't changed.
|
||||
|
||||
**What is cached:**
|
||||
|
||||
```python
|
||||
{
|
||||
"best_price": {
|
||||
|
|
@ -161,6 +175,7 @@ description = get_translation("binary_sensor.best_price_period.description", "en
|
|||
```
|
||||
|
||||
**Cache key:** Hash of relevant inputs
|
||||
|
||||
```python
|
||||
hash_data = (
|
||||
today_signature, # (startsAt, rating_level) for each interval
|
||||
|
|
@ -172,6 +187,7 @@ hash_data = (
|
|||
```
|
||||
|
||||
**Lifetime:**
|
||||
|
||||
- Until price data changes (today's intervals modified)
|
||||
- Until config changes (flex, thresholds, filters)
|
||||
- Recalculated at midnight (new today data)
|
||||
|
|
@ -179,24 +195,27 @@ hash_data = (
|
|||
**Invalidation triggers:**
|
||||
|
||||
1. **Config change** (explicit):
|
||||
```python
|
||||
def invalidate_config_cache() -> None:
|
||||
self._cached_periods = None
|
||||
self._last_periods_hash = None
|
||||
```
|
||||
|
||||
```python
|
||||
def invalidate_config_cache() -> None:
|
||||
self._cached_periods = None
|
||||
self._last_periods_hash = None
|
||||
```
|
||||
|
||||
2. **Price data change** (automatic via hash mismatch):
|
||||
```python
|
||||
current_hash = self._compute_periods_hash(price_info)
|
||||
if self._last_periods_hash != current_hash:
|
||||
# Cache miss - recalculate
|
||||
```
|
||||
```python
|
||||
current_hash = self._compute_periods_hash(price_info)
|
||||
if self._last_periods_hash != current_hash:
|
||||
# Cache miss - recalculate
|
||||
```
|
||||
|
||||
**Cache hit rate:**
|
||||
|
||||
- **High:** During normal operation (coordinator updates every 15min, price data unchanged)
|
||||
- **Low:** After midnight (new today data) or when tomorrow data arrives (~13:00-14:00)
|
||||
|
||||
**Performance impact:**
|
||||
|
||||
- **Period calculation:** ~100-500ms (depends on interval count, relaxation attempts)
|
||||
- **Cache hit:** `<`1ms (hash comparison + dict lookup)
|
||||
- **Savings:** ~70% of calculation time (most updates hit cache)
|
||||
|
|
@ -212,6 +231,7 @@ hash_data = (
|
|||
**Status:** ✅ **Clean separation** - enrichment only, no redundancy
|
||||
|
||||
**What is cached:**
|
||||
|
||||
```python
|
||||
{
|
||||
"timestamp": ...,
|
||||
|
|
@ -224,14 +244,16 @@ hash_data = (
|
|||
**Purpose:** Avoid re-enriching price data when config unchanged between midnight checks.
|
||||
|
||||
**Current behavior:**
|
||||
|
||||
- Caches **only enriched price data** (price + statistics)
|
||||
- **Does NOT cache periods** (handled by Period Calculation Cache)
|
||||
- Invalidated when:
|
||||
- Config changes (thresholds affect enrichment)
|
||||
- Midnight turnover detected
|
||||
- New update cycle begins
|
||||
- Config changes (thresholds affect enrichment)
|
||||
- Midnight turnover detected
|
||||
- New update cycle begins
|
||||
|
||||
**Architecture:**
|
||||
|
||||
- DataTransformer: Handles price enrichment only
|
||||
- PeriodCalculator: Handles period calculation only (with hash-based cache)
|
||||
- Coordinator: Assembles final data on-demand from both caches
|
||||
|
|
@ -243,6 +265,7 @@ hash_data = (
|
|||
## Cache Invalidation Flow
|
||||
|
||||
### User Changes Options (Config Flow)
|
||||
|
||||
```
|
||||
User saves options
|
||||
↓
|
||||
|
|
@ -267,6 +290,7 @@ Fresh data fetch with new config
|
|||
```
|
||||
|
||||
### Midnight Turnover (Day Transition)
|
||||
|
||||
```
|
||||
Timer #2 fires at 00:00
|
||||
↓
|
||||
|
|
@ -286,6 +310,7 @@ Fresh API fetch for new day
|
|||
```
|
||||
|
||||
### Tomorrow Data Arrives (~13:00)
|
||||
|
||||
```
|
||||
Coordinator update cycle
|
||||
↓
|
||||
|
|
@ -327,12 +352,14 @@ API Data Cache (price_data, user_data)
|
|||
```
|
||||
|
||||
**No cache invalidation cascades:**
|
||||
|
||||
- Config cache invalidation is **explicit** (on options update)
|
||||
- Period cache invalidation is **automatic** (via hash mismatch)
|
||||
- Transformation cache invalidation is **automatic** (on midnight/config change)
|
||||
- Translation cache is **never invalidated** (read-only after load)
|
||||
|
||||
**Thread safety:**
|
||||
|
||||
- All caches are accessed from `MainThread` only (Home Assistant event loop)
|
||||
- No locking needed (single-threaded execution model)
|
||||
|
||||
|
|
@ -341,6 +368,7 @@ API Data Cache (price_data, user_data)
|
|||
## Performance Characteristics
|
||||
|
||||
### Typical Operation (No Changes)
|
||||
|
||||
```
|
||||
Coordinator Update (every 15 min)
|
||||
├─> API fetch: SKIP (cache valid)
|
||||
|
|
@ -353,6 +381,7 @@ Total: ~16ms (down from ~600ms without caching)
|
|||
```
|
||||
|
||||
### After Midnight Turnover
|
||||
|
||||
```
|
||||
Coordinator Update (00:00)
|
||||
├─> API fetch: ~500ms (cache cleared, fetch new day)
|
||||
|
|
@ -365,6 +394,7 @@ Total: ~755ms (expected once per day)
|
|||
```
|
||||
|
||||
### After Config Change
|
||||
|
||||
```
|
||||
Options Update
|
||||
├─> Cache invalidation: `<`1ms
|
||||
|
|
@ -381,23 +411,25 @@ Options Update
|
|||
|
||||
## Summary Table
|
||||
|
||||
| Cache Type | Lifetime | Size | Invalidation | Purpose |
|
||||
|------------|----------|------|--------------|---------|
|
||||
| **API Data** | Hours to 1 day | ~50KB | Midnight, validation | Reduce API calls |
|
||||
| **Translations** | Forever (until HA restart) | ~5KB | Never | Avoid file I/O |
|
||||
| **Config Dicts** | Until options change | `<`1KB | Explicit (options update) | Avoid dict lookups |
|
||||
| **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 |
|
||||
| Cache Type | Lifetime | Size | Invalidation | Purpose |
|
||||
| ---------------------- | ---------------------------- | ------ | ------------------------- | ------------------------------- |
|
||||
| **API Data** | Hours to 1 day | ~50KB | Midnight, validation | Reduce API calls |
|
||||
| **Translations** | Forever (until HA restart) | ~5KB | Never | Avoid file I/O |
|
||||
| **Config Dicts** | Until options change | `<`1KB | Explicit (options update) | Avoid dict lookups |
|
||||
| **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 |
|
||||
|
||||
**Total memory overhead:** ~116KB per coordinator instance (main + subentries)
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- 97% reduction in API calls (from every 15min to once per day)
|
||||
- 70% reduction in period calculation time (cache hits during normal operation)
|
||||
- 98% reduction in config access time (30+ lookups → 1 cache check)
|
||||
- Zero file I/O during runtime (translations cached at startup)
|
||||
|
||||
**Trade-offs:**
|
||||
|
||||
- Memory usage: ~116KB per home (negligible for modern systems)
|
||||
- Code complexity: 5 cache invalidation points (well-tested, documented)
|
||||
- Debugging: Must understand cache lifetime when investigating stale data issues
|
||||
|
|
@ -407,7 +439,9 @@ Options Update
|
|||
## Debugging Cache Issues
|
||||
|
||||
### Symptom: Stale data after config change
|
||||
|
||||
**Check:**
|
||||
|
||||
1. Is `_handle_options_update()` called? (should see "Options updated" log)
|
||||
2. Are `invalidate_config_cache()` methods executed?
|
||||
3. Does `async_request_refresh()` trigger?
|
||||
|
|
@ -415,7 +449,9 @@ Options Update
|
|||
**Fix:** Ensure `config_entry.add_update_listener()` is registered in coordinator init.
|
||||
|
||||
### Symptom: Period calculation not updating
|
||||
|
||||
**Check:**
|
||||
|
||||
1. Verify hash changes when data changes: `_compute_periods_hash()`
|
||||
2. Check `_last_periods_hash` vs `current_hash`
|
||||
3. Look for "Using cached period calculation" vs "Calculating periods" logs
|
||||
|
|
@ -423,7 +459,9 @@ Options Update
|
|||
**Fix:** Hash function may not include all relevant data. Review `_compute_periods_hash()` inputs.
|
||||
|
||||
### Symptom: Yesterday's prices shown as today
|
||||
|
||||
**Check:**
|
||||
|
||||
1. `is_cache_valid()` logic in `coordinator/cache.py`
|
||||
2. Midnight turnover execution (Timer #2)
|
||||
3. Cache clear confirmation in logs
|
||||
|
|
@ -431,7 +469,9 @@ Options Update
|
|||
**Fix:** Timer may not be firing. Check `_schedule_midnight_turnover()` registration.
|
||||
|
||||
### Symptom: Missing translations
|
||||
|
||||
**Check:**
|
||||
|
||||
1. `async_load_translations()` called at startup?
|
||||
2. Translation files exist in `/translations/` and `/custom_translations/`?
|
||||
3. Cache population: `_TRANSLATIONS_CACHE` keys
|
||||
|
|
|
|||
|
|
@ -8,10 +8,10 @@ comments: false
|
|||
|
||||
## Code Style
|
||||
|
||||
- **Formatter/Linter**: Ruff (replaces Black, Flake8, isort)
|
||||
- **Max line length**: 120 characters
|
||||
- **Max complexity**: 25 (McCabe)
|
||||
- **Target**: Python 3.13
|
||||
- **Formatter/Linter**: Ruff (replaces Black, Flake8, isort)
|
||||
- **Max line length**: 120 characters
|
||||
- **Max complexity**: 25 (McCabe)
|
||||
- **Target**: Python 3.13
|
||||
|
||||
Run before committing:
|
||||
|
||||
|
|
@ -41,12 +41,14 @@ class TimeService:
|
|||
```
|
||||
|
||||
**When prefix is required:**
|
||||
|
||||
- Public classes used across multiple modules
|
||||
- All exception classes
|
||||
- All coordinator and entity classes
|
||||
- Data classes (dataclasses, NamedTuples) used as public APIs
|
||||
|
||||
**When prefix can be omitted:**
|
||||
|
||||
- Private helper classes within a single module (prefix with `_` underscore)
|
||||
- Type aliases and callbacks (e.g., `TimeServiceCallback`)
|
||||
- Small internal NamedTuples for function returns
|
||||
|
|
@ -71,6 +73,7 @@ class DataFetcher: # Should be TibberPricesDataFetcher
|
|||
**Current Technical Debt:**
|
||||
|
||||
Many existing classes lack the `TibberPrices` prefix. Before refactoring:
|
||||
|
||||
1. Document the plan in `/planning/class-naming-refactoring.md`
|
||||
2. Use `multi_replace_string_in_file` for bulk renames
|
||||
3. Test thoroughly after each module
|
||||
|
|
|
|||
|
|
@ -14,10 +14,10 @@ Welcome! This guide helps you contribute to the Tibber Prices integration.
|
|||
|
||||
1. Fork the repository on GitHub
|
||||
2. Clone your fork:
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/hass.tibber_prices.git
|
||||
cd hass.tibber_prices
|
||||
```
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/hass.tibber_prices.git
|
||||
cd hass.tibber_prices
|
||||
```
|
||||
3. Open in VS Code
|
||||
4. Click "Reopen in Container" when prompted
|
||||
|
||||
|
|
@ -34,6 +34,7 @@ git checkout -b fix/issue-123-description
|
|||
```
|
||||
|
||||
**Branch naming:**
|
||||
|
||||
- `feature/` - New features
|
||||
- `fix/` - Bug fixes
|
||||
- `docs/` - Documentation only
|
||||
|
|
@ -45,6 +46,7 @@ git checkout -b fix/issue-123-description
|
|||
Edit code, following [Coding Guidelines](coding-guidelines.md).
|
||||
|
||||
**Run checks frequently:**
|
||||
|
||||
```bash
|
||||
./scripts/type-check # Pyright type checking
|
||||
./scripts/lint # Ruff linting (auto-fix)
|
||||
|
|
@ -78,6 +80,7 @@ async def test_your_feature(hass, coordinator):
|
|||
```
|
||||
|
||||
Run your test:
|
||||
|
||||
```bash
|
||||
./scripts/test tests/test_your_feature.py -v
|
||||
```
|
||||
|
|
@ -97,6 +100,7 @@ Impact: Users can predict when prices will stabilize or continue fluctuating."
|
|||
```
|
||||
|
||||
**Commit types:**
|
||||
|
||||
- `feat:` - New feature
|
||||
- `fix:` - Bug fix
|
||||
- `docs:` - Documentation
|
||||
|
|
@ -105,6 +109,7 @@ Impact: Users can predict when prices will stabilize or continue fluctuating."
|
|||
- `chore:` - Maintenance
|
||||
|
||||
**Add scope when relevant:**
|
||||
|
||||
- `feat(sensors):` - Sensor platform
|
||||
- `fix(coordinator):` - Data coordinator
|
||||
- `docs(user):` - User documentation
|
||||
|
|
@ -124,32 +129,40 @@ Then open Pull Request on GitHub.
|
|||
Title: Short, descriptive (50 chars max)
|
||||
|
||||
Description should include:
|
||||
|
||||
```markdown
|
||||
## What
|
||||
|
||||
Brief description of changes
|
||||
|
||||
## Why
|
||||
|
||||
Problem being solved or feature rationale
|
||||
|
||||
## How
|
||||
|
||||
Implementation approach
|
||||
|
||||
## Testing
|
||||
|
||||
- [ ] Manual testing in Home Assistant
|
||||
- [ ] Unit tests added/updated
|
||||
- [ ] Type checking passes
|
||||
- [ ] Linting passes
|
||||
|
||||
## Breaking Changes
|
||||
|
||||
(If any - describe migration path)
|
||||
|
||||
## Related Issues
|
||||
|
||||
Closes #123
|
||||
```
|
||||
|
||||
### PR Checklist
|
||||
|
||||
Before submitting:
|
||||
|
||||
- [ ] Code follows [Coding Guidelines](coding-guidelines.md)
|
||||
- [ ] All tests pass (`./scripts/test`)
|
||||
- [ ] Type checking passes (`./scripts/type-check`)
|
||||
|
|
@ -170,6 +183,7 @@ Before submitting:
|
|||
### What Reviewers Look For
|
||||
|
||||
✅ **Good:**
|
||||
|
||||
- Clear, self-explanatory code
|
||||
- Appropriate comments for complex logic
|
||||
- Tests covering edge cases
|
||||
|
|
@ -177,6 +191,7 @@ Before submitting:
|
|||
- Follows existing patterns
|
||||
|
||||
❌ **Avoid:**
|
||||
|
||||
- Large PRs (>500 lines) - split into smaller ones
|
||||
- Mixing unrelated changes
|
||||
- Missing tests for new features
|
||||
|
|
@ -193,6 +208,7 @@ Before submitting:
|
|||
## Finding Issues to Work On
|
||||
|
||||
Good first issues are labeled:
|
||||
|
||||
- `good first issue` - Beginner-friendly
|
||||
- `help wanted` - Maintainers welcome contributions
|
||||
- `documentation` - Docs improvements
|
||||
|
|
@ -210,6 +226,7 @@ Be respectful, constructive, and patient. We're all volunteers! 🙏
|
|||
---
|
||||
|
||||
💡 **Related:**
|
||||
|
||||
- [Setup Guide](setup.md) - DevContainer setup
|
||||
- [Coding Guidelines](coding-guidelines.md) - Style guide
|
||||
- [Testing](testing.md) - Writing tests
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ comments: false
|
|||
## 🎯 Why Are These Tests Critical?
|
||||
|
||||
Home Assistant integrations run **continuously** in the background. Resource leaks lead to:
|
||||
|
||||
- **Memory Leaks**: RAM usage grows over days/weeks until HA becomes unstable
|
||||
- **Callback Leaks**: Listeners remain registered after entity removal → CPU load increases
|
||||
- **Timer Leaks**: Timers continue running after unload → unnecessary background tasks
|
||||
|
|
@ -26,6 +27,7 @@ Home Assistant integrations run **continuously** in the background. Resource lea
|
|||
#### 1.1 Listener Cleanup ✅
|
||||
|
||||
**What is tested:**
|
||||
|
||||
- Time-sensitive listeners are correctly removed (`async_add_time_sensitive_listener()`)
|
||||
- Minute-update listeners are correctly removed (`async_add_minute_update_listener()`)
|
||||
- Lifecycle callbacks are correctly unregistered (`register_lifecycle_callback()`)
|
||||
|
|
@ -33,11 +35,13 @@ Home Assistant integrations run **continuously** in the background. Resource lea
|
|||
- Binary sensor cleanup removes ALL registered listeners
|
||||
|
||||
**Why critical:**
|
||||
|
||||
- Each registered listener holds references to Entity + Coordinator
|
||||
- Without cleanup: Entities are not freed by GC → Memory Leak
|
||||
- With 80+ sensors × 3 listener types = 240+ callbacks that must be cleanly removed
|
||||
|
||||
**Code Locations:**
|
||||
|
||||
- `coordinator/listeners.py` → `async_add_time_sensitive_listener()`, `async_add_minute_update_listener()`
|
||||
- `coordinator/core.py` → `register_lifecycle_callback()`
|
||||
- `sensor/core.py` → `async_will_remove_from_hass()`
|
||||
|
|
@ -46,32 +50,38 @@ Home Assistant integrations run **continuously** in the background. Resource lea
|
|||
#### 1.2 Timer Cleanup ✅
|
||||
|
||||
**What is tested:**
|
||||
|
||||
- Quarter-hour timer is cancelled and reference cleared
|
||||
- Minute timer is cancelled and reference cleared
|
||||
- Both timers are cancelled together
|
||||
- Cleanup works even when timers are `None`
|
||||
|
||||
**Why critical:**
|
||||
|
||||
- Uncancelled timers continue running after integration unload
|
||||
- HA's `async_track_utc_time_change()` creates persistent callbacks
|
||||
- Without cleanup: Timers keep firing → CPU load + unnecessary coordinator updates
|
||||
|
||||
**Code Locations:**
|
||||
|
||||
- `coordinator/listeners.py` → `cancel_timers()`
|
||||
- `coordinator/core.py` → `async_shutdown()`
|
||||
|
||||
#### 1.3 Config Entry Cleanup ✅
|
||||
|
||||
**What is tested:**
|
||||
|
||||
- Options update listener is registered via `async_on_unload()`
|
||||
- Cleanup function is correctly passed to `async_on_unload()`
|
||||
|
||||
**Why critical:**
|
||||
|
||||
- `entry.add_update_listener()` registers permanent callback
|
||||
- Without `async_on_unload()`: Listener remains active after reload → duplicate updates
|
||||
- Pattern: `entry.async_on_unload(entry.add_update_listener(handler))`
|
||||
|
||||
**Code Locations:**
|
||||
|
||||
- `coordinator/core.py` → `__init__()` (listener registration)
|
||||
- `__init__.py` → `async_unload_entry()`
|
||||
|
||||
|
|
@ -82,16 +92,19 @@ Home Assistant integrations run **continuously** in the background. Resource lea
|
|||
#### 2.1 Config Cache Invalidation
|
||||
|
||||
**What is tested:**
|
||||
|
||||
- DataTransformer config cache is invalidated on options change
|
||||
- PeriodCalculator config + period cache is invalidated
|
||||
- Trend calculator cache is cleared on coordinator update
|
||||
|
||||
**Why critical:**
|
||||
|
||||
- Stale config → Sensors use old user settings
|
||||
- Stale period cache → Incorrect best/peak price periods
|
||||
- Stale trend cache → Outdated trend analysis
|
||||
|
||||
**Code Locations:**
|
||||
|
||||
- `coordinator/data_transformation.py` → `invalidate_config_cache()`
|
||||
- `coordinator/periods.py` → `invalidate_config_cache()`
|
||||
- `sensor/calculators/trend.py` → `clear_trend_cache()`
|
||||
|
|
@ -103,15 +116,18 @@ Home Assistant integrations run **continuously** in the background. Resource lea
|
|||
#### 3.1 Persistent Storage Removal
|
||||
|
||||
**What is tested:**
|
||||
|
||||
- Storage file is deleted on config entry removal
|
||||
- Cache is saved on shutdown (no data loss)
|
||||
|
||||
**Why critical:**
|
||||
|
||||
- Without storage removal: Old files remain after uninstallation
|
||||
- Without cache save on shutdown: Data loss on HA restart
|
||||
- Storage path: `.storage/tibber_prices.{entry_id}`
|
||||
|
||||
**Code Locations:**
|
||||
|
||||
- `__init__.py` → `async_remove_entry()`
|
||||
- `coordinator/core.py` → `async_shutdown()`
|
||||
|
||||
|
|
@ -120,12 +136,14 @@ Home Assistant integrations run **continuously** in the background. Resource lea
|
|||
**File:** `tests/test_timer_scheduling.py`
|
||||
|
||||
**What is tested:**
|
||||
|
||||
- Quarter-hour timer is registered with correct parameters
|
||||
- Minute timer is registered with correct parameters
|
||||
- Timers can be re-scheduled (override old timer)
|
||||
- Midnight turnover detection works correctly
|
||||
|
||||
**Why critical:**
|
||||
|
||||
- Wrong timer parameters → Entities update at wrong times
|
||||
- Without timer override on re-schedule → Multiple parallel timers → Performance problem
|
||||
|
||||
|
|
@ -134,12 +152,14 @@ Home Assistant integrations run **continuously** in the background. Resource lea
|
|||
**File:** `tests/test_sensor_timer_assignment.py`
|
||||
|
||||
**What is tested:**
|
||||
|
||||
- All `TIME_SENSITIVE_ENTITY_KEYS` are valid entity keys
|
||||
- All `MINUTE_UPDATE_ENTITY_KEYS` are valid entity keys
|
||||
- Both lists are disjoint (no overlap)
|
||||
- Sensor and binary sensor platforms are checked
|
||||
|
||||
**Why critical:**
|
||||
|
||||
- Wrong timer assignment → Sensors update at wrong times
|
||||
- Overlap → Duplicate updates → Performance problem
|
||||
|
||||
|
|
@ -150,10 +170,12 @@ These patterns were analyzed and classified as **not critical**:
|
|||
### 6. Async Task Management
|
||||
|
||||
**Current Status:** Fire-and-forget pattern for short tasks
|
||||
|
||||
- `sensor/core.py` → Chart data refresh (short-lived, max 1-2 seconds)
|
||||
- `coordinator/core.py` → Cache storage (short-lived, max 100ms)
|
||||
|
||||
**Why no tests needed:**
|
||||
|
||||
- No long-running tasks (all < 2 seconds)
|
||||
- HA's event loop handles short tasks automatically
|
||||
- Task exceptions are already logged
|
||||
|
|
@ -163,6 +185,7 @@ These patterns were analyzed and classified as **not critical**:
|
|||
### 7. API Session Cleanup
|
||||
|
||||
**Current Status:** ✅ Correctly implemented
|
||||
|
||||
- `async_get_clientsession(hass)` is used (shared session)
|
||||
- No new sessions are created
|
||||
- HA manages session lifecycle automatically
|
||||
|
|
@ -172,6 +195,7 @@ These patterns were analyzed and classified as **not critical**:
|
|||
### 8. Translation Cache Memory
|
||||
|
||||
**Current Status:** ✅ Bounded cache
|
||||
|
||||
- Max ~5-10 languages × 5KB = 50KB total
|
||||
- Module-level cache without re-loading
|
||||
- Practically no memory issue
|
||||
|
|
@ -181,11 +205,13 @@ These patterns were analyzed and classified as **not critical**:
|
|||
### 9. Coordinator Data Structure Integrity
|
||||
|
||||
**Current Status:** Manually tested via `./scripts/develop`
|
||||
|
||||
- Midnight turnover works correctly (observed over several days)
|
||||
- Missing keys are handled via `.get()` with defaults
|
||||
- 80+ sensors access `coordinator.data` without errors
|
||||
|
||||
**Structure:**
|
||||
|
||||
```python
|
||||
coordinator.data = {
|
||||
"user_data": {...},
|
||||
|
|
@ -197,6 +223,7 @@ coordinator.data = {
|
|||
### 10. Service Response Memory
|
||||
|
||||
**Current Status:** HA's response lifecycle
|
||||
|
||||
- HA automatically frees service responses after return
|
||||
- ApexCharts ~20KB response is one-time per call
|
||||
- No response accumulation in integration code
|
||||
|
|
@ -207,29 +234,30 @@ coordinator.data = {
|
|||
|
||||
### ✅ Implemented Tests (41 total)
|
||||
|
||||
| Category | Status | Tests | File | Coverage |
|
||||
|----------|--------|-------|------|----------|
|
||||
| Listener Cleanup | ✅ | 5 | `test_resource_cleanup.py` | 100% |
|
||||
| Timer Cleanup | ✅ | 4 | `test_resource_cleanup.py` | 100% |
|
||||
| Config Entry Cleanup | ✅ | 1 | `test_resource_cleanup.py` | 100% |
|
||||
| Cache Invalidation | ✅ | 3 | `test_resource_cleanup.py` | 100% |
|
||||
| Storage Cleanup | ✅ | 1 | `test_resource_cleanup.py` | 100% |
|
||||
| Storage Persistence | ✅ | 2 | `test_coordinator_shutdown.py` | 100% |
|
||||
| Timer Scheduling | ✅ | 8 | `test_timer_scheduling.py` | 100% |
|
||||
| Sensor-Timer Assignment | ✅ | 17 | `test_sensor_timer_assignment.py` | 100% |
|
||||
| **TOTAL** | **✅** | **41** | | **100% (critical)** |
|
||||
| Category | Status | Tests | File | Coverage |
|
||||
| ----------------------- | ------ | ------ | --------------------------------- | ------------------- |
|
||||
| Listener Cleanup | ✅ | 5 | `test_resource_cleanup.py` | 100% |
|
||||
| Timer Cleanup | ✅ | 4 | `test_resource_cleanup.py` | 100% |
|
||||
| Config Entry Cleanup | ✅ | 1 | `test_resource_cleanup.py` | 100% |
|
||||
| Cache Invalidation | ✅ | 3 | `test_resource_cleanup.py` | 100% |
|
||||
| Storage Cleanup | ✅ | 1 | `test_resource_cleanup.py` | 100% |
|
||||
| Storage Persistence | ✅ | 2 | `test_coordinator_shutdown.py` | 100% |
|
||||
| Timer Scheduling | ✅ | 8 | `test_timer_scheduling.py` | 100% |
|
||||
| Sensor-Timer Assignment | ✅ | 17 | `test_sensor_timer_assignment.py` | 100% |
|
||||
| **TOTAL** | **✅** | **41** | | **100% (critical)** |
|
||||
|
||||
### 📋 Analyzed but Not Implemented (Nice-to-Have)
|
||||
|
||||
| Category | Status | Rationale |
|
||||
|----------|--------|-----------|
|
||||
| Async Task Management | 📋 | Fire-and-forget pattern used (no long-running tasks) |
|
||||
| API Session Cleanup | ✅ | Pattern correct (`async_get_clientsession` used) |
|
||||
| Translation Cache | ✅ | Cache size bounded (~50KB max for 10 languages) |
|
||||
| Data Structure Integrity | 📋 | Would add test time without finding real issues |
|
||||
| Service Response Memory | 📋 | HA automatically frees service responses |
|
||||
| Category | Status | Rationale |
|
||||
| ------------------------ | ------ | ---------------------------------------------------- |
|
||||
| Async Task Management | 📋 | Fire-and-forget pattern used (no long-running tasks) |
|
||||
| API Session Cleanup | ✅ | Pattern correct (`async_get_clientsession` used) |
|
||||
| Translation Cache | ✅ | Cache size bounded (~50KB max for 10 languages) |
|
||||
| Data Structure Integrity | 📋 | Would add test time without finding real issues |
|
||||
| Service Response Memory | 📋 | HA automatically frees service responses |
|
||||
|
||||
**Legend:**
|
||||
|
||||
- ✅ = Fully tested or pattern verified correct
|
||||
- 📋 = Analyzed, low priority for testing (no known issues)
|
||||
|
||||
|
|
@ -238,6 +266,7 @@ coordinator.data = {
|
|||
### ✅ All Critical Patterns Tested
|
||||
|
||||
All essential memory leak prevention patterns are covered by 41 tests:
|
||||
|
||||
- ✅ Listeners are correctly removed (no callback leaks)
|
||||
- ✅ Timers are cancelled (no background task leaks)
|
||||
- ✅ Config entry cleanup works (no dangling listeners)
|
||||
|
|
|
|||
|
|
@ -10,9 +10,9 @@ Add to `configuration.yaml`:
|
|||
|
||||
```yaml
|
||||
logger:
|
||||
default: info
|
||||
logs:
|
||||
custom_components.tibber_prices: debug
|
||||
default: info
|
||||
logs:
|
||||
custom_components.tibber_prices: debug
|
||||
```
|
||||
|
||||
Restart Home Assistant to apply.
|
||||
|
|
@ -20,6 +20,7 @@ Restart Home Assistant to apply.
|
|||
### Key Log Messages
|
||||
|
||||
**Coordinator Updates:**
|
||||
|
||||
```
|
||||
[custom_components.tibber_prices.coordinator] Successfully fetched price data
|
||||
[custom_components.tibber_prices.coordinator] Cache valid, using cached data
|
||||
|
|
@ -27,6 +28,7 @@ Restart Home Assistant to apply.
|
|||
```
|
||||
|
||||
**Period Calculation:**
|
||||
|
||||
```
|
||||
[custom_components.tibber_prices.coordinator.periods] Calculating BEST PRICE periods: flex=15.0%
|
||||
[custom_components.tibber_prices.coordinator.periods] Day 2024-12-06: Found 2 periods
|
||||
|
|
@ -34,6 +36,7 @@ Restart Home Assistant to apply.
|
|||
```
|
||||
|
||||
**API Errors:**
|
||||
|
||||
```
|
||||
[custom_components.tibber_prices.api] API request failed: Unauthorized
|
||||
[custom_components.tibber_prices.api] Retrying (attempt 2/3) after 2.0s
|
||||
|
|
@ -47,26 +50,27 @@ Restart Home Assistant to apply.
|
|||
|
||||
```json
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Home Assistant",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"module": "homeassistant",
|
||||
"args": ["-c", "config", "--debug"],
|
||||
"justMyCode": false,
|
||||
"env": {
|
||||
"PYTHONPATH": "${workspaceFolder}/.venv/lib/python3.13/site-packages"
|
||||
}
|
||||
}
|
||||
]
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Home Assistant",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"module": "homeassistant",
|
||||
"args": ["-c", "config", "--debug"],
|
||||
"justMyCode": false,
|
||||
"env": {
|
||||
"PYTHONPATH": "${workspaceFolder}/.venv/lib/python3.13/site-packages"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Set Breakpoints
|
||||
|
||||
**Coordinator update:**
|
||||
|
||||
```python
|
||||
# coordinator/core.py
|
||||
async def _async_update_data(self) -> dict:
|
||||
|
|
@ -75,6 +79,7 @@ async def _async_update_data(self) -> dict:
|
|||
```
|
||||
|
||||
**Period calculation:**
|
||||
|
||||
```python
|
||||
# coordinator/period_handlers/core.py
|
||||
def calculate_periods(...) -> list[dict]:
|
||||
|
|
@ -91,6 +96,7 @@ def calculate_periods(...) -> list[dict]:
|
|||
```
|
||||
|
||||
**Flags:**
|
||||
|
||||
- `-v` - Verbose output
|
||||
- `-s` - Show print statements
|
||||
- `-k pattern` - Run tests matching pattern
|
||||
|
|
@ -102,6 +108,7 @@ Set breakpoint in test file, use "Debug Test" CodeLens.
|
|||
### Useful Test Patterns
|
||||
|
||||
**Print coordinator data:**
|
||||
|
||||
```python
|
||||
def test_something(coordinator):
|
||||
print(f"Coordinator data: {coordinator.data}")
|
||||
|
|
@ -109,6 +116,7 @@ def test_something(coordinator):
|
|||
```
|
||||
|
||||
**Inspect period attributes:**
|
||||
|
||||
```python
|
||||
def test_periods(hass, coordinator):
|
||||
periods = coordinator.data.get('best_price_periods', [])
|
||||
|
|
@ -122,11 +130,13 @@ def test_periods(hass, coordinator):
|
|||
### Integration Not Loading
|
||||
|
||||
**Check:**
|
||||
|
||||
```bash
|
||||
grep "tibber_prices" config/home-assistant.log
|
||||
```
|
||||
|
||||
**Common causes:**
|
||||
|
||||
- Syntax error in Python code → Check logs for traceback
|
||||
- Missing dependency → Run `uv sync`
|
||||
- Wrong file permissions → `chmod +x scripts/*`
|
||||
|
|
@ -134,12 +144,14 @@ grep "tibber_prices" config/home-assistant.log
|
|||
### Sensors Not Updating
|
||||
|
||||
**Check coordinator state:**
|
||||
|
||||
```python
|
||||
# In Developer Tools > Template
|
||||
{{ states.sensor.tibber_home_current_interval_price.last_updated }}
|
||||
```
|
||||
|
||||
**Debug in code:**
|
||||
|
||||
```python
|
||||
# Add logging in sensor/core.py
|
||||
_LOGGER.debug("Updating sensor %s: old=%s new=%s",
|
||||
|
|
@ -149,6 +161,7 @@ _LOGGER.debug("Updating sensor %s: old=%s new=%s",
|
|||
### Period Calculation Wrong
|
||||
|
||||
**Enable detailed period logs:**
|
||||
|
||||
```python
|
||||
# coordinator/period_handlers/period_building.py
|
||||
_LOGGER.debug("Candidate intervals: %s",
|
||||
|
|
@ -156,6 +169,7 @@ _LOGGER.debug("Candidate intervals: %s",
|
|||
```
|
||||
|
||||
**Check filter statistics:**
|
||||
|
||||
```
|
||||
[period_building] Flex filter blocked: 45 intervals
|
||||
[period_building] Min distance blocked: 12 intervals
|
||||
|
|
@ -200,6 +214,7 @@ python -m pstats profile.stats
|
|||
### Remote Debugging with debugpy
|
||||
|
||||
Add to coordinator code:
|
||||
|
||||
```python
|
||||
import debugpy
|
||||
debugpy.listen(5678)
|
||||
|
|
@ -212,11 +227,13 @@ Connect from VS Code with remote attach configuration.
|
|||
### IPython REPL
|
||||
|
||||
Install in container:
|
||||
|
||||
```bash
|
||||
uv pip install ipython
|
||||
```
|
||||
|
||||
Add breakpoint:
|
||||
|
||||
```python
|
||||
from IPython import embed
|
||||
embed() # Drops into interactive shell
|
||||
|
|
@ -225,6 +242,7 @@ embed() # Drops into interactive shell
|
|||
---
|
||||
|
||||
💡 **Related:**
|
||||
|
||||
- [Testing Guide](testing.md) - Writing and running tests
|
||||
- [Setup Guide](setup.md) - Development environment
|
||||
- [Architecture](architecture.md) - Code structure
|
||||
|
|
|
|||
|
|
@ -8,25 +8,25 @@ This is an independent, community-maintained custom integration for Home Assista
|
|||
|
||||
## 📚 Developer Guides
|
||||
|
||||
- **[Setup](setup.md)** - DevContainer, environment setup, and dependencies
|
||||
- **[Architecture](architecture.md)** - Code structure, patterns, and conventions
|
||||
- **[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)
|
||||
- **[Caching Strategy](caching-strategy.md)** - Cache layers, invalidation, debugging
|
||||
- **[Testing](testing.md)** - How to run tests and write new test cases
|
||||
- **[Release Management](release-management.md)** - Release workflow and versioning process
|
||||
- **[Coding Guidelines](coding-guidelines.md)** - Style guide, linting, and best practices
|
||||
- **[Refactoring Guide](refactoring-guide.md)** - How to plan and execute major refactorings
|
||||
- **[Setup](setup.md)** - DevContainer, environment setup, and dependencies
|
||||
- **[Architecture](architecture.md)** - Code structure, patterns, and conventions
|
||||
- **[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)
|
||||
- **[Caching Strategy](caching-strategy.md)** - Cache layers, invalidation, debugging
|
||||
- **[Testing](testing.md)** - How to run tests and write new test cases
|
||||
- **[Release Management](release-management.md)** - Release workflow and versioning process
|
||||
- **[Coding Guidelines](coding-guidelines.md)** - Style guide, linting, and best practices
|
||||
- **[Refactoring Guide](refactoring-guide.md)** - How to plan and execute major refactorings
|
||||
|
||||
## 🤖 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:
|
||||
|
||||
- Detailed architectural patterns
|
||||
- Code quality rules and conventions
|
||||
- Development workflow guidance
|
||||
- Common pitfalls and anti-patterns
|
||||
- Project-specific patterns and utilities
|
||||
- Detailed architectural patterns
|
||||
- Code quality rules and conventions
|
||||
- Development workflow guidance
|
||||
- Common pitfalls and anti-patterns
|
||||
- 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.
|
||||
|
||||
|
|
@ -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:
|
||||
|
||||
- **Pattern Recognition**: Understanding and applying Home Assistant best practices
|
||||
- **Code Generation**: Implementing features with proper type hints, error handling, and documentation
|
||||
- **Refactoring**: Maintaining consistency across the codebase during structural changes
|
||||
- **Translation Management**: Keeping 5 language files synchronized
|
||||
- **Documentation**: Generating and maintaining comprehensive documentation
|
||||
- **Pattern Recognition**: Understanding and applying Home Assistant best practices
|
||||
- **Code Generation**: Implementing features with proper type hints, error handling, and documentation
|
||||
- **Refactoring**: Maintaining consistency across the codebase during structural changes
|
||||
- **Translation Management**: Keeping 5 language files synchronized
|
||||
- **Documentation**: Generating and maintaining comprehensive documentation
|
||||
|
||||
**Quality Assurance:**
|
||||
|
||||
- Automated linting with Ruff (120-char line length, max complexity 25)
|
||||
- Home Assistant's type checking and validation
|
||||
- Real-world testing in development environment
|
||||
- Code review by maintainer before merging
|
||||
- Automated linting with Ruff (120-char line length, max complexity 25)
|
||||
- Home Assistant's type checking and validation
|
||||
- Real-world testing in development environment
|
||||
- Code review by maintainer before merging
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- Rapid feature development while maintaining quality
|
||||
- Consistent code patterns across all modules
|
||||
- Comprehensive documentation maintained alongside code
|
||||
- Quick bug fixes with proper understanding of context
|
||||
- Rapid feature development while maintaining quality
|
||||
- Consistent code patterns across all modules
|
||||
- Comprehensive documentation maintained alongside code
|
||||
- Quick bug fixes with proper understanding of context
|
||||
|
||||
**Limitations:**
|
||||
|
||||
- AI may occasionally miss edge cases or subtle bugs
|
||||
- Some complex Home Assistant patterns may need human review
|
||||
- Translation quality depends on AI's understanding of target language
|
||||
- User feedback is crucial for discovering real-world issues
|
||||
- AI may occasionally miss edge cases or subtle bugs
|
||||
- Some complex Home Assistant patterns may need human review
|
||||
- Translation quality depends on AI's understanding of target language
|
||||
- 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.
|
||||
|
||||
|
|
@ -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/`:
|
||||
|
||||
- `bootstrap` - Initial setup of dependencies
|
||||
- `develop` - Start Home Assistant in debug mode (auto-cleans .egg-info)
|
||||
- `clean` - Remove build artifacts and caches
|
||||
- `lint` - Auto-fix code issues with ruff
|
||||
- `lint-check` - Check code without modifications (CI mode)
|
||||
- `hassfest` - Validate integration structure (JSON, Python syntax, required files)
|
||||
- `setup` - Install development tools (git-cliff, @github/copilot)
|
||||
- `prepare-release` - Prepare a new release (bump version, create tag)
|
||||
- `generate-release-notes` - Generate release notes from commits
|
||||
- `bootstrap` - Initial setup of dependencies
|
||||
- `develop` - Start Home Assistant in debug mode (auto-cleans .egg-info)
|
||||
- `clean` - Remove build artifacts and caches
|
||||
- `lint` - Auto-fix code issues with ruff
|
||||
- `lint-check` - Check code without modifications (CI mode)
|
||||
- `hassfest` - Validate integration structure (JSON, Python syntax, required files)
|
||||
- `setup` - Install development tools (git-cliff, @github/copilot)
|
||||
- `prepare-release` - Prepare a new release (bump version, create tag)
|
||||
- `generate-release-notes` - Generate release notes from commits
|
||||
|
||||
## 📦 Project Structure
|
||||
|
||||
|
|
@ -121,23 +121,23 @@ custom_components/tibber_prices/
|
|||
|
||||
**DataUpdateCoordinator Pattern:**
|
||||
|
||||
- Centralized data fetching and caching
|
||||
- Automatic entity updates on data changes
|
||||
- Persistent storage via `Store`
|
||||
- Quarter-hour boundary refresh scheduling
|
||||
- Centralized data fetching and caching
|
||||
- Automatic entity updates on data changes
|
||||
- Persistent storage via `Store`
|
||||
- Quarter-hour boundary refresh scheduling
|
||||
|
||||
**Price Data Enrichment:**
|
||||
|
||||
- Raw API data is enriched with statistical analysis
|
||||
- Trailing/leading 24h averages calculated per interval
|
||||
- Price differences and ratings added
|
||||
- All via pure functions in `price_utils.py`
|
||||
- Raw API data is enriched with statistical analysis
|
||||
- Trailing/leading 24h averages calculated per interval
|
||||
- Price differences and ratings added
|
||||
- All via pure functions in `price_utils.py`
|
||||
|
||||
**Translation System:**
|
||||
|
||||
- Dual system: `/translations/` (HA schema) + `/custom_translations/` (extended)
|
||||
- Both must stay in sync across all languages (de, en, nb, nl, sv)
|
||||
- Async loading at integration setup
|
||||
- Dual system: `/translations/` (HA schema) + `/custom_translations/` (extended)
|
||||
- Both must stay in sync across all languages (de, en, nb, nl, sv)
|
||||
- Async loading at integration setup
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
|
|
@ -159,18 +159,19 @@ pytest --cov=custom_components.tibber_prices tests/
|
|||
|
||||
Documentation is organized in two Docusaurus sites:
|
||||
|
||||
- **User docs** (`docs/user/`): Installation, configuration, usage guides
|
||||
- Markdown files in `docs/user/docs/*.md`
|
||||
- Navigation managed via `docs/user/sidebars.ts`
|
||||
- **Developer docs** (`docs/developer/`): Architecture, patterns, contribution guides
|
||||
- Markdown files in `docs/developer/docs/*.md`
|
||||
- Navigation managed via `docs/developer/sidebars.ts`
|
||||
- **AI guidance**: `AGENTS.md` (patterns, conventions, long-term memory)
|
||||
- **User docs** (`docs/user/`): Installation, configuration, usage guides
|
||||
- Markdown files in `docs/user/docs/*.md`
|
||||
- Navigation managed via `docs/user/sidebars.ts`
|
||||
- **Developer docs** (`docs/developer/`): Architecture, patterns, contribution guides
|
||||
- Markdown files in `docs/developer/docs/*.md`
|
||||
- Navigation managed via `docs/developer/sidebars.ts`
|
||||
- **AI guidance**: `AGENTS.md` (patterns, conventions, long-term memory)
|
||||
|
||||
**Best practices:**
|
||||
- Use clear examples and code snippets
|
||||
- Keep docs up-to-date with code changes
|
||||
- Add new pages to appropriate `sidebars.ts` for navigation
|
||||
|
||||
- Use clear examples and code snippets
|
||||
- Keep docs up-to-date with code changes
|
||||
- Add new pages to appropriate `sidebars.ts` for navigation
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ Guidelines for maintaining and improving integration performance.
|
|||
## Performance Goals
|
||||
|
||||
Target metrics:
|
||||
|
||||
- **Coordinator update**: <500ms (typical: 200-300ms)
|
||||
- **Sensor update**: <10ms per sensor
|
||||
- **Period calculation**: <100ms (typical: 20-50ms)
|
||||
|
|
@ -64,6 +65,7 @@ python -m aioprof homeassistant -c config
|
|||
### Caching
|
||||
|
||||
**1. Persistent Cache** (API data):
|
||||
|
||||
```python
|
||||
# Already implemented in coordinator/cache.py
|
||||
store = Store(hass, STORAGE_VERSION, STORAGE_KEY)
|
||||
|
|
@ -71,6 +73,7 @@ data = await store.async_load()
|
|||
```
|
||||
|
||||
**2. Translation Cache** (in-memory):
|
||||
|
||||
```python
|
||||
# Already implemented in const.py
|
||||
_TRANSLATION_CACHE: dict[str, dict] = {}
|
||||
|
|
@ -83,6 +86,7 @@ def get_translation(path: str, language: str) -> dict:
|
|||
```
|
||||
|
||||
**3. Config Cache** (invalidated on options change):
|
||||
|
||||
```python
|
||||
class DataTransformer:
|
||||
def __init__(self):
|
||||
|
|
@ -100,6 +104,7 @@ class DataTransformer:
|
|||
### Lazy Loading
|
||||
|
||||
**Load data only when needed:**
|
||||
|
||||
```python
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict | None:
|
||||
|
|
@ -113,6 +118,7 @@ def extra_state_attributes(self) -> dict | None:
|
|||
### Bulk Operations
|
||||
|
||||
**Process multiple items at once:**
|
||||
|
||||
```python
|
||||
# ❌ Slow - loop with individual operations
|
||||
for interval in intervals:
|
||||
|
|
@ -126,6 +132,7 @@ results = enrich_intervals_bulk(intervals)
|
|||
### Async Best Practices
|
||||
|
||||
**1. Concurrent API calls:**
|
||||
|
||||
```python
|
||||
# ❌ Sequential (slow)
|
||||
user_data = await fetch_user_data()
|
||||
|
|
@ -139,6 +146,7 @@ user_data, price_data = await asyncio.gather(
|
|||
```
|
||||
|
||||
**2. Don't block event loop:**
|
||||
|
||||
```python
|
||||
# ❌ Blocking
|
||||
result = heavy_computation() # Blocks for seconds
|
||||
|
|
@ -152,6 +160,7 @@ result = await hass.async_add_executor_job(heavy_computation)
|
|||
### Avoid Memory Leaks
|
||||
|
||||
**1. Clear references:**
|
||||
|
||||
```python
|
||||
class Coordinator:
|
||||
async def async_shutdown(self):
|
||||
|
|
@ -162,6 +171,7 @@ class Coordinator:
|
|||
```
|
||||
|
||||
**2. Use weak references for callbacks:**
|
||||
|
||||
```python
|
||||
import weakref
|
||||
|
||||
|
|
@ -176,6 +186,7 @@ class Manager:
|
|||
### Efficient Data Structures
|
||||
|
||||
**Use appropriate types:**
|
||||
|
||||
```python
|
||||
# ❌ List for lookups (O(n))
|
||||
if timestamp in timestamp_list:
|
||||
|
|
@ -197,11 +208,13 @@ results = (x for x in items if condition(x))
|
|||
### Minimize API Calls
|
||||
|
||||
**Already implemented:**
|
||||
|
||||
- Cache valid until midnight
|
||||
- User data cached for 24h
|
||||
- Only poll when tomorrow data expected
|
||||
|
||||
**Monitor API usage:**
|
||||
|
||||
```python
|
||||
_LOGGER.debug("API call: %s (cache_age=%s)",
|
||||
endpoint, cache_age)
|
||||
|
|
@ -210,6 +223,7 @@ _LOGGER.debug("API call: %s (cache_age=%s)",
|
|||
### Smart Updates
|
||||
|
||||
**Only update when needed:**
|
||||
|
||||
```python
|
||||
async def _async_update_data(self) -> dict:
|
||||
"""Fetch data from API."""
|
||||
|
|
@ -226,6 +240,7 @@ async def _async_update_data(self) -> dict:
|
|||
### State Class Selection
|
||||
|
||||
**Affects long-term statistics storage:**
|
||||
|
||||
```python
|
||||
# ❌ MEASUREMENT for prices (stores every change)
|
||||
state_class=SensorStateClass.MEASUREMENT # ~35K records/year
|
||||
|
|
@ -240,6 +255,7 @@ state_class=SensorStateClass.TOTAL # For cumulative values
|
|||
### Attribute Size
|
||||
|
||||
**Keep attributes minimal:**
|
||||
|
||||
```python
|
||||
# ❌ Large nested structures (KB per update)
|
||||
attributes = {
|
||||
|
|
@ -317,6 +333,7 @@ _LOGGER.debug("Current memory usage: %.2f MB", memory_mb)
|
|||
---
|
||||
|
||||
💡 **Related:**
|
||||
|
||||
- [Caching Strategy](caching-strategy.md) - Cache layers
|
||||
- [Architecture](architecture.md) - System design
|
||||
- [Debugging](debugging.md) - Profiling tools
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ This document explains the mathematical foundations and design decisions behind
|
|||
**Target Audience:** Developers maintaining or extending the period calculation logic.
|
||||
|
||||
**Related Files:**
|
||||
|
||||
- `coordinator/period_handlers/core.py` - Main calculation entry point
|
||||
- `coordinator/period_handlers/level_filtering.py` - Flex and distance filtering
|
||||
- `coordinator/period_handlers/relaxation.py` - Multi-phase relaxation strategy
|
||||
|
|
@ -23,6 +24,7 @@ Period detection uses **three independent filters** (all must pass):
|
|||
**Purpose:** Limit how far prices can deviate from the daily min/max.
|
||||
|
||||
**Logic:**
|
||||
|
||||
```python
|
||||
# Best Price: Price must be within flex% ABOVE daily minimum
|
||||
in_flex = price <= (daily_min + daily_min × flex)
|
||||
|
|
@ -32,6 +34,7 @@ in_flex = price >= (daily_max - daily_max × flex)
|
|||
```
|
||||
|
||||
**Example (Best Price):**
|
||||
|
||||
- Daily Min: 10 ct/kWh
|
||||
- Flex: 15%
|
||||
- Acceptance Range: 0 - 11.5 ct/kWh (10 + 10×0.15)
|
||||
|
|
@ -41,6 +44,7 @@ in_flex = price >= (daily_max - daily_max × flex)
|
|||
**Purpose:** Ensure periods are **significantly** cheaper/more expensive than average, not just marginally better.
|
||||
|
||||
**Logic:**
|
||||
|
||||
```python
|
||||
# Best Price: Price must be at least min_distance% BELOW daily average
|
||||
meets_distance = price <= (daily_avg × (1 - min_distance/100))
|
||||
|
|
@ -50,6 +54,7 @@ meets_distance = price >= (daily_avg × (1 + min_distance/100))
|
|||
```
|
||||
|
||||
**Example (Best Price):**
|
||||
|
||||
- Daily Avg: 15 ct/kWh
|
||||
- Min Distance: 5%
|
||||
- Acceptance Range: 0 - 14.25 ct/kWh (15 × 0.95)
|
||||
|
|
@ -65,17 +70,17 @@ meets_distance = price >= (daily_avg × (1 + min_distance/100))
|
|||
The integration maintains **two independent sets** of volatility thresholds:
|
||||
|
||||
1. **Sensor Thresholds** (user-configurable via `CONF_VOLATILITY_*_THRESHOLD`)
|
||||
- Purpose: Display classification in `sensor.tibber_home_volatility_*`
|
||||
- Default: LOW < 10%, MEDIUM < 20%, HIGH ≥ 20%
|
||||
- User can adjust in config flow options
|
||||
- Affects: Sensor state/attributes only
|
||||
- Purpose: Display classification in `sensor.tibber_home_volatility_*`
|
||||
- Default: LOW < 10%, MEDIUM < 20%, HIGH ≥ 20%
|
||||
- User can adjust in config flow options
|
||||
- Affects: Sensor state/attributes only
|
||||
|
||||
2. **Period Filter Thresholds** (internal, fixed)
|
||||
- Purpose: Level filter criteria when using `level="volatility_low"` etc.
|
||||
- Source: `PRICE_LEVEL_THRESHOLDS` in `const.py`
|
||||
- Values: Same as sensor defaults (LOW < 10%, MEDIUM < 20%, HIGH ≥ 20%)
|
||||
- User **cannot** adjust these
|
||||
- Affects: Period candidate selection
|
||||
- Purpose: Level filter criteria when using `level="volatility_low"` etc.
|
||||
- Source: `PRICE_LEVEL_THRESHOLDS` in `const.py`
|
||||
- Values: Same as sensor defaults (LOW < 10%, MEDIUM < 20%, HIGH ≥ 20%)
|
||||
- User **cannot** adjust these
|
||||
- Affects: Period candidate selection
|
||||
|
||||
**Rationale for Separation:**
|
||||
|
||||
|
|
@ -86,6 +91,7 @@ The integration maintains **two independent sets** of volatility thresholds:
|
|||
- Period calculation has many interacting filters (Flex, Distance, Level) - exposing all internals would be error-prone
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```python
|
||||
# Sensor classification uses user config
|
||||
user_low_threshold = config_entry.options.get(CONF_VOLATILITY_LOW_THRESHOLD, 10)
|
||||
|
|
@ -107,36 +113,42 @@ period_low_threshold = PRICE_LEVEL_THRESHOLDS["volatility_low"] # Always 10%
|
|||
#### Scenario: Best Price with Flex=50%, Min_Distance=5%
|
||||
|
||||
**Given:**
|
||||
|
||||
- Daily Min: 10 ct/kWh
|
||||
- Daily Avg: 15 ct/kWh
|
||||
- Daily Max: 20 ct/kWh
|
||||
|
||||
**Flex Filter (50%):**
|
||||
|
||||
```
|
||||
Max accepted = 10 + (10 × 0.50) = 15 ct/kWh
|
||||
```
|
||||
|
||||
**Min Distance Filter (5%):**
|
||||
|
||||
```
|
||||
Max accepted = 15 × (1 - 0.05) = 14.25 ct/kWh
|
||||
```
|
||||
|
||||
**Conflict:**
|
||||
|
||||
- Interval at 14.8 ct/kWh:
|
||||
- ✅ Flex: 14.8 ≤ 15 (PASS)
|
||||
- ❌ Distance: 14.8 > 14.25 (FAIL)
|
||||
- **Result:** Rejected by Min_Distance even though Flex allows it!
|
||||
- ✅ Flex: 14.8 ≤ 15 (PASS)
|
||||
- ❌ Distance: 14.8 > 14.25 (FAIL)
|
||||
- **Result:** Rejected by Min_Distance even though Flex allows it!
|
||||
|
||||
**The Issue:** At high Flex values, Min_Distance becomes the dominant filter and blocks intervals that Flex would permit. This defeats the purpose of having high Flex.
|
||||
|
||||
### Mathematical Analysis
|
||||
|
||||
**Conflict condition for Best Price:**
|
||||
|
||||
```
|
||||
daily_min × (1 + flex) > daily_avg × (1 - min_distance/100)
|
||||
```
|
||||
|
||||
**Typical values:**
|
||||
|
||||
- Min = 10, Avg = 15, Min_Distance = 5%
|
||||
- Conflict occurs when: `10 × (1 + flex) > 14.25`
|
||||
- Simplify: `flex > 0.425` (42.5%)
|
||||
|
|
@ -149,6 +161,7 @@ daily_min × (1 + flex) > daily_avg × (1 - min_distance/100)
|
|||
**Approach:** Reduce Min_Distance proportionally as Flex increases.
|
||||
|
||||
**Formula:**
|
||||
|
||||
```python
|
||||
if flex > 0.20: # 20% threshold
|
||||
flex_excess = flex - 0.20
|
||||
|
|
@ -158,15 +171,16 @@ if flex > 0.20: # 20% threshold
|
|||
|
||||
**Scaling Table (Original Min_Distance = 5%):**
|
||||
|
||||
| Flex | Scale Factor | Adjusted Min_Distance | Rationale |
|
||||
|-------|--------------|----------------------|-----------|
|
||||
| ≤20% | 1.00 | 5.0% | Standard - both filters relevant |
|
||||
| 25% | 0.88 | 4.4% | Slight reduction |
|
||||
| 30% | 0.75 | 3.75% | Moderate reduction |
|
||||
| 40% | 0.50 | 2.5% | Strong reduction - Flex dominates |
|
||||
| 50% | 0.25 | 1.25% | Minimal distance - Flex decides |
|
||||
| Flex | Scale Factor | Adjusted Min_Distance | Rationale |
|
||||
| ---- | ------------ | --------------------- | --------------------------------- |
|
||||
| ≤20% | 1.00 | 5.0% | Standard - both filters relevant |
|
||||
| 25% | 0.88 | 4.4% | Slight reduction |
|
||||
| 30% | 0.75 | 3.75% | Moderate reduction |
|
||||
| 40% | 0.50 | 2.5% | Strong reduction - Flex dominates |
|
||||
| 50% | 0.25 | 1.25% | Minimal distance - Flex decides |
|
||||
|
||||
**Why stop at 25% of original?**
|
||||
|
||||
- Min_Distance ensures periods are **significantly** different from average
|
||||
- Even at 1.25%, prevents "flat days" (little price variation) from accepting every interval
|
||||
- Maintains semantic meaning: "this is a meaningful best/peak price period"
|
||||
|
|
@ -174,6 +188,7 @@ if flex > 0.20: # 20% threshold
|
|||
**Implementation:** See `level_filtering.py` → `check_interval_criteria()`
|
||||
|
||||
**Code Extract:**
|
||||
|
||||
```python
|
||||
# coordinator/period_handlers/level_filtering.py
|
||||
|
||||
|
|
@ -209,12 +224,14 @@ def check_interval_criteria(price, criteria):
|
|||
```
|
||||
|
||||
**Why Linear Scaling?**
|
||||
|
||||
- Simple and predictable
|
||||
- No abrupt behavior changes
|
||||
- Easy to reason about for users and developers
|
||||
- Alternative considered: Exponential scaling (rejected as too aggressive)
|
||||
|
||||
**Why 25% Minimum?**
|
||||
|
||||
- Below this, min_distance loses semantic meaning
|
||||
- Even on flat days, some quality filter needed
|
||||
- Prevents "every interval is a period" scenario
|
||||
|
|
@ -227,12 +244,14 @@ def check_interval_criteria(price, criteria):
|
|||
### Implementation Constants
|
||||
|
||||
**Defined in `coordinator/period_handlers/core.py`:**
|
||||
|
||||
```python
|
||||
MAX_SAFE_FLEX = 0.50 # 50% - hard cap: above this, period detection becomes unreliable
|
||||
MAX_OUTLIER_FLEX = 0.25 # 25% - cap for outlier filtering: above this, spike detection too permissive
|
||||
```
|
||||
|
||||
**Defined in `const.py`:**
|
||||
|
||||
```python
|
||||
DEFAULT_BEST_PRICE_FLEX = 15 # 15% base - optimal for relaxation mode (default enabled)
|
||||
DEFAULT_PEAK_PRICE_FLEX = -20 # 20% base (negative for peak detection)
|
||||
|
|
@ -255,16 +274,19 @@ The different defaults reflect fundamentally different use cases:
|
|||
**Goal:** Find practical time windows for running appliances
|
||||
|
||||
**Constraints:**
|
||||
|
||||
- Appliances need time to complete cycles (dishwasher: 2-3h, EV charging: 4-8h)
|
||||
- Short periods are impractical (not worth automation overhead)
|
||||
- User wants genuinely cheap times, not just "slightly below average"
|
||||
|
||||
**Defaults:**
|
||||
|
||||
- **60 min minimum** - Ensures period is long enough for meaningful use
|
||||
- **15% flex** - Stricter selection, focuses on truly cheap times
|
||||
- **Reasoning:** Better to find fewer, higher-quality periods than many mediocre ones
|
||||
|
||||
**User behavior:**
|
||||
|
||||
- Automations trigger actions (turn on devices)
|
||||
- Wrong automation = wasted energy/money
|
||||
- Preference: Conservative (miss some savings) over aggressive (false positives)
|
||||
|
|
@ -274,16 +296,19 @@ The different defaults reflect fundamentally different use cases:
|
|||
**Goal:** Alert users to expensive periods for consumption reduction
|
||||
|
||||
**Constraints:**
|
||||
|
||||
- Brief price spikes still matter (even 15-30 min is worth avoiding)
|
||||
- Early warning more valuable than perfect accuracy
|
||||
- User can manually decide whether to react
|
||||
|
||||
**Defaults:**
|
||||
|
||||
- **30 min minimum** - Catches shorter expensive spikes
|
||||
- **20% flex** - More permissive, earlier detection
|
||||
- **Reasoning:** Better to warn early (even if not peak) than miss expensive periods
|
||||
|
||||
**User behavior:**
|
||||
|
||||
- Notifications/alerts (informational)
|
||||
- Wrong alert = minor inconvenience, not cost
|
||||
- Preference: Sensitive (catch more) over specific (catch only extremes)
|
||||
|
|
@ -293,17 +318,20 @@ The different defaults reflect fundamentally different use cases:
|
|||
**Peak Price Volatility:**
|
||||
|
||||
Price curves tend to have:
|
||||
|
||||
- **Sharp spikes** during peak hours (morning/evening)
|
||||
- **Shorter duration** at maximum (1-2 hours typical)
|
||||
- **Higher variance** in peak times than cheap times
|
||||
|
||||
**Example day:**
|
||||
|
||||
```
|
||||
Cheap period: 02:00-07:00 (5 hours at 10-12 ct) ← Gradual, stable
|
||||
Expensive period: 17:00-18:30 (1.5 hours at 35-40 ct) ← Sharp, brief
|
||||
```
|
||||
|
||||
**Implication:**
|
||||
|
||||
- Stricter flex on peak (15%) might miss real expensive periods (too brief)
|
||||
- Longer min_length (60 min) might exclude legitimate spikes
|
||||
- Solution: More flexible thresholds for peak detection
|
||||
|
|
@ -311,16 +339,19 @@ Expensive period: 17:00-18:30 (1.5 hours at 35-40 ct) ← Sharp, brief
|
|||
#### Design Alternatives Considered
|
||||
|
||||
**Option 1: Symmetric defaults (rejected)**
|
||||
|
||||
- Both 60 min, both 15% flex
|
||||
- Problem: Misses short but expensive spikes
|
||||
- User feedback: "Why didn't I get warned about the 30-min price spike?"
|
||||
|
||||
**Option 2: Same defaults, let users figure it out (rejected)**
|
||||
|
||||
- No guidance on best practices
|
||||
- Users would need to experiment to find good values
|
||||
- Most users stick with defaults, so defaults matter
|
||||
|
||||
**Option 3: Current approach (adopted)**
|
||||
|
||||
- **All values user-configurable** via config flow options
|
||||
- **Different installation defaults** for Best Price vs. Peak Price
|
||||
- Defaults reflect recommended practices for each use case
|
||||
|
|
@ -336,12 +367,14 @@ Expensive period: 17:00-18:30 (1.5 hours at 35-40 ct) ← Sharp, brief
|
|||
**Enforcement:** `core.py` caps `abs(flex)` at 0.50 (50%)
|
||||
|
||||
**Rationale:**
|
||||
|
||||
- Above 50%, period detection becomes unreliable
|
||||
- Best Price: Almost entire day qualifies (Min + 50% typically covers 60-80% of intervals)
|
||||
- Peak Price: Similar issue with Max - 50%
|
||||
- **Result:** Either massive periods (entire day) or no periods (min_length not met)
|
||||
|
||||
**Warning Message:**
|
||||
|
||||
```
|
||||
Flex XX% exceeds maximum safe value! Capping at 50%.
|
||||
Recommendation: Use 15-20% with relaxation enabled, or 25-35% without relaxation.
|
||||
|
|
@ -352,6 +385,7 @@ Recommendation: Use 15-20% with relaxation enabled, or 25-35% without relaxation
|
|||
**Enforcement:** `core.py` caps outlier filtering flex at 0.25 (25%)
|
||||
|
||||
**Rationale:**
|
||||
|
||||
- Outlier filtering uses Flex to determine "stable context" threshold
|
||||
- At > 25% Flex, almost any price swing is considered "stable"
|
||||
- **Result:** Legitimate price shifts aren't smoothed, breaking period formation
|
||||
|
|
@ -363,23 +397,28 @@ Recommendation: Use 15-20% with relaxation enabled, or 25-35% without relaxation
|
|||
#### With Relaxation Enabled (Recommended)
|
||||
|
||||
**Optimal:** 10-20%
|
||||
|
||||
- Relaxation increases Flex incrementally: 15% → 18% → 21% → ...
|
||||
- Low baseline ensures relaxation has room to work
|
||||
|
||||
**Warning Threshold:** > 25%
|
||||
|
||||
- INFO log: "Base flex is on the high side"
|
||||
|
||||
**High Warning:** > 30%
|
||||
|
||||
- WARNING log: "Base flex is very high for relaxation mode!"
|
||||
- Recommendation: Lower to 15-20%
|
||||
|
||||
#### Without Relaxation
|
||||
|
||||
**Optimal:** 20-35%
|
||||
|
||||
- No automatic adjustment, must be sufficient from start
|
||||
- Higher baseline acceptable since no relaxation fallback
|
||||
|
||||
**Maximum Useful:** ~50%
|
||||
|
||||
- Above this, period detection degrades (see Hard Limits)
|
||||
|
||||
---
|
||||
|
|
@ -395,6 +434,7 @@ Ensure **minimum periods per day** are found even when baseline filters are too
|
|||
### Multi-Phase Approach
|
||||
|
||||
**Each day processed independently:**
|
||||
|
||||
1. Calculate baseline periods with user's config
|
||||
2. If insufficient periods found, enter relaxation loop
|
||||
3. Try progressively relaxed filter combinations
|
||||
|
|
@ -418,6 +458,7 @@ for attempt in range(max_relaxation_attempts):
|
|||
```
|
||||
|
||||
**Constants:**
|
||||
|
||||
```python
|
||||
FLEX_WARNING_THRESHOLD_RELAXATION = 0.25 # 25% - INFO: suggest lowering to 15-20%
|
||||
FLEX_HIGH_THRESHOLD_RELAXATION = 0.30 # 30% - WARNING: very high for relaxation mode
|
||||
|
|
@ -427,26 +468,27 @@ MAX_FLEX_HARD_LIMIT = 0.50 # 50% - absolute maximum (enforced in core.py)
|
|||
**Design Decisions:**
|
||||
|
||||
1. **Why 3% fixed increment?**
|
||||
- Predictable escalation path (15% → 18% → 21% → ...)
|
||||
- Independent of base flex (works consistently)
|
||||
- 11 attempts covers full useful range (15% → 48%)
|
||||
- Balance: Not too slow (2%), not too fast (5%)
|
||||
- Predictable escalation path (15% → 18% → 21% → ...)
|
||||
- Independent of base flex (works consistently)
|
||||
- 11 attempts covers full useful range (15% → 48%)
|
||||
- Balance: Not too slow (2%), not too fast (5%)
|
||||
|
||||
2. **Why hard-coded, not configurable?**
|
||||
- Prevents user misconfiguration
|
||||
- Simplifies mental model (fewer knobs to turn)
|
||||
- Reliable behavior across all configurations
|
||||
- If needed, user adjusts `max_relaxation_attempts` (fewer/more steps)
|
||||
- Prevents user misconfiguration
|
||||
- Simplifies mental model (fewer knobs to turn)
|
||||
- Reliable behavior across all configurations
|
||||
- If needed, user adjusts `max_relaxation_attempts` (fewer/more steps)
|
||||
|
||||
3. **Why warn at 25% base flex?**
|
||||
- At 25% base, first relaxation step reaches 28%
|
||||
- Above 30%, entering diminishing returns territory
|
||||
- User likely doesn't need relaxation with such high base flex
|
||||
- Should either: (a) lower base flex, or (b) disable relaxation
|
||||
- At 25% base, first relaxation step reaches 28%
|
||||
- Above 30%, entering diminishing returns territory
|
||||
- User likely doesn't need relaxation with such high base flex
|
||||
- Should either: (a) lower base flex, or (b) disable relaxation
|
||||
|
||||
**Historical Context (Pre-November 2025):**
|
||||
|
||||
The algorithm previously used percentage-based increments that scaled with base flex:
|
||||
|
||||
```python
|
||||
increment = base_flex × (step_pct / 100) # REMOVED
|
||||
```
|
||||
|
|
@ -454,6 +496,7 @@ increment = base_flex × (step_pct / 100) # REMOVED
|
|||
This caused exponential escalation with high base flex values (e.g., 40% → 50% → 60% → 70% in just 6 steps), making behavior unpredictable. The fixed 3% increment solves this by providing consistent, controlled escalation regardless of starting point.
|
||||
|
||||
**Warning Messages:**
|
||||
|
||||
```python
|
||||
if base_flex >= FLEX_HIGH_THRESHOLD_RELAXATION: # 30%
|
||||
_LOGGER.warning(
|
||||
|
|
@ -472,12 +515,14 @@ elif base_flex >= FLEX_WARNING_THRESHOLD_RELAXATION: # 25%
|
|||
### Filter Combination Strategy
|
||||
|
||||
**Per Flex level, try in order:**
|
||||
|
||||
1. Original Level filter
|
||||
2. Level filter = "any" (disabled)
|
||||
|
||||
**Early Exit:** Stop immediately when target reached (don't try unnecessary combinations)
|
||||
|
||||
**Example Flow (target=2 periods/day):**
|
||||
|
||||
```
|
||||
Day 2025-11-19:
|
||||
1. Baseline flex=15%: Found 1 period (need 2)
|
||||
|
|
@ -492,6 +537,7 @@ Day 2025-11-19:
|
|||
### Key Files and Functions
|
||||
|
||||
**Period Calculation Entry Point:**
|
||||
|
||||
```python
|
||||
# coordinator/period_handlers/core.py
|
||||
def calculate_periods(
|
||||
|
|
@ -502,6 +548,7 @@ def calculate_periods(
|
|||
```
|
||||
|
||||
**Flex + Distance Filtering:**
|
||||
|
||||
```python
|
||||
# coordinator/period_handlers/level_filtering.py
|
||||
def check_interval_criteria(
|
||||
|
|
@ -511,6 +558,7 @@ def check_interval_criteria(
|
|||
```
|
||||
|
||||
**Relaxation Orchestration:**
|
||||
|
||||
```python
|
||||
# coordinator/period_handlers/relaxation.py
|
||||
def calculate_periods_with_relaxation(...) -> tuple[dict, dict]
|
||||
|
|
@ -526,43 +574,45 @@ def relax_single_day(...) -> tuple[dict, dict]
|
|||
**Algorithm Details:**
|
||||
|
||||
1. **Linear Regression Prediction:**
|
||||
- Uses surrounding intervals to predict expected price
|
||||
- Window size: 3+ intervals (MIN_CONTEXT_SIZE)
|
||||
- Calculates trend slope and standard deviation
|
||||
- Formula: `predicted = mean + slope × (position - center)`
|
||||
- Uses surrounding intervals to predict expected price
|
||||
- Window size: 3+ intervals (MIN_CONTEXT_SIZE)
|
||||
- Calculates trend slope and standard deviation
|
||||
- Formula: `predicted = mean + slope × (position - center)`
|
||||
|
||||
2. **Confidence Intervals:**
|
||||
- 95% confidence level (2 standard deviations)
|
||||
- Tolerance = 2.0 × std_dev (CONFIDENCE_LEVEL constant)
|
||||
- Outlier if: `|actual - predicted| > tolerance`
|
||||
- Accounts for natural price volatility in context window
|
||||
- 95% confidence level (2 standard deviations)
|
||||
- Tolerance = 2.0 × std_dev (CONFIDENCE_LEVEL constant)
|
||||
- Outlier if: `|actual - predicted| > tolerance`
|
||||
- Accounts for natural price volatility in context window
|
||||
|
||||
3. **Symmetry Check:**
|
||||
- Rejects asymmetric outliers (threshold: 1.5 std dev)
|
||||
- Preserves legitimate price shifts (morning/evening peaks)
|
||||
- Algorithm:
|
||||
```python
|
||||
residual = abs(actual - predicted)
|
||||
symmetry_threshold = 1.5 × std_dev
|
||||
- Rejects asymmetric outliers (threshold: 1.5 std dev)
|
||||
- Preserves legitimate price shifts (morning/evening peaks)
|
||||
- Algorithm:
|
||||
|
||||
if residual > tolerance:
|
||||
# Check if spike is symmetric in context
|
||||
context_residuals = [abs(p - pred) for p, pred in context]
|
||||
avg_context_residual = mean(context_residuals)
|
||||
```python
|
||||
residual = abs(actual - predicted)
|
||||
symmetry_threshold = 1.5 × std_dev
|
||||
|
||||
if residual > symmetry_threshold × avg_context_residual:
|
||||
# Asymmetric spike → smooth it
|
||||
else:
|
||||
# Symmetric (part of trend) → keep it
|
||||
```
|
||||
if residual > tolerance:
|
||||
# Check if spike is symmetric in context
|
||||
context_residuals = [abs(p - pred) for p, pred in context]
|
||||
avg_context_residual = mean(context_residuals)
|
||||
|
||||
if residual > symmetry_threshold × avg_context_residual:
|
||||
# Asymmetric spike → smooth it
|
||||
else:
|
||||
# Symmetric (part of trend) → keep it
|
||||
```
|
||||
|
||||
4. **Enhanced Zigzag Detection:**
|
||||
- Detects spike clusters via relative volatility
|
||||
- Threshold: 2.0× local volatility (RELATIVE_VOLATILITY_THRESHOLD)
|
||||
- Single-pass algorithm (no iteration needed)
|
||||
- Catches patterns like: 18, 35, 19, 34, 18 (alternating spikes)
|
||||
- Detects spike clusters via relative volatility
|
||||
- Threshold: 2.0× local volatility (RELATIVE_VOLATILITY_THRESHOLD)
|
||||
- Single-pass algorithm (no iteration needed)
|
||||
- Catches patterns like: 18, 35, 19, 34, 18 (alternating spikes)
|
||||
|
||||
**Constants:**
|
||||
|
||||
```python
|
||||
# coordinator/period_handlers/outlier_filtering.py
|
||||
|
||||
|
|
@ -573,18 +623,21 @@ MIN_CONTEXT_SIZE = 3 # Minimum intervals for regression
|
|||
```
|
||||
|
||||
**Data Integrity:**
|
||||
|
||||
- Original prices stored in `_original_price` field
|
||||
- All statistics (daily min/max/avg) use original prices
|
||||
- Smoothing only affects period formation logic
|
||||
- Smart counting: Only counts smoothing that changed period outcome
|
||||
|
||||
**Performance:**
|
||||
|
||||
- Single pass through price data
|
||||
- O(n) complexity with small context window
|
||||
- No iterative refinement needed
|
||||
- Typical processing time: `<`1ms for 96 intervals
|
||||
|
||||
**Example Debug Output:**
|
||||
|
||||
```
|
||||
DEBUG: [2025-11-11T14:30:00+01:00] Outlier detected: 35.2 ct
|
||||
DEBUG: Context: 18.5, 19.1, 19.3, 19.8, 20.2 ct
|
||||
|
|
@ -598,19 +651,19 @@ DEBUG: Asymmetry ratio: 3.2 (>1.5 threshold) → confirmed outlier
|
|||
**Why This Approach?**
|
||||
|
||||
1. **Linear regression over moving average:**
|
||||
- Accounts for price trends (morning ramp-up, evening decline)
|
||||
- Moving average can't predict direction, only level
|
||||
- Better accuracy on non-stationary price curves
|
||||
- Accounts for price trends (morning ramp-up, evening decline)
|
||||
- Moving average can't predict direction, only level
|
||||
- Better accuracy on non-stationary price curves
|
||||
|
||||
2. **Symmetry check over fixed threshold:**
|
||||
- Prevents false positives on legitimate price shifts
|
||||
- Adapts to local volatility patterns
|
||||
- Preserves user expectation: "expensive during peak hours"
|
||||
- Prevents false positives on legitimate price shifts
|
||||
- Adapts to local volatility patterns
|
||||
- Preserves user expectation: "expensive during peak hours"
|
||||
|
||||
3. **Single-pass over iterative:**
|
||||
- Predictable behavior (no convergence issues)
|
||||
- Fast and deterministic
|
||||
- Easier to debug and reason about
|
||||
- Predictable behavior (no convergence issues)
|
||||
- Fast and deterministic
|
||||
- Easier to debug and reason about
|
||||
|
||||
**Alternative Approaches Considered:**
|
||||
|
||||
|
|
@ -624,15 +677,17 @@ DEBUG: Asymmetry ratio: 3.2 (>1.5 threshold) → confirmed outlier
|
|||
## Debugging Tips
|
||||
|
||||
**Enable DEBUG logging:**
|
||||
|
||||
```yaml
|
||||
# configuration.yaml
|
||||
logger:
|
||||
default: info
|
||||
logs:
|
||||
custom_components.tibber_prices.coordinator.period_handlers: debug
|
||||
default: info
|
||||
logs:
|
||||
custom_components.tibber_prices.coordinator.period_handlers: debug
|
||||
```
|
||||
|
||||
**Key log messages to watch:**
|
||||
|
||||
1. `"Filter statistics: X intervals checked"` - Shows how many intervals filtered by each criterion
|
||||
2. `"After build_periods: X raw periods found"` - Periods before min_length filtering
|
||||
3. `"Day X: Success with flex=Y%"` - Relaxation succeeded
|
||||
|
|
@ -645,52 +700,61 @@ logger:
|
|||
### ❌ Anti-Pattern 1: High Flex with Relaxation
|
||||
|
||||
**Configuration:**
|
||||
|
||||
```yaml
|
||||
best_price_flex: 40
|
||||
enable_relaxation_best: true
|
||||
```
|
||||
|
||||
**Problem:**
|
||||
|
||||
- Base Flex 40% already very permissive
|
||||
- Relaxation increments further (43%, 46%, 49%, ...)
|
||||
- Quickly approaches 50% cap with diminishing returns
|
||||
|
||||
**Solution:**
|
||||
|
||||
```yaml
|
||||
best_price_flex: 15 # Let relaxation increase it
|
||||
best_price_flex: 15 # Let relaxation increase it
|
||||
enable_relaxation_best: true
|
||||
```
|
||||
|
||||
### ❌ Anti-Pattern 2: Zero Min_Distance
|
||||
|
||||
**Configuration:**
|
||||
|
||||
```yaml
|
||||
best_price_min_distance_from_avg: 0
|
||||
```
|
||||
|
||||
**Problem:**
|
||||
|
||||
- "Flat days" (little price variation) accept all intervals
|
||||
- Periods lose semantic meaning ("significantly cheap")
|
||||
- May create periods during barely-below-average times
|
||||
|
||||
**Solution:**
|
||||
|
||||
```yaml
|
||||
best_price_min_distance_from_avg: 5 # Use default 5%
|
||||
best_price_min_distance_from_avg: 5 # Use default 5%
|
||||
```
|
||||
|
||||
### ❌ Anti-Pattern 3: Conflicting Flex + Distance
|
||||
|
||||
**Configuration:**
|
||||
|
||||
```yaml
|
||||
best_price_flex: 45
|
||||
best_price_min_distance_from_avg: 10
|
||||
```
|
||||
|
||||
**Problem:**
|
||||
|
||||
- Distance filter dominates, making Flex irrelevant
|
||||
- Dynamic scaling helps but still suboptimal
|
||||
|
||||
**Solution:**
|
||||
|
||||
```yaml
|
||||
best_price_flex: 20
|
||||
best_price_min_distance_from_avg: 5
|
||||
|
|
@ -706,11 +770,13 @@ best_price_min_distance_from_avg: 5
|
|||
**Average:** 15 ct/kWh
|
||||
|
||||
**Expected Behavior:**
|
||||
|
||||
- Flex 15%: Should find 2-4 clear best price periods
|
||||
- Flex 30%: Should find 4-8 periods (more lenient)
|
||||
- Min_Distance 5%: Effective throughout range
|
||||
|
||||
**Debug Checks:**
|
||||
|
||||
```
|
||||
DEBUG: Filter statistics: 96 intervals checked
|
||||
DEBUG: Filtered by FLEX: 12/96 (12.5%) ← Low percentage = good variation
|
||||
|
|
@ -724,11 +790,13 @@ DEBUG: After build_periods: 3 raw periods found
|
|||
**Average:** 15 ct/kWh
|
||||
|
||||
**Expected Behavior:**
|
||||
|
||||
- Flex 15%: May find 1-2 small periods (or zero if no clear winners)
|
||||
- Min_Distance 5%: Critical here - ensures only truly cheaper intervals qualify
|
||||
- Without Min_Distance: Would accept almost entire day as "best price"
|
||||
|
||||
**Debug Checks:**
|
||||
|
||||
```
|
||||
DEBUG: Filter statistics: 96 intervals checked
|
||||
DEBUG: Filtered by FLEX: 45/96 (46.9%) ← High percentage = poor variation
|
||||
|
|
@ -743,11 +811,13 @@ DEBUG: Day 2025-11-11: Baseline insufficient (1 < 2), starting relaxation
|
|||
**Average:** 18 ct/kWh
|
||||
|
||||
**Expected Behavior:**
|
||||
|
||||
- Flex 15%: Finds multiple very cheap periods (5-6 ct)
|
||||
- Outlier filtering: May smooth isolated spikes (30-40 ct)
|
||||
- Distance filter: Less impactful (clear separation between cheap/expensive)
|
||||
|
||||
**Debug Checks:**
|
||||
|
||||
```
|
||||
DEBUG: Outlier detected: 38.5 ct (threshold: 4.2 ct)
|
||||
DEBUG: Smoothed to: 20.1 ct (trend prediction)
|
||||
|
|
@ -762,6 +832,7 @@ DEBUG: After build_periods: 4 raw periods found
|
|||
**Initial State:** Baseline finds 1 period, target is 2
|
||||
|
||||
**Expected Flow:**
|
||||
|
||||
```
|
||||
INFO: Calculating BEST PRICE periods: relaxation=ON, target=2/day, flex=15.0%
|
||||
DEBUG: Day 2025-11-11: Baseline found 1 period (need 2)
|
||||
|
|
@ -777,6 +848,7 @@ INFO: Day 2025-11-11: Success after 1 relaxation phase (2 periods)
|
|||
**Initial State:** Strict filters, very flat day
|
||||
|
||||
**Expected Flow:**
|
||||
|
||||
```
|
||||
INFO: Calculating BEST PRICE periods: relaxation=ON, target=2/day, flex=15.0%
|
||||
DEBUG: Day 2025-11-11: Baseline found 0 periods (need 2)
|
||||
|
|
@ -790,31 +862,31 @@ INFO: Period calculation completed: 1/2 days reached target
|
|||
When debugging period calculation issues:
|
||||
|
||||
1. **Check Filter Statistics**
|
||||
- Which filter blocks most intervals? (flex, distance, or level)
|
||||
- High flex filtering (>30%) = Need more flexibility or relaxation
|
||||
- High distance filtering (>50%) = Min_distance too strict or flat day
|
||||
- High level filtering = Level filter too restrictive
|
||||
- Which filter blocks most intervals? (flex, distance, or level)
|
||||
- High flex filtering (>30%) = Need more flexibility or relaxation
|
||||
- High distance filtering (>50%) = Min_distance too strict or flat day
|
||||
- High level filtering = Level filter too restrictive
|
||||
|
||||
2. **Check Relaxation Behavior**
|
||||
- Did relaxation activate? Check for "Baseline insufficient" message
|
||||
- Which phase succeeded? Early success (phase 1-3) = good config
|
||||
- Late success (phase 8-11) = Consider adjusting base config
|
||||
- Exhausted all phases = Unrealistic target for this day's price curve
|
||||
- Did relaxation activate? Check for "Baseline insufficient" message
|
||||
- Which phase succeeded? Early success (phase 1-3) = good config
|
||||
- Late success (phase 8-11) = Consider adjusting base config
|
||||
- Exhausted all phases = Unrealistic target for this day's price curve
|
||||
|
||||
3. **Check Flex Warnings**
|
||||
- INFO at 25% base flex = On the high side
|
||||
- WARNING at 30% base flex = Too high for relaxation
|
||||
- If seeing these: Lower base flex to 15-20%
|
||||
- INFO at 25% base flex = On the high side
|
||||
- WARNING at 30% base flex = Too high for relaxation
|
||||
- If seeing these: Lower base flex to 15-20%
|
||||
|
||||
4. **Check Min_Distance Scaling**
|
||||
- Debug messages show "High flex X% detected: Reducing min_distance Y% → Z%"
|
||||
- If scale factor `<`0.8 (20% reduction): High flex is active
|
||||
- If periods still not found: Filters conflict even with scaling
|
||||
- Debug messages show "High flex X% detected: Reducing min_distance Y% → Z%"
|
||||
- If scale factor `<`0.8 (20% reduction): High flex is active
|
||||
- If periods still not found: Filters conflict even with scaling
|
||||
|
||||
5. **Check Outlier Filtering**
|
||||
- Look for "Outlier detected" messages
|
||||
- Check `period_interval_smoothed_count` attribute
|
||||
- If no smoothing but periods fragmented: Not isolated spikes, but legitimate price levels
|
||||
- Look for "Outlier detected" messages
|
||||
- Check `period_interval_smoothed_count` attribute
|
||||
- If no smoothing but periods fragmented: Not isolated spikes, but legitimate price levels
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -823,19 +895,19 @@ When debugging period calculation issues:
|
|||
### Potential Improvements
|
||||
|
||||
1. **Adaptive Flex Calculation:**
|
||||
- Auto-adjust Flex based on daily price variation
|
||||
- High variation days: Lower Flex needed
|
||||
- Low variation days: Higher Flex needed
|
||||
- Auto-adjust Flex based on daily price variation
|
||||
- High variation days: Lower Flex needed
|
||||
- Low variation days: Higher Flex needed
|
||||
|
||||
2. **Machine Learning Approach:**
|
||||
- Learn optimal Flex/Distance from user feedback
|
||||
- Classify days by pattern (normal/flat/volatile/bimodal)
|
||||
- Apply pattern-specific defaults
|
||||
- Learn optimal Flex/Distance from user feedback
|
||||
- Classify days by pattern (normal/flat/volatile/bimodal)
|
||||
- Apply pattern-specific defaults
|
||||
|
||||
3. **Multi-Objective Optimization:**
|
||||
- Balance period count vs. quality
|
||||
- Consider period duration vs. price level
|
||||
- Optimize for user's stated use case (EV charging vs. heat pump)
|
||||
- Balance period count vs. quality
|
||||
- Consider period duration vs. price level
|
||||
- Optimize for user's stated use case (EV charging vs. heat pump)
|
||||
|
||||
### Known Limitations
|
||||
|
||||
|
|
@ -854,6 +926,7 @@ When debugging period calculation issues:
|
|||
**Concept:** Auto-adjust Flex based on daily price variation
|
||||
|
||||
**Algorithm:**
|
||||
|
||||
```python
|
||||
# Pseudo-code for adaptive flex
|
||||
variation = (daily_max - daily_min) / daily_avg
|
||||
|
|
@ -867,11 +940,13 @@ else: # Normal day
|
|||
```
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- Eliminates need for relaxation on most days
|
||||
- Self-adjusting to market conditions
|
||||
- Better user experience (less configuration needed)
|
||||
|
||||
**Challenges:**
|
||||
|
||||
- Harder to predict behavior (less transparent)
|
||||
- May conflict with user's mental model
|
||||
- Needs extensive testing across different markets
|
||||
|
|
@ -883,17 +958,20 @@ else: # Normal day
|
|||
**Concept:** Learn optimal Flex/Distance from user feedback
|
||||
|
||||
**Approach:**
|
||||
|
||||
- Track which periods user actually uses (automation triggers)
|
||||
- Classify days by pattern (normal/flat/volatile/bimodal)
|
||||
- Apply pattern-specific defaults
|
||||
- Learn per-user preferences over time
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- Personalized to user's actual behavior
|
||||
- Adapts to local market patterns
|
||||
- Could discover non-obvious patterns
|
||||
|
||||
**Challenges:**
|
||||
|
||||
- Requires user feedback mechanism (not implemented)
|
||||
- Privacy concerns (storing usage patterns)
|
||||
- Complexity for users to understand "why this period?"
|
||||
|
|
@ -906,22 +984,26 @@ else: # Normal day
|
|||
**Concept:** Balance multiple goals simultaneously
|
||||
|
||||
**Goals:**
|
||||
|
||||
- Period count vs. quality (cheap vs. very cheap)
|
||||
- Period duration vs. price level (long mediocre vs. short excellent)
|
||||
- Temporal distribution (spread throughout day vs. clustered)
|
||||
- User's stated use case (EV charging vs. heat pump vs. dishwasher)
|
||||
|
||||
**Algorithm:**
|
||||
|
||||
- Pareto optimization (find trade-off frontier)
|
||||
- User chooses point on frontier via preferences
|
||||
- Genetic algorithm or simulated annealing
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- More sophisticated period selection
|
||||
- Better match to user's actual needs
|
||||
- Could handle complex appliance requirements
|
||||
|
||||
**Challenges:**
|
||||
|
||||
- Much more complex to implement
|
||||
- Harder to explain to users
|
||||
- Computational cost (may need caching)
|
||||
|
|
@ -936,14 +1018,17 @@ else: # Normal day
|
|||
**Current:** 3% cap may be too aggressive for very low base Flex
|
||||
|
||||
**Example:**
|
||||
|
||||
- Base flex 5% + 3% increment = 8% (60% increase!)
|
||||
- Base flex 15% + 3% increment = 18% (20% increase)
|
||||
|
||||
**Possible Solution:**
|
||||
|
||||
- Percentage-based increment: `increment = max(base_flex × 0.20, 0.03)`
|
||||
- This gives: 5% → 6% (20%), 15% → 18% (20%), 40% → 43% (7.5%)
|
||||
|
||||
**Why Not Implemented:**
|
||||
|
||||
- Very low base flex (`<`10%) unusual
|
||||
- Users with strict requirements likely disable relaxation
|
||||
- Simplicity preferred over edge case optimization
|
||||
|
|
@ -953,6 +1038,7 @@ else: # Normal day
|
|||
**Current:** Linear scaling may be too aggressive/conservative
|
||||
|
||||
**Alternative:** Non-linear curve
|
||||
|
||||
```python
|
||||
# Example: Exponential scaling
|
||||
scale_factor = 0.25 + 0.75 × exp(-5 × (flex - 0.20))
|
||||
|
|
@ -962,6 +1048,7 @@ scale_factor = 0.25 + 0.75 / (1 + exp(10 × (flex - 0.35)))
|
|||
```
|
||||
|
||||
**Why Not Implemented:**
|
||||
|
||||
- Linear is easier to reason about
|
||||
- No evidence that non-linear is better
|
||||
- Would need extensive testing
|
||||
|
|
@ -971,15 +1058,18 @@ scale_factor = 0.25 + 0.75 / (1 + exp(10 × (flex - 0.35)))
|
|||
**Issue:** May find all periods in one part of day
|
||||
|
||||
**Example:**
|
||||
|
||||
- All 3 "best price" periods between 02:00-08:00
|
||||
- No periods in evening (when user might want to run appliances)
|
||||
|
||||
**Possible Solution:**
|
||||
|
||||
- Add "spread" parameter (prefer distributed periods)
|
||||
- Weight periods by time-of-day preferences
|
||||
- Consider user's typical usage patterns
|
||||
|
||||
**Why Not Implemented:**
|
||||
|
||||
- Adds complexity
|
||||
- Users can work around with multiple automations
|
||||
- Different users have different needs (no one-size-fits-all)
|
||||
|
|
@ -991,6 +1081,7 @@ scale_factor = 0.25 + 0.75 / (1 + exp(10 × (flex - 0.35)))
|
|||
**Design Principle:** Each interval is evaluated using its **own day's** reference prices (daily min/max/avg).
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```python
|
||||
# In period_building.py build_periods():
|
||||
for price_data in all_prices:
|
||||
|
|
@ -1042,6 +1133,7 @@ Period crossing midnight: 23:45 Day 1 → 00:15 Day 2
|
|||
**Trade-off: Periods May Break at Midnight**
|
||||
|
||||
When days differ significantly, period can split:
|
||||
|
||||
```
|
||||
Day 1: Min=10ct, Avg=20ct, 23:45=11ct → ✅ Cheap (relative to Day 1)
|
||||
Day 2: Min=25ct, Avg=35ct, 00:00=21ct → ❌ Expensive (relative to Day 2)
|
||||
|
|
@ -1053,6 +1145,7 @@ This is **mathematically correct** - 21ct is genuinely expensive on a day where
|
|||
**Market Reality Explains Price Jumps:**
|
||||
|
||||
Day-ahead electricity markets (EPEX SPOT) set prices at 12:00 CET for all next-day hours:
|
||||
|
||||
- Late intervals (23:45): Priced ~36h before delivery → high forecast uncertainty → risk premium
|
||||
- Early intervals (00:00): Priced ~12h before delivery → better forecasts → lower risk buffer
|
||||
|
||||
|
|
@ -1061,15 +1154,17 @@ This explains why absolute prices jump at midnight despite minimal demand change
|
|||
**User-Facing Solution (Nov 2025):**
|
||||
|
||||
Added per-period day volatility attributes to detect when classification changes are meaningful:
|
||||
|
||||
- `day_volatility_%`: Percentage spread (span/avg × 100)
|
||||
- `day_price_min`, `day_price_max`, `day_price_span`: Daily price range (ct/øre)
|
||||
|
||||
Automations can check volatility before acting:
|
||||
|
||||
```yaml
|
||||
condition:
|
||||
- condition: template
|
||||
value_template: >
|
||||
{{ state_attr('binary_sensor.tibber_home_best_price_period', 'day_volatility_%') | float(0) > 15 }}
|
||||
- condition: template
|
||||
value_template: >
|
||||
{{ state_attr('binary_sensor.tibber_home_best_price_period', 'day_volatility_%') | float(0) > 15 }}
|
||||
```
|
||||
|
||||
Low volatility (< 15%) means classification changes are less economically significant.
|
||||
|
|
@ -1077,24 +1172,25 @@ Low volatility (< 15%) means classification changes are less economically signif
|
|||
**Alternative Approaches Rejected:**
|
||||
|
||||
1. **Use period start day for all intervals**
|
||||
- Problem: Mathematically incorrect - lends cheap day's criteria to expensive day
|
||||
- Rejected: Violates relative evaluation principle
|
||||
- Problem: Mathematically incorrect - lends cheap day's criteria to expensive day
|
||||
- Rejected: Violates relative evaluation principle
|
||||
|
||||
2. **Adjust flex/distance at midnight**
|
||||
- Problem: Complex, unpredictable, hides market reality
|
||||
- Rejected: Users should understand price context, not have it hidden
|
||||
- Problem: Complex, unpredictable, hides market reality
|
||||
- Rejected: Users should understand price context, not have it hidden
|
||||
|
||||
3. **Split at midnight always**
|
||||
- Problem: Artificially fragments natural periods
|
||||
- Rejected: Worse user experience
|
||||
- Problem: Artificially fragments natural periods
|
||||
- Rejected: Worse user experience
|
||||
|
||||
4. **Use next day's reference after midnight**
|
||||
- Problem: Period criteria inconsistent across duration
|
||||
- Rejected: Confusing and unpredictable
|
||||
- Problem: Period criteria inconsistent across duration
|
||||
- Rejected: Confusing and unpredictable
|
||||
|
||||
**Status:** Per-day evaluation is intentional design prioritizing mathematical correctness.
|
||||
|
||||
**See Also:**
|
||||
|
||||
- User documentation: `docs/user/docs/period-calculation.md` → "Midnight Price Classification Changes"
|
||||
- Implementation: `coordinator/period_handlers/period_building.py` (line ~126: `ref_date = date_key`)
|
||||
- Attributes: `coordinator/period_handlers/period_statistics.py` (day volatility calculation)
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
```
|
||||
|
||||
**Key Points:**
|
||||
|
||||
- Must be a **class attribute** (not instance attribute)
|
||||
- Use `frozenset` for immutability and performance
|
||||
- Applied automatically by Home Assistant's Recorder component
|
||||
|
|
@ -40,6 +41,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
**Attributes:** `description`, `usage_tips`
|
||||
|
||||
**Reason:** Static, large text strings (100-500 chars each) that:
|
||||
|
||||
- Never change or change very rarely
|
||||
- Don't provide analytical value in history
|
||||
- Consume significant database space when recorded every state change
|
||||
|
|
@ -50,6 +52,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
### 2. Large Nested Structures
|
||||
|
||||
**Attributes:**
|
||||
|
||||
- `periods` (binary_sensor) - Array of all period summaries
|
||||
- `data` (chart_data_export) - Complete price data arrays
|
||||
- `trend_attributes` - Detailed trend analysis
|
||||
|
|
@ -58,6 +61,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
- `volatility_attributes` - Detailed volatility breakdown
|
||||
|
||||
**Reason:** Complex nested data structures that are:
|
||||
|
||||
- Serialized to JSON for storage (expensive)
|
||||
- Create large database rows (2-20 KB each)
|
||||
- Slow down history queries
|
||||
|
|
@ -66,20 +70,21 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
**Impact:** ~10-30 KB saved per state change for affected sensors
|
||||
|
||||
**Example - periods array:**
|
||||
|
||||
```json
|
||||
{
|
||||
"periods": [
|
||||
{
|
||||
"start": "2025-12-07T06:00:00+01:00",
|
||||
"end": "2025-12-07T08:00:00+01:00",
|
||||
"duration_minutes": 120,
|
||||
"price_mean": 18.5,
|
||||
"price_median": 18.3,
|
||||
"price_min": 17.2,
|
||||
"price_max": 19.8,
|
||||
// ... 10+ more attributes × 10-20 periods
|
||||
}
|
||||
]
|
||||
"periods": [
|
||||
{
|
||||
"start": "2025-12-07T06:00:00+01:00",
|
||||
"end": "2025-12-07T08:00:00+01:00",
|
||||
"duration_minutes": 120,
|
||||
"price_mean": 18.5,
|
||||
"price_median": 18.3,
|
||||
"price_min": 17.2,
|
||||
"price_max": 19.8
|
||||
// ... 10+ more attributes × 10-20 periods
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -88,6 +93,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
**Attributes:** `icon_color`, `cache_age`, `cache_validity`, `data_completeness`, `data_status`
|
||||
|
||||
**Reason:**
|
||||
|
||||
- Change every update cycle (every 15 minutes or more frequently)
|
||||
- Don't provide long-term analytical value
|
||||
- Create state changes even when core values haven't changed
|
||||
|
|
@ -103,6 +109,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
**Attributes:** `tomorrow_expected_after`, `level_value`, `rating_value`, `level_id`, `rating_id`, `currency`, `resolution`, `yaxis_min`, `yaxis_max`
|
||||
|
||||
**Reason:**
|
||||
|
||||
- Configuration values that rarely change
|
||||
- Wastes space when recorded repeatedly
|
||||
- Can be derived from other attributes or from entity state
|
||||
|
|
@ -114,6 +121,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
**Attributes:** `next_api_poll`, `next_midnight_turnover`, `last_api_fetch`, `last_cache_update`, `last_turnover`, `last_error`, `error`
|
||||
|
||||
**Reason:**
|
||||
|
||||
- Only relevant at moment of reading
|
||||
- Won't be valid after some time
|
||||
- Similar to `entity_picture` in HA core image entities
|
||||
|
|
@ -128,6 +136,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
**Attributes:** `relaxation_level`, `relaxation_threshold_original_%`, `relaxation_threshold_applied_%`
|
||||
|
||||
**Reason:**
|
||||
|
||||
- Detailed technical information not needed for historical analysis
|
||||
- Only useful for debugging during active development
|
||||
- Boolean `relaxation_active` is kept for high-level analysis
|
||||
|
|
@ -139,6 +148,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
**Attributes:** `price_spread`, `volatility`, `diff_%`, `rating_difference_%`, `period_price_diff_from_daily_min`, `period_price_diff_from_daily_min_%`, `periods_total`, `periods_remaining`
|
||||
|
||||
**Reason:**
|
||||
|
||||
- Can be calculated from other attributes
|
||||
- Redundant information
|
||||
- Doesn't add analytical value to history
|
||||
|
|
@ -152,22 +162,27 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
These attributes **remain in history** because they provide essential analytical value:
|
||||
|
||||
### Time-Series Core
|
||||
|
||||
- `timestamp` - Critical for time-series analysis (ALWAYS FIRST)
|
||||
- All price values - Core sensor states
|
||||
|
||||
### Diagnostics & Tracking
|
||||
|
||||
- `cache_age_minutes` - Numeric value for diagnostics tracking over time
|
||||
- `updates_today` - Tracking API usage patterns
|
||||
|
||||
### Data Completeness
|
||||
|
||||
- `interval_count`, `intervals_available` - Data completeness metrics
|
||||
- `yesterday_available`, `today_available`, `tomorrow_available` - Boolean status
|
||||
|
||||
### Period Data
|
||||
|
||||
- `start`, `end`, `duration_minutes` - Core period timing
|
||||
- `price_mean`, `price_median`, `price_min`, `price_max` - Core price statistics
|
||||
|
||||
### High-Level Status
|
||||
|
||||
- `relaxation_active` - Whether relaxation was used (boolean, useful for analyzing when periods needed relaxation)
|
||||
|
||||
## Expected Database Impact
|
||||
|
|
@ -175,6 +190,7 @@ These attributes **remain in history** because they provide essential analytical
|
|||
### Space Savings
|
||||
|
||||
**Per state change:**
|
||||
|
||||
- Before: ~3-8 KB average
|
||||
- After: ~0.5-1.5 KB average
|
||||
- **Reduction: 60-85%**
|
||||
|
|
@ -196,6 +212,7 @@ These attributes **remain in history** because they provide essential analytical
|
|||
### Real-World Impact
|
||||
|
||||
For a typical installation with:
|
||||
|
||||
- 80+ sensors
|
||||
- Updates every 15 minutes
|
||||
- ~10 sensors updating every minute
|
||||
|
|
@ -207,14 +224,14 @@ For a typical installation with:
|
|||
## Implementation Files
|
||||
|
||||
- **Sensor Platform**: `custom_components/tibber_prices/sensor/core.py`
|
||||
- Class: `TibberPricesSensor`
|
||||
- 47 attributes excluded
|
||||
- Class: `TibberPricesSensor`
|
||||
- 47 attributes excluded
|
||||
|
||||
- **Binary Sensor Platform**: `custom_components/tibber_prices/binary_sensor/core.py`
|
||||
- Class: `TibberPricesBinarySensor`
|
||||
- 30 attributes excluded
|
||||
- Class: `TibberPricesBinarySensor`
|
||||
- 30 attributes excluded
|
||||
|
||||
## When to Update _unrecorded_attributes
|
||||
## When to Update \_unrecorded_attributes
|
||||
|
||||
### Add to Exclusion List When:
|
||||
|
||||
|
|
@ -236,24 +253,24 @@ For a typical installation with:
|
|||
When adding a new attribute, ask:
|
||||
|
||||
1. **Will this be useful in history queries 1 week from now?**
|
||||
- No → Exclude
|
||||
- Yes → Keep
|
||||
- No → Exclude
|
||||
- Yes → Keep
|
||||
|
||||
2. **Can this be calculated from other recorded attributes?**
|
||||
- Yes → Exclude
|
||||
- No → Keep
|
||||
- Yes → Exclude
|
||||
- No → Keep
|
||||
|
||||
3. **Is this primarily for current UI display?**
|
||||
- Yes → Exclude
|
||||
- No → Keep
|
||||
- Yes → Exclude
|
||||
- No → Keep
|
||||
|
||||
4. **Does this change frequently without indicating state change?**
|
||||
- Yes → Exclude
|
||||
- No → Keep
|
||||
- Yes → Exclude
|
||||
- No → Keep
|
||||
|
||||
5. **Is this larger than 100 bytes and not essential for analysis?**
|
||||
- Yes → Exclude
|
||||
- No → Keep
|
||||
- Yes → Exclude
|
||||
- No → Keep
|
||||
|
||||
## Testing
|
||||
|
||||
|
|
@ -265,6 +282,7 @@ After modifying `_unrecorded_attributes`:
|
|||
4. **Confirm excluded attributes** don't appear in new state writes
|
||||
|
||||
**SQL Query to check attribute presence:**
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
state_id,
|
||||
|
|
|
|||
|
|
@ -8,22 +8,22 @@ Not every code change needs a detailed plan. Create a refactoring plan when:
|
|||
|
||||
🔴 **Major changes requiring planning:**
|
||||
|
||||
- Splitting modules into packages (>5 files affected, >500 lines moved)
|
||||
- Architectural changes (new packages, module restructuring)
|
||||
- Breaking changes (API changes, config format migrations)
|
||||
- Splitting modules into packages (>5 files affected, >500 lines moved)
|
||||
- Architectural changes (new packages, module restructuring)
|
||||
- Breaking changes (API changes, config format migrations)
|
||||
|
||||
🟡 **Medium changes that might benefit from planning:**
|
||||
|
||||
- Complex features with multiple moving parts
|
||||
- Changes affecting many files (>3 files, unclear best approach)
|
||||
- Refactorings with unclear scope
|
||||
- Complex features with multiple moving parts
|
||||
- Changes affecting many files (>3 files, unclear best approach)
|
||||
- Refactorings with unclear scope
|
||||
|
||||
🟢 **Small changes - no planning needed:**
|
||||
|
||||
- Bug fixes (straightforward, `<`100 lines)
|
||||
- Small features (`<`3 files, clear approach)
|
||||
- Documentation updates
|
||||
- Cosmetic changes (formatting, renaming)
|
||||
- Bug fixes (straightforward, `<`100 lines)
|
||||
- Small features (`<`3 files, clear approach)
|
||||
- Documentation updates
|
||||
- Cosmetic changes (formatting, renaming)
|
||||
|
||||
## The Planning Process
|
||||
|
||||
|
|
@ -51,34 +51,34 @@ Every planning document should include:
|
|||
|
||||
## Problem Statement
|
||||
|
||||
- What's the issue?
|
||||
- Why does it need fixing?
|
||||
- Current pain points
|
||||
- What's the issue?
|
||||
- Why does it need fixing?
|
||||
- Current pain points
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
- High-level approach
|
||||
- File structure (before/after)
|
||||
- Module responsibilities
|
||||
- High-level approach
|
||||
- File structure (before/after)
|
||||
- Module responsibilities
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
- Phase-by-phase breakdown
|
||||
- File lifecycle (CREATE/MODIFY/DELETE/RENAME)
|
||||
- Dependencies between phases
|
||||
- Testing checkpoints
|
||||
- Phase-by-phase breakdown
|
||||
- File lifecycle (CREATE/MODIFY/DELETE/RENAME)
|
||||
- Dependencies between phases
|
||||
- Testing checkpoints
|
||||
|
||||
## Risks & Mitigation
|
||||
|
||||
- What could go wrong?
|
||||
- How to prevent it?
|
||||
- Rollback strategy
|
||||
- What could go wrong?
|
||||
- How to prevent it?
|
||||
- Rollback strategy
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- Measurable improvements
|
||||
- Testing requirements
|
||||
- Verification steps
|
||||
- Measurable improvements
|
||||
- Testing requirements
|
||||
- Verification steps
|
||||
```
|
||||
|
||||
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:
|
||||
|
||||
- Draft multiple versions
|
||||
- Get AI assistance without commit pressure
|
||||
- Refine until the plan is solid
|
||||
- No need to clean up intermediate versions
|
||||
- Draft multiple versions
|
||||
- Get AI assistance without commit pressure
|
||||
- Refine until the plan is solid
|
||||
- No need to clean up intermediate versions
|
||||
|
||||
### 4. Implementation Phase
|
||||
|
||||
Once plan is approved:
|
||||
|
||||
- Follow the phases defined in the plan
|
||||
- Test after each phase (don't skip!)
|
||||
- Update plan if issues discovered
|
||||
- Track progress through phase status
|
||||
- Follow the phases defined in the plan
|
||||
- Test after each phase (don't skip!)
|
||||
- Update plan if issues discovered
|
||||
- Track progress through phase status
|
||||
|
||||
### 5. After Completion
|
||||
|
||||
|
|
@ -134,13 +134,13 @@ The **sensor/ package refactoring** (Nov 2025) is a successful example:
|
|||
|
||||
**Before:**
|
||||
|
||||
- `sensor.py` - 2,574 lines, hard to navigate
|
||||
- `sensor.py` - 2,574 lines, hard to navigate
|
||||
|
||||
**After:**
|
||||
|
||||
- `sensor/` package with 5 focused modules
|
||||
- Each module `<`800 lines
|
||||
- Clear separation of concerns
|
||||
- `sensor/` package with 5 focused modules
|
||||
- Each module `<`800 lines
|
||||
- Clear separation of concerns
|
||||
|
||||
**Process:**
|
||||
|
||||
|
|
@ -153,10 +153,10 @@ The **sensor/ package refactoring** (Nov 2025) is a successful example:
|
|||
|
||||
**Key learnings:**
|
||||
|
||||
- Temporary `_impl.py` files avoid Python package conflicts
|
||||
- Test after EVERY phase (don't accumulate changes)
|
||||
- Clear file lifecycle (CREATE/MODIFY/DELETE/RENAME)
|
||||
- Phase-by-phase approach enables safe rollback
|
||||
- Temporary `_impl.py` files avoid Python package conflicts
|
||||
- Test after EVERY phase (don't accumulate changes)
|
||||
- Clear file lifecycle (CREATE/MODIFY/DELETE/RENAME)
|
||||
- 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.
|
||||
|
||||
|
|
@ -166,11 +166,11 @@ The **sensor/ package refactoring** (Nov 2025) is a successful example:
|
|||
|
||||
Breaking refactorings into phases:
|
||||
|
||||
- ✅ Enables testing after each change (catch bugs early)
|
||||
- ✅ Allows rollback to last good state
|
||||
- ✅ Makes progress visible
|
||||
- ✅ Reduces cognitive load (focus on one thing)
|
||||
- ❌ Takes more time (but worth it!)
|
||||
- ✅ Enables testing after each change (catch bugs early)
|
||||
- ✅ Allows rollback to last good state
|
||||
- ✅ Makes progress visible
|
||||
- ✅ Reduces cognitive load (focus on one thing)
|
||||
- ❌ Takes more time (but worth it!)
|
||||
|
||||
### Phase Structure
|
||||
|
||||
|
|
@ -191,8 +191,8 @@ Each phase should:
|
|||
|
||||
**File Lifecycle**:
|
||||
|
||||
- ✨ CREATE `sensor/helpers.py` (utility functions)
|
||||
- ✏️ MODIFY `sensor/core.py` (import from helpers.py)
|
||||
- ✨ CREATE `sensor/helpers.py` (utility functions)
|
||||
- ✏️ MODIFY `sensor/core.py` (import from helpers.py)
|
||||
|
||||
**Steps**:
|
||||
|
||||
|
|
@ -205,10 +205,10 @@ Each phase should:
|
|||
|
||||
**Success criteria**:
|
||||
|
||||
- ✅ All pure functions moved
|
||||
- ✅ `./scripts/lint-check` passes
|
||||
- ✅ HA starts successfully
|
||||
- ✅ All entities work correctly
|
||||
- ✅ All pure functions moved
|
||||
- ✅ `./scripts/lint-check` passes
|
||||
- ✅ HA starts successfully
|
||||
- ✅ All entities work correctly
|
||||
```
|
||||
|
||||
## Testing Strategy
|
||||
|
|
@ -238,13 +238,13 @@ Minimum testing checklist:
|
|||
|
||||
After completing all phases:
|
||||
|
||||
- Test all entities (sensors, binary sensors)
|
||||
- Test configuration flow (add/modify/remove)
|
||||
- Test options flow (change settings)
|
||||
- Test services (custom service calls)
|
||||
- Test error handling (disconnect API, invalid data)
|
||||
- Test caching (restart HA, verify cache loads)
|
||||
- Test time-based updates (quarter-hour refresh)
|
||||
- Test all entities (sensors, binary sensors)
|
||||
- Test configuration flow (add/modify/remove)
|
||||
- Test options flow (change settings)
|
||||
- Test services (custom service calls)
|
||||
- Test error handling (disconnect API, invalid data)
|
||||
- Test caching (restart HA, verify cache loads)
|
||||
- Test time-based updates (quarter-hour refresh)
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
|
|
@ -286,21 +286,21 @@ This project uses AI heavily (GitHub Copilot, Claude). The planning process supp
|
|||
|
||||
**AI reads from:**
|
||||
|
||||
- `AGENTS.md` - Long-term memory, patterns, conventions (AI-focused)
|
||||
- `docs/development/` - Human-readable guides (human-focused)
|
||||
- `planning/` - Active refactoring plans (shared context)
|
||||
- `AGENTS.md` - Long-term memory, patterns, conventions (AI-focused)
|
||||
- `docs/development/` - Human-readable guides (human-focused)
|
||||
- `planning/` - Active refactoring plans (shared context)
|
||||
|
||||
**AI updates:**
|
||||
|
||||
- `AGENTS.md` - When patterns change
|
||||
- `planning/*.md` - During refactoring implementation
|
||||
- `docs/development/` - After successful completion
|
||||
- `AGENTS.md` - When patterns change
|
||||
- `planning/*.md` - During refactoring implementation
|
||||
- `docs/development/` - After successful completion
|
||||
|
||||
**Why separate AGENTS.md and docs/development/?**
|
||||
|
||||
- `AGENTS.md`: Technical, comprehensive, AI-optimized
|
||||
- `docs/development/`: Practical, focused, human-optimized
|
||||
- Both stay in sync but serve different audiences
|
||||
- `AGENTS.md`: Technical, comprehensive, AI-optimized
|
||||
- `docs/development/`: Practical, focused, human-optimized
|
||||
- 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.
|
||||
|
||||
|
|
@ -308,16 +308,16 @@ See [AGENTS.md](https://github.com/jpawlowski/hass.tibber_prices/blob/main/AGENT
|
|||
|
||||
### Planning Directory
|
||||
|
||||
- `planning/` - Git-ignored workspace for drafts
|
||||
- `planning/README.md` - Detailed planning documentation
|
||||
- `planning/*.md` - Active refactoring plans
|
||||
- `planning/` - Git-ignored workspace for drafts
|
||||
- `planning/README.md` - Detailed planning documentation
|
||||
- `planning/*.md` - Active refactoring plans
|
||||
|
||||
### Example Plans
|
||||
|
||||
- `docs/development/module-splitting-plan.md` - ✅ Completed, archived
|
||||
- `planning/config-flow-refactoring-plan.md` - 🔄 Planned (1013 lines → 4 modules)
|
||||
- `planning/binary-sensor-refactoring-plan.md` - 🔄 Planned (644 lines → 4 modules)
|
||||
- `planning/coordinator-refactoring-plan.md` - 🔄 Planned (1446 lines, high complexity)
|
||||
- `docs/development/module-splitting-plan.md` - ✅ Completed, archived
|
||||
- `planning/config-flow-refactoring-plan.md` - 🔄 Planned (1013 lines → 4 modules)
|
||||
- `planning/binary-sensor-refactoring-plan.md` - 🔄 Planned (644 lines → 4 modules)
|
||||
- `planning/coordinator-refactoring-plan.md` - 🔄 Planned (1446 lines, high complexity)
|
||||
|
||||
### 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:
|
||||
|
||||
- Lists all files affected (CREATE/MODIFY/DELETE)
|
||||
- Defines phases with clear boundaries
|
||||
- Includes testing strategy
|
||||
- Estimates time per phase
|
||||
- Lists all files affected (CREATE/MODIFY/DELETE)
|
||||
- Defines phases with clear boundaries
|
||||
- Includes testing strategy
|
||||
- Estimates time per phase
|
||||
|
||||
Too detailed:
|
||||
|
||||
- Exact code snippets for every change
|
||||
- Line-by-line instructions
|
||||
- Exact code snippets for every change
|
||||
- Line-by-line instructions
|
||||
|
||||
Too vague:
|
||||
|
||||
- "Refactor sensor.py to be better"
|
||||
- No phase breakdown
|
||||
- No testing strategy
|
||||
- "Refactor sensor.py to be better"
|
||||
- No phase breakdown
|
||||
- No testing strategy
|
||||
|
||||
### Q: What if the plan changes during implementation?
|
||||
|
||||
|
|
@ -363,9 +363,9 @@ Too vague:
|
|||
|
||||
If you discover:
|
||||
|
||||
- Better approach → Update "Proposed Solution"
|
||||
- More phases needed → Add to "Migration Strategy"
|
||||
- New risks → Update "Risks & Mitigation"
|
||||
- Better approach → Update "Proposed Solution"
|
||||
- More phases needed → Add to "Migration Strategy"
|
||||
- New risks → Update "Risks & Mitigation"
|
||||
|
||||
Document WHY the plan changed (helps future refactorings).
|
||||
|
||||
|
|
@ -373,9 +373,9 @@ Document WHY the plan changed (helps future refactorings).
|
|||
|
||||
**A:** No! Use judgment:
|
||||
|
||||
- **Small changes (`<`100 lines, clear approach)**: Just do it, no plan needed
|
||||
- **Medium changes (unclear scope)**: Write rough outline, refine if needed
|
||||
- **Large changes (>500 lines, >5 files)**: Full planning process
|
||||
- **Small changes (`<`100 lines, clear approach)**: Just do it, no plan needed
|
||||
- **Medium changes (unclear scope)**: Write rough outline, refine if needed
|
||||
- **Large changes (>500 lines, >5 files)**: Full planning process
|
||||
|
||||
### Q: How do I know when a refactoring is successful?
|
||||
|
||||
|
|
@ -383,12 +383,12 @@ Document WHY the plan changed (helps future refactorings).
|
|||
|
||||
Typical criteria:
|
||||
|
||||
- ✅ All linting checks pass
|
||||
- ✅ HA starts without errors
|
||||
- ✅ All entities functional
|
||||
- ✅ No regressions (existing features work)
|
||||
- ✅ Code easier to understand/modify
|
||||
- ✅ Documentation updated
|
||||
- ✅ All linting checks pass
|
||||
- ✅ HA starts without errors
|
||||
- ✅ All entities functional
|
||||
- ✅ No regressions (existing features work)
|
||||
- ✅ Code easier to understand/modify
|
||||
- ✅ Documentation updated
|
||||
|
||||
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:**
|
||||
|
||||
- Read `planning/README.md` for detailed template
|
||||
- Check `docs/development/module-splitting-plan.md` for real example
|
||||
- Browse `planning/` for active refactoring plans
|
||||
- Read `planning/README.md` for detailed template
|
||||
- Check `docs/development/module-splitting-plan.md` for real example
|
||||
- Browse `planning/` for active refactoring plans
|
||||
|
|
|
|||
|
|
@ -112,6 +112,7 @@ In CI/CD (`$CI` or `$GITHUB_ACTIONS`), AI is automatically disabled.
|
|||
**In DevContainer (automatic):**
|
||||
|
||||
git-cliff is automatically installed when the DevContainer is built:
|
||||
|
||||
- **Rust toolchain**: Installed via `ghcr.io/devcontainers/features/rust:1` (minimal profile)
|
||||
- **git-cliff**: Installed via cargo in `scripts/setup/setup`
|
||||
|
||||
|
|
@ -120,6 +121,7 @@ Simply rebuild the container (VS Code: "Dev Containers: Rebuild Container") and
|
|||
**Manual installation (outside DevContainer):**
|
||||
|
||||
**git-cliff** (template-based):
|
||||
|
||||
```bash
|
||||
# See: https://git-cliff.org/docs/installation
|
||||
|
||||
|
|
@ -190,13 +192,13 @@ All methods produce GitHub-flavored Markdown with emoji categories:
|
|||
|
||||
## 🎯 When to Use Which
|
||||
|
||||
| Method | Use Case | Pros | Cons |
|
||||
|--------|----------|------|------|
|
||||
| **Helper Script** | Normal releases | Foolproof, automatic | Requires script |
|
||||
| **Auto-Tag Workflow** | Forgot script | Safety net, automatic tagging | Still need manifest bump |
|
||||
| **GitHub Button** | Manual quick release | Easy, no script | Limited categorization |
|
||||
| **Local Script** | Testing release notes | Preview before release | Manual process |
|
||||
| **CI/CD** | After tag push | Fully automatic | Needs tag first |
|
||||
| Method | Use Case | Pros | Cons |
|
||||
| --------------------- | --------------------- | ----------------------------- | ------------------------ |
|
||||
| **Helper Script** | Normal releases | Foolproof, automatic | Requires script |
|
||||
| **Auto-Tag Workflow** | Forgot script | Safety net, automatic tagging | Still need manifest bump |
|
||||
| **GitHub Button** | Manual quick release | Easy, no script | Limited categorization |
|
||||
| **Local Script** | Testing release notes | Preview before release | Manual process |
|
||||
| **CI/CD** | After tag push | Fully automatic | Needs tag first |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -219,6 +221,7 @@ git push origin main v0.3.0
|
|||
```
|
||||
|
||||
**What happens:**
|
||||
|
||||
1. Script bumps manifest.json → commits → creates tag locally
|
||||
2. You push commit + tag together
|
||||
3. Release workflow sees tag → generates notes → creates release
|
||||
|
|
@ -242,6 +245,7 @@ git push
|
|||
```
|
||||
|
||||
**What happens:**
|
||||
|
||||
1. You push manifest.json change
|
||||
2. Auto-Tag workflow detects change → creates tag automatically
|
||||
3. Release workflow sees new tag → creates release
|
||||
|
|
@ -263,6 +267,7 @@ git push origin main v0.3.0
|
|||
```
|
||||
|
||||
**What happens:**
|
||||
|
||||
1. You create and push tag manually
|
||||
2. Release workflow creates release
|
||||
3. Auto-Tag workflow skips (tag already exists)
|
||||
|
|
@ -282,19 +287,24 @@ git push origin main v0.3.0
|
|||
## 🛡️ Safety Features
|
||||
|
||||
### 1. **Version Validation**
|
||||
|
||||
Both helper script and auto-tag workflow validate version format (X.Y.Z).
|
||||
|
||||
### 2. **No Duplicate Tags**
|
||||
|
||||
- Helper script checks if tag exists (local + remote)
|
||||
- Auto-tag workflow checks if tag exists before creating
|
||||
|
||||
### 3. **Atomic Operations**
|
||||
|
||||
Helper script creates commit + tag locally. You decide when to push.
|
||||
|
||||
### 4. **Version Bumps Filtered**
|
||||
|
||||
Release notes automatically exclude `chore(release): bump version` commits.
|
||||
|
||||
### 5. **Rollback Instructions**
|
||||
|
||||
Helper script shows how to undo if you change your mind.
|
||||
|
||||
---
|
||||
|
|
@ -330,6 +340,7 @@ git push -f origin main v0.3.0
|
|||
**Auto-tag didn't create tag:**
|
||||
|
||||
Check workflow runs in GitHub Actions. Common causes:
|
||||
|
||||
- Tag already exists remotely
|
||||
- Invalid version format in manifest.json
|
||||
- manifest.json not in the commit that was pushed
|
||||
|
|
@ -348,13 +359,14 @@ Check workflow runs in GitHub Actions. Common causes:
|
|||
## 💡 Tips
|
||||
|
||||
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
|
||||
|
||||
Impact: Users can now do X and Y.
|
||||
```
|
||||
Detailed description of what changed.
|
||||
|
||||
Impact: Users can now do X and Y.
|
||||
```
|
||||
|
||||
2. **Impact Section:** Add `Impact:` in commit body for user-friendly descriptions
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ The Tibber Prices integration includes a proactive repair notification system th
|
|||
The repairs system is implemented in `coordinator/repairs.py` via the `TibberPricesRepairManager` class, which is instantiated in the coordinator and integrated into the update cycle.
|
||||
|
||||
**Design Principles:**
|
||||
|
||||
- **Proactive**: Detect issues before they become critical
|
||||
- **User-friendly**: Clear explanations with actionable guidance
|
||||
- **Auto-clearing**: Repairs automatically disappear when conditions resolve
|
||||
|
|
@ -19,10 +20,12 @@ The repairs system is implemented in `coordinator/repairs.py` via the `TibberPri
|
|||
**Issue ID:** `tomorrow_data_missing_{entry_id}`
|
||||
|
||||
**When triggered:**
|
||||
|
||||
- Current time is after 18:00 (configurable via `TOMORROW_DATA_WARNING_HOUR`)
|
||||
- Tomorrow's electricity price data is still not available
|
||||
|
||||
**When cleared:**
|
||||
|
||||
- Tomorrow's data becomes available
|
||||
- Automatically checks on every successful API update
|
||||
|
||||
|
|
@ -30,6 +33,7 @@ The repairs system is implemented in `coordinator/repairs.py` via the `TibberPri
|
|||
Users cannot plan ahead for tomorrow's electricity usage optimization. Automations relying on tomorrow's prices will not work.
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```python
|
||||
# In coordinator update cycle
|
||||
has_tomorrow_data = self._data_fetcher.has_tomorrow_data(result["priceInfo"])
|
||||
|
|
@ -40,6 +44,7 @@ await self._repair_manager.check_tomorrow_data_availability(
|
|||
```
|
||||
|
||||
**Translation placeholders:**
|
||||
|
||||
- `home_name`: Name of the affected home
|
||||
- `warning_hour`: Hour after which warning appears (default: 18)
|
||||
|
||||
|
|
@ -48,10 +53,12 @@ await self._repair_manager.check_tomorrow_data_availability(
|
|||
**Issue ID:** `rate_limit_exceeded_{entry_id}`
|
||||
|
||||
**When triggered:**
|
||||
|
||||
- Integration encounters 3 or more consecutive rate limit errors (HTTP 429)
|
||||
- Threshold configurable via `RATE_LIMIT_WARNING_THRESHOLD`
|
||||
|
||||
**When cleared:**
|
||||
|
||||
- Successful API call completes (no rate limit error)
|
||||
- Error counter resets to 0
|
||||
|
||||
|
|
@ -59,6 +66,7 @@ await self._repair_manager.check_tomorrow_data_availability(
|
|||
API requests are being throttled, causing stale data. Updates may be delayed until rate limit expires.
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```python
|
||||
# In error handler
|
||||
is_rate_limit = (
|
||||
|
|
@ -74,6 +82,7 @@ await self._repair_manager.clear_rate_limit_tracking()
|
|||
```
|
||||
|
||||
**Translation placeholders:**
|
||||
|
||||
- `home_name`: Name of the affected home
|
||||
- `error_count`: Number of consecutive rate limit errors
|
||||
|
||||
|
|
@ -82,10 +91,12 @@ await self._repair_manager.clear_rate_limit_tracking()
|
|||
**Issue ID:** `home_not_found_{entry_id}`
|
||||
|
||||
**When triggered:**
|
||||
|
||||
- Home configured in this integration is no longer present in Tibber account
|
||||
- Detected during user data refresh (daily check)
|
||||
|
||||
**When cleared:**
|
||||
|
||||
- Home reappears in Tibber account (unlikely - manual cleanup expected)
|
||||
- Integration entry is removed (shutdown cleanup)
|
||||
|
||||
|
|
@ -93,6 +104,7 @@ await self._repair_manager.clear_rate_limit_tracking()
|
|||
Integration cannot fetch data for a non-existent home. User must remove the config entry and re-add if needed.
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```python
|
||||
# After user data update
|
||||
home_exists = self._data_fetcher._check_home_exists(home_id)
|
||||
|
|
@ -103,6 +115,7 @@ else:
|
|||
```
|
||||
|
||||
**Translation placeholders:**
|
||||
|
||||
- `home_name`: Name of the missing home
|
||||
- `entry_id`: Config entry ID for reference
|
||||
|
||||
|
|
@ -153,6 +166,7 @@ Each repair type maintains internal state to avoid redundant operations:
|
|||
### Lifecycle Integration
|
||||
|
||||
**Coordinator Initialization:**
|
||||
|
||||
```python
|
||||
self._repair_manager = TibberPricesRepairManager(
|
||||
hass=hass,
|
||||
|
|
@ -162,6 +176,7 @@ self._repair_manager = TibberPricesRepairManager(
|
|||
```
|
||||
|
||||
**Update Cycle Integration:**
|
||||
|
||||
```python
|
||||
# Success path - check conditions
|
||||
if result and "priceInfo" in result:
|
||||
|
|
@ -178,6 +193,7 @@ if is_rate_limit:
|
|||
```
|
||||
|
||||
**Shutdown Cleanup:**
|
||||
|
||||
```python
|
||||
async def async_shutdown(self) -> None:
|
||||
"""Shut down coordinator and clean up."""
|
||||
|
|
@ -196,24 +212,27 @@ Repairs use Home Assistant's standard translation system. Translations are defin
|
|||
- `/translations/sv.json`
|
||||
|
||||
**Structure:**
|
||||
|
||||
```json
|
||||
{
|
||||
"issues": {
|
||||
"tomorrow_data_missing": {
|
||||
"title": "Tomorrow's price data missing for {home_name}",
|
||||
"description": "Detailed explanation with multiple paragraphs...\n\nPossible causes:\n- Cause 1\n- Cause 2"
|
||||
"issues": {
|
||||
"tomorrow_data_missing": {
|
||||
"title": "Tomorrow's price data missing for {home_name}",
|
||||
"description": "Detailed explanation with multiple paragraphs...\n\nPossible causes:\n- Cause 1\n- Cause 2"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Home Assistant Integration
|
||||
|
||||
Repairs appear in:
|
||||
|
||||
- **Settings → System → Repairs** (main repairs panel)
|
||||
- **Notifications** (bell icon in UI shows repair count)
|
||||
|
||||
Repair properties:
|
||||
|
||||
- **`is_fixable=False`**: No automated fix available (user action required)
|
||||
- **`severity=IssueSeverity.WARNING`**: Yellow warning level (not critical)
|
||||
- **`translation_key`**: References `issues.{key}` in translation files
|
||||
|
|
@ -228,6 +247,7 @@ Repair properties:
|
|||
4. When tomorrow data arrives (next API fetch), repair clears
|
||||
|
||||
**Manual trigger:**
|
||||
|
||||
```python
|
||||
# Temporarily set warning hour to current hour for testing
|
||||
TOMORROW_DATA_WARNING_HOUR = datetime.now().hour
|
||||
|
|
@ -240,6 +260,7 @@ TOMORROW_DATA_WARNING_HOUR = datetime.now().hour
|
|||
3. Successful API call clears the repair
|
||||
|
||||
**Manual test:**
|
||||
|
||||
- Reduce API polling interval to trigger rate limiting
|
||||
- Or temporarily return HTTP 429 in API client
|
||||
|
||||
|
|
@ -263,6 +284,7 @@ To add a new repair type:
|
|||
7. **Document** in this file
|
||||
|
||||
**Example template:**
|
||||
|
||||
```python
|
||||
async def check_new_condition(self, *, param: bool) -> None:
|
||||
"""Check new condition and create/clear repair."""
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@
|
|||
|
||||
## Prerequisites
|
||||
|
||||
- VS Code with Dev Container support
|
||||
- Docker installed and running
|
||||
- GitHub account (for Tibber API token)
|
||||
- VS Code with Dev Container support
|
||||
- Docker installed and running
|
||||
- GitHub account (for Tibber API token)
|
||||
|
||||
## Quick Setup
|
||||
|
||||
|
|
@ -26,11 +26,11 @@ code .
|
|||
|
||||
The DevContainer includes:
|
||||
|
||||
- Python 3.13 with `.venv` at `/home/vscode/.venv/`
|
||||
- `uv` package manager (fast, modern Python tooling)
|
||||
- Home Assistant development dependencies
|
||||
- Ruff linter/formatter
|
||||
- Git, GitHub CLI, Node.js, Rust toolchain
|
||||
- Python 3.13 with `.venv` at `/home/vscode/.venv/`
|
||||
- `uv` package manager (fast, modern Python tooling)
|
||||
- Home Assistant development dependencies
|
||||
- Ruff linter/formatter
|
||||
- Git, GitHub CLI, Node.js, Rust toolchain
|
||||
|
||||
## Running the Integration
|
||||
|
||||
|
|
|
|||
|
|
@ -13,10 +13,10 @@ Before running tests or committing changes, validate the integration structure:
|
|||
|
||||
This lightweight script checks:
|
||||
|
||||
- ✓ `config_flow.py` exists
|
||||
- ✓ `manifest.json` is valid JSON with required fields
|
||||
- ✓ Translation files have valid JSON syntax
|
||||
- ✓ All Python files compile without syntax errors
|
||||
- ✓ `config_flow.py` exists
|
||||
- ✓ `manifest.json` is valid JSON with required fields
|
||||
- ✓ Translation files have valid JSON syntax
|
||||
- ✓ All Python files compile without syntax errors
|
||||
|
||||
**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:
|
||||
|
||||
- Configuration flow
|
||||
- Sensor states and attributes
|
||||
- Services
|
||||
- Translation strings
|
||||
- Configuration flow
|
||||
- Sensor states and attributes
|
||||
- Services
|
||||
- Translation strings
|
||||
|
||||
## Test Guidelines
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
| Timer | Type | Interval | Purpose | Trigger Method |
|
||||
|-------|------|----------|---------|----------------|
|
||||
| **Timer #1** | HA built-in | 15 minutes | API data updates | `DataUpdateCoordinator` |
|
||||
| **Timer #2** | Custom | :00, :15, :30, :45 | Entity state refresh | `async_track_utc_time_change()` |
|
||||
| **Timer #3** | Custom | Every minute | Countdown/progress | `async_track_utc_time_change()` |
|
||||
| Timer | Type | Interval | Purpose | Trigger Method |
|
||||
| ------------ | ----------- | ------------------ | -------------------- | ------------------------------- |
|
||||
| **Timer #1** | HA built-in | 15 minutes | API data updates | `DataUpdateCoordinator` |
|
||||
| **Timer #2** | Custom | :00, :15, :30, :45 | Entity state refresh | `async_track_utc_time_change()` |
|
||||
| **Timer #3** | Custom | Every minute | Countdown/progress | `async_track_utc_time_change()` |
|
||||
|
||||
**Key principle:** Timer #1 (HA) controls **data fetching**, Timer #2 controls **entity updates**, Timer #3 controls **timing displays**.
|
||||
|
||||
|
|
@ -27,6 +27,7 @@ The integration uses **three independent timer mechanisms** for different purpos
|
|||
**Type:** Home Assistant's built-in `DataUpdateCoordinator` with `UPDATE_INTERVAL = 15 minutes`
|
||||
|
||||
**What it is:**
|
||||
|
||||
- HA provides this timer system automatically when you inherit from `DataUpdateCoordinator`
|
||||
- Triggers `_async_update_data()` method every 15 minutes
|
||||
- **Not** synchronized to clock boundaries (each installation has different start time)
|
||||
|
|
@ -53,16 +54,19 @@ async def _async_update_data(self) -> TibberPricesData:
|
|||
```
|
||||
|
||||
**Load Distribution:**
|
||||
|
||||
- Each HA installation starts Timer #1 at different times → natural distribution
|
||||
- Tomorrow data check adds 0-30s random delay → prevents "thundering herd" on Tibber API
|
||||
- Result: API load spread over ~30 minutes instead of all at once
|
||||
|
||||
**Midnight Coordination:**
|
||||
|
||||
- Atomic check: `_check_midnight_turnover_needed(now)` compares dates only (no side effects)
|
||||
- If midnight turnover needed → performs it and returns early
|
||||
- Timer #2 will see turnover already done and skip gracefully
|
||||
|
||||
**Why we use HA's timer:**
|
||||
|
||||
- Automatic restart after HA restart
|
||||
- Built-in retry logic for temporary failures
|
||||
- Standard HA integration pattern
|
||||
|
|
@ -79,6 +83,7 @@ async def _async_update_data(self) -> TibberPricesData:
|
|||
**Purpose:** Update time-sensitive entity states at interval boundaries **without waiting for API poll**
|
||||
|
||||
**Problem it solves:**
|
||||
|
||||
- Timer #1 runs every 15 minutes but NOT synchronized to clock (:03, :18, :33, :48)
|
||||
- Current price changes at :00, :15, :30, :45 → entities would show stale data for up to 15 minutes
|
||||
- Example: 14:00 new price, but Timer #1 ran at 13:58 → next update at 14:13 → users see old price until 14:13
|
||||
|
|
@ -100,22 +105,26 @@ async def _handle_quarter_hour_refresh(self, now: datetime) -> None:
|
|||
```
|
||||
|
||||
**Smart Boundary Tolerance:**
|
||||
|
||||
- Uses `round_to_nearest_quarter_hour()` with ±2 second tolerance
|
||||
- HA may schedule timer at 14:59:58 → rounds to 15:00:00 (shows new interval)
|
||||
- HA restart at 14:59:30 → stays at 14:45:00 (shows current interval)
|
||||
- See [Architecture](./architecture.md#3-quarter-hour-precision) for details
|
||||
|
||||
**Absolute Time Scheduling:**
|
||||
|
||||
- `async_track_utc_time_change()` plans for **all future boundaries** (15:00, 15:15, 15:30, ...)
|
||||
- NOT relative delays ("in 15 minutes")
|
||||
- If triggered at 14:59:58 → next trigger is 15:15:00, NOT 15:00:00 (prevents double updates)
|
||||
|
||||
**Which entities listen:**
|
||||
|
||||
- All sensors that depend on "current interval" (e.g., `current_interval_price`, `next_interval_price`)
|
||||
- Binary sensors that check "is now in period?" (e.g., `best_price_period_active`)
|
||||
- ~50-60 entities out of 120+ total
|
||||
|
||||
**Why custom timer:**
|
||||
|
||||
- HA's built-in coordinator doesn't support exact boundary timing
|
||||
- We need **absolute time** triggers, not periodic intervals
|
||||
- Allows fast entity updates without expensive data transformation
|
||||
|
|
@ -140,6 +149,7 @@ async def _handle_minute_refresh(self, now: datetime) -> None:
|
|||
```
|
||||
|
||||
**Which entities listen:**
|
||||
|
||||
- `best_price_remaining_minutes` - Countdown timer
|
||||
- `peak_price_remaining_minutes` - Countdown timer
|
||||
- `best_price_progress` - Progress bar (0-100%)
|
||||
|
|
@ -147,11 +157,13 @@ async def _handle_minute_refresh(self, now: datetime) -> None:
|
|||
- ~10 entities total
|
||||
|
||||
**Why custom timer:**
|
||||
|
||||
- Users want smooth countdowns (not jumping 15 minutes at a time)
|
||||
- Progress bars need minute-by-minute updates
|
||||
- Very lightweight (no data processing, just state recalculation)
|
||||
|
||||
**Why NOT every second:**
|
||||
|
||||
- Minute precision sufficient for countdown UX
|
||||
- Reduces CPU load (60× fewer updates than seconds)
|
||||
- Home Assistant best practice (avoid sub-minute updates)
|
||||
|
|
@ -194,6 +206,7 @@ class ListenerManager:
|
|||
```
|
||||
|
||||
**Why this pattern:**
|
||||
|
||||
- Decouples timer logic from entity logic
|
||||
- One timer can notify many entities efficiently
|
||||
- Entities can unregister when removed (cleanup)
|
||||
|
|
@ -279,11 +292,13 @@ class ListenerManager:
|
|||
### Reason 1: Load Distribution on Tibber API
|
||||
|
||||
If all installations used synchronized timers:
|
||||
|
||||
- ❌ Everyone fetches at 13:00:00 → Tibber API overload
|
||||
- ❌ Everyone fetches at 14:00:00 → Tibber API overload
|
||||
- ❌ "Thundering herd" problem
|
||||
|
||||
With HA's unsynchronized timer:
|
||||
|
||||
- ✅ Installation A: 13:03:12, 13:18:12, 13:33:12, ...
|
||||
- ✅ Installation B: 13:07:45, 13:22:45, 13:37:45, ...
|
||||
- ✅ Installation C: 13:11:28, 13:26:28, 13:41:28, ...
|
||||
|
|
@ -316,6 +331,7 @@ def _should_update_price_data(self) -> str:
|
|||
**Most Timer #1 cycles:** Fast path (~2ms), no API call, just returns cached data.
|
||||
|
||||
**API fetch only when:**
|
||||
|
||||
- Tomorrow data missing/invalid (after 13:00)
|
||||
- Cache expired (midnight turnover)
|
||||
- Explicit user refresh
|
||||
|
|
@ -339,6 +355,7 @@ def _should_update_price_data(self) -> str:
|
|||
## Performance Characteristics
|
||||
|
||||
### Timer #1 (DataUpdateCoordinator)
|
||||
|
||||
- **Triggers:** Every 15 minutes (unsynchronized)
|
||||
- **Fast path:** ~2ms (cache check, return existing data)
|
||||
- **Slow path:** ~600ms (API fetch + transform + calculate)
|
||||
|
|
@ -346,12 +363,14 @@ def _should_update_price_data(self) -> str:
|
|||
- **API calls:** ~1-2 times/day (cached otherwise)
|
||||
|
||||
### Timer #2 (Quarter-Hour Refresh)
|
||||
|
||||
- **Triggers:** 96 times/day (exact boundaries)
|
||||
- **Processing:** ~5ms (notify 60 entities)
|
||||
- **No API calls:** Uses cached/transformed data
|
||||
- **No transformation:** Just entity state updates
|
||||
|
||||
### Timer #3 (Minute Refresh)
|
||||
|
||||
- **Triggers:** 1440 times/day (every minute)
|
||||
- **Processing:** ~1ms (notify 10 entities)
|
||||
- **No API calls:** No data processing at all
|
||||
|
|
@ -393,16 +412,16 @@ _LOGGER.setLevel(logging.DEBUG)
|
|||
### Common Issues
|
||||
|
||||
1. **Timer #2 not triggering:**
|
||||
- Check: `schedule_quarter_hour_refresh()` called in `__init__`?
|
||||
- Check: `_quarter_hour_timer_cancel` properly stored?
|
||||
- Check: `schedule_quarter_hour_refresh()` called in `__init__`?
|
||||
- Check: `_quarter_hour_timer_cancel` properly stored?
|
||||
|
||||
2. **Double updates at midnight:**
|
||||
- Should NOT happen (atomic coordination)
|
||||
- Check: Both timers use same date comparison logic?
|
||||
- Should NOT happen (atomic coordination)
|
||||
- Check: Both timers use same date comparison logic?
|
||||
|
||||
3. **API overload:**
|
||||
- Check: Random delay working? (0-30s jitter on tomorrow check)
|
||||
- Check: Cache validation logic correct?
|
||||
- Check: Random delay working? (0-30s jitter on tomorrow check)
|
||||
- Check: Cache validation logic correct?
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -417,17 +436,20 @@ _LOGGER.setLevel(logging.DEBUG)
|
|||
## Summary
|
||||
|
||||
**Three independent timers:**
|
||||
|
||||
1. **Timer #1** (HA built-in, 15 min, unsynchronized) → Data fetching (when needed)
|
||||
2. **Timer #2** (Custom, :00/:15/:30/:45) → Entity state updates (always)
|
||||
3. **Timer #3** (Custom, every minute) → Countdown/progress (always)
|
||||
|
||||
**Key insights:**
|
||||
|
||||
- Timer #1 unsynchronized = good (load distribution on API)
|
||||
- Timer #2 synchronized = good (user sees correct data immediately)
|
||||
- Timer #3 synchronized = good (smooth countdown UX)
|
||||
- All three coordinate gracefully (atomic midnight checks, no conflicts)
|
||||
|
||||
**"Listener" terminology:**
|
||||
|
||||
- Timer = mechanism that triggers
|
||||
- Listener = callback that gets called
|
||||
- Observer pattern = entities register, coordinator notifies
|
||||
|
|
|
|||
|
|
@ -22,30 +22,30 @@ Fetches home information and metadata:
|
|||
|
||||
```graphql
|
||||
query {
|
||||
viewer {
|
||||
homes {
|
||||
id
|
||||
appNickname
|
||||
address {
|
||||
address1
|
||||
postalCode
|
||||
city
|
||||
country
|
||||
}
|
||||
timeZone
|
||||
currentSubscription {
|
||||
priceInfo {
|
||||
current {
|
||||
currency
|
||||
}
|
||||
viewer {
|
||||
homes {
|
||||
id
|
||||
appNickname
|
||||
address {
|
||||
address1
|
||||
postalCode
|
||||
city
|
||||
country
|
||||
}
|
||||
timeZone
|
||||
currentSubscription {
|
||||
priceInfo {
|
||||
current {
|
||||
currency
|
||||
}
|
||||
}
|
||||
}
|
||||
meteringPointData {
|
||||
consumptionEan
|
||||
gridAreaCode
|
||||
}
|
||||
}
|
||||
}
|
||||
meteringPointData {
|
||||
consumptionEan
|
||||
gridAreaCode
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -56,26 +56,27 @@ query {
|
|||
Fetches quarter-hourly prices:
|
||||
|
||||
```graphql
|
||||
query($homeId: ID!) {
|
||||
viewer {
|
||||
home(id: $homeId) {
|
||||
currentSubscription {
|
||||
priceInfo {
|
||||
range(resolution: QUARTER_HOURLY, first: 384) {
|
||||
nodes {
|
||||
total
|
||||
startsAt
|
||||
level
|
||||
query ($homeId: ID!) {
|
||||
viewer {
|
||||
home(id: $homeId) {
|
||||
currentSubscription {
|
||||
priceInfo {
|
||||
range(resolution: QUARTER_HOURLY, first: 384) {
|
||||
nodes {
|
||||
total
|
||||
startsAt
|
||||
level
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `homeId`: Tibber home identifier
|
||||
- `resolution`: Always `QUARTER_HOURLY`
|
||||
- `first`: 384 intervals (4 days of data)
|
||||
|
|
@ -85,10 +86,12 @@ query($homeId: ID!) {
|
|||
## Rate Limits
|
||||
|
||||
Tibber API rate limits (as of 2024):
|
||||
|
||||
- **5000 requests per hour** per token
|
||||
- **Burst limit:** 100 requests per minute
|
||||
|
||||
Integration stays well below these limits:
|
||||
|
||||
- Polls every 15 minutes = 96 requests/day
|
||||
- User data cached for 24h = 1 request/day
|
||||
- **Total:** ~100 requests/day per home
|
||||
|
|
@ -99,13 +102,14 @@ Integration stays well below these limits:
|
|||
|
||||
```json
|
||||
{
|
||||
"total": 0.2456,
|
||||
"startsAt": "2024-12-06T14:00:00.000+01:00",
|
||||
"level": "NORMAL"
|
||||
"total": 0.2456,
|
||||
"startsAt": "2024-12-06T14:00:00.000+01:00",
|
||||
"level": "NORMAL"
|
||||
}
|
||||
```
|
||||
|
||||
**Fields:**
|
||||
|
||||
- `total`: Price including VAT and fees (currency's major unit, e.g., EUR)
|
||||
- `startsAt`: ISO 8601 timestamp with timezone
|
||||
- `level`: Tibber's own classification (VERY_CHEAP, CHEAP, NORMAL, EXPENSIVE, VERY_EXPENSIVE)
|
||||
|
|
@ -114,11 +118,12 @@ Integration stays well below these limits:
|
|||
|
||||
```json
|
||||
{
|
||||
"currency": "EUR"
|
||||
"currency": "EUR"
|
||||
}
|
||||
```
|
||||
|
||||
Supported currencies:
|
||||
|
||||
- `EUR` (Euro) - displayed as ct/kWh
|
||||
- `NOK` (Norwegian Krone) - displayed as øre/kWh
|
||||
- `SEK` (Swedish Krona) - displayed as öre/kWh
|
||||
|
|
@ -128,42 +133,52 @@ Supported currencies:
|
|||
### Common Error Responses
|
||||
|
||||
**Invalid Token:**
|
||||
|
||||
```json
|
||||
{
|
||||
"errors": [{
|
||||
"message": "Unauthorized",
|
||||
"extensions": {
|
||||
"code": "UNAUTHENTICATED"
|
||||
}
|
||||
}]
|
||||
"errors": [
|
||||
{
|
||||
"message": "Unauthorized",
|
||||
"extensions": {
|
||||
"code": "UNAUTHENTICATED"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Rate Limit Exceeded:**
|
||||
|
||||
```json
|
||||
{
|
||||
"errors": [{
|
||||
"message": "Too Many Requests",
|
||||
"extensions": {
|
||||
"code": "RATE_LIMIT_EXCEEDED"
|
||||
}
|
||||
}]
|
||||
"errors": [
|
||||
{
|
||||
"message": "Too Many Requests",
|
||||
"extensions": {
|
||||
"code": "RATE_LIMIT_EXCEEDED"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Home Not Found:**
|
||||
|
||||
```json
|
||||
{
|
||||
"errors": [{
|
||||
"message": "Home not found",
|
||||
"extensions": {
|
||||
"code": "NOT_FOUND"
|
||||
}
|
||||
}]
|
||||
"errors": [
|
||||
{
|
||||
"message": "Home not found",
|
||||
"extensions": {
|
||||
"code": "NOT_FOUND"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Integration handles these with:
|
||||
|
||||
- Exponential backoff retry (3 attempts)
|
||||
- ConfigEntryAuthFailed for auth errors
|
||||
- ConfigEntryNotReady for temporary failures
|
||||
|
|
@ -171,6 +186,7 @@ Integration handles these with:
|
|||
## Data Transformation
|
||||
|
||||
Raw API data is enriched with:
|
||||
|
||||
- **Trailing 24h average** - Calculated from previous intervals
|
||||
- **Leading 24h average** - Calculated from future intervals
|
||||
- **Price difference %** - Deviation from average
|
||||
|
|
@ -181,6 +197,7 @@ See `utils/price.py` for enrichment logic.
|
|||
---
|
||||
|
||||
💡 **External Resources:**
|
||||
|
||||
- [Tibber API Documentation](https://developer.tibber.com/docs/overview)
|
||||
- [GraphQL Explorer](https://developer.tibber.com/explorer)
|
||||
- [Get API Token](https://developer.tibber.com/settings/access-token)
|
||||
|
|
|
|||
|
|
@ -100,43 +100,43 @@ flowchart TB
|
|||
### Flow Description
|
||||
|
||||
1. **Setup** (`__init__.py`)
|
||||
- Integration loads, creates coordinator instance
|
||||
- Registers entity platforms (sensor, binary_sensor)
|
||||
- Sets up custom services
|
||||
- Integration loads, creates coordinator instance
|
||||
- Registers entity platforms (sensor, binary_sensor)
|
||||
- Sets up custom services
|
||||
|
||||
2. **Data Fetch** (every 15 minutes)
|
||||
- Coordinator triggers update via `api.py`
|
||||
- API client checks **persistent cache** first (`coordinator/cache.py`)
|
||||
- If cache valid → return cached data
|
||||
- If cache stale → query Tibber GraphQL API
|
||||
- Store fresh data in persistent cache (survives HA restart)
|
||||
- Coordinator triggers update via `api.py`
|
||||
- API client checks **persistent cache** first (`coordinator/cache.py`)
|
||||
- If cache valid → return cached data
|
||||
- If cache stale → query Tibber GraphQL API
|
||||
- Store fresh data in persistent cache (survives HA restart)
|
||||
|
||||
3. **Price Enrichment**
|
||||
- Coordinator passes raw prices to `DataTransformer`
|
||||
- Transformer checks **transformation cache** (memory)
|
||||
- If cache valid → return enriched data
|
||||
- If cache invalid → enrich via `price_utils.py` + `average_utils.py`
|
||||
- Calculate 24h trailing/leading averages
|
||||
- Calculate price differences (% from average)
|
||||
- Assign rating levels (LOW/NORMAL/HIGH)
|
||||
- Store enriched data in transformation cache
|
||||
- Coordinator passes raw prices to `DataTransformer`
|
||||
- Transformer checks **transformation cache** (memory)
|
||||
- If cache valid → return enriched data
|
||||
- If cache invalid → enrich via `price_utils.py` + `average_utils.py`
|
||||
- Calculate 24h trailing/leading averages
|
||||
- Calculate price differences (% from average)
|
||||
- Assign rating levels (LOW/NORMAL/HIGH)
|
||||
- Store enriched data in transformation cache
|
||||
|
||||
4. **Period Calculation**
|
||||
- Coordinator passes enriched data to `PeriodCalculator`
|
||||
- Calculator computes **hash** from prices + config
|
||||
- If hash matches cache → return cached periods
|
||||
- If hash differs → recalculate best/peak price periods
|
||||
- Store periods with new hash
|
||||
- Coordinator passes enriched data to `PeriodCalculator`
|
||||
- Calculator computes **hash** from prices + config
|
||||
- If hash matches cache → return cached periods
|
||||
- If hash differs → recalculate best/peak price periods
|
||||
- Store periods with new hash
|
||||
|
||||
5. **Entity Updates**
|
||||
- Coordinator provides complete data (prices + periods)
|
||||
- Sensors read values via unified handlers
|
||||
- Binary sensors evaluate period states
|
||||
- Entities update on quarter-hour boundaries (00/15/30/45)
|
||||
- Coordinator provides complete data (prices + periods)
|
||||
- Sensors read values via unified handlers
|
||||
- Binary sensors evaluate period states
|
||||
- Entities update on quarter-hour boundaries (00/15/30/45)
|
||||
|
||||
6. **Service Calls**
|
||||
- Custom services access coordinator data directly
|
||||
- Return formatted responses (JSON, ApexCharts format)
|
||||
- Custom services access coordinator data directly
|
||||
- Return formatted responses (JSON, ApexCharts format)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -146,13 +146,13 @@ flowchart TB
|
|||
|
||||
The integration uses **5 independent caching layers** for optimal performance:
|
||||
|
||||
| Layer | Location | Lifetime | Invalidation | Memory |
|
||||
|-------|----------|----------|--------------|--------|
|
||||
| **API Cache** | `coordinator/cache.py` | 24h (user)<br/>Until midnight (prices) | Automatic | 50KB |
|
||||
| **Translation Cache** | `const.py` | Until HA restart | Never | 5KB |
|
||||
| **Config Cache** | `coordinator/*` | Until config change | Explicit | 1KB |
|
||||
| **Period Cache** | `coordinator/periods.py` | Until data/config change | Hash-based | 10KB |
|
||||
| **Transformation Cache** | `coordinator/data_transformation.py` | Until midnight/config | Automatic | 60KB |
|
||||
| Layer | Location | Lifetime | Invalidation | Memory |
|
||||
| ------------------------ | ------------------------------------ | -------------------------------------- | ------------ | ------ |
|
||||
| **API Cache** | `coordinator/cache.py` | 24h (user)<br/>Until midnight (prices) | Automatic | 50KB |
|
||||
| **Translation Cache** | `const.py` | Until HA restart | Never | 5KB |
|
||||
| **Config Cache** | `coordinator/*` | Until config change | Explicit | 1KB |
|
||||
| **Period Cache** | `coordinator/periods.py` | Until data/config change | Hash-based | 10KB |
|
||||
| **Transformation Cache** | `coordinator/data_transformation.py` | Until midnight/config | Automatic | 60KB |
|
||||
|
||||
**Total cache overhead:** ~126KB per coordinator instance (main entry + subentries)
|
||||
|
||||
|
|
@ -195,30 +195,31 @@ For detailed cache behavior, see [Caching Strategy](./caching-strategy.md).
|
|||
|
||||
### Core Components
|
||||
|
||||
| Component | File | Responsibility |
|
||||
|-----------|------|----------------|
|
||||
| **API Client** | `api.py` | GraphQL queries to Tibber, retry logic, error handling |
|
||||
| **Coordinator** | `coordinator.py` | Update orchestration, cache management, absolute-time scheduling with boundary tolerance |
|
||||
| **Data Transformer** | `coordinator/data_transformation.py` | Price enrichment (averages, ratings, differences) |
|
||||
| **Period Calculator** | `coordinator/periods.py` | Best/peak price period calculation with relaxation |
|
||||
| **Sensors** | `sensor/` | 80+ entities for prices, levels, ratings, statistics |
|
||||
| **Binary Sensors** | `binary_sensor/` | Period indicators (best/peak price active) |
|
||||
| **Services** | `services/` | Custom service endpoints (get_chartdata, get_apexcharts_yaml, refresh_user_data) |
|
||||
| Component | File | Responsibility |
|
||||
| --------------------- | ------------------------------------ | ---------------------------------------------------------------------------------------- |
|
||||
| **API Client** | `api.py` | GraphQL queries to Tibber, retry logic, error handling |
|
||||
| **Coordinator** | `coordinator.py` | Update orchestration, cache management, absolute-time scheduling with boundary tolerance |
|
||||
| **Data Transformer** | `coordinator/data_transformation.py` | Price enrichment (averages, ratings, differences) |
|
||||
| **Period Calculator** | `coordinator/periods.py` | Best/peak price period calculation with relaxation |
|
||||
| **Sensors** | `sensor/` | 80+ entities for prices, levels, ratings, statistics |
|
||||
| **Binary Sensors** | `binary_sensor/` | Period indicators (best/peak price active) |
|
||||
| **Services** | `services/` | Custom service endpoints (get_chartdata, get_apexcharts_yaml, refresh_user_data) |
|
||||
|
||||
### Sensor Architecture (Calculator Pattern)
|
||||
|
||||
The sensor platform uses **Calculator Pattern** for clean separation of concerns (refactored Nov 2025):
|
||||
|
||||
| Component | Files | Lines | Responsibility |
|
||||
|-----------|-------|-------|----------------|
|
||||
| **Entity Class** | `sensor/core.py` | 909 | Entity lifecycle, coordinator, delegates to calculators |
|
||||
| **Calculators** | `sensor/calculators/` | 1,838 | Business logic (8 specialized calculators) |
|
||||
| **Attributes** | `sensor/attributes/` | 1,209 | State presentation (8 specialized modules) |
|
||||
| **Routing** | `sensor/value_getters.py` | 276 | Centralized sensor → calculator mapping |
|
||||
| **Chart Export** | `sensor/chart_data.py` | 144 | Service call handling, YAML parsing |
|
||||
| **Helpers** | `sensor/helpers.py` | 188 | Aggregation functions, utilities |
|
||||
| Component | Files | Lines | Responsibility |
|
||||
| ---------------- | ------------------------- | ----- | ------------------------------------------------------- |
|
||||
| **Entity Class** | `sensor/core.py` | 909 | Entity lifecycle, coordinator, delegates to calculators |
|
||||
| **Calculators** | `sensor/calculators/` | 1,838 | Business logic (8 specialized calculators) |
|
||||
| **Attributes** | `sensor/attributes/` | 1,209 | State presentation (8 specialized modules) |
|
||||
| **Routing** | `sensor/value_getters.py` | 276 | Centralized sensor → calculator mapping |
|
||||
| **Chart Export** | `sensor/chart_data.py` | 144 | Service call handling, YAML parsing |
|
||||
| **Helpers** | `sensor/helpers.py` | 188 | Aggregation functions, utilities |
|
||||
|
||||
**Calculator Package** (`sensor/calculators/`):
|
||||
|
||||
- `base.py` - Abstract BaseCalculator with coordinator access
|
||||
- `interval.py` - Single interval calculations (current/next/previous)
|
||||
- `rolling_hour.py` - 5-interval rolling windows
|
||||
|
|
@ -230,6 +231,7 @@ The sensor platform uses **Calculator Pattern** for clean separation of concerns
|
|||
- `metadata.py` - Home/metering metadata
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- 58% reduction in core.py (2,170 → 909 lines)
|
||||
- Clear separation: Calculators (logic) vs Attributes (presentation)
|
||||
- Independent testability for each calculator
|
||||
|
|
@ -237,12 +239,12 @@ The sensor platform uses **Calculator Pattern** for clean separation of concerns
|
|||
|
||||
### Helper Utilities
|
||||
|
||||
| Utility | File | Purpose |
|
||||
|---------|------|---------|
|
||||
| **Price Utils** | `utils/price.py` | Rating calculation, enrichment, level aggregation |
|
||||
| **Average Utils** | `utils/average.py` | Trailing/leading 24h average calculations |
|
||||
| **Entity Utils** | `entity_utils/` | Shared icon/color/attribute logic |
|
||||
| **Translations** | `const.py` | Translation loading and caching |
|
||||
| Utility | File | Purpose |
|
||||
| ----------------- | ------------------ | ------------------------------------------------- |
|
||||
| **Price Utils** | `utils/price.py` | Rating calculation, enrichment, level aggregation |
|
||||
| **Average Utils** | `utils/average.py` | Trailing/leading 24h average calculations |
|
||||
| **Entity Utils** | `entity_utils/` | Shared icon/color/attribute logic |
|
||||
| **Translations** | `const.py` | Translation loading and caching |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -283,12 +285,12 @@ All quarter-hourly price intervals get augmented via `utils/price.py`:
|
|||
- **API polling**: Every 15 minutes (coordinator fetch cycle)
|
||||
- **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)`
|
||||
- HA may trigger ±few milliseconds before/after exact boundary
|
||||
- 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 restarts at 14:59:30 → stays at 14:45:00 (shows current interval data)
|
||||
- HA may trigger ±few milliseconds before/after exact boundary
|
||||
- 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 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)
|
||||
- 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
|
||||
|
||||
### 4. Calculator Pattern (Sensor Platform)
|
||||
|
|
@ -296,26 +298,31 @@ All quarter-hourly price intervals get augmented via `utils/price.py`:
|
|||
Sensors organized by **calculation method** (refactored Nov 2025):
|
||||
|
||||
**Unified Handler Methods** (`sensor/core.py`):
|
||||
|
||||
- `_get_interval_value(offset, type)` - current/next/previous intervals
|
||||
- `_get_rolling_hour_value(offset, type)` - 5-interval rolling windows
|
||||
- `_get_daily_stat_value(day, stat_func)` - calendar day min/max/avg
|
||||
- `_get_24h_window_value(stat_func)` - trailing/leading statistics
|
||||
|
||||
**Routing** (`sensor/value_getters.py`):
|
||||
|
||||
- Single source of truth mapping 80+ entity keys to calculator methods
|
||||
- Organized by calculation type (Interval, Rolling Hour, Daily Stats, etc.)
|
||||
|
||||
**Calculators** (`sensor/calculators/`):
|
||||
|
||||
- Each calculator inherits from `BaseCalculator` with coordinator access
|
||||
- Focused responsibility: `IntervalCalculator`, `TrendCalculator`, etc.
|
||||
- Complex logic isolated (e.g., `TrendCalculator` has internal caching)
|
||||
|
||||
**Attributes** (`sensor/attributes/`):
|
||||
|
||||
- Separate from business logic, handles state presentation
|
||||
- Builds extra_state_attributes dicts for entity classes
|
||||
- Unified builders: `build_sensor_attributes()`, `build_extra_state_attributes()`
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- Minimal code duplication across 80+ sensors
|
||||
- Clear separation of concerns (calculation vs presentation)
|
||||
- Easy to extend: Add sensor → choose pattern → add to routing
|
||||
|
|
@ -333,12 +340,12 @@ Sensors organized by **calculation method** (refactored Nov 2025):
|
|||
|
||||
### CPU Optimization
|
||||
|
||||
| Optimization | Location | Savings |
|
||||
|--------------|----------|---------|
|
||||
| Config caching | `coordinator/*` | ~50% on config checks |
|
||||
| Period caching | `coordinator/periods.py` | ~70% on period recalculation |
|
||||
| Lazy logging | Throughout | ~15% on log-heavy operations |
|
||||
| Import optimization | Module structure | ~20% faster loading |
|
||||
| Optimization | Location | Savings |
|
||||
| ------------------- | ------------------------ | ---------------------------- |
|
||||
| Config caching | `coordinator/*` | ~50% on config checks |
|
||||
| Period caching | `coordinator/periods.py` | ~70% on period recalculation |
|
||||
| Lazy logging | Throughout | ~15% on log-heavy operations |
|
||||
| Import optimization | Module structure | ~20% faster loading |
|
||||
|
||||
### Memory Usage
|
||||
|
||||
|
|
|
|||
|
|
@ -24,11 +24,13 @@ The integration uses **4 distinct caching layers** with different purposes and l
|
|||
**Purpose:** Reduce API calls to Tibber by caching user data and price data between HA restarts.
|
||||
|
||||
**What is cached:**
|
||||
|
||||
- **Price data** (`price_data`): Day before yesterday/yesterday/today/tomorrow price intervals with enriched fields (384 intervals total)
|
||||
- **User data** (`user_data`): Homes, subscriptions, features from Tibber GraphQL `viewer` query
|
||||
- **Timestamps**: Last update times for validation
|
||||
|
||||
**Lifetime:**
|
||||
|
||||
- **Price data**: Until midnight turnover (cleared daily at 00:00 local time)
|
||||
- **User data**: 24 hours (refreshed daily)
|
||||
- **Survives**: HA restarts via persistent Storage
|
||||
|
|
@ -36,29 +38,31 @@ The integration uses **4 distinct caching layers** with different purposes and l
|
|||
**Invalidation triggers:**
|
||||
|
||||
1. **Midnight turnover** (Timer #2 in coordinator):
|
||||
```python
|
||||
# coordinator/day_transitions.py
|
||||
def _handle_midnight_turnover() -> None:
|
||||
self._cached_price_data = None # Force fresh fetch for new day
|
||||
self._last_price_update = None
|
||||
await self.store_cache()
|
||||
```
|
||||
|
||||
```python
|
||||
# coordinator/day_transitions.py
|
||||
def _handle_midnight_turnover() -> None:
|
||||
self._cached_price_data = None # Force fresh fetch for new day
|
||||
self._last_price_update = None
|
||||
await self.store_cache()
|
||||
```
|
||||
|
||||
2. **Cache validation on load**:
|
||||
```python
|
||||
# coordinator/cache.py
|
||||
def is_cache_valid(cache_data: CacheData) -> bool:
|
||||
# Checks if price data is from a previous day
|
||||
if today_date < local_now.date(): # Yesterday's data
|
||||
return False
|
||||
```
|
||||
|
||||
```python
|
||||
# coordinator/cache.py
|
||||
def is_cache_valid(cache_data: CacheData) -> bool:
|
||||
# Checks if price data is from a previous day
|
||||
if today_date < local_now.date(): # Yesterday's data
|
||||
return False
|
||||
```
|
||||
|
||||
3. **Tomorrow data check** (after 13:00):
|
||||
```python
|
||||
# coordinator/data_fetching.py
|
||||
if tomorrow_missing or tomorrow_invalid:
|
||||
return "tomorrow_check" # Update needed
|
||||
```
|
||||
```python
|
||||
# coordinator/data_fetching.py
|
||||
if tomorrow_missing or tomorrow_invalid:
|
||||
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.
|
||||
|
||||
|
|
@ -71,18 +75,22 @@ The integration uses **4 distinct caching layers** with different purposes and l
|
|||
**Purpose:** Avoid repeated file I/O when accessing entity descriptions, UI strings, etc.
|
||||
|
||||
**What is cached:**
|
||||
|
||||
- **Standard translations** (`/translations/*.json`): Config flow, selector options, entity names
|
||||
- **Custom translations** (`/custom_translations/*.json`): Entity descriptions, usage tips, long descriptions
|
||||
|
||||
**Lifetime:**
|
||||
|
||||
- **Forever** (until HA restart)
|
||||
- No invalidation during runtime
|
||||
|
||||
**When populated:**
|
||||
|
||||
- At integration setup: `async_load_translations(hass, "en")` in `__init__.py`
|
||||
- Lazy loading: If translation missing, attempts file load once
|
||||
|
||||
**Access pattern:**
|
||||
|
||||
```python
|
||||
# Non-blocking synchronous access from cached data
|
||||
description = get_translation("binary_sensor.best_price_period.description", "en")
|
||||
|
|
@ -101,6 +109,7 @@ description = get_translation("binary_sensor.best_price_period.description", "en
|
|||
**What is cached:**
|
||||
|
||||
### DataTransformer Config Cache
|
||||
|
||||
```python
|
||||
{
|
||||
"thresholds": {"low": 15, "high": 35},
|
||||
|
|
@ -110,6 +119,7 @@ description = get_translation("binary_sensor.best_price_period.description", "en
|
|||
```
|
||||
|
||||
### PeriodCalculator Config Cache
|
||||
|
||||
```python
|
||||
{
|
||||
"best": {"flex": 0.15, "min_distance_from_avg": 5.0, "min_period_length": 60},
|
||||
|
|
@ -118,20 +128,23 @@ description = get_translation("binary_sensor.best_price_period.description", "en
|
|||
```
|
||||
|
||||
**Lifetime:**
|
||||
|
||||
- Until `invalidate_config_cache()` is called
|
||||
- Built once on first use per coordinator update cycle
|
||||
|
||||
**Invalidation trigger:**
|
||||
|
||||
- **Options change** (user reconfigures integration):
|
||||
```python
|
||||
# coordinator/core.py
|
||||
async def _handle_options_update(...) -> None:
|
||||
self._data_transformer.invalidate_config_cache()
|
||||
self._period_calculator.invalidate_config_cache()
|
||||
await self.async_request_refresh()
|
||||
```
|
||||
```python
|
||||
# coordinator/core.py
|
||||
async def _handle_options_update(...) -> None:
|
||||
self._data_transformer.invalidate_config_cache()
|
||||
self._period_calculator.invalidate_config_cache()
|
||||
await self.async_request_refresh()
|
||||
```
|
||||
|
||||
**Performance impact:**
|
||||
|
||||
- **Before:** ~30 dict lookups + type conversions per update = ~50μs
|
||||
- **After:** 1 cache check = ~1μs
|
||||
- **Savings:** ~98% (50μs → 1μs per update)
|
||||
|
|
@ -147,6 +160,7 @@ description = get_translation("binary_sensor.best_price_period.description", "en
|
|||
**Purpose:** Avoid expensive period calculations (~100-500ms) when price data and config haven't changed.
|
||||
|
||||
**What is cached:**
|
||||
|
||||
```python
|
||||
{
|
||||
"best_price": {
|
||||
|
|
@ -161,6 +175,7 @@ description = get_translation("binary_sensor.best_price_period.description", "en
|
|||
```
|
||||
|
||||
**Cache key:** Hash of relevant inputs
|
||||
|
||||
```python
|
||||
hash_data = (
|
||||
today_signature, # (startsAt, rating_level) for each interval
|
||||
|
|
@ -172,6 +187,7 @@ hash_data = (
|
|||
```
|
||||
|
||||
**Lifetime:**
|
||||
|
||||
- Until price data changes (today's intervals modified)
|
||||
- Until config changes (flex, thresholds, filters)
|
||||
- Recalculated at midnight (new today data)
|
||||
|
|
@ -179,24 +195,27 @@ hash_data = (
|
|||
**Invalidation triggers:**
|
||||
|
||||
1. **Config change** (explicit):
|
||||
```python
|
||||
def invalidate_config_cache() -> None:
|
||||
self._cached_periods = None
|
||||
self._last_periods_hash = None
|
||||
```
|
||||
|
||||
```python
|
||||
def invalidate_config_cache() -> None:
|
||||
self._cached_periods = None
|
||||
self._last_periods_hash = None
|
||||
```
|
||||
|
||||
2. **Price data change** (automatic via hash mismatch):
|
||||
```python
|
||||
current_hash = self._compute_periods_hash(price_info)
|
||||
if self._last_periods_hash != current_hash:
|
||||
# Cache miss - recalculate
|
||||
```
|
||||
```python
|
||||
current_hash = self._compute_periods_hash(price_info)
|
||||
if self._last_periods_hash != current_hash:
|
||||
# Cache miss - recalculate
|
||||
```
|
||||
|
||||
**Cache hit rate:**
|
||||
|
||||
- **High:** During normal operation (coordinator updates every 15min, price data unchanged)
|
||||
- **Low:** After midnight (new today data) or when tomorrow data arrives (~13:00-14:00)
|
||||
|
||||
**Performance impact:**
|
||||
|
||||
- **Period calculation:** ~100-500ms (depends on interval count, relaxation attempts)
|
||||
- **Cache hit:** `<`1ms (hash comparison + dict lookup)
|
||||
- **Savings:** ~70% of calculation time (most updates hit cache)
|
||||
|
|
@ -212,6 +231,7 @@ hash_data = (
|
|||
**Status:** ✅ **Clean separation** - enrichment only, no redundancy
|
||||
|
||||
**What is cached:**
|
||||
|
||||
```python
|
||||
{
|
||||
"timestamp": ...,
|
||||
|
|
@ -224,14 +244,16 @@ hash_data = (
|
|||
**Purpose:** Avoid re-enriching price data when config unchanged between midnight checks.
|
||||
|
||||
**Current behavior:**
|
||||
|
||||
- Caches **only enriched price data** (price + statistics)
|
||||
- **Does NOT cache periods** (handled by Period Calculation Cache)
|
||||
- Invalidated when:
|
||||
- Config changes (thresholds affect enrichment)
|
||||
- Midnight turnover detected
|
||||
- New update cycle begins
|
||||
- Config changes (thresholds affect enrichment)
|
||||
- Midnight turnover detected
|
||||
- New update cycle begins
|
||||
|
||||
**Architecture:**
|
||||
|
||||
- DataTransformer: Handles price enrichment only
|
||||
- PeriodCalculator: Handles period calculation only (with hash-based cache)
|
||||
- Coordinator: Assembles final data on-demand from both caches
|
||||
|
|
@ -243,6 +265,7 @@ hash_data = (
|
|||
## Cache Invalidation Flow
|
||||
|
||||
### User Changes Options (Config Flow)
|
||||
|
||||
```
|
||||
User saves options
|
||||
↓
|
||||
|
|
@ -267,6 +290,7 @@ Fresh data fetch with new config
|
|||
```
|
||||
|
||||
### Midnight Turnover (Day Transition)
|
||||
|
||||
```
|
||||
Timer #2 fires at 00:00
|
||||
↓
|
||||
|
|
@ -286,6 +310,7 @@ Fresh API fetch for new day
|
|||
```
|
||||
|
||||
### Tomorrow Data Arrives (~13:00)
|
||||
|
||||
```
|
||||
Coordinator update cycle
|
||||
↓
|
||||
|
|
@ -327,12 +352,14 @@ API Data Cache (price_data, user_data)
|
|||
```
|
||||
|
||||
**No cache invalidation cascades:**
|
||||
|
||||
- Config cache invalidation is **explicit** (on options update)
|
||||
- Period cache invalidation is **automatic** (via hash mismatch)
|
||||
- Transformation cache invalidation is **automatic** (on midnight/config change)
|
||||
- Translation cache is **never invalidated** (read-only after load)
|
||||
|
||||
**Thread safety:**
|
||||
|
||||
- All caches are accessed from `MainThread` only (Home Assistant event loop)
|
||||
- No locking needed (single-threaded execution model)
|
||||
|
||||
|
|
@ -341,6 +368,7 @@ API Data Cache (price_data, user_data)
|
|||
## Performance Characteristics
|
||||
|
||||
### Typical Operation (No Changes)
|
||||
|
||||
```
|
||||
Coordinator Update (every 15 min)
|
||||
├─> API fetch: SKIP (cache valid)
|
||||
|
|
@ -353,6 +381,7 @@ Total: ~16ms (down from ~600ms without caching)
|
|||
```
|
||||
|
||||
### After Midnight Turnover
|
||||
|
||||
```
|
||||
Coordinator Update (00:00)
|
||||
├─> API fetch: ~500ms (cache cleared, fetch new day)
|
||||
|
|
@ -365,6 +394,7 @@ Total: ~755ms (expected once per day)
|
|||
```
|
||||
|
||||
### After Config Change
|
||||
|
||||
```
|
||||
Options Update
|
||||
├─> Cache invalidation: `<`1ms
|
||||
|
|
@ -381,23 +411,25 @@ Options Update
|
|||
|
||||
## Summary Table
|
||||
|
||||
| Cache Type | Lifetime | Size | Invalidation | Purpose |
|
||||
|------------|----------|------|--------------|---------|
|
||||
| **API Data** | Hours to 1 day | ~50KB | Midnight, validation | Reduce API calls |
|
||||
| **Translations** | Forever (until HA restart) | ~5KB | Never | Avoid file I/O |
|
||||
| **Config Dicts** | Until options change | `<`1KB | Explicit (options update) | Avoid dict lookups |
|
||||
| **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 |
|
||||
| Cache Type | Lifetime | Size | Invalidation | Purpose |
|
||||
| ---------------------- | ---------------------------- | ------ | ------------------------- | ------------------------------- |
|
||||
| **API Data** | Hours to 1 day | ~50KB | Midnight, validation | Reduce API calls |
|
||||
| **Translations** | Forever (until HA restart) | ~5KB | Never | Avoid file I/O |
|
||||
| **Config Dicts** | Until options change | `<`1KB | Explicit (options update) | Avoid dict lookups |
|
||||
| **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 |
|
||||
|
||||
**Total memory overhead:** ~116KB per coordinator instance (main + subentries)
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- 97% reduction in API calls (from every 15min to once per day)
|
||||
- 70% reduction in period calculation time (cache hits during normal operation)
|
||||
- 98% reduction in config access time (30+ lookups → 1 cache check)
|
||||
- Zero file I/O during runtime (translations cached at startup)
|
||||
|
||||
**Trade-offs:**
|
||||
|
||||
- Memory usage: ~116KB per home (negligible for modern systems)
|
||||
- Code complexity: 5 cache invalidation points (well-tested, documented)
|
||||
- Debugging: Must understand cache lifetime when investigating stale data issues
|
||||
|
|
@ -407,7 +439,9 @@ Options Update
|
|||
## Debugging Cache Issues
|
||||
|
||||
### Symptom: Stale data after config change
|
||||
|
||||
**Check:**
|
||||
|
||||
1. Is `_handle_options_update()` called? (should see "Options updated" log)
|
||||
2. Are `invalidate_config_cache()` methods executed?
|
||||
3. Does `async_request_refresh()` trigger?
|
||||
|
|
@ -415,7 +449,9 @@ Options Update
|
|||
**Fix:** Ensure `config_entry.add_update_listener()` is registered in coordinator init.
|
||||
|
||||
### Symptom: Period calculation not updating
|
||||
|
||||
**Check:**
|
||||
|
||||
1. Verify hash changes when data changes: `_compute_periods_hash()`
|
||||
2. Check `_last_periods_hash` vs `current_hash`
|
||||
3. Look for "Using cached period calculation" vs "Calculating periods" logs
|
||||
|
|
@ -423,7 +459,9 @@ Options Update
|
|||
**Fix:** Hash function may not include all relevant data. Review `_compute_periods_hash()` inputs.
|
||||
|
||||
### Symptom: Yesterday's prices shown as today
|
||||
|
||||
**Check:**
|
||||
|
||||
1. `is_cache_valid()` logic in `coordinator/cache.py`
|
||||
2. Midnight turnover execution (Timer #2)
|
||||
3. Cache clear confirmation in logs
|
||||
|
|
@ -431,7 +469,9 @@ Options Update
|
|||
**Fix:** Timer may not be firing. Check `_schedule_midnight_turnover()` registration.
|
||||
|
||||
### Symptom: Missing translations
|
||||
|
||||
**Check:**
|
||||
|
||||
1. `async_load_translations()` called at startup?
|
||||
2. Translation files exist in `/translations/` and `/custom_translations/`?
|
||||
3. Cache population: `_TRANSLATIONS_CACHE` keys
|
||||
|
|
|
|||
|
|
@ -8,10 +8,10 @@ comments: false
|
|||
|
||||
## Code Style
|
||||
|
||||
- **Formatter/Linter**: Ruff (replaces Black, Flake8, isort)
|
||||
- **Max line length**: 120 characters
|
||||
- **Max complexity**: 25 (McCabe)
|
||||
- **Target**: Python 3.13
|
||||
- **Formatter/Linter**: Ruff (replaces Black, Flake8, isort)
|
||||
- **Max line length**: 120 characters
|
||||
- **Max complexity**: 25 (McCabe)
|
||||
- **Target**: Python 3.13
|
||||
|
||||
Run before committing:
|
||||
|
||||
|
|
@ -41,12 +41,14 @@ class TimeService:
|
|||
```
|
||||
|
||||
**When prefix is required:**
|
||||
|
||||
- Public classes used across multiple modules
|
||||
- All exception classes
|
||||
- All coordinator and entity classes
|
||||
- Data classes (dataclasses, NamedTuples) used as public APIs
|
||||
|
||||
**When prefix can be omitted:**
|
||||
|
||||
- Private helper classes within a single module (prefix with `_` underscore)
|
||||
- Type aliases and callbacks (e.g., `TimeServiceCallback`)
|
||||
- Small internal NamedTuples for function returns
|
||||
|
|
@ -71,6 +73,7 @@ class DataFetcher: # Should be TibberPricesDataFetcher
|
|||
**Current Technical Debt:**
|
||||
|
||||
Many existing classes lack the `TibberPrices` prefix. Before refactoring:
|
||||
|
||||
1. Document the plan in `/planning/class-naming-refactoring.md`
|
||||
2. Use `multi_replace_string_in_file` for bulk renames
|
||||
3. Test thoroughly after each module
|
||||
|
|
|
|||
|
|
@ -14,10 +14,10 @@ Welcome! This guide helps you contribute to the Tibber Prices integration.
|
|||
|
||||
1. Fork the repository on GitHub
|
||||
2. Clone your fork:
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/hass.tibber_prices.git
|
||||
cd hass.tibber_prices
|
||||
```
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/hass.tibber_prices.git
|
||||
cd hass.tibber_prices
|
||||
```
|
||||
3. Open in VS Code
|
||||
4. Click "Reopen in Container" when prompted
|
||||
|
||||
|
|
@ -34,6 +34,7 @@ git checkout -b fix/issue-123-description
|
|||
```
|
||||
|
||||
**Branch naming:**
|
||||
|
||||
- `feature/` - New features
|
||||
- `fix/` - Bug fixes
|
||||
- `docs/` - Documentation only
|
||||
|
|
@ -45,6 +46,7 @@ git checkout -b fix/issue-123-description
|
|||
Edit code, following [Coding Guidelines](coding-guidelines.md).
|
||||
|
||||
**Run checks frequently:**
|
||||
|
||||
```bash
|
||||
./scripts/type-check # Pyright type checking
|
||||
./scripts/lint # Ruff linting (auto-fix)
|
||||
|
|
@ -78,6 +80,7 @@ async def test_your_feature(hass, coordinator):
|
|||
```
|
||||
|
||||
Run your test:
|
||||
|
||||
```bash
|
||||
./scripts/test tests/test_your_feature.py -v
|
||||
```
|
||||
|
|
@ -97,6 +100,7 @@ Impact: Users can predict when prices will stabilize or continue fluctuating."
|
|||
```
|
||||
|
||||
**Commit types:**
|
||||
|
||||
- `feat:` - New feature
|
||||
- `fix:` - Bug fix
|
||||
- `docs:` - Documentation
|
||||
|
|
@ -105,6 +109,7 @@ Impact: Users can predict when prices will stabilize or continue fluctuating."
|
|||
- `chore:` - Maintenance
|
||||
|
||||
**Add scope when relevant:**
|
||||
|
||||
- `feat(sensors):` - Sensor platform
|
||||
- `fix(coordinator):` - Data coordinator
|
||||
- `docs(user):` - User documentation
|
||||
|
|
@ -124,32 +129,40 @@ Then open Pull Request on GitHub.
|
|||
Title: Short, descriptive (50 chars max)
|
||||
|
||||
Description should include:
|
||||
|
||||
```markdown
|
||||
## What
|
||||
|
||||
Brief description of changes
|
||||
|
||||
## Why
|
||||
|
||||
Problem being solved or feature rationale
|
||||
|
||||
## How
|
||||
|
||||
Implementation approach
|
||||
|
||||
## Testing
|
||||
|
||||
- [ ] Manual testing in Home Assistant
|
||||
- [ ] Unit tests added/updated
|
||||
- [ ] Type checking passes
|
||||
- [ ] Linting passes
|
||||
|
||||
## Breaking Changes
|
||||
|
||||
(If any - describe migration path)
|
||||
|
||||
## Related Issues
|
||||
|
||||
Closes #123
|
||||
```
|
||||
|
||||
### PR Checklist
|
||||
|
||||
Before submitting:
|
||||
|
||||
- [ ] Code follows [Coding Guidelines](coding-guidelines.md)
|
||||
- [ ] All tests pass (`./scripts/test`)
|
||||
- [ ] Type checking passes (`./scripts/type-check`)
|
||||
|
|
@ -170,6 +183,7 @@ Before submitting:
|
|||
### What Reviewers Look For
|
||||
|
||||
✅ **Good:**
|
||||
|
||||
- Clear, self-explanatory code
|
||||
- Appropriate comments for complex logic
|
||||
- Tests covering edge cases
|
||||
|
|
@ -177,6 +191,7 @@ Before submitting:
|
|||
- Follows existing patterns
|
||||
|
||||
❌ **Avoid:**
|
||||
|
||||
- Large PRs (>500 lines) - split into smaller ones
|
||||
- Mixing unrelated changes
|
||||
- Missing tests for new features
|
||||
|
|
@ -193,6 +208,7 @@ Before submitting:
|
|||
## Finding Issues to Work On
|
||||
|
||||
Good first issues are labeled:
|
||||
|
||||
- `good first issue` - Beginner-friendly
|
||||
- `help wanted` - Maintainers welcome contributions
|
||||
- `documentation` - Docs improvements
|
||||
|
|
@ -210,6 +226,7 @@ Be respectful, constructive, and patient. We're all volunteers! 🙏
|
|||
---
|
||||
|
||||
💡 **Related:**
|
||||
|
||||
- [Setup Guide](setup.md) - DevContainer setup
|
||||
- [Coding Guidelines](coding-guidelines.md) - Style guide
|
||||
- [Testing](testing.md) - Writing tests
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ comments: false
|
|||
## 🎯 Why Are These Tests Critical?
|
||||
|
||||
Home Assistant integrations run **continuously** in the background. Resource leaks lead to:
|
||||
|
||||
- **Memory Leaks**: RAM usage grows over days/weeks until HA becomes unstable
|
||||
- **Callback Leaks**: Listeners remain registered after entity removal → CPU load increases
|
||||
- **Timer Leaks**: Timers continue running after unload → unnecessary background tasks
|
||||
|
|
@ -26,6 +27,7 @@ Home Assistant integrations run **continuously** in the background. Resource lea
|
|||
#### 1.1 Listener Cleanup ✅
|
||||
|
||||
**What is tested:**
|
||||
|
||||
- Time-sensitive listeners are correctly removed (`async_add_time_sensitive_listener()`)
|
||||
- Minute-update listeners are correctly removed (`async_add_minute_update_listener()`)
|
||||
- Lifecycle callbacks are correctly unregistered (`register_lifecycle_callback()`)
|
||||
|
|
@ -33,11 +35,13 @@ Home Assistant integrations run **continuously** in the background. Resource lea
|
|||
- Binary sensor cleanup removes ALL registered listeners
|
||||
|
||||
**Why critical:**
|
||||
|
||||
- Each registered listener holds references to Entity + Coordinator
|
||||
- Without cleanup: Entities are not freed by GC → Memory Leak
|
||||
- With 80+ sensors × 3 listener types = 240+ callbacks that must be cleanly removed
|
||||
|
||||
**Code Locations:**
|
||||
|
||||
- `coordinator/listeners.py` → `async_add_time_sensitive_listener()`, `async_add_minute_update_listener()`
|
||||
- `coordinator/core.py` → `register_lifecycle_callback()`
|
||||
- `sensor/core.py` → `async_will_remove_from_hass()`
|
||||
|
|
@ -46,32 +50,38 @@ Home Assistant integrations run **continuously** in the background. Resource lea
|
|||
#### 1.2 Timer Cleanup ✅
|
||||
|
||||
**What is tested:**
|
||||
|
||||
- Quarter-hour timer is cancelled and reference cleared
|
||||
- Minute timer is cancelled and reference cleared
|
||||
- Both timers are cancelled together
|
||||
- Cleanup works even when timers are `None`
|
||||
|
||||
**Why critical:**
|
||||
|
||||
- Uncancelled timers continue running after integration unload
|
||||
- HA's `async_track_utc_time_change()` creates persistent callbacks
|
||||
- Without cleanup: Timers keep firing → CPU load + unnecessary coordinator updates
|
||||
|
||||
**Code Locations:**
|
||||
|
||||
- `coordinator/listeners.py` → `cancel_timers()`
|
||||
- `coordinator/core.py` → `async_shutdown()`
|
||||
|
||||
#### 1.3 Config Entry Cleanup ✅
|
||||
|
||||
**What is tested:**
|
||||
|
||||
- Options update listener is registered via `async_on_unload()`
|
||||
- Cleanup function is correctly passed to `async_on_unload()`
|
||||
|
||||
**Why critical:**
|
||||
|
||||
- `entry.add_update_listener()` registers permanent callback
|
||||
- Without `async_on_unload()`: Listener remains active after reload → duplicate updates
|
||||
- Pattern: `entry.async_on_unload(entry.add_update_listener(handler))`
|
||||
|
||||
**Code Locations:**
|
||||
|
||||
- `coordinator/core.py` → `__init__()` (listener registration)
|
||||
- `__init__.py` → `async_unload_entry()`
|
||||
|
||||
|
|
@ -82,16 +92,19 @@ Home Assistant integrations run **continuously** in the background. Resource lea
|
|||
#### 2.1 Config Cache Invalidation
|
||||
|
||||
**What is tested:**
|
||||
|
||||
- DataTransformer config cache is invalidated on options change
|
||||
- PeriodCalculator config + period cache is invalidated
|
||||
- Trend calculator cache is cleared on coordinator update
|
||||
|
||||
**Why critical:**
|
||||
|
||||
- Stale config → Sensors use old user settings
|
||||
- Stale period cache → Incorrect best/peak price periods
|
||||
- Stale trend cache → Outdated trend analysis
|
||||
|
||||
**Code Locations:**
|
||||
|
||||
- `coordinator/data_transformation.py` → `invalidate_config_cache()`
|
||||
- `coordinator/periods.py` → `invalidate_config_cache()`
|
||||
- `sensor/calculators/trend.py` → `clear_trend_cache()`
|
||||
|
|
@ -103,15 +116,18 @@ Home Assistant integrations run **continuously** in the background. Resource lea
|
|||
#### 3.1 Persistent Storage Removal
|
||||
|
||||
**What is tested:**
|
||||
|
||||
- Storage file is deleted on config entry removal
|
||||
- Cache is saved on shutdown (no data loss)
|
||||
|
||||
**Why critical:**
|
||||
|
||||
- Without storage removal: Old files remain after uninstallation
|
||||
- Without cache save on shutdown: Data loss on HA restart
|
||||
- Storage path: `.storage/tibber_prices.{entry_id}`
|
||||
|
||||
**Code Locations:**
|
||||
|
||||
- `__init__.py` → `async_remove_entry()`
|
||||
- `coordinator/core.py` → `async_shutdown()`
|
||||
|
||||
|
|
@ -120,12 +136,14 @@ Home Assistant integrations run **continuously** in the background. Resource lea
|
|||
**File:** `tests/test_timer_scheduling.py`
|
||||
|
||||
**What is tested:**
|
||||
|
||||
- Quarter-hour timer is registered with correct parameters
|
||||
- Minute timer is registered with correct parameters
|
||||
- Timers can be re-scheduled (override old timer)
|
||||
- Midnight turnover detection works correctly
|
||||
|
||||
**Why critical:**
|
||||
|
||||
- Wrong timer parameters → Entities update at wrong times
|
||||
- Without timer override on re-schedule → Multiple parallel timers → Performance problem
|
||||
|
||||
|
|
@ -134,12 +152,14 @@ Home Assistant integrations run **continuously** in the background. Resource lea
|
|||
**File:** `tests/test_sensor_timer_assignment.py`
|
||||
|
||||
**What is tested:**
|
||||
|
||||
- All `TIME_SENSITIVE_ENTITY_KEYS` are valid entity keys
|
||||
- All `MINUTE_UPDATE_ENTITY_KEYS` are valid entity keys
|
||||
- Both lists are disjoint (no overlap)
|
||||
- Sensor and binary sensor platforms are checked
|
||||
|
||||
**Why critical:**
|
||||
|
||||
- Wrong timer assignment → Sensors update at wrong times
|
||||
- Overlap → Duplicate updates → Performance problem
|
||||
|
||||
|
|
@ -150,10 +170,12 @@ These patterns were analyzed and classified as **not critical**:
|
|||
### 6. Async Task Management
|
||||
|
||||
**Current Status:** Fire-and-forget pattern for short tasks
|
||||
|
||||
- `sensor/core.py` → Chart data refresh (short-lived, max 1-2 seconds)
|
||||
- `coordinator/core.py` → Cache storage (short-lived, max 100ms)
|
||||
|
||||
**Why no tests needed:**
|
||||
|
||||
- No long-running tasks (all < 2 seconds)
|
||||
- HA's event loop handles short tasks automatically
|
||||
- Task exceptions are already logged
|
||||
|
|
@ -163,6 +185,7 @@ These patterns were analyzed and classified as **not critical**:
|
|||
### 7. API Session Cleanup
|
||||
|
||||
**Current Status:** ✅ Correctly implemented
|
||||
|
||||
- `async_get_clientsession(hass)` is used (shared session)
|
||||
- No new sessions are created
|
||||
- HA manages session lifecycle automatically
|
||||
|
|
@ -172,6 +195,7 @@ These patterns were analyzed and classified as **not critical**:
|
|||
### 8. Translation Cache Memory
|
||||
|
||||
**Current Status:** ✅ Bounded cache
|
||||
|
||||
- Max ~5-10 languages × 5KB = 50KB total
|
||||
- Module-level cache without re-loading
|
||||
- Practically no memory issue
|
||||
|
|
@ -181,11 +205,13 @@ These patterns were analyzed and classified as **not critical**:
|
|||
### 9. Coordinator Data Structure Integrity
|
||||
|
||||
**Current Status:** Manually tested via `./scripts/develop`
|
||||
|
||||
- Midnight turnover works correctly (observed over several days)
|
||||
- Missing keys are handled via `.get()` with defaults
|
||||
- 80+ sensors access `coordinator.data` without errors
|
||||
|
||||
**Structure:**
|
||||
|
||||
```python
|
||||
coordinator.data = {
|
||||
"user_data": {...},
|
||||
|
|
@ -197,6 +223,7 @@ coordinator.data = {
|
|||
### 10. Service Response Memory
|
||||
|
||||
**Current Status:** HA's response lifecycle
|
||||
|
||||
- HA automatically frees service responses after return
|
||||
- ApexCharts ~20KB response is one-time per call
|
||||
- No response accumulation in integration code
|
||||
|
|
@ -207,29 +234,30 @@ coordinator.data = {
|
|||
|
||||
### ✅ Implemented Tests (41 total)
|
||||
|
||||
| Category | Status | Tests | File | Coverage |
|
||||
|----------|--------|-------|------|----------|
|
||||
| Listener Cleanup | ✅ | 5 | `test_resource_cleanup.py` | 100% |
|
||||
| Timer Cleanup | ✅ | 4 | `test_resource_cleanup.py` | 100% |
|
||||
| Config Entry Cleanup | ✅ | 1 | `test_resource_cleanup.py` | 100% |
|
||||
| Cache Invalidation | ✅ | 3 | `test_resource_cleanup.py` | 100% |
|
||||
| Storage Cleanup | ✅ | 1 | `test_resource_cleanup.py` | 100% |
|
||||
| Storage Persistence | ✅ | 2 | `test_coordinator_shutdown.py` | 100% |
|
||||
| Timer Scheduling | ✅ | 8 | `test_timer_scheduling.py` | 100% |
|
||||
| Sensor-Timer Assignment | ✅ | 17 | `test_sensor_timer_assignment.py` | 100% |
|
||||
| **TOTAL** | **✅** | **41** | | **100% (critical)** |
|
||||
| Category | Status | Tests | File | Coverage |
|
||||
| ----------------------- | ------ | ------ | --------------------------------- | ------------------- |
|
||||
| Listener Cleanup | ✅ | 5 | `test_resource_cleanup.py` | 100% |
|
||||
| Timer Cleanup | ✅ | 4 | `test_resource_cleanup.py` | 100% |
|
||||
| Config Entry Cleanup | ✅ | 1 | `test_resource_cleanup.py` | 100% |
|
||||
| Cache Invalidation | ✅ | 3 | `test_resource_cleanup.py` | 100% |
|
||||
| Storage Cleanup | ✅ | 1 | `test_resource_cleanup.py` | 100% |
|
||||
| Storage Persistence | ✅ | 2 | `test_coordinator_shutdown.py` | 100% |
|
||||
| Timer Scheduling | ✅ | 8 | `test_timer_scheduling.py` | 100% |
|
||||
| Sensor-Timer Assignment | ✅ | 17 | `test_sensor_timer_assignment.py` | 100% |
|
||||
| **TOTAL** | **✅** | **41** | | **100% (critical)** |
|
||||
|
||||
### 📋 Analyzed but Not Implemented (Nice-to-Have)
|
||||
|
||||
| Category | Status | Rationale |
|
||||
|----------|--------|-----------|
|
||||
| Async Task Management | 📋 | Fire-and-forget pattern used (no long-running tasks) |
|
||||
| API Session Cleanup | ✅ | Pattern correct (`async_get_clientsession` used) |
|
||||
| Translation Cache | ✅ | Cache size bounded (~50KB max for 10 languages) |
|
||||
| Data Structure Integrity | 📋 | Would add test time without finding real issues |
|
||||
| Service Response Memory | 📋 | HA automatically frees service responses |
|
||||
| Category | Status | Rationale |
|
||||
| ------------------------ | ------ | ---------------------------------------------------- |
|
||||
| Async Task Management | 📋 | Fire-and-forget pattern used (no long-running tasks) |
|
||||
| API Session Cleanup | ✅ | Pattern correct (`async_get_clientsession` used) |
|
||||
| Translation Cache | ✅ | Cache size bounded (~50KB max for 10 languages) |
|
||||
| Data Structure Integrity | 📋 | Would add test time without finding real issues |
|
||||
| Service Response Memory | 📋 | HA automatically frees service responses |
|
||||
|
||||
**Legend:**
|
||||
|
||||
- ✅ = Fully tested or pattern verified correct
|
||||
- 📋 = Analyzed, low priority for testing (no known issues)
|
||||
|
||||
|
|
@ -238,6 +266,7 @@ coordinator.data = {
|
|||
### ✅ All Critical Patterns Tested
|
||||
|
||||
All essential memory leak prevention patterns are covered by 41 tests:
|
||||
|
||||
- ✅ Listeners are correctly removed (no callback leaks)
|
||||
- ✅ Timers are cancelled (no background task leaks)
|
||||
- ✅ Config entry cleanup works (no dangling listeners)
|
||||
|
|
|
|||
|
|
@ -10,9 +10,9 @@ Add to `configuration.yaml`:
|
|||
|
||||
```yaml
|
||||
logger:
|
||||
default: info
|
||||
logs:
|
||||
custom_components.tibber_prices: debug
|
||||
default: info
|
||||
logs:
|
||||
custom_components.tibber_prices: debug
|
||||
```
|
||||
|
||||
Restart Home Assistant to apply.
|
||||
|
|
@ -20,6 +20,7 @@ Restart Home Assistant to apply.
|
|||
### Key Log Messages
|
||||
|
||||
**Coordinator Updates:**
|
||||
|
||||
```
|
||||
[custom_components.tibber_prices.coordinator] Successfully fetched price data
|
||||
[custom_components.tibber_prices.coordinator] Cache valid, using cached data
|
||||
|
|
@ -27,6 +28,7 @@ Restart Home Assistant to apply.
|
|||
```
|
||||
|
||||
**Period Calculation:**
|
||||
|
||||
```
|
||||
[custom_components.tibber_prices.coordinator.periods] Calculating BEST PRICE periods: flex=15.0%
|
||||
[custom_components.tibber_prices.coordinator.periods] Day 2024-12-06: Found 2 periods
|
||||
|
|
@ -34,6 +36,7 @@ Restart Home Assistant to apply.
|
|||
```
|
||||
|
||||
**API Errors:**
|
||||
|
||||
```
|
||||
[custom_components.tibber_prices.api] API request failed: Unauthorized
|
||||
[custom_components.tibber_prices.api] Retrying (attempt 2/3) after 2.0s
|
||||
|
|
@ -47,26 +50,27 @@ Restart Home Assistant to apply.
|
|||
|
||||
```json
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Home Assistant",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"module": "homeassistant",
|
||||
"args": ["-c", "config", "--debug"],
|
||||
"justMyCode": false,
|
||||
"env": {
|
||||
"PYTHONPATH": "${workspaceFolder}/.venv/lib/python3.13/site-packages"
|
||||
}
|
||||
}
|
||||
]
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Home Assistant",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"module": "homeassistant",
|
||||
"args": ["-c", "config", "--debug"],
|
||||
"justMyCode": false,
|
||||
"env": {
|
||||
"PYTHONPATH": "${workspaceFolder}/.venv/lib/python3.13/site-packages"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Set Breakpoints
|
||||
|
||||
**Coordinator update:**
|
||||
|
||||
```python
|
||||
# coordinator/core.py
|
||||
async def _async_update_data(self) -> dict:
|
||||
|
|
@ -75,6 +79,7 @@ async def _async_update_data(self) -> dict:
|
|||
```
|
||||
|
||||
**Period calculation:**
|
||||
|
||||
```python
|
||||
# coordinator/period_handlers/core.py
|
||||
def calculate_periods(...) -> list[dict]:
|
||||
|
|
@ -91,6 +96,7 @@ def calculate_periods(...) -> list[dict]:
|
|||
```
|
||||
|
||||
**Flags:**
|
||||
|
||||
- `-v` - Verbose output
|
||||
- `-s` - Show print statements
|
||||
- `-k pattern` - Run tests matching pattern
|
||||
|
|
@ -102,6 +108,7 @@ Set breakpoint in test file, use "Debug Test" CodeLens.
|
|||
### Useful Test Patterns
|
||||
|
||||
**Print coordinator data:**
|
||||
|
||||
```python
|
||||
def test_something(coordinator):
|
||||
print(f"Coordinator data: {coordinator.data}")
|
||||
|
|
@ -109,6 +116,7 @@ def test_something(coordinator):
|
|||
```
|
||||
|
||||
**Inspect period attributes:**
|
||||
|
||||
```python
|
||||
def test_periods(hass, coordinator):
|
||||
periods = coordinator.data.get('best_price_periods', [])
|
||||
|
|
@ -122,11 +130,13 @@ def test_periods(hass, coordinator):
|
|||
### Integration Not Loading
|
||||
|
||||
**Check:**
|
||||
|
||||
```bash
|
||||
grep "tibber_prices" config/home-assistant.log
|
||||
```
|
||||
|
||||
**Common causes:**
|
||||
|
||||
- Syntax error in Python code → Check logs for traceback
|
||||
- Missing dependency → Run `uv sync`
|
||||
- Wrong file permissions → `chmod +x scripts/*`
|
||||
|
|
@ -134,12 +144,14 @@ grep "tibber_prices" config/home-assistant.log
|
|||
### Sensors Not Updating
|
||||
|
||||
**Check coordinator state:**
|
||||
|
||||
```python
|
||||
# In Developer Tools > Template
|
||||
{{ states.sensor.tibber_home_current_interval_price.last_updated }}
|
||||
```
|
||||
|
||||
**Debug in code:**
|
||||
|
||||
```python
|
||||
# Add logging in sensor/core.py
|
||||
_LOGGER.debug("Updating sensor %s: old=%s new=%s",
|
||||
|
|
@ -149,6 +161,7 @@ _LOGGER.debug("Updating sensor %s: old=%s new=%s",
|
|||
### Period Calculation Wrong
|
||||
|
||||
**Enable detailed period logs:**
|
||||
|
||||
```python
|
||||
# coordinator/period_handlers/period_building.py
|
||||
_LOGGER.debug("Candidate intervals: %s",
|
||||
|
|
@ -156,6 +169,7 @@ _LOGGER.debug("Candidate intervals: %s",
|
|||
```
|
||||
|
||||
**Check filter statistics:**
|
||||
|
||||
```
|
||||
[period_building] Flex filter blocked: 45 intervals
|
||||
[period_building] Min distance blocked: 12 intervals
|
||||
|
|
@ -200,6 +214,7 @@ python -m pstats profile.stats
|
|||
### Remote Debugging with debugpy
|
||||
|
||||
Add to coordinator code:
|
||||
|
||||
```python
|
||||
import debugpy
|
||||
debugpy.listen(5678)
|
||||
|
|
@ -212,11 +227,13 @@ Connect from VS Code with remote attach configuration.
|
|||
### IPython REPL
|
||||
|
||||
Install in container:
|
||||
|
||||
```bash
|
||||
uv pip install ipython
|
||||
```
|
||||
|
||||
Add breakpoint:
|
||||
|
||||
```python
|
||||
from IPython import embed
|
||||
embed() # Drops into interactive shell
|
||||
|
|
@ -225,6 +242,7 @@ embed() # Drops into interactive shell
|
|||
---
|
||||
|
||||
💡 **Related:**
|
||||
|
||||
- [Testing Guide](testing.md) - Writing and running tests
|
||||
- [Setup Guide](setup.md) - Development environment
|
||||
- [Architecture](architecture.md) - Code structure
|
||||
|
|
|
|||
|
|
@ -8,25 +8,25 @@ This is an independent, community-maintained custom integration for Home Assista
|
|||
|
||||
## 📚 Developer Guides
|
||||
|
||||
- **[Setup](setup.md)** - DevContainer, environment setup, and dependencies
|
||||
- **[Architecture](architecture.md)** - Code structure, patterns, and conventions
|
||||
- **[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)
|
||||
- **[Caching Strategy](caching-strategy.md)** - Cache layers, invalidation, debugging
|
||||
- **[Testing](testing.md)** - How to run tests and write new test cases
|
||||
- **[Release Management](release-management.md)** - Release workflow and versioning process
|
||||
- **[Coding Guidelines](coding-guidelines.md)** - Style guide, linting, and best practices
|
||||
- **[Refactoring Guide](refactoring-guide.md)** - How to plan and execute major refactorings
|
||||
- **[Setup](setup.md)** - DevContainer, environment setup, and dependencies
|
||||
- **[Architecture](architecture.md)** - Code structure, patterns, and conventions
|
||||
- **[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)
|
||||
- **[Caching Strategy](caching-strategy.md)** - Cache layers, invalidation, debugging
|
||||
- **[Testing](testing.md)** - How to run tests and write new test cases
|
||||
- **[Release Management](release-management.md)** - Release workflow and versioning process
|
||||
- **[Coding Guidelines](coding-guidelines.md)** - Style guide, linting, and best practices
|
||||
- **[Refactoring Guide](refactoring-guide.md)** - How to plan and execute major refactorings
|
||||
|
||||
## 🤖 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:
|
||||
|
||||
- Detailed architectural patterns
|
||||
- Code quality rules and conventions
|
||||
- Development workflow guidance
|
||||
- Common pitfalls and anti-patterns
|
||||
- Project-specific patterns and utilities
|
||||
- Detailed architectural patterns
|
||||
- Code quality rules and conventions
|
||||
- Development workflow guidance
|
||||
- Common pitfalls and anti-patterns
|
||||
- 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.
|
||||
|
||||
|
|
@ -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:
|
||||
|
||||
- **Pattern Recognition**: Understanding and applying Home Assistant best practices
|
||||
- **Code Generation**: Implementing features with proper type hints, error handling, and documentation
|
||||
- **Refactoring**: Maintaining consistency across the codebase during structural changes
|
||||
- **Translation Management**: Keeping 5 language files synchronized
|
||||
- **Documentation**: Generating and maintaining comprehensive documentation
|
||||
- **Pattern Recognition**: Understanding and applying Home Assistant best practices
|
||||
- **Code Generation**: Implementing features with proper type hints, error handling, and documentation
|
||||
- **Refactoring**: Maintaining consistency across the codebase during structural changes
|
||||
- **Translation Management**: Keeping 5 language files synchronized
|
||||
- **Documentation**: Generating and maintaining comprehensive documentation
|
||||
|
||||
**Quality Assurance:**
|
||||
|
||||
- Automated linting with Ruff (120-char line length, max complexity 25)
|
||||
- Home Assistant's type checking and validation
|
||||
- Real-world testing in development environment
|
||||
- Code review by maintainer before merging
|
||||
- Automated linting with Ruff (120-char line length, max complexity 25)
|
||||
- Home Assistant's type checking and validation
|
||||
- Real-world testing in development environment
|
||||
- Code review by maintainer before merging
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- Rapid feature development while maintaining quality
|
||||
- Consistent code patterns across all modules
|
||||
- Comprehensive documentation maintained alongside code
|
||||
- Quick bug fixes with proper understanding of context
|
||||
- Rapid feature development while maintaining quality
|
||||
- Consistent code patterns across all modules
|
||||
- Comprehensive documentation maintained alongside code
|
||||
- Quick bug fixes with proper understanding of context
|
||||
|
||||
**Limitations:**
|
||||
|
||||
- AI may occasionally miss edge cases or subtle bugs
|
||||
- Some complex Home Assistant patterns may need human review
|
||||
- Translation quality depends on AI's understanding of target language
|
||||
- User feedback is crucial for discovering real-world issues
|
||||
- AI may occasionally miss edge cases or subtle bugs
|
||||
- Some complex Home Assistant patterns may need human review
|
||||
- Translation quality depends on AI's understanding of target language
|
||||
- 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.
|
||||
|
||||
|
|
@ -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/`:
|
||||
|
||||
- `bootstrap` - Initial setup of dependencies
|
||||
- `develop` - Start Home Assistant in debug mode (auto-cleans .egg-info)
|
||||
- `clean` - Remove build artifacts and caches
|
||||
- `lint` - Auto-fix code issues with ruff
|
||||
- `lint-check` - Check code without modifications (CI mode)
|
||||
- `hassfest` - Validate integration structure (JSON, Python syntax, required files)
|
||||
- `setup` - Install development tools (git-cliff, @github/copilot)
|
||||
- `prepare-release` - Prepare a new release (bump version, create tag)
|
||||
- `generate-release-notes` - Generate release notes from commits
|
||||
- `bootstrap` - Initial setup of dependencies
|
||||
- `develop` - Start Home Assistant in debug mode (auto-cleans .egg-info)
|
||||
- `clean` - Remove build artifacts and caches
|
||||
- `lint` - Auto-fix code issues with ruff
|
||||
- `lint-check` - Check code without modifications (CI mode)
|
||||
- `hassfest` - Validate integration structure (JSON, Python syntax, required files)
|
||||
- `setup` - Install development tools (git-cliff, @github/copilot)
|
||||
- `prepare-release` - Prepare a new release (bump version, create tag)
|
||||
- `generate-release-notes` - Generate release notes from commits
|
||||
|
||||
## 📦 Project Structure
|
||||
|
||||
|
|
@ -121,23 +121,23 @@ custom_components/tibber_prices/
|
|||
|
||||
**DataUpdateCoordinator Pattern:**
|
||||
|
||||
- Centralized data fetching and caching
|
||||
- Automatic entity updates on data changes
|
||||
- Persistent storage via `Store`
|
||||
- Quarter-hour boundary refresh scheduling
|
||||
- Centralized data fetching and caching
|
||||
- Automatic entity updates on data changes
|
||||
- Persistent storage via `Store`
|
||||
- Quarter-hour boundary refresh scheduling
|
||||
|
||||
**Price Data Enrichment:**
|
||||
|
||||
- Raw API data is enriched with statistical analysis
|
||||
- Trailing/leading 24h averages calculated per interval
|
||||
- Price differences and ratings added
|
||||
- All via pure functions in `price_utils.py`
|
||||
- Raw API data is enriched with statistical analysis
|
||||
- Trailing/leading 24h averages calculated per interval
|
||||
- Price differences and ratings added
|
||||
- All via pure functions in `price_utils.py`
|
||||
|
||||
**Translation System:**
|
||||
|
||||
- Dual system: `/translations/` (HA schema) + `/custom_translations/` (extended)
|
||||
- Both must stay in sync across all languages (de, en, nb, nl, sv)
|
||||
- Async loading at integration setup
|
||||
- Dual system: `/translations/` (HA schema) + `/custom_translations/` (extended)
|
||||
- Both must stay in sync across all languages (de, en, nb, nl, sv)
|
||||
- Async loading at integration setup
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
|
|
@ -159,18 +159,19 @@ pytest --cov=custom_components.tibber_prices tests/
|
|||
|
||||
Documentation is organized in two Docusaurus sites:
|
||||
|
||||
- **User docs** (`docs/user/`): Installation, configuration, usage guides
|
||||
- Markdown files in `docs/user/docs/*.md`
|
||||
- Navigation managed via `docs/user/sidebars.ts`
|
||||
- **Developer docs** (`docs/developer/`): Architecture, patterns, contribution guides
|
||||
- Markdown files in `docs/developer/docs/*.md`
|
||||
- Navigation managed via `docs/developer/sidebars.ts`
|
||||
- **AI guidance**: `AGENTS.md` (patterns, conventions, long-term memory)
|
||||
- **User docs** (`docs/user/`): Installation, configuration, usage guides
|
||||
- Markdown files in `docs/user/docs/*.md`
|
||||
- Navigation managed via `docs/user/sidebars.ts`
|
||||
- **Developer docs** (`docs/developer/`): Architecture, patterns, contribution guides
|
||||
- Markdown files in `docs/developer/docs/*.md`
|
||||
- Navigation managed via `docs/developer/sidebars.ts`
|
||||
- **AI guidance**: `AGENTS.md` (patterns, conventions, long-term memory)
|
||||
|
||||
**Best practices:**
|
||||
- Use clear examples and code snippets
|
||||
- Keep docs up-to-date with code changes
|
||||
- Add new pages to appropriate `sidebars.ts` for navigation
|
||||
|
||||
- Use clear examples and code snippets
|
||||
- Keep docs up-to-date with code changes
|
||||
- Add new pages to appropriate `sidebars.ts` for navigation
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ Guidelines for maintaining and improving integration performance.
|
|||
## Performance Goals
|
||||
|
||||
Target metrics:
|
||||
|
||||
- **Coordinator update**: <500ms (typical: 200-300ms)
|
||||
- **Sensor update**: <10ms per sensor
|
||||
- **Period calculation**: <100ms (typical: 20-50ms)
|
||||
|
|
@ -64,6 +65,7 @@ python -m aioprof homeassistant -c config
|
|||
### Caching
|
||||
|
||||
**1. Persistent Cache** (API data):
|
||||
|
||||
```python
|
||||
# Already implemented in coordinator/cache.py
|
||||
store = Store(hass, STORAGE_VERSION, STORAGE_KEY)
|
||||
|
|
@ -71,6 +73,7 @@ data = await store.async_load()
|
|||
```
|
||||
|
||||
**2. Translation Cache** (in-memory):
|
||||
|
||||
```python
|
||||
# Already implemented in const.py
|
||||
_TRANSLATION_CACHE: dict[str, dict] = {}
|
||||
|
|
@ -83,6 +86,7 @@ def get_translation(path: str, language: str) -> dict:
|
|||
```
|
||||
|
||||
**3. Config Cache** (invalidated on options change):
|
||||
|
||||
```python
|
||||
class DataTransformer:
|
||||
def __init__(self):
|
||||
|
|
@ -100,6 +104,7 @@ class DataTransformer:
|
|||
### Lazy Loading
|
||||
|
||||
**Load data only when needed:**
|
||||
|
||||
```python
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict | None:
|
||||
|
|
@ -113,6 +118,7 @@ def extra_state_attributes(self) -> dict | None:
|
|||
### Bulk Operations
|
||||
|
||||
**Process multiple items at once:**
|
||||
|
||||
```python
|
||||
# ❌ Slow - loop with individual operations
|
||||
for interval in intervals:
|
||||
|
|
@ -126,6 +132,7 @@ results = enrich_intervals_bulk(intervals)
|
|||
### Async Best Practices
|
||||
|
||||
**1. Concurrent API calls:**
|
||||
|
||||
```python
|
||||
# ❌ Sequential (slow)
|
||||
user_data = await fetch_user_data()
|
||||
|
|
@ -139,6 +146,7 @@ user_data, price_data = await asyncio.gather(
|
|||
```
|
||||
|
||||
**2. Don't block event loop:**
|
||||
|
||||
```python
|
||||
# ❌ Blocking
|
||||
result = heavy_computation() # Blocks for seconds
|
||||
|
|
@ -152,6 +160,7 @@ result = await hass.async_add_executor_job(heavy_computation)
|
|||
### Avoid Memory Leaks
|
||||
|
||||
**1. Clear references:**
|
||||
|
||||
```python
|
||||
class Coordinator:
|
||||
async def async_shutdown(self):
|
||||
|
|
@ -162,6 +171,7 @@ class Coordinator:
|
|||
```
|
||||
|
||||
**2. Use weak references for callbacks:**
|
||||
|
||||
```python
|
||||
import weakref
|
||||
|
||||
|
|
@ -176,6 +186,7 @@ class Manager:
|
|||
### Efficient Data Structures
|
||||
|
||||
**Use appropriate types:**
|
||||
|
||||
```python
|
||||
# ❌ List for lookups (O(n))
|
||||
if timestamp in timestamp_list:
|
||||
|
|
@ -197,11 +208,13 @@ results = (x for x in items if condition(x))
|
|||
### Minimize API Calls
|
||||
|
||||
**Already implemented:**
|
||||
|
||||
- Cache valid until midnight
|
||||
- User data cached for 24h
|
||||
- Only poll when tomorrow data expected
|
||||
|
||||
**Monitor API usage:**
|
||||
|
||||
```python
|
||||
_LOGGER.debug("API call: %s (cache_age=%s)",
|
||||
endpoint, cache_age)
|
||||
|
|
@ -210,6 +223,7 @@ _LOGGER.debug("API call: %s (cache_age=%s)",
|
|||
### Smart Updates
|
||||
|
||||
**Only update when needed:**
|
||||
|
||||
```python
|
||||
async def _async_update_data(self) -> dict:
|
||||
"""Fetch data from API."""
|
||||
|
|
@ -226,6 +240,7 @@ async def _async_update_data(self) -> dict:
|
|||
### State Class Selection
|
||||
|
||||
**Affects long-term statistics storage:**
|
||||
|
||||
```python
|
||||
# ❌ MEASUREMENT for prices (stores every change)
|
||||
state_class=SensorStateClass.MEASUREMENT # ~35K records/year
|
||||
|
|
@ -240,6 +255,7 @@ state_class=SensorStateClass.TOTAL # For cumulative values
|
|||
### Attribute Size
|
||||
|
||||
**Keep attributes minimal:**
|
||||
|
||||
```python
|
||||
# ❌ Large nested structures (KB per update)
|
||||
attributes = {
|
||||
|
|
@ -317,6 +333,7 @@ _LOGGER.debug("Current memory usage: %.2f MB", memory_mb)
|
|||
---
|
||||
|
||||
💡 **Related:**
|
||||
|
||||
- [Caching Strategy](caching-strategy.md) - Cache layers
|
||||
- [Architecture](architecture.md) - System design
|
||||
- [Debugging](debugging.md) - Profiling tools
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ This document explains the mathematical foundations and design decisions behind
|
|||
**Target Audience:** Developers maintaining or extending the period calculation logic.
|
||||
|
||||
**Related Files:**
|
||||
|
||||
- `coordinator/period_handlers/core.py` - Main calculation entry point
|
||||
- `coordinator/period_handlers/level_filtering.py` - Flex and distance filtering
|
||||
- `coordinator/period_handlers/relaxation.py` - Multi-phase relaxation strategy
|
||||
|
|
@ -23,6 +24,7 @@ Period detection uses **three independent filters** (all must pass):
|
|||
**Purpose:** Limit how far prices can deviate from the daily min/max.
|
||||
|
||||
**Logic:**
|
||||
|
||||
```python
|
||||
# Best Price: Price must be within flex% ABOVE daily minimum
|
||||
in_flex = price <= (daily_min + daily_min × flex)
|
||||
|
|
@ -32,6 +34,7 @@ in_flex = price >= (daily_max - daily_max × flex)
|
|||
```
|
||||
|
||||
**Example (Best Price):**
|
||||
|
||||
- Daily Min: 10 ct/kWh
|
||||
- Flex: 15%
|
||||
- Acceptance Range: 0 - 11.5 ct/kWh (10 + 10×0.15)
|
||||
|
|
@ -41,6 +44,7 @@ in_flex = price >= (daily_max - daily_max × flex)
|
|||
**Purpose:** Ensure periods are **significantly** cheaper/more expensive than average, not just marginally better.
|
||||
|
||||
**Logic:**
|
||||
|
||||
```python
|
||||
# Best Price: Price must be at least min_distance% BELOW daily average
|
||||
meets_distance = price <= (daily_avg × (1 - min_distance/100))
|
||||
|
|
@ -50,6 +54,7 @@ meets_distance = price >= (daily_avg × (1 + min_distance/100))
|
|||
```
|
||||
|
||||
**Example (Best Price):**
|
||||
|
||||
- Daily Avg: 15 ct/kWh
|
||||
- Min Distance: 5%
|
||||
- Acceptance Range: 0 - 14.25 ct/kWh (15 × 0.95)
|
||||
|
|
@ -65,17 +70,17 @@ meets_distance = price >= (daily_avg × (1 + min_distance/100))
|
|||
The integration maintains **two independent sets** of volatility thresholds:
|
||||
|
||||
1. **Sensor Thresholds** (user-configurable via `CONF_VOLATILITY_*_THRESHOLD`)
|
||||
- Purpose: Display classification in `sensor.tibber_home_volatility_*`
|
||||
- Default: LOW < 10%, MEDIUM < 20%, HIGH ≥ 20%
|
||||
- User can adjust in config flow options
|
||||
- Affects: Sensor state/attributes only
|
||||
- Purpose: Display classification in `sensor.tibber_home_volatility_*`
|
||||
- Default: LOW < 10%, MEDIUM < 20%, HIGH ≥ 20%
|
||||
- User can adjust in config flow options
|
||||
- Affects: Sensor state/attributes only
|
||||
|
||||
2. **Period Filter Thresholds** (internal, fixed)
|
||||
- Purpose: Level filter criteria when using `level="volatility_low"` etc.
|
||||
- Source: `PRICE_LEVEL_THRESHOLDS` in `const.py`
|
||||
- Values: Same as sensor defaults (LOW < 10%, MEDIUM < 20%, HIGH ≥ 20%)
|
||||
- User **cannot** adjust these
|
||||
- Affects: Period candidate selection
|
||||
- Purpose: Level filter criteria when using `level="volatility_low"` etc.
|
||||
- Source: `PRICE_LEVEL_THRESHOLDS` in `const.py`
|
||||
- Values: Same as sensor defaults (LOW < 10%, MEDIUM < 20%, HIGH ≥ 20%)
|
||||
- User **cannot** adjust these
|
||||
- Affects: Period candidate selection
|
||||
|
||||
**Rationale for Separation:**
|
||||
|
||||
|
|
@ -86,6 +91,7 @@ The integration maintains **two independent sets** of volatility thresholds:
|
|||
- Period calculation has many interacting filters (Flex, Distance, Level) - exposing all internals would be error-prone
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```python
|
||||
# Sensor classification uses user config
|
||||
user_low_threshold = config_entry.options.get(CONF_VOLATILITY_LOW_THRESHOLD, 10)
|
||||
|
|
@ -107,36 +113,42 @@ period_low_threshold = PRICE_LEVEL_THRESHOLDS["volatility_low"] # Always 10%
|
|||
#### Scenario: Best Price with Flex=50%, Min_Distance=5%
|
||||
|
||||
**Given:**
|
||||
|
||||
- Daily Min: 10 ct/kWh
|
||||
- Daily Avg: 15 ct/kWh
|
||||
- Daily Max: 20 ct/kWh
|
||||
|
||||
**Flex Filter (50%):**
|
||||
|
||||
```
|
||||
Max accepted = 10 + (10 × 0.50) = 15 ct/kWh
|
||||
```
|
||||
|
||||
**Min Distance Filter (5%):**
|
||||
|
||||
```
|
||||
Max accepted = 15 × (1 - 0.05) = 14.25 ct/kWh
|
||||
```
|
||||
|
||||
**Conflict:**
|
||||
|
||||
- Interval at 14.8 ct/kWh:
|
||||
- ✅ Flex: 14.8 ≤ 15 (PASS)
|
||||
- ❌ Distance: 14.8 > 14.25 (FAIL)
|
||||
- **Result:** Rejected by Min_Distance even though Flex allows it!
|
||||
- ✅ Flex: 14.8 ≤ 15 (PASS)
|
||||
- ❌ Distance: 14.8 > 14.25 (FAIL)
|
||||
- **Result:** Rejected by Min_Distance even though Flex allows it!
|
||||
|
||||
**The Issue:** At high Flex values, Min_Distance becomes the dominant filter and blocks intervals that Flex would permit. This defeats the purpose of having high Flex.
|
||||
|
||||
### Mathematical Analysis
|
||||
|
||||
**Conflict condition for Best Price:**
|
||||
|
||||
```
|
||||
daily_min × (1 + flex) > daily_avg × (1 - min_distance/100)
|
||||
```
|
||||
|
||||
**Typical values:**
|
||||
|
||||
- Min = 10, Avg = 15, Min_Distance = 5%
|
||||
- Conflict occurs when: `10 × (1 + flex) > 14.25`
|
||||
- Simplify: `flex > 0.425` (42.5%)
|
||||
|
|
@ -149,6 +161,7 @@ daily_min × (1 + flex) > daily_avg × (1 - min_distance/100)
|
|||
**Approach:** Reduce Min_Distance proportionally as Flex increases.
|
||||
|
||||
**Formula:**
|
||||
|
||||
```python
|
||||
if flex > 0.20: # 20% threshold
|
||||
flex_excess = flex - 0.20
|
||||
|
|
@ -158,15 +171,16 @@ if flex > 0.20: # 20% threshold
|
|||
|
||||
**Scaling Table (Original Min_Distance = 5%):**
|
||||
|
||||
| Flex | Scale Factor | Adjusted Min_Distance | Rationale |
|
||||
|-------|--------------|----------------------|-----------|
|
||||
| ≤20% | 1.00 | 5.0% | Standard - both filters relevant |
|
||||
| 25% | 0.88 | 4.4% | Slight reduction |
|
||||
| 30% | 0.75 | 3.75% | Moderate reduction |
|
||||
| 40% | 0.50 | 2.5% | Strong reduction - Flex dominates |
|
||||
| 50% | 0.25 | 1.25% | Minimal distance - Flex decides |
|
||||
| Flex | Scale Factor | Adjusted Min_Distance | Rationale |
|
||||
| ---- | ------------ | --------------------- | --------------------------------- |
|
||||
| ≤20% | 1.00 | 5.0% | Standard - both filters relevant |
|
||||
| 25% | 0.88 | 4.4% | Slight reduction |
|
||||
| 30% | 0.75 | 3.75% | Moderate reduction |
|
||||
| 40% | 0.50 | 2.5% | Strong reduction - Flex dominates |
|
||||
| 50% | 0.25 | 1.25% | Minimal distance - Flex decides |
|
||||
|
||||
**Why stop at 25% of original?**
|
||||
|
||||
- Min_Distance ensures periods are **significantly** different from average
|
||||
- Even at 1.25%, prevents "flat days" (little price variation) from accepting every interval
|
||||
- Maintains semantic meaning: "this is a meaningful best/peak price period"
|
||||
|
|
@ -174,6 +188,7 @@ if flex > 0.20: # 20% threshold
|
|||
**Implementation:** See `level_filtering.py` → `check_interval_criteria()`
|
||||
|
||||
**Code Extract:**
|
||||
|
||||
```python
|
||||
# coordinator/period_handlers/level_filtering.py
|
||||
|
||||
|
|
@ -209,12 +224,14 @@ def check_interval_criteria(price, criteria):
|
|||
```
|
||||
|
||||
**Why Linear Scaling?**
|
||||
|
||||
- Simple and predictable
|
||||
- No abrupt behavior changes
|
||||
- Easy to reason about for users and developers
|
||||
- Alternative considered: Exponential scaling (rejected as too aggressive)
|
||||
|
||||
**Why 25% Minimum?**
|
||||
|
||||
- Below this, min_distance loses semantic meaning
|
||||
- Even on flat days, some quality filter needed
|
||||
- Prevents "every interval is a period" scenario
|
||||
|
|
@ -227,12 +244,14 @@ def check_interval_criteria(price, criteria):
|
|||
### Implementation Constants
|
||||
|
||||
**Defined in `coordinator/period_handlers/core.py`:**
|
||||
|
||||
```python
|
||||
MAX_SAFE_FLEX = 0.50 # 50% - hard cap: above this, period detection becomes unreliable
|
||||
MAX_OUTLIER_FLEX = 0.25 # 25% - cap for outlier filtering: above this, spike detection too permissive
|
||||
```
|
||||
|
||||
**Defined in `const.py`:**
|
||||
|
||||
```python
|
||||
DEFAULT_BEST_PRICE_FLEX = 15 # 15% base - optimal for relaxation mode (default enabled)
|
||||
DEFAULT_PEAK_PRICE_FLEX = -20 # 20% base (negative for peak detection)
|
||||
|
|
@ -255,16 +274,19 @@ The different defaults reflect fundamentally different use cases:
|
|||
**Goal:** Find practical time windows for running appliances
|
||||
|
||||
**Constraints:**
|
||||
|
||||
- Appliances need time to complete cycles (dishwasher: 2-3h, EV charging: 4-8h)
|
||||
- Short periods are impractical (not worth automation overhead)
|
||||
- User wants genuinely cheap times, not just "slightly below average"
|
||||
|
||||
**Defaults:**
|
||||
|
||||
- **60 min minimum** - Ensures period is long enough for meaningful use
|
||||
- **15% flex** - Stricter selection, focuses on truly cheap times
|
||||
- **Reasoning:** Better to find fewer, higher-quality periods than many mediocre ones
|
||||
|
||||
**User behavior:**
|
||||
|
||||
- Automations trigger actions (turn on devices)
|
||||
- Wrong automation = wasted energy/money
|
||||
- Preference: Conservative (miss some savings) over aggressive (false positives)
|
||||
|
|
@ -274,16 +296,19 @@ The different defaults reflect fundamentally different use cases:
|
|||
**Goal:** Alert users to expensive periods for consumption reduction
|
||||
|
||||
**Constraints:**
|
||||
|
||||
- Brief price spikes still matter (even 15-30 min is worth avoiding)
|
||||
- Early warning more valuable than perfect accuracy
|
||||
- User can manually decide whether to react
|
||||
|
||||
**Defaults:**
|
||||
|
||||
- **30 min minimum** - Catches shorter expensive spikes
|
||||
- **20% flex** - More permissive, earlier detection
|
||||
- **Reasoning:** Better to warn early (even if not peak) than miss expensive periods
|
||||
|
||||
**User behavior:**
|
||||
|
||||
- Notifications/alerts (informational)
|
||||
- Wrong alert = minor inconvenience, not cost
|
||||
- Preference: Sensitive (catch more) over specific (catch only extremes)
|
||||
|
|
@ -293,17 +318,20 @@ The different defaults reflect fundamentally different use cases:
|
|||
**Peak Price Volatility:**
|
||||
|
||||
Price curves tend to have:
|
||||
|
||||
- **Sharp spikes** during peak hours (morning/evening)
|
||||
- **Shorter duration** at maximum (1-2 hours typical)
|
||||
- **Higher variance** in peak times than cheap times
|
||||
|
||||
**Example day:**
|
||||
|
||||
```
|
||||
Cheap period: 02:00-07:00 (5 hours at 10-12 ct) ← Gradual, stable
|
||||
Expensive period: 17:00-18:30 (1.5 hours at 35-40 ct) ← Sharp, brief
|
||||
```
|
||||
|
||||
**Implication:**
|
||||
|
||||
- Stricter flex on peak (15%) might miss real expensive periods (too brief)
|
||||
- Longer min_length (60 min) might exclude legitimate spikes
|
||||
- Solution: More flexible thresholds for peak detection
|
||||
|
|
@ -311,16 +339,19 @@ Expensive period: 17:00-18:30 (1.5 hours at 35-40 ct) ← Sharp, brief
|
|||
#### Design Alternatives Considered
|
||||
|
||||
**Option 1: Symmetric defaults (rejected)**
|
||||
|
||||
- Both 60 min, both 15% flex
|
||||
- Problem: Misses short but expensive spikes
|
||||
- User feedback: "Why didn't I get warned about the 30-min price spike?"
|
||||
|
||||
**Option 2: Same defaults, let users figure it out (rejected)**
|
||||
|
||||
- No guidance on best practices
|
||||
- Users would need to experiment to find good values
|
||||
- Most users stick with defaults, so defaults matter
|
||||
|
||||
**Option 3: Current approach (adopted)**
|
||||
|
||||
- **All values user-configurable** via config flow options
|
||||
- **Different installation defaults** for Best Price vs. Peak Price
|
||||
- Defaults reflect recommended practices for each use case
|
||||
|
|
@ -336,12 +367,14 @@ Expensive period: 17:00-18:30 (1.5 hours at 35-40 ct) ← Sharp, brief
|
|||
**Enforcement:** `core.py` caps `abs(flex)` at 0.50 (50%)
|
||||
|
||||
**Rationale:**
|
||||
|
||||
- Above 50%, period detection becomes unreliable
|
||||
- Best Price: Almost entire day qualifies (Min + 50% typically covers 60-80% of intervals)
|
||||
- Peak Price: Similar issue with Max - 50%
|
||||
- **Result:** Either massive periods (entire day) or no periods (min_length not met)
|
||||
|
||||
**Warning Message:**
|
||||
|
||||
```
|
||||
Flex XX% exceeds maximum safe value! Capping at 50%.
|
||||
Recommendation: Use 15-20% with relaxation enabled, or 25-35% without relaxation.
|
||||
|
|
@ -352,6 +385,7 @@ Recommendation: Use 15-20% with relaxation enabled, or 25-35% without relaxation
|
|||
**Enforcement:** `core.py` caps outlier filtering flex at 0.25 (25%)
|
||||
|
||||
**Rationale:**
|
||||
|
||||
- Outlier filtering uses Flex to determine "stable context" threshold
|
||||
- At > 25% Flex, almost any price swing is considered "stable"
|
||||
- **Result:** Legitimate price shifts aren't smoothed, breaking period formation
|
||||
|
|
@ -363,23 +397,28 @@ Recommendation: Use 15-20% with relaxation enabled, or 25-35% without relaxation
|
|||
#### With Relaxation Enabled (Recommended)
|
||||
|
||||
**Optimal:** 10-20%
|
||||
|
||||
- Relaxation increases Flex incrementally: 15% → 18% → 21% → ...
|
||||
- Low baseline ensures relaxation has room to work
|
||||
|
||||
**Warning Threshold:** > 25%
|
||||
|
||||
- INFO log: "Base flex is on the high side"
|
||||
|
||||
**High Warning:** > 30%
|
||||
|
||||
- WARNING log: "Base flex is very high for relaxation mode!"
|
||||
- Recommendation: Lower to 15-20%
|
||||
|
||||
#### Without Relaxation
|
||||
|
||||
**Optimal:** 20-35%
|
||||
|
||||
- No automatic adjustment, must be sufficient from start
|
||||
- Higher baseline acceptable since no relaxation fallback
|
||||
|
||||
**Maximum Useful:** ~50%
|
||||
|
||||
- Above this, period detection degrades (see Hard Limits)
|
||||
|
||||
---
|
||||
|
|
@ -395,6 +434,7 @@ Ensure **minimum periods per day** are found even when baseline filters are too
|
|||
### Multi-Phase Approach
|
||||
|
||||
**Each day processed independently:**
|
||||
|
||||
1. Calculate baseline periods with user's config
|
||||
2. If insufficient periods found, enter relaxation loop
|
||||
3. Try progressively relaxed filter combinations
|
||||
|
|
@ -418,6 +458,7 @@ for attempt in range(max_relaxation_attempts):
|
|||
```
|
||||
|
||||
**Constants:**
|
||||
|
||||
```python
|
||||
FLEX_WARNING_THRESHOLD_RELAXATION = 0.25 # 25% - INFO: suggest lowering to 15-20%
|
||||
FLEX_HIGH_THRESHOLD_RELAXATION = 0.30 # 30% - WARNING: very high for relaxation mode
|
||||
|
|
@ -427,26 +468,27 @@ MAX_FLEX_HARD_LIMIT = 0.50 # 50% - absolute maximum (enforced in core.py)
|
|||
**Design Decisions:**
|
||||
|
||||
1. **Why 3% fixed increment?**
|
||||
- Predictable escalation path (15% → 18% → 21% → ...)
|
||||
- Independent of base flex (works consistently)
|
||||
- 11 attempts covers full useful range (15% → 48%)
|
||||
- Balance: Not too slow (2%), not too fast (5%)
|
||||
- Predictable escalation path (15% → 18% → 21% → ...)
|
||||
- Independent of base flex (works consistently)
|
||||
- 11 attempts covers full useful range (15% → 48%)
|
||||
- Balance: Not too slow (2%), not too fast (5%)
|
||||
|
||||
2. **Why hard-coded, not configurable?**
|
||||
- Prevents user misconfiguration
|
||||
- Simplifies mental model (fewer knobs to turn)
|
||||
- Reliable behavior across all configurations
|
||||
- If needed, user adjusts `max_relaxation_attempts` (fewer/more steps)
|
||||
- Prevents user misconfiguration
|
||||
- Simplifies mental model (fewer knobs to turn)
|
||||
- Reliable behavior across all configurations
|
||||
- If needed, user adjusts `max_relaxation_attempts` (fewer/more steps)
|
||||
|
||||
3. **Why warn at 25% base flex?**
|
||||
- At 25% base, first relaxation step reaches 28%
|
||||
- Above 30%, entering diminishing returns territory
|
||||
- User likely doesn't need relaxation with such high base flex
|
||||
- Should either: (a) lower base flex, or (b) disable relaxation
|
||||
- At 25% base, first relaxation step reaches 28%
|
||||
- Above 30%, entering diminishing returns territory
|
||||
- User likely doesn't need relaxation with such high base flex
|
||||
- Should either: (a) lower base flex, or (b) disable relaxation
|
||||
|
||||
**Historical Context (Pre-November 2025):**
|
||||
|
||||
The algorithm previously used percentage-based increments that scaled with base flex:
|
||||
|
||||
```python
|
||||
increment = base_flex × (step_pct / 100) # REMOVED
|
||||
```
|
||||
|
|
@ -454,6 +496,7 @@ increment = base_flex × (step_pct / 100) # REMOVED
|
|||
This caused exponential escalation with high base flex values (e.g., 40% → 50% → 60% → 70% in just 6 steps), making behavior unpredictable. The fixed 3% increment solves this by providing consistent, controlled escalation regardless of starting point.
|
||||
|
||||
**Warning Messages:**
|
||||
|
||||
```python
|
||||
if base_flex >= FLEX_HIGH_THRESHOLD_RELAXATION: # 30%
|
||||
_LOGGER.warning(
|
||||
|
|
@ -472,12 +515,14 @@ elif base_flex >= FLEX_WARNING_THRESHOLD_RELAXATION: # 25%
|
|||
### Filter Combination Strategy
|
||||
|
||||
**Per Flex level, try in order:**
|
||||
|
||||
1. Original Level filter
|
||||
2. Level filter = "any" (disabled)
|
||||
|
||||
**Early Exit:** Stop immediately when target reached (don't try unnecessary combinations)
|
||||
|
||||
**Example Flow (target=2 periods/day):**
|
||||
|
||||
```
|
||||
Day 2025-11-19:
|
||||
1. Baseline flex=15%: Found 1 period (need 2)
|
||||
|
|
@ -492,6 +537,7 @@ Day 2025-11-19:
|
|||
### Key Files and Functions
|
||||
|
||||
**Period Calculation Entry Point:**
|
||||
|
||||
```python
|
||||
# coordinator/period_handlers/core.py
|
||||
def calculate_periods(
|
||||
|
|
@ -502,6 +548,7 @@ def calculate_periods(
|
|||
```
|
||||
|
||||
**Flex + Distance Filtering:**
|
||||
|
||||
```python
|
||||
# coordinator/period_handlers/level_filtering.py
|
||||
def check_interval_criteria(
|
||||
|
|
@ -511,6 +558,7 @@ def check_interval_criteria(
|
|||
```
|
||||
|
||||
**Relaxation Orchestration:**
|
||||
|
||||
```python
|
||||
# coordinator/period_handlers/relaxation.py
|
||||
def calculate_periods_with_relaxation(...) -> tuple[dict, dict]
|
||||
|
|
@ -526,43 +574,45 @@ def relax_single_day(...) -> tuple[dict, dict]
|
|||
**Algorithm Details:**
|
||||
|
||||
1. **Linear Regression Prediction:**
|
||||
- Uses surrounding intervals to predict expected price
|
||||
- Window size: 3+ intervals (MIN_CONTEXT_SIZE)
|
||||
- Calculates trend slope and standard deviation
|
||||
- Formula: `predicted = mean + slope × (position - center)`
|
||||
- Uses surrounding intervals to predict expected price
|
||||
- Window size: 3+ intervals (MIN_CONTEXT_SIZE)
|
||||
- Calculates trend slope and standard deviation
|
||||
- Formula: `predicted = mean + slope × (position - center)`
|
||||
|
||||
2. **Confidence Intervals:**
|
||||
- 95% confidence level (2 standard deviations)
|
||||
- Tolerance = 2.0 × std_dev (CONFIDENCE_LEVEL constant)
|
||||
- Outlier if: `|actual - predicted| > tolerance`
|
||||
- Accounts for natural price volatility in context window
|
||||
- 95% confidence level (2 standard deviations)
|
||||
- Tolerance = 2.0 × std_dev (CONFIDENCE_LEVEL constant)
|
||||
- Outlier if: `|actual - predicted| > tolerance`
|
||||
- Accounts for natural price volatility in context window
|
||||
|
||||
3. **Symmetry Check:**
|
||||
- Rejects asymmetric outliers (threshold: 1.5 std dev)
|
||||
- Preserves legitimate price shifts (morning/evening peaks)
|
||||
- Algorithm:
|
||||
```python
|
||||
residual = abs(actual - predicted)
|
||||
symmetry_threshold = 1.5 × std_dev
|
||||
- Rejects asymmetric outliers (threshold: 1.5 std dev)
|
||||
- Preserves legitimate price shifts (morning/evening peaks)
|
||||
- Algorithm:
|
||||
|
||||
if residual > tolerance:
|
||||
# Check if spike is symmetric in context
|
||||
context_residuals = [abs(p - pred) for p, pred in context]
|
||||
avg_context_residual = mean(context_residuals)
|
||||
```python
|
||||
residual = abs(actual - predicted)
|
||||
symmetry_threshold = 1.5 × std_dev
|
||||
|
||||
if residual > symmetry_threshold × avg_context_residual:
|
||||
# Asymmetric spike → smooth it
|
||||
else:
|
||||
# Symmetric (part of trend) → keep it
|
||||
```
|
||||
if residual > tolerance:
|
||||
# Check if spike is symmetric in context
|
||||
context_residuals = [abs(p - pred) for p, pred in context]
|
||||
avg_context_residual = mean(context_residuals)
|
||||
|
||||
if residual > symmetry_threshold × avg_context_residual:
|
||||
# Asymmetric spike → smooth it
|
||||
else:
|
||||
# Symmetric (part of trend) → keep it
|
||||
```
|
||||
|
||||
4. **Enhanced Zigzag Detection:**
|
||||
- Detects spike clusters via relative volatility
|
||||
- Threshold: 2.0× local volatility (RELATIVE_VOLATILITY_THRESHOLD)
|
||||
- Single-pass algorithm (no iteration needed)
|
||||
- Catches patterns like: 18, 35, 19, 34, 18 (alternating spikes)
|
||||
- Detects spike clusters via relative volatility
|
||||
- Threshold: 2.0× local volatility (RELATIVE_VOLATILITY_THRESHOLD)
|
||||
- Single-pass algorithm (no iteration needed)
|
||||
- Catches patterns like: 18, 35, 19, 34, 18 (alternating spikes)
|
||||
|
||||
**Constants:**
|
||||
|
||||
```python
|
||||
# coordinator/period_handlers/outlier_filtering.py
|
||||
|
||||
|
|
@ -573,18 +623,21 @@ MIN_CONTEXT_SIZE = 3 # Minimum intervals for regression
|
|||
```
|
||||
|
||||
**Data Integrity:**
|
||||
|
||||
- Original prices stored in `_original_price` field
|
||||
- All statistics (daily min/max/avg) use original prices
|
||||
- Smoothing only affects period formation logic
|
||||
- Smart counting: Only counts smoothing that changed period outcome
|
||||
|
||||
**Performance:**
|
||||
|
||||
- Single pass through price data
|
||||
- O(n) complexity with small context window
|
||||
- No iterative refinement needed
|
||||
- Typical processing time: `<`1ms for 96 intervals
|
||||
|
||||
**Example Debug Output:**
|
||||
|
||||
```
|
||||
DEBUG: [2025-11-11T14:30:00+01:00] Outlier detected: 35.2 ct
|
||||
DEBUG: Context: 18.5, 19.1, 19.3, 19.8, 20.2 ct
|
||||
|
|
@ -598,19 +651,19 @@ DEBUG: Asymmetry ratio: 3.2 (>1.5 threshold) → confirmed outlier
|
|||
**Why This Approach?**
|
||||
|
||||
1. **Linear regression over moving average:**
|
||||
- Accounts for price trends (morning ramp-up, evening decline)
|
||||
- Moving average can't predict direction, only level
|
||||
- Better accuracy on non-stationary price curves
|
||||
- Accounts for price trends (morning ramp-up, evening decline)
|
||||
- Moving average can't predict direction, only level
|
||||
- Better accuracy on non-stationary price curves
|
||||
|
||||
2. **Symmetry check over fixed threshold:**
|
||||
- Prevents false positives on legitimate price shifts
|
||||
- Adapts to local volatility patterns
|
||||
- Preserves user expectation: "expensive during peak hours"
|
||||
- Prevents false positives on legitimate price shifts
|
||||
- Adapts to local volatility patterns
|
||||
- Preserves user expectation: "expensive during peak hours"
|
||||
|
||||
3. **Single-pass over iterative:**
|
||||
- Predictable behavior (no convergence issues)
|
||||
- Fast and deterministic
|
||||
- Easier to debug and reason about
|
||||
- Predictable behavior (no convergence issues)
|
||||
- Fast and deterministic
|
||||
- Easier to debug and reason about
|
||||
|
||||
**Alternative Approaches Considered:**
|
||||
|
||||
|
|
@ -624,15 +677,17 @@ DEBUG: Asymmetry ratio: 3.2 (>1.5 threshold) → confirmed outlier
|
|||
## Debugging Tips
|
||||
|
||||
**Enable DEBUG logging:**
|
||||
|
||||
```yaml
|
||||
# configuration.yaml
|
||||
logger:
|
||||
default: info
|
||||
logs:
|
||||
custom_components.tibber_prices.coordinator.period_handlers: debug
|
||||
default: info
|
||||
logs:
|
||||
custom_components.tibber_prices.coordinator.period_handlers: debug
|
||||
```
|
||||
|
||||
**Key log messages to watch:**
|
||||
|
||||
1. `"Filter statistics: X intervals checked"` - Shows how many intervals filtered by each criterion
|
||||
2. `"After build_periods: X raw periods found"` - Periods before min_length filtering
|
||||
3. `"Day X: Success with flex=Y%"` - Relaxation succeeded
|
||||
|
|
@ -645,52 +700,61 @@ logger:
|
|||
### ❌ Anti-Pattern 1: High Flex with Relaxation
|
||||
|
||||
**Configuration:**
|
||||
|
||||
```yaml
|
||||
best_price_flex: 40
|
||||
enable_relaxation_best: true
|
||||
```
|
||||
|
||||
**Problem:**
|
||||
|
||||
- Base Flex 40% already very permissive
|
||||
- Relaxation increments further (43%, 46%, 49%, ...)
|
||||
- Quickly approaches 50% cap with diminishing returns
|
||||
|
||||
**Solution:**
|
||||
|
||||
```yaml
|
||||
best_price_flex: 15 # Let relaxation increase it
|
||||
best_price_flex: 15 # Let relaxation increase it
|
||||
enable_relaxation_best: true
|
||||
```
|
||||
|
||||
### ❌ Anti-Pattern 2: Zero Min_Distance
|
||||
|
||||
**Configuration:**
|
||||
|
||||
```yaml
|
||||
best_price_min_distance_from_avg: 0
|
||||
```
|
||||
|
||||
**Problem:**
|
||||
|
||||
- "Flat days" (little price variation) accept all intervals
|
||||
- Periods lose semantic meaning ("significantly cheap")
|
||||
- May create periods during barely-below-average times
|
||||
|
||||
**Solution:**
|
||||
|
||||
```yaml
|
||||
best_price_min_distance_from_avg: 5 # Use default 5%
|
||||
best_price_min_distance_from_avg: 5 # Use default 5%
|
||||
```
|
||||
|
||||
### ❌ Anti-Pattern 3: Conflicting Flex + Distance
|
||||
|
||||
**Configuration:**
|
||||
|
||||
```yaml
|
||||
best_price_flex: 45
|
||||
best_price_min_distance_from_avg: 10
|
||||
```
|
||||
|
||||
**Problem:**
|
||||
|
||||
- Distance filter dominates, making Flex irrelevant
|
||||
- Dynamic scaling helps but still suboptimal
|
||||
|
||||
**Solution:**
|
||||
|
||||
```yaml
|
||||
best_price_flex: 20
|
||||
best_price_min_distance_from_avg: 5
|
||||
|
|
@ -706,11 +770,13 @@ best_price_min_distance_from_avg: 5
|
|||
**Average:** 15 ct/kWh
|
||||
|
||||
**Expected Behavior:**
|
||||
|
||||
- Flex 15%: Should find 2-4 clear best price periods
|
||||
- Flex 30%: Should find 4-8 periods (more lenient)
|
||||
- Min_Distance 5%: Effective throughout range
|
||||
|
||||
**Debug Checks:**
|
||||
|
||||
```
|
||||
DEBUG: Filter statistics: 96 intervals checked
|
||||
DEBUG: Filtered by FLEX: 12/96 (12.5%) ← Low percentage = good variation
|
||||
|
|
@ -724,11 +790,13 @@ DEBUG: After build_periods: 3 raw periods found
|
|||
**Average:** 15 ct/kWh
|
||||
|
||||
**Expected Behavior:**
|
||||
|
||||
- Flex 15%: May find 1-2 small periods (or zero if no clear winners)
|
||||
- Min_Distance 5%: Critical here - ensures only truly cheaper intervals qualify
|
||||
- Without Min_Distance: Would accept almost entire day as "best price"
|
||||
|
||||
**Debug Checks:**
|
||||
|
||||
```
|
||||
DEBUG: Filter statistics: 96 intervals checked
|
||||
DEBUG: Filtered by FLEX: 45/96 (46.9%) ← High percentage = poor variation
|
||||
|
|
@ -743,11 +811,13 @@ DEBUG: Day 2025-11-11: Baseline insufficient (1 < 2), starting relaxation
|
|||
**Average:** 18 ct/kWh
|
||||
|
||||
**Expected Behavior:**
|
||||
|
||||
- Flex 15%: Finds multiple very cheap periods (5-6 ct)
|
||||
- Outlier filtering: May smooth isolated spikes (30-40 ct)
|
||||
- Distance filter: Less impactful (clear separation between cheap/expensive)
|
||||
|
||||
**Debug Checks:**
|
||||
|
||||
```
|
||||
DEBUG: Outlier detected: 38.5 ct (threshold: 4.2 ct)
|
||||
DEBUG: Smoothed to: 20.1 ct (trend prediction)
|
||||
|
|
@ -762,6 +832,7 @@ DEBUG: After build_periods: 4 raw periods found
|
|||
**Initial State:** Baseline finds 1 period, target is 2
|
||||
|
||||
**Expected Flow:**
|
||||
|
||||
```
|
||||
INFO: Calculating BEST PRICE periods: relaxation=ON, target=2/day, flex=15.0%
|
||||
DEBUG: Day 2025-11-11: Baseline found 1 period (need 2)
|
||||
|
|
@ -777,6 +848,7 @@ INFO: Day 2025-11-11: Success after 1 relaxation phase (2 periods)
|
|||
**Initial State:** Strict filters, very flat day
|
||||
|
||||
**Expected Flow:**
|
||||
|
||||
```
|
||||
INFO: Calculating BEST PRICE periods: relaxation=ON, target=2/day, flex=15.0%
|
||||
DEBUG: Day 2025-11-11: Baseline found 0 periods (need 2)
|
||||
|
|
@ -790,31 +862,31 @@ INFO: Period calculation completed: 1/2 days reached target
|
|||
When debugging period calculation issues:
|
||||
|
||||
1. **Check Filter Statistics**
|
||||
- Which filter blocks most intervals? (flex, distance, or level)
|
||||
- High flex filtering (>30%) = Need more flexibility or relaxation
|
||||
- High distance filtering (>50%) = Min_distance too strict or flat day
|
||||
- High level filtering = Level filter too restrictive
|
||||
- Which filter blocks most intervals? (flex, distance, or level)
|
||||
- High flex filtering (>30%) = Need more flexibility or relaxation
|
||||
- High distance filtering (>50%) = Min_distance too strict or flat day
|
||||
- High level filtering = Level filter too restrictive
|
||||
|
||||
2. **Check Relaxation Behavior**
|
||||
- Did relaxation activate? Check for "Baseline insufficient" message
|
||||
- Which phase succeeded? Early success (phase 1-3) = good config
|
||||
- Late success (phase 8-11) = Consider adjusting base config
|
||||
- Exhausted all phases = Unrealistic target for this day's price curve
|
||||
- Did relaxation activate? Check for "Baseline insufficient" message
|
||||
- Which phase succeeded? Early success (phase 1-3) = good config
|
||||
- Late success (phase 8-11) = Consider adjusting base config
|
||||
- Exhausted all phases = Unrealistic target for this day's price curve
|
||||
|
||||
3. **Check Flex Warnings**
|
||||
- INFO at 25% base flex = On the high side
|
||||
- WARNING at 30% base flex = Too high for relaxation
|
||||
- If seeing these: Lower base flex to 15-20%
|
||||
- INFO at 25% base flex = On the high side
|
||||
- WARNING at 30% base flex = Too high for relaxation
|
||||
- If seeing these: Lower base flex to 15-20%
|
||||
|
||||
4. **Check Min_Distance Scaling**
|
||||
- Debug messages show "High flex X% detected: Reducing min_distance Y% → Z%"
|
||||
- If scale factor `<`0.8 (20% reduction): High flex is active
|
||||
- If periods still not found: Filters conflict even with scaling
|
||||
- Debug messages show "High flex X% detected: Reducing min_distance Y% → Z%"
|
||||
- If scale factor `<`0.8 (20% reduction): High flex is active
|
||||
- If periods still not found: Filters conflict even with scaling
|
||||
|
||||
5. **Check Outlier Filtering**
|
||||
- Look for "Outlier detected" messages
|
||||
- Check `period_interval_smoothed_count` attribute
|
||||
- If no smoothing but periods fragmented: Not isolated spikes, but legitimate price levels
|
||||
- Look for "Outlier detected" messages
|
||||
- Check `period_interval_smoothed_count` attribute
|
||||
- If no smoothing but periods fragmented: Not isolated spikes, but legitimate price levels
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -823,19 +895,19 @@ When debugging period calculation issues:
|
|||
### Potential Improvements
|
||||
|
||||
1. **Adaptive Flex Calculation:**
|
||||
- Auto-adjust Flex based on daily price variation
|
||||
- High variation days: Lower Flex needed
|
||||
- Low variation days: Higher Flex needed
|
||||
- Auto-adjust Flex based on daily price variation
|
||||
- High variation days: Lower Flex needed
|
||||
- Low variation days: Higher Flex needed
|
||||
|
||||
2. **Machine Learning Approach:**
|
||||
- Learn optimal Flex/Distance from user feedback
|
||||
- Classify days by pattern (normal/flat/volatile/bimodal)
|
||||
- Apply pattern-specific defaults
|
||||
- Learn optimal Flex/Distance from user feedback
|
||||
- Classify days by pattern (normal/flat/volatile/bimodal)
|
||||
- Apply pattern-specific defaults
|
||||
|
||||
3. **Multi-Objective Optimization:**
|
||||
- Balance period count vs. quality
|
||||
- Consider period duration vs. price level
|
||||
- Optimize for user's stated use case (EV charging vs. heat pump)
|
||||
- Balance period count vs. quality
|
||||
- Consider period duration vs. price level
|
||||
- Optimize for user's stated use case (EV charging vs. heat pump)
|
||||
|
||||
### Known Limitations
|
||||
|
||||
|
|
@ -854,6 +926,7 @@ When debugging period calculation issues:
|
|||
**Concept:** Auto-adjust Flex based on daily price variation
|
||||
|
||||
**Algorithm:**
|
||||
|
||||
```python
|
||||
# Pseudo-code for adaptive flex
|
||||
variation = (daily_max - daily_min) / daily_avg
|
||||
|
|
@ -867,11 +940,13 @@ else: # Normal day
|
|||
```
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- Eliminates need for relaxation on most days
|
||||
- Self-adjusting to market conditions
|
||||
- Better user experience (less configuration needed)
|
||||
|
||||
**Challenges:**
|
||||
|
||||
- Harder to predict behavior (less transparent)
|
||||
- May conflict with user's mental model
|
||||
- Needs extensive testing across different markets
|
||||
|
|
@ -883,17 +958,20 @@ else: # Normal day
|
|||
**Concept:** Learn optimal Flex/Distance from user feedback
|
||||
|
||||
**Approach:**
|
||||
|
||||
- Track which periods user actually uses (automation triggers)
|
||||
- Classify days by pattern (normal/flat/volatile/bimodal)
|
||||
- Apply pattern-specific defaults
|
||||
- Learn per-user preferences over time
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- Personalized to user's actual behavior
|
||||
- Adapts to local market patterns
|
||||
- Could discover non-obvious patterns
|
||||
|
||||
**Challenges:**
|
||||
|
||||
- Requires user feedback mechanism (not implemented)
|
||||
- Privacy concerns (storing usage patterns)
|
||||
- Complexity for users to understand "why this period?"
|
||||
|
|
@ -906,22 +984,26 @@ else: # Normal day
|
|||
**Concept:** Balance multiple goals simultaneously
|
||||
|
||||
**Goals:**
|
||||
|
||||
- Period count vs. quality (cheap vs. very cheap)
|
||||
- Period duration vs. price level (long mediocre vs. short excellent)
|
||||
- Temporal distribution (spread throughout day vs. clustered)
|
||||
- User's stated use case (EV charging vs. heat pump vs. dishwasher)
|
||||
|
||||
**Algorithm:**
|
||||
|
||||
- Pareto optimization (find trade-off frontier)
|
||||
- User chooses point on frontier via preferences
|
||||
- Genetic algorithm or simulated annealing
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- More sophisticated period selection
|
||||
- Better match to user's actual needs
|
||||
- Could handle complex appliance requirements
|
||||
|
||||
**Challenges:**
|
||||
|
||||
- Much more complex to implement
|
||||
- Harder to explain to users
|
||||
- Computational cost (may need caching)
|
||||
|
|
@ -936,14 +1018,17 @@ else: # Normal day
|
|||
**Current:** 3% cap may be too aggressive for very low base Flex
|
||||
|
||||
**Example:**
|
||||
|
||||
- Base flex 5% + 3% increment = 8% (60% increase!)
|
||||
- Base flex 15% + 3% increment = 18% (20% increase)
|
||||
|
||||
**Possible Solution:**
|
||||
|
||||
- Percentage-based increment: `increment = max(base_flex × 0.20, 0.03)`
|
||||
- This gives: 5% → 6% (20%), 15% → 18% (20%), 40% → 43% (7.5%)
|
||||
|
||||
**Why Not Implemented:**
|
||||
|
||||
- Very low base flex (`<`10%) unusual
|
||||
- Users with strict requirements likely disable relaxation
|
||||
- Simplicity preferred over edge case optimization
|
||||
|
|
@ -953,6 +1038,7 @@ else: # Normal day
|
|||
**Current:** Linear scaling may be too aggressive/conservative
|
||||
|
||||
**Alternative:** Non-linear curve
|
||||
|
||||
```python
|
||||
# Example: Exponential scaling
|
||||
scale_factor = 0.25 + 0.75 × exp(-5 × (flex - 0.20))
|
||||
|
|
@ -962,6 +1048,7 @@ scale_factor = 0.25 + 0.75 / (1 + exp(10 × (flex - 0.35)))
|
|||
```
|
||||
|
||||
**Why Not Implemented:**
|
||||
|
||||
- Linear is easier to reason about
|
||||
- No evidence that non-linear is better
|
||||
- Would need extensive testing
|
||||
|
|
@ -971,15 +1058,18 @@ scale_factor = 0.25 + 0.75 / (1 + exp(10 × (flex - 0.35)))
|
|||
**Issue:** May find all periods in one part of day
|
||||
|
||||
**Example:**
|
||||
|
||||
- All 3 "best price" periods between 02:00-08:00
|
||||
- No periods in evening (when user might want to run appliances)
|
||||
|
||||
**Possible Solution:**
|
||||
|
||||
- Add "spread" parameter (prefer distributed periods)
|
||||
- Weight periods by time-of-day preferences
|
||||
- Consider user's typical usage patterns
|
||||
|
||||
**Why Not Implemented:**
|
||||
|
||||
- Adds complexity
|
||||
- Users can work around with multiple automations
|
||||
- Different users have different needs (no one-size-fits-all)
|
||||
|
|
@ -991,6 +1081,7 @@ scale_factor = 0.25 + 0.75 / (1 + exp(10 × (flex - 0.35)))
|
|||
**Design Principle:** Each interval is evaluated using its **own day's** reference prices (daily min/max/avg).
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```python
|
||||
# In period_building.py build_periods():
|
||||
for price_data in all_prices:
|
||||
|
|
@ -1042,6 +1133,7 @@ Period crossing midnight: 23:45 Day 1 → 00:15 Day 2
|
|||
**Trade-off: Periods May Break at Midnight**
|
||||
|
||||
When days differ significantly, period can split:
|
||||
|
||||
```
|
||||
Day 1: Min=10ct, Avg=20ct, 23:45=11ct → ✅ Cheap (relative to Day 1)
|
||||
Day 2: Min=25ct, Avg=35ct, 00:00=21ct → ❌ Expensive (relative to Day 2)
|
||||
|
|
@ -1053,6 +1145,7 @@ This is **mathematically correct** - 21ct is genuinely expensive on a day where
|
|||
**Market Reality Explains Price Jumps:**
|
||||
|
||||
Day-ahead electricity markets (EPEX SPOT) set prices at 12:00 CET for all next-day hours:
|
||||
|
||||
- Late intervals (23:45): Priced ~36h before delivery → high forecast uncertainty → risk premium
|
||||
- Early intervals (00:00): Priced ~12h before delivery → better forecasts → lower risk buffer
|
||||
|
||||
|
|
@ -1061,15 +1154,17 @@ This explains why absolute prices jump at midnight despite minimal demand change
|
|||
**User-Facing Solution (Nov 2025):**
|
||||
|
||||
Added per-period day volatility attributes to detect when classification changes are meaningful:
|
||||
|
||||
- `day_volatility_%`: Percentage spread (span/avg × 100)
|
||||
- `day_price_min`, `day_price_max`, `day_price_span`: Daily price range (ct/øre)
|
||||
|
||||
Automations can check volatility before acting:
|
||||
|
||||
```yaml
|
||||
condition:
|
||||
- condition: template
|
||||
value_template: >
|
||||
{{ state_attr('binary_sensor.tibber_home_best_price_period', 'day_volatility_%') | float(0) > 15 }}
|
||||
- condition: template
|
||||
value_template: >
|
||||
{{ state_attr('binary_sensor.tibber_home_best_price_period', 'day_volatility_%') | float(0) > 15 }}
|
||||
```
|
||||
|
||||
Low volatility (< 15%) means classification changes are less economically significant.
|
||||
|
|
@ -1077,24 +1172,25 @@ Low volatility (< 15%) means classification changes are less economically signif
|
|||
**Alternative Approaches Rejected:**
|
||||
|
||||
1. **Use period start day for all intervals**
|
||||
- Problem: Mathematically incorrect - lends cheap day's criteria to expensive day
|
||||
- Rejected: Violates relative evaluation principle
|
||||
- Problem: Mathematically incorrect - lends cheap day's criteria to expensive day
|
||||
- Rejected: Violates relative evaluation principle
|
||||
|
||||
2. **Adjust flex/distance at midnight**
|
||||
- Problem: Complex, unpredictable, hides market reality
|
||||
- Rejected: Users should understand price context, not have it hidden
|
||||
- Problem: Complex, unpredictable, hides market reality
|
||||
- Rejected: Users should understand price context, not have it hidden
|
||||
|
||||
3. **Split at midnight always**
|
||||
- Problem: Artificially fragments natural periods
|
||||
- Rejected: Worse user experience
|
||||
- Problem: Artificially fragments natural periods
|
||||
- Rejected: Worse user experience
|
||||
|
||||
4. **Use next day's reference after midnight**
|
||||
- Problem: Period criteria inconsistent across duration
|
||||
- Rejected: Confusing and unpredictable
|
||||
- Problem: Period criteria inconsistent across duration
|
||||
- Rejected: Confusing and unpredictable
|
||||
|
||||
**Status:** Per-day evaluation is intentional design prioritizing mathematical correctness.
|
||||
|
||||
**See Also:**
|
||||
|
||||
- User documentation: `docs/user/docs/period-calculation.md` → "Midnight Price Classification Changes"
|
||||
- Implementation: `coordinator/period_handlers/period_building.py` (line ~126: `ref_date = date_key`)
|
||||
- Attributes: `coordinator/period_handlers/period_statistics.py` (day volatility calculation)
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ Both `TibberPricesSensor` and `TibberPricesBinarySensor` implement `_unrecorded_
|
|||
```python
|
||||
class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
||||
"""tibber_prices Sensor class."""
|
||||
|
||||
|
||||
_unrecorded_attributes = frozenset(
|
||||
{
|
||||
"description",
|
||||
|
|
@ -29,6 +29,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
```
|
||||
|
||||
**Key Points:**
|
||||
|
||||
- Must be a **class attribute** (not instance attribute)
|
||||
- Use `frozenset` for immutability and performance
|
||||
- Applied automatically by Home Assistant's Recorder component
|
||||
|
|
@ -40,6 +41,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
**Attributes:** `description`, `usage_tips`
|
||||
|
||||
**Reason:** Static, large text strings (100-500 chars each) that:
|
||||
|
||||
- Never change or change very rarely
|
||||
- Don't provide analytical value in history
|
||||
- Consume significant database space when recorded every state change
|
||||
|
|
@ -50,6 +52,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
### 2. Large Nested Structures
|
||||
|
||||
**Attributes:**
|
||||
|
||||
- `periods` (binary_sensor) - Array of all period summaries
|
||||
- `data` (chart_data_export) - Complete price data arrays
|
||||
- `trend_attributes` - Detailed trend analysis
|
||||
|
|
@ -58,6 +61,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
- `volatility_attributes` - Detailed volatility breakdown
|
||||
|
||||
**Reason:** Complex nested data structures that are:
|
||||
|
||||
- Serialized to JSON for storage (expensive)
|
||||
- Create large database rows (2-20 KB each)
|
||||
- Slow down history queries
|
||||
|
|
@ -66,20 +70,21 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
**Impact:** ~10-30 KB saved per state change for affected sensors
|
||||
|
||||
**Example - periods array:**
|
||||
|
||||
```json
|
||||
{
|
||||
"periods": [
|
||||
{
|
||||
"start": "2025-12-07T06:00:00+01:00",
|
||||
"end": "2025-12-07T08:00:00+01:00",
|
||||
"duration_minutes": 120,
|
||||
"price_mean": 18.5,
|
||||
"price_median": 18.3,
|
||||
"price_min": 17.2,
|
||||
"price_max": 19.8,
|
||||
// ... 10+ more attributes × 10-20 periods
|
||||
}
|
||||
]
|
||||
"periods": [
|
||||
{
|
||||
"start": "2025-12-07T06:00:00+01:00",
|
||||
"end": "2025-12-07T08:00:00+01:00",
|
||||
"duration_minutes": 120,
|
||||
"price_mean": 18.5,
|
||||
"price_median": 18.3,
|
||||
"price_min": 17.2,
|
||||
"price_max": 19.8
|
||||
// ... 10+ more attributes × 10-20 periods
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -88,6 +93,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
**Attributes:** `icon_color`, `cache_age`, `cache_validity`, `data_completeness`, `data_status`
|
||||
|
||||
**Reason:**
|
||||
|
||||
- Change every update cycle (every 15 minutes or more frequently)
|
||||
- Don't provide long-term analytical value
|
||||
- Create state changes even when core values haven't changed
|
||||
|
|
@ -103,6 +109,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
**Attributes:** `tomorrow_expected_after`, `level_value`, `rating_value`, `level_id`, `rating_id`, `currency`, `resolution`, `yaxis_min`, `yaxis_max`
|
||||
|
||||
**Reason:**
|
||||
|
||||
- Configuration values that rarely change
|
||||
- Wastes space when recorded repeatedly
|
||||
- Can be derived from other attributes or from entity state
|
||||
|
|
@ -114,6 +121,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
**Attributes:** `next_api_poll`, `next_midnight_turnover`, `last_api_fetch`, `last_cache_update`, `last_turnover`, `last_error`, `error`
|
||||
|
||||
**Reason:**
|
||||
|
||||
- Only relevant at moment of reading
|
||||
- Won't be valid after some time
|
||||
- Similar to `entity_picture` in HA core image entities
|
||||
|
|
@ -128,6 +136,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
**Attributes:** `relaxation_level`, `relaxation_threshold_original_%`, `relaxation_threshold_applied_%`
|
||||
|
||||
**Reason:**
|
||||
|
||||
- Detailed technical information not needed for historical analysis
|
||||
- Only useful for debugging during active development
|
||||
- Boolean `relaxation_active` is kept for high-level analysis
|
||||
|
|
@ -139,6 +148,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
**Attributes:** `price_spread`, `volatility`, `diff_%`, `rating_difference_%`, `period_price_diff_from_daily_min`, `period_price_diff_from_daily_min_%`, `periods_total`, `periods_remaining`
|
||||
|
||||
**Reason:**
|
||||
|
||||
- Can be calculated from other attributes
|
||||
- Redundant information
|
||||
- Doesn't add analytical value to history
|
||||
|
|
@ -152,22 +162,27 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
|
|||
These attributes **remain in history** because they provide essential analytical value:
|
||||
|
||||
### Time-Series Core
|
||||
|
||||
- `timestamp` - Critical for time-series analysis (ALWAYS FIRST)
|
||||
- All price values - Core sensor states
|
||||
|
||||
### Diagnostics & Tracking
|
||||
|
||||
- `cache_age_minutes` - Numeric value for diagnostics tracking over time
|
||||
- `updates_today` - Tracking API usage patterns
|
||||
|
||||
### Data Completeness
|
||||
|
||||
- `interval_count`, `intervals_available` - Data completeness metrics
|
||||
- `yesterday_available`, `today_available`, `tomorrow_available` - Boolean status
|
||||
|
||||
### Period Data
|
||||
|
||||
- `start`, `end`, `duration_minutes` - Core period timing
|
||||
- `price_mean`, `price_median`, `price_min`, `price_max` - Core price statistics
|
||||
|
||||
### High-Level Status
|
||||
|
||||
- `relaxation_active` - Whether relaxation was used (boolean, useful for analyzing when periods needed relaxation)
|
||||
|
||||
## Expected Database Impact
|
||||
|
|
@ -175,6 +190,7 @@ These attributes **remain in history** because they provide essential analytical
|
|||
### Space Savings
|
||||
|
||||
**Per state change:**
|
||||
|
||||
- Before: ~3-8 KB average
|
||||
- After: ~0.5-1.5 KB average
|
||||
- **Reduction: 60-85%**
|
||||
|
|
@ -196,6 +212,7 @@ These attributes **remain in history** because they provide essential analytical
|
|||
### Real-World Impact
|
||||
|
||||
For a typical installation with:
|
||||
|
||||
- 80+ sensors
|
||||
- Updates every 15 minutes
|
||||
- ~10 sensors updating every minute
|
||||
|
|
@ -207,14 +224,14 @@ For a typical installation with:
|
|||
## Implementation Files
|
||||
|
||||
- **Sensor Platform**: `custom_components/tibber_prices/sensor/core.py`
|
||||
- Class: `TibberPricesSensor`
|
||||
- 47 attributes excluded
|
||||
- Class: `TibberPricesSensor`
|
||||
- 47 attributes excluded
|
||||
|
||||
- **Binary Sensor Platform**: `custom_components/tibber_prices/binary_sensor/core.py`
|
||||
- Class: `TibberPricesBinarySensor`
|
||||
- 30 attributes excluded
|
||||
- Class: `TibberPricesBinarySensor`
|
||||
- 30 attributes excluded
|
||||
|
||||
## When to Update _unrecorded_attributes
|
||||
## When to Update \_unrecorded_attributes
|
||||
|
||||
### Add to Exclusion List When:
|
||||
|
||||
|
|
@ -236,24 +253,24 @@ For a typical installation with:
|
|||
When adding a new attribute, ask:
|
||||
|
||||
1. **Will this be useful in history queries 1 week from now?**
|
||||
- No → Exclude
|
||||
- Yes → Keep
|
||||
- No → Exclude
|
||||
- Yes → Keep
|
||||
|
||||
2. **Can this be calculated from other recorded attributes?**
|
||||
- Yes → Exclude
|
||||
- No → Keep
|
||||
- Yes → Exclude
|
||||
- No → Keep
|
||||
|
||||
3. **Is this primarily for current UI display?**
|
||||
- Yes → Exclude
|
||||
- No → Keep
|
||||
- Yes → Exclude
|
||||
- No → Keep
|
||||
|
||||
4. **Does this change frequently without indicating state change?**
|
||||
- Yes → Exclude
|
||||
- No → Keep
|
||||
- Yes → Exclude
|
||||
- No → Keep
|
||||
|
||||
5. **Is this larger than 100 bytes and not essential for analysis?**
|
||||
- Yes → Exclude
|
||||
- No → Keep
|
||||
- Yes → Exclude
|
||||
- No → Keep
|
||||
|
||||
## Testing
|
||||
|
||||
|
|
@ -265,13 +282,14 @@ After modifying `_unrecorded_attributes`:
|
|||
4. **Confirm excluded attributes** don't appear in new state writes
|
||||
|
||||
**SQL Query to check attribute presence:**
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
SELECT
|
||||
state_id,
|
||||
attributes
|
||||
FROM states
|
||||
FROM states
|
||||
WHERE entity_id = 'sensor.tibber_home_current_interval_price'
|
||||
ORDER BY last_updated DESC
|
||||
ORDER BY last_updated DESC
|
||||
LIMIT 5;
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -8,22 +8,22 @@ Not every code change needs a detailed plan. Create a refactoring plan when:
|
|||
|
||||
🔴 **Major changes requiring planning:**
|
||||
|
||||
- Splitting modules into packages (>5 files affected, >500 lines moved)
|
||||
- Architectural changes (new packages, module restructuring)
|
||||
- Breaking changes (API changes, config format migrations)
|
||||
- Splitting modules into packages (>5 files affected, >500 lines moved)
|
||||
- Architectural changes (new packages, module restructuring)
|
||||
- Breaking changes (API changes, config format migrations)
|
||||
|
||||
🟡 **Medium changes that might benefit from planning:**
|
||||
|
||||
- Complex features with multiple moving parts
|
||||
- Changes affecting many files (>3 files, unclear best approach)
|
||||
- Refactorings with unclear scope
|
||||
- Complex features with multiple moving parts
|
||||
- Changes affecting many files (>3 files, unclear best approach)
|
||||
- Refactorings with unclear scope
|
||||
|
||||
🟢 **Small changes - no planning needed:**
|
||||
|
||||
- Bug fixes (straightforward, `<`100 lines)
|
||||
- Small features (`<`3 files, clear approach)
|
||||
- Documentation updates
|
||||
- Cosmetic changes (formatting, renaming)
|
||||
- Bug fixes (straightforward, `<`100 lines)
|
||||
- Small features (`<`3 files, clear approach)
|
||||
- Documentation updates
|
||||
- Cosmetic changes (formatting, renaming)
|
||||
|
||||
## The Planning Process
|
||||
|
||||
|
|
@ -51,34 +51,34 @@ Every planning document should include:
|
|||
|
||||
## Problem Statement
|
||||
|
||||
- What's the issue?
|
||||
- Why does it need fixing?
|
||||
- Current pain points
|
||||
- What's the issue?
|
||||
- Why does it need fixing?
|
||||
- Current pain points
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
- High-level approach
|
||||
- File structure (before/after)
|
||||
- Module responsibilities
|
||||
- High-level approach
|
||||
- File structure (before/after)
|
||||
- Module responsibilities
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
- Phase-by-phase breakdown
|
||||
- File lifecycle (CREATE/MODIFY/DELETE/RENAME)
|
||||
- Dependencies between phases
|
||||
- Testing checkpoints
|
||||
- Phase-by-phase breakdown
|
||||
- File lifecycle (CREATE/MODIFY/DELETE/RENAME)
|
||||
- Dependencies between phases
|
||||
- Testing checkpoints
|
||||
|
||||
## Risks & Mitigation
|
||||
|
||||
- What could go wrong?
|
||||
- How to prevent it?
|
||||
- Rollback strategy
|
||||
- What could go wrong?
|
||||
- How to prevent it?
|
||||
- Rollback strategy
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- Measurable improvements
|
||||
- Testing requirements
|
||||
- Verification steps
|
||||
- Measurable improvements
|
||||
- Testing requirements
|
||||
- Verification steps
|
||||
```
|
||||
|
||||
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:
|
||||
|
||||
- Draft multiple versions
|
||||
- Get AI assistance without commit pressure
|
||||
- Refine until the plan is solid
|
||||
- No need to clean up intermediate versions
|
||||
- Draft multiple versions
|
||||
- Get AI assistance without commit pressure
|
||||
- Refine until the plan is solid
|
||||
- No need to clean up intermediate versions
|
||||
|
||||
### 4. Implementation Phase
|
||||
|
||||
Once plan is approved:
|
||||
|
||||
- Follow the phases defined in the plan
|
||||
- Test after each phase (don't skip!)
|
||||
- Update plan if issues discovered
|
||||
- Track progress through phase status
|
||||
- Follow the phases defined in the plan
|
||||
- Test after each phase (don't skip!)
|
||||
- Update plan if issues discovered
|
||||
- Track progress through phase status
|
||||
|
||||
### 5. After Completion
|
||||
|
||||
|
|
@ -134,13 +134,13 @@ The **sensor/ package refactoring** (Nov 2025) is a successful example:
|
|||
|
||||
**Before:**
|
||||
|
||||
- `sensor.py` - 2,574 lines, hard to navigate
|
||||
- `sensor.py` - 2,574 lines, hard to navigate
|
||||
|
||||
**After:**
|
||||
|
||||
- `sensor/` package with 5 focused modules
|
||||
- Each module `<`800 lines
|
||||
- Clear separation of concerns
|
||||
- `sensor/` package with 5 focused modules
|
||||
- Each module `<`800 lines
|
||||
- Clear separation of concerns
|
||||
|
||||
**Process:**
|
||||
|
||||
|
|
@ -153,10 +153,10 @@ The **sensor/ package refactoring** (Nov 2025) is a successful example:
|
|||
|
||||
**Key learnings:**
|
||||
|
||||
- Temporary `_impl.py` files avoid Python package conflicts
|
||||
- Test after EVERY phase (don't accumulate changes)
|
||||
- Clear file lifecycle (CREATE/MODIFY/DELETE/RENAME)
|
||||
- Phase-by-phase approach enables safe rollback
|
||||
- Temporary `_impl.py` files avoid Python package conflicts
|
||||
- Test after EVERY phase (don't accumulate changes)
|
||||
- Clear file lifecycle (CREATE/MODIFY/DELETE/RENAME)
|
||||
- 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.
|
||||
|
||||
|
|
@ -166,11 +166,11 @@ The **sensor/ package refactoring** (Nov 2025) is a successful example:
|
|||
|
||||
Breaking refactorings into phases:
|
||||
|
||||
- ✅ Enables testing after each change (catch bugs early)
|
||||
- ✅ Allows rollback to last good state
|
||||
- ✅ Makes progress visible
|
||||
- ✅ Reduces cognitive load (focus on one thing)
|
||||
- ❌ Takes more time (but worth it!)
|
||||
- ✅ Enables testing after each change (catch bugs early)
|
||||
- ✅ Allows rollback to last good state
|
||||
- ✅ Makes progress visible
|
||||
- ✅ Reduces cognitive load (focus on one thing)
|
||||
- ❌ Takes more time (but worth it!)
|
||||
|
||||
### Phase Structure
|
||||
|
||||
|
|
@ -191,8 +191,8 @@ Each phase should:
|
|||
|
||||
**File Lifecycle**:
|
||||
|
||||
- ✨ CREATE `sensor/helpers.py` (utility functions)
|
||||
- ✏️ MODIFY `sensor/core.py` (import from helpers.py)
|
||||
- ✨ CREATE `sensor/helpers.py` (utility functions)
|
||||
- ✏️ MODIFY `sensor/core.py` (import from helpers.py)
|
||||
|
||||
**Steps**:
|
||||
|
||||
|
|
@ -205,10 +205,10 @@ Each phase should:
|
|||
|
||||
**Success criteria**:
|
||||
|
||||
- ✅ All pure functions moved
|
||||
- ✅ `./scripts/lint-check` passes
|
||||
- ✅ HA starts successfully
|
||||
- ✅ All entities work correctly
|
||||
- ✅ All pure functions moved
|
||||
- ✅ `./scripts/lint-check` passes
|
||||
- ✅ HA starts successfully
|
||||
- ✅ All entities work correctly
|
||||
```
|
||||
|
||||
## Testing Strategy
|
||||
|
|
@ -238,13 +238,13 @@ Minimum testing checklist:
|
|||
|
||||
After completing all phases:
|
||||
|
||||
- Test all entities (sensors, binary sensors)
|
||||
- Test configuration flow (add/modify/remove)
|
||||
- Test options flow (change settings)
|
||||
- Test services (custom service calls)
|
||||
- Test error handling (disconnect API, invalid data)
|
||||
- Test caching (restart HA, verify cache loads)
|
||||
- Test time-based updates (quarter-hour refresh)
|
||||
- Test all entities (sensors, binary sensors)
|
||||
- Test configuration flow (add/modify/remove)
|
||||
- Test options flow (change settings)
|
||||
- Test services (custom service calls)
|
||||
- Test error handling (disconnect API, invalid data)
|
||||
- Test caching (restart HA, verify cache loads)
|
||||
- Test time-based updates (quarter-hour refresh)
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
|
|
@ -286,21 +286,21 @@ This project uses AI heavily (GitHub Copilot, Claude). The planning process supp
|
|||
|
||||
**AI reads from:**
|
||||
|
||||
- `AGENTS.md` - Long-term memory, patterns, conventions (AI-focused)
|
||||
- `docs/development/` - Human-readable guides (human-focused)
|
||||
- `planning/` - Active refactoring plans (shared context)
|
||||
- `AGENTS.md` - Long-term memory, patterns, conventions (AI-focused)
|
||||
- `docs/development/` - Human-readable guides (human-focused)
|
||||
- `planning/` - Active refactoring plans (shared context)
|
||||
|
||||
**AI updates:**
|
||||
|
||||
- `AGENTS.md` - When patterns change
|
||||
- `planning/*.md` - During refactoring implementation
|
||||
- `docs/development/` - After successful completion
|
||||
- `AGENTS.md` - When patterns change
|
||||
- `planning/*.md` - During refactoring implementation
|
||||
- `docs/development/` - After successful completion
|
||||
|
||||
**Why separate AGENTS.md and docs/development/?**
|
||||
|
||||
- `AGENTS.md`: Technical, comprehensive, AI-optimized
|
||||
- `docs/development/`: Practical, focused, human-optimized
|
||||
- Both stay in sync but serve different audiences
|
||||
- `AGENTS.md`: Technical, comprehensive, AI-optimized
|
||||
- `docs/development/`: Practical, focused, human-optimized
|
||||
- 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.
|
||||
|
||||
|
|
@ -308,16 +308,16 @@ See [AGENTS.md](https://github.com/jpawlowski/hass.tibber_prices/blob/main/AGENT
|
|||
|
||||
### Planning Directory
|
||||
|
||||
- `planning/` - Git-ignored workspace for drafts
|
||||
- `planning/README.md` - Detailed planning documentation
|
||||
- `planning/*.md` - Active refactoring plans
|
||||
- `planning/` - Git-ignored workspace for drafts
|
||||
- `planning/README.md` - Detailed planning documentation
|
||||
- `planning/*.md` - Active refactoring plans
|
||||
|
||||
### Example Plans
|
||||
|
||||
- `docs/development/module-splitting-plan.md` - ✅ Completed, archived
|
||||
- `planning/config-flow-refactoring-plan.md` - 🔄 Planned (1013 lines → 4 modules)
|
||||
- `planning/binary-sensor-refactoring-plan.md` - 🔄 Planned (644 lines → 4 modules)
|
||||
- `planning/coordinator-refactoring-plan.md` - 🔄 Planned (1446 lines, high complexity)
|
||||
- `docs/development/module-splitting-plan.md` - ✅ Completed, archived
|
||||
- `planning/config-flow-refactoring-plan.md` - 🔄 Planned (1013 lines → 4 modules)
|
||||
- `planning/binary-sensor-refactoring-plan.md` - 🔄 Planned (644 lines → 4 modules)
|
||||
- `planning/coordinator-refactoring-plan.md` - 🔄 Planned (1446 lines, high complexity)
|
||||
|
||||
### 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:
|
||||
|
||||
- Lists all files affected (CREATE/MODIFY/DELETE)
|
||||
- Defines phases with clear boundaries
|
||||
- Includes testing strategy
|
||||
- Estimates time per phase
|
||||
- Lists all files affected (CREATE/MODIFY/DELETE)
|
||||
- Defines phases with clear boundaries
|
||||
- Includes testing strategy
|
||||
- Estimates time per phase
|
||||
|
||||
Too detailed:
|
||||
|
||||
- Exact code snippets for every change
|
||||
- Line-by-line instructions
|
||||
- Exact code snippets for every change
|
||||
- Line-by-line instructions
|
||||
|
||||
Too vague:
|
||||
|
||||
- "Refactor sensor.py to be better"
|
||||
- No phase breakdown
|
||||
- No testing strategy
|
||||
- "Refactor sensor.py to be better"
|
||||
- No phase breakdown
|
||||
- No testing strategy
|
||||
|
||||
### Q: What if the plan changes during implementation?
|
||||
|
||||
|
|
@ -363,9 +363,9 @@ Too vague:
|
|||
|
||||
If you discover:
|
||||
|
||||
- Better approach → Update "Proposed Solution"
|
||||
- More phases needed → Add to "Migration Strategy"
|
||||
- New risks → Update "Risks & Mitigation"
|
||||
- Better approach → Update "Proposed Solution"
|
||||
- More phases needed → Add to "Migration Strategy"
|
||||
- New risks → Update "Risks & Mitigation"
|
||||
|
||||
Document WHY the plan changed (helps future refactorings).
|
||||
|
||||
|
|
@ -373,9 +373,9 @@ Document WHY the plan changed (helps future refactorings).
|
|||
|
||||
**A:** No! Use judgment:
|
||||
|
||||
- **Small changes (`<`100 lines, clear approach)**: Just do it, no plan needed
|
||||
- **Medium changes (unclear scope)**: Write rough outline, refine if needed
|
||||
- **Large changes (>500 lines, >5 files)**: Full planning process
|
||||
- **Small changes (`<`100 lines, clear approach)**: Just do it, no plan needed
|
||||
- **Medium changes (unclear scope)**: Write rough outline, refine if needed
|
||||
- **Large changes (>500 lines, >5 files)**: Full planning process
|
||||
|
||||
### Q: How do I know when a refactoring is successful?
|
||||
|
||||
|
|
@ -383,12 +383,12 @@ Document WHY the plan changed (helps future refactorings).
|
|||
|
||||
Typical criteria:
|
||||
|
||||
- ✅ All linting checks pass
|
||||
- ✅ HA starts without errors
|
||||
- ✅ All entities functional
|
||||
- ✅ No regressions (existing features work)
|
||||
- ✅ Code easier to understand/modify
|
||||
- ✅ Documentation updated
|
||||
- ✅ All linting checks pass
|
||||
- ✅ HA starts without errors
|
||||
- ✅ All entities functional
|
||||
- ✅ No regressions (existing features work)
|
||||
- ✅ Code easier to understand/modify
|
||||
- ✅ Documentation updated
|
||||
|
||||
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:**
|
||||
|
||||
- Read `planning/README.md` for detailed template
|
||||
- Check `docs/development/module-splitting-plan.md` for real example
|
||||
- Browse `planning/` for active refactoring plans
|
||||
- Read `planning/README.md` for detailed template
|
||||
- Check `docs/development/module-splitting-plan.md` for real example
|
||||
- Browse `planning/` for active refactoring plans
|
||||
|
|
|
|||
|
|
@ -112,6 +112,7 @@ In CI/CD (`$CI` or `$GITHUB_ACTIONS`), AI is automatically disabled.
|
|||
**In DevContainer (automatic):**
|
||||
|
||||
git-cliff is automatically installed when the DevContainer is built:
|
||||
|
||||
- **Rust toolchain**: Installed via `ghcr.io/devcontainers/features/rust:1` (minimal profile)
|
||||
- **git-cliff**: Installed via cargo in `scripts/setup/setup`
|
||||
|
||||
|
|
@ -120,6 +121,7 @@ Simply rebuild the container (VS Code: "Dev Containers: Rebuild Container") and
|
|||
**Manual installation (outside DevContainer):**
|
||||
|
||||
**git-cliff** (template-based):
|
||||
|
||||
```bash
|
||||
# See: https://git-cliff.org/docs/installation
|
||||
|
||||
|
|
@ -190,13 +192,13 @@ All methods produce GitHub-flavored Markdown with emoji categories:
|
|||
|
||||
## 🎯 When to Use Which
|
||||
|
||||
| Method | Use Case | Pros | Cons |
|
||||
|--------|----------|------|------|
|
||||
| **Helper Script** | Normal releases | Foolproof, automatic | Requires script |
|
||||
| **Auto-Tag Workflow** | Forgot script | Safety net, automatic tagging | Still need manifest bump |
|
||||
| **GitHub Button** | Manual quick release | Easy, no script | Limited categorization |
|
||||
| **Local Script** | Testing release notes | Preview before release | Manual process |
|
||||
| **CI/CD** | After tag push | Fully automatic | Needs tag first |
|
||||
| Method | Use Case | Pros | Cons |
|
||||
| --------------------- | --------------------- | ----------------------------- | ------------------------ |
|
||||
| **Helper Script** | Normal releases | Foolproof, automatic | Requires script |
|
||||
| **Auto-Tag Workflow** | Forgot script | Safety net, automatic tagging | Still need manifest bump |
|
||||
| **GitHub Button** | Manual quick release | Easy, no script | Limited categorization |
|
||||
| **Local Script** | Testing release notes | Preview before release | Manual process |
|
||||
| **CI/CD** | After tag push | Fully automatic | Needs tag first |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -219,6 +221,7 @@ git push origin main v0.3.0
|
|||
```
|
||||
|
||||
**What happens:**
|
||||
|
||||
1. Script bumps manifest.json → commits → creates tag locally
|
||||
2. You push commit + tag together
|
||||
3. Release workflow sees tag → generates notes → creates release
|
||||
|
|
@ -242,6 +245,7 @@ git push
|
|||
```
|
||||
|
||||
**What happens:**
|
||||
|
||||
1. You push manifest.json change
|
||||
2. Auto-Tag workflow detects change → creates tag automatically
|
||||
3. Release workflow sees new tag → creates release
|
||||
|
|
@ -263,6 +267,7 @@ git push origin main v0.3.0
|
|||
```
|
||||
|
||||
**What happens:**
|
||||
|
||||
1. You create and push tag manually
|
||||
2. Release workflow creates release
|
||||
3. Auto-Tag workflow skips (tag already exists)
|
||||
|
|
@ -282,19 +287,24 @@ git push origin main v0.3.0
|
|||
## 🛡️ Safety Features
|
||||
|
||||
### 1. **Version Validation**
|
||||
|
||||
Both helper script and auto-tag workflow validate version format (X.Y.Z).
|
||||
|
||||
### 2. **No Duplicate Tags**
|
||||
|
||||
- Helper script checks if tag exists (local + remote)
|
||||
- Auto-tag workflow checks if tag exists before creating
|
||||
|
||||
### 3. **Atomic Operations**
|
||||
|
||||
Helper script creates commit + tag locally. You decide when to push.
|
||||
|
||||
### 4. **Version Bumps Filtered**
|
||||
|
||||
Release notes automatically exclude `chore(release): bump version` commits.
|
||||
|
||||
### 5. **Rollback Instructions**
|
||||
|
||||
Helper script shows how to undo if you change your mind.
|
||||
|
||||
---
|
||||
|
|
@ -330,6 +340,7 @@ git push -f origin main v0.3.0
|
|||
**Auto-tag didn't create tag:**
|
||||
|
||||
Check workflow runs in GitHub Actions. Common causes:
|
||||
|
||||
- Tag already exists remotely
|
||||
- Invalid version format in manifest.json
|
||||
- manifest.json not in the commit that was pushed
|
||||
|
|
@ -348,13 +359,14 @@ Check workflow runs in GitHub Actions. Common causes:
|
|||
## 💡 Tips
|
||||
|
||||
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
|
||||
|
||||
Impact: Users can now do X and Y.
|
||||
```
|
||||
Detailed description of what changed.
|
||||
|
||||
Impact: Users can now do X and Y.
|
||||
```
|
||||
|
||||
2. **Impact Section:** Add `Impact:` in commit body for user-friendly descriptions
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ The Tibber Prices integration includes a proactive repair notification system th
|
|||
The repairs system is implemented in `coordinator/repairs.py` via the `TibberPricesRepairManager` class, which is instantiated in the coordinator and integrated into the update cycle.
|
||||
|
||||
**Design Principles:**
|
||||
|
||||
- **Proactive**: Detect issues before they become critical
|
||||
- **User-friendly**: Clear explanations with actionable guidance
|
||||
- **Auto-clearing**: Repairs automatically disappear when conditions resolve
|
||||
|
|
@ -19,10 +20,12 @@ The repairs system is implemented in `coordinator/repairs.py` via the `TibberPri
|
|||
**Issue ID:** `tomorrow_data_missing_{entry_id}`
|
||||
|
||||
**When triggered:**
|
||||
|
||||
- Current time is after 18:00 (configurable via `TOMORROW_DATA_WARNING_HOUR`)
|
||||
- Tomorrow's electricity price data is still not available
|
||||
|
||||
**When cleared:**
|
||||
|
||||
- Tomorrow's data becomes available
|
||||
- Automatically checks on every successful API update
|
||||
|
||||
|
|
@ -30,6 +33,7 @@ The repairs system is implemented in `coordinator/repairs.py` via the `TibberPri
|
|||
Users cannot plan ahead for tomorrow's electricity usage optimization. Automations relying on tomorrow's prices will not work.
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```python
|
||||
# In coordinator update cycle
|
||||
has_tomorrow_data = self._data_fetcher.has_tomorrow_data(result["priceInfo"])
|
||||
|
|
@ -40,6 +44,7 @@ await self._repair_manager.check_tomorrow_data_availability(
|
|||
```
|
||||
|
||||
**Translation placeholders:**
|
||||
|
||||
- `home_name`: Name of the affected home
|
||||
- `warning_hour`: Hour after which warning appears (default: 18)
|
||||
|
||||
|
|
@ -48,10 +53,12 @@ await self._repair_manager.check_tomorrow_data_availability(
|
|||
**Issue ID:** `rate_limit_exceeded_{entry_id}`
|
||||
|
||||
**When triggered:**
|
||||
|
||||
- Integration encounters 3 or more consecutive rate limit errors (HTTP 429)
|
||||
- Threshold configurable via `RATE_LIMIT_WARNING_THRESHOLD`
|
||||
|
||||
**When cleared:**
|
||||
|
||||
- Successful API call completes (no rate limit error)
|
||||
- Error counter resets to 0
|
||||
|
||||
|
|
@ -59,6 +66,7 @@ await self._repair_manager.check_tomorrow_data_availability(
|
|||
API requests are being throttled, causing stale data. Updates may be delayed until rate limit expires.
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```python
|
||||
# In error handler
|
||||
is_rate_limit = (
|
||||
|
|
@ -74,6 +82,7 @@ await self._repair_manager.clear_rate_limit_tracking()
|
|||
```
|
||||
|
||||
**Translation placeholders:**
|
||||
|
||||
- `home_name`: Name of the affected home
|
||||
- `error_count`: Number of consecutive rate limit errors
|
||||
|
||||
|
|
@ -82,10 +91,12 @@ await self._repair_manager.clear_rate_limit_tracking()
|
|||
**Issue ID:** `home_not_found_{entry_id}`
|
||||
|
||||
**When triggered:**
|
||||
|
||||
- Home configured in this integration is no longer present in Tibber account
|
||||
- Detected during user data refresh (daily check)
|
||||
|
||||
**When cleared:**
|
||||
|
||||
- Home reappears in Tibber account (unlikely - manual cleanup expected)
|
||||
- Integration entry is removed (shutdown cleanup)
|
||||
|
||||
|
|
@ -93,6 +104,7 @@ await self._repair_manager.clear_rate_limit_tracking()
|
|||
Integration cannot fetch data for a non-existent home. User must remove the config entry and re-add if needed.
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```python
|
||||
# After user data update
|
||||
home_exists = self._data_fetcher._check_home_exists(home_id)
|
||||
|
|
@ -103,6 +115,7 @@ else:
|
|||
```
|
||||
|
||||
**Translation placeholders:**
|
||||
|
||||
- `home_name`: Name of the missing home
|
||||
- `entry_id`: Config entry ID for reference
|
||||
|
||||
|
|
@ -153,6 +166,7 @@ Each repair type maintains internal state to avoid redundant operations:
|
|||
### Lifecycle Integration
|
||||
|
||||
**Coordinator Initialization:**
|
||||
|
||||
```python
|
||||
self._repair_manager = TibberPricesRepairManager(
|
||||
hass=hass,
|
||||
|
|
@ -162,6 +176,7 @@ self._repair_manager = TibberPricesRepairManager(
|
|||
```
|
||||
|
||||
**Update Cycle Integration:**
|
||||
|
||||
```python
|
||||
# Success path - check conditions
|
||||
if result and "priceInfo" in result:
|
||||
|
|
@ -178,6 +193,7 @@ if is_rate_limit:
|
|||
```
|
||||
|
||||
**Shutdown Cleanup:**
|
||||
|
||||
```python
|
||||
async def async_shutdown(self) -> None:
|
||||
"""Shut down coordinator and clean up."""
|
||||
|
|
@ -196,24 +212,27 @@ Repairs use Home Assistant's standard translation system. Translations are defin
|
|||
- `/translations/sv.json`
|
||||
|
||||
**Structure:**
|
||||
|
||||
```json
|
||||
{
|
||||
"issues": {
|
||||
"tomorrow_data_missing": {
|
||||
"title": "Tomorrow's price data missing for {home_name}",
|
||||
"description": "Detailed explanation with multiple paragraphs...\n\nPossible causes:\n- Cause 1\n- Cause 2"
|
||||
"issues": {
|
||||
"tomorrow_data_missing": {
|
||||
"title": "Tomorrow's price data missing for {home_name}",
|
||||
"description": "Detailed explanation with multiple paragraphs...\n\nPossible causes:\n- Cause 1\n- Cause 2"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Home Assistant Integration
|
||||
|
||||
Repairs appear in:
|
||||
|
||||
- **Settings → System → Repairs** (main repairs panel)
|
||||
- **Notifications** (bell icon in UI shows repair count)
|
||||
|
||||
Repair properties:
|
||||
|
||||
- **`is_fixable=False`**: No automated fix available (user action required)
|
||||
- **`severity=IssueSeverity.WARNING`**: Yellow warning level (not critical)
|
||||
- **`translation_key`**: References `issues.{key}` in translation files
|
||||
|
|
@ -228,6 +247,7 @@ Repair properties:
|
|||
4. When tomorrow data arrives (next API fetch), repair clears
|
||||
|
||||
**Manual trigger:**
|
||||
|
||||
```python
|
||||
# Temporarily set warning hour to current hour for testing
|
||||
TOMORROW_DATA_WARNING_HOUR = datetime.now().hour
|
||||
|
|
@ -240,6 +260,7 @@ TOMORROW_DATA_WARNING_HOUR = datetime.now().hour
|
|||
3. Successful API call clears the repair
|
||||
|
||||
**Manual test:**
|
||||
|
||||
- Reduce API polling interval to trigger rate limiting
|
||||
- Or temporarily return HTTP 429 in API client
|
||||
|
||||
|
|
@ -263,6 +284,7 @@ To add a new repair type:
|
|||
7. **Document** in this file
|
||||
|
||||
**Example template:**
|
||||
|
||||
```python
|
||||
async def check_new_condition(self, *, param: bool) -> None:
|
||||
"""Check new condition and create/clear repair."""
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@
|
|||
|
||||
## Prerequisites
|
||||
|
||||
- VS Code with Dev Container support
|
||||
- Docker installed and running
|
||||
- GitHub account (for Tibber API token)
|
||||
- VS Code with Dev Container support
|
||||
- Docker installed and running
|
||||
- GitHub account (for Tibber API token)
|
||||
|
||||
## Quick Setup
|
||||
|
||||
|
|
@ -26,11 +26,11 @@ code .
|
|||
|
||||
The DevContainer includes:
|
||||
|
||||
- Python 3.13 with `.venv` at `/home/vscode/.venv/`
|
||||
- `uv` package manager (fast, modern Python tooling)
|
||||
- Home Assistant development dependencies
|
||||
- Ruff linter/formatter
|
||||
- Git, GitHub CLI, Node.js, Rust toolchain
|
||||
- Python 3.13 with `.venv` at `/home/vscode/.venv/`
|
||||
- `uv` package manager (fast, modern Python tooling)
|
||||
- Home Assistant development dependencies
|
||||
- Ruff linter/formatter
|
||||
- Git, GitHub CLI, Node.js, Rust toolchain
|
||||
|
||||
## Running the Integration
|
||||
|
||||
|
|
|
|||
|
|
@ -13,10 +13,10 @@ Before running tests or committing changes, validate the integration structure:
|
|||
|
||||
This lightweight script checks:
|
||||
|
||||
- ✓ `config_flow.py` exists
|
||||
- ✓ `manifest.json` is valid JSON with required fields
|
||||
- ✓ Translation files have valid JSON syntax
|
||||
- ✓ All Python files compile without syntax errors
|
||||
- ✓ `config_flow.py` exists
|
||||
- ✓ `manifest.json` is valid JSON with required fields
|
||||
- ✓ Translation files have valid JSON syntax
|
||||
- ✓ All Python files compile without syntax errors
|
||||
|
||||
**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:
|
||||
|
||||
- Configuration flow
|
||||
- Sensor states and attributes
|
||||
- Services
|
||||
- Translation strings
|
||||
- Configuration flow
|
||||
- Sensor states and attributes
|
||||
- Services
|
||||
- Translation strings
|
||||
|
||||
## Test Guidelines
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
| Timer | Type | Interval | Purpose | Trigger Method |
|
||||
|-------|------|----------|---------|----------------|
|
||||
| **Timer #1** | HA built-in | 15 minutes | API data updates | `DataUpdateCoordinator` |
|
||||
| **Timer #2** | Custom | :00, :15, :30, :45 | Entity state refresh | `async_track_utc_time_change()` |
|
||||
| **Timer #3** | Custom | Every minute | Countdown/progress | `async_track_utc_time_change()` |
|
||||
| Timer | Type | Interval | Purpose | Trigger Method |
|
||||
| ------------ | ----------- | ------------------ | -------------------- | ------------------------------- |
|
||||
| **Timer #1** | HA built-in | 15 minutes | API data updates | `DataUpdateCoordinator` |
|
||||
| **Timer #2** | Custom | :00, :15, :30, :45 | Entity state refresh | `async_track_utc_time_change()` |
|
||||
| **Timer #3** | Custom | Every minute | Countdown/progress | `async_track_utc_time_change()` |
|
||||
|
||||
**Key principle:** Timer #1 (HA) controls **data fetching**, Timer #2 controls **entity updates**, Timer #3 controls **timing displays**.
|
||||
|
||||
|
|
@ -27,6 +27,7 @@ The integration uses **three independent timer mechanisms** for different purpos
|
|||
**Type:** Home Assistant's built-in `DataUpdateCoordinator` with `UPDATE_INTERVAL = 15 minutes`
|
||||
|
||||
**What it is:**
|
||||
|
||||
- HA provides this timer system automatically when you inherit from `DataUpdateCoordinator`
|
||||
- Triggers `_async_update_data()` method every 15 minutes
|
||||
- **Not** synchronized to clock boundaries (each installation has different start time)
|
||||
|
|
@ -53,16 +54,19 @@ async def _async_update_data(self) -> TibberPricesData:
|
|||
```
|
||||
|
||||
**Load Distribution:**
|
||||
|
||||
- Each HA installation starts Timer #1 at different times → natural distribution
|
||||
- Tomorrow data check adds 0-30s random delay → prevents "thundering herd" on Tibber API
|
||||
- Result: API load spread over ~30 minutes instead of all at once
|
||||
|
||||
**Midnight Coordination:**
|
||||
|
||||
- Atomic check: `_check_midnight_turnover_needed(now)` compares dates only (no side effects)
|
||||
- If midnight turnover needed → performs it and returns early
|
||||
- Timer #2 will see turnover already done and skip gracefully
|
||||
|
||||
**Why we use HA's timer:**
|
||||
|
||||
- Automatic restart after HA restart
|
||||
- Built-in retry logic for temporary failures
|
||||
- Standard HA integration pattern
|
||||
|
|
@ -79,6 +83,7 @@ async def _async_update_data(self) -> TibberPricesData:
|
|||
**Purpose:** Update time-sensitive entity states at interval boundaries **without waiting for API poll**
|
||||
|
||||
**Problem it solves:**
|
||||
|
||||
- Timer #1 runs every 15 minutes but NOT synchronized to clock (:03, :18, :33, :48)
|
||||
- Current price changes at :00, :15, :30, :45 → entities would show stale data for up to 15 minutes
|
||||
- Example: 14:00 new price, but Timer #1 ran at 13:58 → next update at 14:13 → users see old price until 14:13
|
||||
|
|
@ -100,22 +105,26 @@ async def _handle_quarter_hour_refresh(self, now: datetime) -> None:
|
|||
```
|
||||
|
||||
**Smart Boundary Tolerance:**
|
||||
|
||||
- Uses `round_to_nearest_quarter_hour()` with ±2 second tolerance
|
||||
- HA may schedule timer at 14:59:58 → rounds to 15:00:00 (shows new interval)
|
||||
- HA restart at 14:59:30 → stays at 14:45:00 (shows current interval)
|
||||
- See [Architecture](./architecture.md#3-quarter-hour-precision) for details
|
||||
|
||||
**Absolute Time Scheduling:**
|
||||
|
||||
- `async_track_utc_time_change()` plans for **all future boundaries** (15:00, 15:15, 15:30, ...)
|
||||
- NOT relative delays ("in 15 minutes")
|
||||
- If triggered at 14:59:58 → next trigger is 15:15:00, NOT 15:00:00 (prevents double updates)
|
||||
|
||||
**Which entities listen:**
|
||||
|
||||
- All sensors that depend on "current interval" (e.g., `current_interval_price`, `next_interval_price`)
|
||||
- Binary sensors that check "is now in period?" (e.g., `best_price_period_active`)
|
||||
- ~50-60 entities out of 120+ total
|
||||
|
||||
**Why custom timer:**
|
||||
|
||||
- HA's built-in coordinator doesn't support exact boundary timing
|
||||
- We need **absolute time** triggers, not periodic intervals
|
||||
- Allows fast entity updates without expensive data transformation
|
||||
|
|
@ -140,6 +149,7 @@ async def _handle_minute_refresh(self, now: datetime) -> None:
|
|||
```
|
||||
|
||||
**Which entities listen:**
|
||||
|
||||
- `best_price_remaining_minutes` - Countdown timer
|
||||
- `peak_price_remaining_minutes` - Countdown timer
|
||||
- `best_price_progress` - Progress bar (0-100%)
|
||||
|
|
@ -147,11 +157,13 @@ async def _handle_minute_refresh(self, now: datetime) -> None:
|
|||
- ~10 entities total
|
||||
|
||||
**Why custom timer:**
|
||||
|
||||
- Users want smooth countdowns (not jumping 15 minutes at a time)
|
||||
- Progress bars need minute-by-minute updates
|
||||
- Very lightweight (no data processing, just state recalculation)
|
||||
|
||||
**Why NOT every second:**
|
||||
|
||||
- Minute precision sufficient for countdown UX
|
||||
- Reduces CPU load (60× fewer updates than seconds)
|
||||
- Home Assistant best practice (avoid sub-minute updates)
|
||||
|
|
@ -194,6 +206,7 @@ class ListenerManager:
|
|||
```
|
||||
|
||||
**Why this pattern:**
|
||||
|
||||
- Decouples timer logic from entity logic
|
||||
- One timer can notify many entities efficiently
|
||||
- Entities can unregister when removed (cleanup)
|
||||
|
|
@ -279,11 +292,13 @@ class ListenerManager:
|
|||
### Reason 1: Load Distribution on Tibber API
|
||||
|
||||
If all installations used synchronized timers:
|
||||
|
||||
- ❌ Everyone fetches at 13:00:00 → Tibber API overload
|
||||
- ❌ Everyone fetches at 14:00:00 → Tibber API overload
|
||||
- ❌ "Thundering herd" problem
|
||||
|
||||
With HA's unsynchronized timer:
|
||||
|
||||
- ✅ Installation A: 13:03:12, 13:18:12, 13:33:12, ...
|
||||
- ✅ Installation B: 13:07:45, 13:22:45, 13:37:45, ...
|
||||
- ✅ Installation C: 13:11:28, 13:26:28, 13:41:28, ...
|
||||
|
|
@ -316,6 +331,7 @@ def _should_update_price_data(self) -> str:
|
|||
**Most Timer #1 cycles:** Fast path (~2ms), no API call, just returns cached data.
|
||||
|
||||
**API fetch only when:**
|
||||
|
||||
- Tomorrow data missing/invalid (after 13:00)
|
||||
- Cache expired (midnight turnover)
|
||||
- Explicit user refresh
|
||||
|
|
@ -339,6 +355,7 @@ def _should_update_price_data(self) -> str:
|
|||
## Performance Characteristics
|
||||
|
||||
### Timer #1 (DataUpdateCoordinator)
|
||||
|
||||
- **Triggers:** Every 15 minutes (unsynchronized)
|
||||
- **Fast path:** ~2ms (cache check, return existing data)
|
||||
- **Slow path:** ~600ms (API fetch + transform + calculate)
|
||||
|
|
@ -346,12 +363,14 @@ def _should_update_price_data(self) -> str:
|
|||
- **API calls:** ~1-2 times/day (cached otherwise)
|
||||
|
||||
### Timer #2 (Quarter-Hour Refresh)
|
||||
|
||||
- **Triggers:** 96 times/day (exact boundaries)
|
||||
- **Processing:** ~5ms (notify 60 entities)
|
||||
- **No API calls:** Uses cached/transformed data
|
||||
- **No transformation:** Just entity state updates
|
||||
|
||||
### Timer #3 (Minute Refresh)
|
||||
|
||||
- **Triggers:** 1440 times/day (every minute)
|
||||
- **Processing:** ~1ms (notify 10 entities)
|
||||
- **No API calls:** No data processing at all
|
||||
|
|
@ -393,16 +412,16 @@ _LOGGER.setLevel(logging.DEBUG)
|
|||
### Common Issues
|
||||
|
||||
1. **Timer #2 not triggering:**
|
||||
- Check: `schedule_quarter_hour_refresh()` called in `__init__`?
|
||||
- Check: `_quarter_hour_timer_cancel` properly stored?
|
||||
- Check: `schedule_quarter_hour_refresh()` called in `__init__`?
|
||||
- Check: `_quarter_hour_timer_cancel` properly stored?
|
||||
|
||||
2. **Double updates at midnight:**
|
||||
- Should NOT happen (atomic coordination)
|
||||
- Check: Both timers use same date comparison logic?
|
||||
- Should NOT happen (atomic coordination)
|
||||
- Check: Both timers use same date comparison logic?
|
||||
|
||||
3. **API overload:**
|
||||
- Check: Random delay working? (0-30s jitter on tomorrow check)
|
||||
- Check: Cache validation logic correct?
|
||||
- Check: Random delay working? (0-30s jitter on tomorrow check)
|
||||
- Check: Cache validation logic correct?
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -417,17 +436,20 @@ _LOGGER.setLevel(logging.DEBUG)
|
|||
## Summary
|
||||
|
||||
**Three independent timers:**
|
||||
|
||||
1. **Timer #1** (HA built-in, 15 min, unsynchronized) → Data fetching (when needed)
|
||||
2. **Timer #2** (Custom, :00/:15/:30/:45) → Entity state updates (always)
|
||||
3. **Timer #3** (Custom, every minute) → Countdown/progress (always)
|
||||
|
||||
**Key insights:**
|
||||
|
||||
- Timer #1 unsynchronized = good (load distribution on API)
|
||||
- Timer #2 synchronized = good (user sees correct data immediately)
|
||||
- Timer #3 synchronized = good (smooth countdown UX)
|
||||
- All three coordinate gracefully (atomic midnight checks, no conflicts)
|
||||
|
||||
**"Listener" terminology:**
|
||||
|
||||
- Timer = mechanism that triggers
|
||||
- Listener = callback that gets called
|
||||
- Observer pattern = entities register, coordinator notifies
|
||||
|
|
|
|||
|
|
@ -22,30 +22,30 @@ Fetches home information and metadata:
|
|||
|
||||
```graphql
|
||||
query {
|
||||
viewer {
|
||||
homes {
|
||||
id
|
||||
appNickname
|
||||
address {
|
||||
address1
|
||||
postalCode
|
||||
city
|
||||
country
|
||||
}
|
||||
timeZone
|
||||
currentSubscription {
|
||||
priceInfo {
|
||||
current {
|
||||
currency
|
||||
}
|
||||
viewer {
|
||||
homes {
|
||||
id
|
||||
appNickname
|
||||
address {
|
||||
address1
|
||||
postalCode
|
||||
city
|
||||
country
|
||||
}
|
||||
timeZone
|
||||
currentSubscription {
|
||||
priceInfo {
|
||||
current {
|
||||
currency
|
||||
}
|
||||
}
|
||||
}
|
||||
meteringPointData {
|
||||
consumptionEan
|
||||
gridAreaCode
|
||||
}
|
||||
}
|
||||
}
|
||||
meteringPointData {
|
||||
consumptionEan
|
||||
gridAreaCode
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -56,26 +56,27 @@ query {
|
|||
Fetches quarter-hourly prices:
|
||||
|
||||
```graphql
|
||||
query($homeId: ID!) {
|
||||
viewer {
|
||||
home(id: $homeId) {
|
||||
currentSubscription {
|
||||
priceInfo {
|
||||
range(resolution: QUARTER_HOURLY, first: 384) {
|
||||
nodes {
|
||||
total
|
||||
startsAt
|
||||
level
|
||||
query ($homeId: ID!) {
|
||||
viewer {
|
||||
home(id: $homeId) {
|
||||
currentSubscription {
|
||||
priceInfo {
|
||||
range(resolution: QUARTER_HOURLY, first: 384) {
|
||||
nodes {
|
||||
total
|
||||
startsAt
|
||||
level
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `homeId`: Tibber home identifier
|
||||
- `resolution`: Always `QUARTER_HOURLY`
|
||||
- `first`: 384 intervals (4 days of data)
|
||||
|
|
@ -85,10 +86,12 @@ query($homeId: ID!) {
|
|||
## Rate Limits
|
||||
|
||||
Tibber API rate limits (as of 2024):
|
||||
|
||||
- **5000 requests per hour** per token
|
||||
- **Burst limit:** 100 requests per minute
|
||||
|
||||
Integration stays well below these limits:
|
||||
|
||||
- Polls every 15 minutes = 96 requests/day
|
||||
- User data cached for 24h = 1 request/day
|
||||
- **Total:** ~100 requests/day per home
|
||||
|
|
@ -99,13 +102,14 @@ Integration stays well below these limits:
|
|||
|
||||
```json
|
||||
{
|
||||
"total": 0.2456,
|
||||
"startsAt": "2024-12-06T14:00:00.000+01:00",
|
||||
"level": "NORMAL"
|
||||
"total": 0.2456,
|
||||
"startsAt": "2024-12-06T14:00:00.000+01:00",
|
||||
"level": "NORMAL"
|
||||
}
|
||||
```
|
||||
|
||||
**Fields:**
|
||||
|
||||
- `total`: Price including VAT and fees (currency's major unit, e.g., EUR)
|
||||
- `startsAt`: ISO 8601 timestamp with timezone
|
||||
- `level`: Tibber's own classification (VERY_CHEAP, CHEAP, NORMAL, EXPENSIVE, VERY_EXPENSIVE)
|
||||
|
|
@ -114,11 +118,12 @@ Integration stays well below these limits:
|
|||
|
||||
```json
|
||||
{
|
||||
"currency": "EUR"
|
||||
"currency": "EUR"
|
||||
}
|
||||
```
|
||||
|
||||
Supported currencies:
|
||||
|
||||
- `EUR` (Euro) - displayed as ct/kWh
|
||||
- `NOK` (Norwegian Krone) - displayed as øre/kWh
|
||||
- `SEK` (Swedish Krona) - displayed as öre/kWh
|
||||
|
|
@ -128,42 +133,52 @@ Supported currencies:
|
|||
### Common Error Responses
|
||||
|
||||
**Invalid Token:**
|
||||
|
||||
```json
|
||||
{
|
||||
"errors": [{
|
||||
"message": "Unauthorized",
|
||||
"extensions": {
|
||||
"code": "UNAUTHENTICATED"
|
||||
}
|
||||
}]
|
||||
"errors": [
|
||||
{
|
||||
"message": "Unauthorized",
|
||||
"extensions": {
|
||||
"code": "UNAUTHENTICATED"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Rate Limit Exceeded:**
|
||||
|
||||
```json
|
||||
{
|
||||
"errors": [{
|
||||
"message": "Too Many Requests",
|
||||
"extensions": {
|
||||
"code": "RATE_LIMIT_EXCEEDED"
|
||||
}
|
||||
}]
|
||||
"errors": [
|
||||
{
|
||||
"message": "Too Many Requests",
|
||||
"extensions": {
|
||||
"code": "RATE_LIMIT_EXCEEDED"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Home Not Found:**
|
||||
|
||||
```json
|
||||
{
|
||||
"errors": [{
|
||||
"message": "Home not found",
|
||||
"extensions": {
|
||||
"code": "NOT_FOUND"
|
||||
}
|
||||
}]
|
||||
"errors": [
|
||||
{
|
||||
"message": "Home not found",
|
||||
"extensions": {
|
||||
"code": "NOT_FOUND"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Integration handles these with:
|
||||
|
||||
- Exponential backoff retry (3 attempts)
|
||||
- ConfigEntryAuthFailed for auth errors
|
||||
- ConfigEntryNotReady for temporary failures
|
||||
|
|
@ -171,6 +186,7 @@ Integration handles these with:
|
|||
## Data Transformation
|
||||
|
||||
Raw API data is enriched with:
|
||||
|
||||
- **Trailing 24h average** - Calculated from previous intervals
|
||||
- **Leading 24h average** - Calculated from future intervals
|
||||
- **Price difference %** - Deviation from average
|
||||
|
|
@ -181,6 +197,7 @@ See `utils/price.py` for enrichment logic.
|
|||
---
|
||||
|
||||
💡 **External Resources:**
|
||||
|
||||
- [Tibber API Documentation](https://developer.tibber.com/docs/overview)
|
||||
- [GraphQL Explorer](https://developer.tibber.com/explorer)
|
||||
- [Get API Token](https://developer.tibber.com/settings/access-token)
|
||||
|
|
|
|||
|
|
@ -100,43 +100,43 @@ flowchart TB
|
|||
### Flow Description
|
||||
|
||||
1. **Setup** (`__init__.py`)
|
||||
- Integration loads, creates coordinator instance
|
||||
- Registers entity platforms (sensor, binary_sensor)
|
||||
- Sets up custom services
|
||||
- Integration loads, creates coordinator instance
|
||||
- Registers entity platforms (sensor, binary_sensor)
|
||||
- Sets up custom services
|
||||
|
||||
2. **Data Fetch** (every 15 minutes)
|
||||
- Coordinator triggers update via `api.py`
|
||||
- API client checks **persistent cache** first (`coordinator/cache.py`)
|
||||
- If cache valid → return cached data
|
||||
- If cache stale → query Tibber GraphQL API
|
||||
- Store fresh data in persistent cache (survives HA restart)
|
||||
- Coordinator triggers update via `api.py`
|
||||
- API client checks **persistent cache** first (`coordinator/cache.py`)
|
||||
- If cache valid → return cached data
|
||||
- If cache stale → query Tibber GraphQL API
|
||||
- Store fresh data in persistent cache (survives HA restart)
|
||||
|
||||
3. **Price Enrichment**
|
||||
- Coordinator passes raw prices to `DataTransformer`
|
||||
- Transformer checks **transformation cache** (memory)
|
||||
- If cache valid → return enriched data
|
||||
- If cache invalid → enrich via `price_utils.py` + `average_utils.py`
|
||||
- Calculate 24h trailing/leading averages
|
||||
- Calculate price differences (% from average)
|
||||
- Assign rating levels (LOW/NORMAL/HIGH)
|
||||
- Store enriched data in transformation cache
|
||||
- Coordinator passes raw prices to `DataTransformer`
|
||||
- Transformer checks **transformation cache** (memory)
|
||||
- If cache valid → return enriched data
|
||||
- If cache invalid → enrich via `price_utils.py` + `average_utils.py`
|
||||
- Calculate 24h trailing/leading averages
|
||||
- Calculate price differences (% from average)
|
||||
- Assign rating levels (LOW/NORMAL/HIGH)
|
||||
- Store enriched data in transformation cache
|
||||
|
||||
4. **Period Calculation**
|
||||
- Coordinator passes enriched data to `PeriodCalculator`
|
||||
- Calculator computes **hash** from prices + config
|
||||
- If hash matches cache → return cached periods
|
||||
- If hash differs → recalculate best/peak price periods
|
||||
- Store periods with new hash
|
||||
- Coordinator passes enriched data to `PeriodCalculator`
|
||||
- Calculator computes **hash** from prices + config
|
||||
- If hash matches cache → return cached periods
|
||||
- If hash differs → recalculate best/peak price periods
|
||||
- Store periods with new hash
|
||||
|
||||
5. **Entity Updates**
|
||||
- Coordinator provides complete data (prices + periods)
|
||||
- Sensors read values via unified handlers
|
||||
- Binary sensors evaluate period states
|
||||
- Entities update on quarter-hour boundaries (00/15/30/45)
|
||||
- Coordinator provides complete data (prices + periods)
|
||||
- Sensors read values via unified handlers
|
||||
- Binary sensors evaluate period states
|
||||
- Entities update on quarter-hour boundaries (00/15/30/45)
|
||||
|
||||
6. **Service Calls**
|
||||
- Custom services access coordinator data directly
|
||||
- Return formatted responses (JSON, ApexCharts format)
|
||||
- Custom services access coordinator data directly
|
||||
- Return formatted responses (JSON, ApexCharts format)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -146,13 +146,13 @@ flowchart TB
|
|||
|
||||
The integration uses **5 independent caching layers** for optimal performance:
|
||||
|
||||
| Layer | Location | Lifetime | Invalidation | Memory |
|
||||
|-------|----------|----------|--------------|--------|
|
||||
| **API Cache** | `coordinator/cache.py` | 24h (user)<br/>Until midnight (prices) | Automatic | 50KB |
|
||||
| **Translation Cache** | `const.py` | Until HA restart | Never | 5KB |
|
||||
| **Config Cache** | `coordinator/*` | Until config change | Explicit | 1KB |
|
||||
| **Period Cache** | `coordinator/periods.py` | Until data/config change | Hash-based | 10KB |
|
||||
| **Transformation Cache** | `coordinator/data_transformation.py` | Until midnight/config | Automatic | 60KB |
|
||||
| Layer | Location | Lifetime | Invalidation | Memory |
|
||||
| ------------------------ | ------------------------------------ | -------------------------------------- | ------------ | ------ |
|
||||
| **API Cache** | `coordinator/cache.py` | 24h (user)<br/>Until midnight (prices) | Automatic | 50KB |
|
||||
| **Translation Cache** | `const.py` | Until HA restart | Never | 5KB |
|
||||
| **Config Cache** | `coordinator/*` | Until config change | Explicit | 1KB |
|
||||
| **Period Cache** | `coordinator/periods.py` | Until data/config change | Hash-based | 10KB |
|
||||
| **Transformation Cache** | `coordinator/data_transformation.py` | Until midnight/config | Automatic | 60KB |
|
||||
|
||||
**Total cache overhead:** ~126KB per coordinator instance (main entry + subentries)
|
||||
|
||||
|
|
@ -195,30 +195,31 @@ For detailed cache behavior, see [Caching Strategy](./caching-strategy.md).
|
|||
|
||||
### Core Components
|
||||
|
||||
| Component | File | Responsibility |
|
||||
|-----------|------|----------------|
|
||||
| **API Client** | `api.py` | GraphQL queries to Tibber, retry logic, error handling |
|
||||
| **Coordinator** | `coordinator.py` | Update orchestration, cache management, absolute-time scheduling with boundary tolerance |
|
||||
| **Data Transformer** | `coordinator/data_transformation.py` | Price enrichment (averages, ratings, differences) |
|
||||
| **Period Calculator** | `coordinator/periods.py` | Best/peak price period calculation with relaxation |
|
||||
| **Sensors** | `sensor/` | 80+ entities for prices, levels, ratings, statistics |
|
||||
| **Binary Sensors** | `binary_sensor/` | Period indicators (best/peak price active) |
|
||||
| **Services** | `services/` | Custom service endpoints (get_chartdata, get_apexcharts_yaml, refresh_user_data) |
|
||||
| Component | File | Responsibility |
|
||||
| --------------------- | ------------------------------------ | ---------------------------------------------------------------------------------------- |
|
||||
| **API Client** | `api.py` | GraphQL queries to Tibber, retry logic, error handling |
|
||||
| **Coordinator** | `coordinator.py` | Update orchestration, cache management, absolute-time scheduling with boundary tolerance |
|
||||
| **Data Transformer** | `coordinator/data_transformation.py` | Price enrichment (averages, ratings, differences) |
|
||||
| **Period Calculator** | `coordinator/periods.py` | Best/peak price period calculation with relaxation |
|
||||
| **Sensors** | `sensor/` | 80+ entities for prices, levels, ratings, statistics |
|
||||
| **Binary Sensors** | `binary_sensor/` | Period indicators (best/peak price active) |
|
||||
| **Services** | `services/` | Custom service endpoints (get_chartdata, get_apexcharts_yaml, refresh_user_data) |
|
||||
|
||||
### Sensor Architecture (Calculator Pattern)
|
||||
|
||||
The sensor platform uses **Calculator Pattern** for clean separation of concerns (refactored Nov 2025):
|
||||
|
||||
| Component | Files | Lines | Responsibility |
|
||||
|-----------|-------|-------|----------------|
|
||||
| **Entity Class** | `sensor/core.py` | 909 | Entity lifecycle, coordinator, delegates to calculators |
|
||||
| **Calculators** | `sensor/calculators/` | 1,838 | Business logic (8 specialized calculators) |
|
||||
| **Attributes** | `sensor/attributes/` | 1,209 | State presentation (8 specialized modules) |
|
||||
| **Routing** | `sensor/value_getters.py` | 276 | Centralized sensor → calculator mapping |
|
||||
| **Chart Export** | `sensor/chart_data.py` | 144 | Service call handling, YAML parsing |
|
||||
| **Helpers** | `sensor/helpers.py` | 188 | Aggregation functions, utilities |
|
||||
| Component | Files | Lines | Responsibility |
|
||||
| ---------------- | ------------------------- | ----- | ------------------------------------------------------- |
|
||||
| **Entity Class** | `sensor/core.py` | 909 | Entity lifecycle, coordinator, delegates to calculators |
|
||||
| **Calculators** | `sensor/calculators/` | 1,838 | Business logic (8 specialized calculators) |
|
||||
| **Attributes** | `sensor/attributes/` | 1,209 | State presentation (8 specialized modules) |
|
||||
| **Routing** | `sensor/value_getters.py` | 276 | Centralized sensor → calculator mapping |
|
||||
| **Chart Export** | `sensor/chart_data.py` | 144 | Service call handling, YAML parsing |
|
||||
| **Helpers** | `sensor/helpers.py` | 188 | Aggregation functions, utilities |
|
||||
|
||||
**Calculator Package** (`sensor/calculators/`):
|
||||
|
||||
- `base.py` - Abstract BaseCalculator with coordinator access
|
||||
- `interval.py` - Single interval calculations (current/next/previous)
|
||||
- `rolling_hour.py` - 5-interval rolling windows
|
||||
|
|
@ -230,6 +231,7 @@ The sensor platform uses **Calculator Pattern** for clean separation of concerns
|
|||
- `metadata.py` - Home/metering metadata
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- 58% reduction in core.py (2,170 → 909 lines)
|
||||
- Clear separation: Calculators (logic) vs Attributes (presentation)
|
||||
- Independent testability for each calculator
|
||||
|
|
@ -237,12 +239,12 @@ The sensor platform uses **Calculator Pattern** for clean separation of concerns
|
|||
|
||||
### Helper Utilities
|
||||
|
||||
| Utility | File | Purpose |
|
||||
|---------|------|---------|
|
||||
| **Price Utils** | `utils/price.py` | Rating calculation, enrichment, level aggregation |
|
||||
| **Average Utils** | `utils/average.py` | Trailing/leading 24h average calculations |
|
||||
| **Entity Utils** | `entity_utils/` | Shared icon/color/attribute logic |
|
||||
| **Translations** | `const.py` | Translation loading and caching |
|
||||
| Utility | File | Purpose |
|
||||
| ----------------- | ------------------ | ------------------------------------------------- |
|
||||
| **Price Utils** | `utils/price.py` | Rating calculation, enrichment, level aggregation |
|
||||
| **Average Utils** | `utils/average.py` | Trailing/leading 24h average calculations |
|
||||
| **Entity Utils** | `entity_utils/` | Shared icon/color/attribute logic |
|
||||
| **Translations** | `const.py` | Translation loading and caching |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -283,12 +285,12 @@ All quarter-hourly price intervals get augmented via `utils/price.py`:
|
|||
- **API polling**: Every 15 minutes (coordinator fetch cycle)
|
||||
- **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)`
|
||||
- HA may trigger ±few milliseconds before/after exact boundary
|
||||
- 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 restarts at 14:59:30 → stays at 14:45:00 (shows current interval data)
|
||||
- HA may trigger ±few milliseconds before/after exact boundary
|
||||
- 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 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)
|
||||
- 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
|
||||
|
||||
### 4. Calculator Pattern (Sensor Platform)
|
||||
|
|
@ -296,26 +298,31 @@ All quarter-hourly price intervals get augmented via `utils/price.py`:
|
|||
Sensors organized by **calculation method** (refactored Nov 2025):
|
||||
|
||||
**Unified Handler Methods** (`sensor/core.py`):
|
||||
|
||||
- `_get_interval_value(offset, type)` - current/next/previous intervals
|
||||
- `_get_rolling_hour_value(offset, type)` - 5-interval rolling windows
|
||||
- `_get_daily_stat_value(day, stat_func)` - calendar day min/max/avg
|
||||
- `_get_24h_window_value(stat_func)` - trailing/leading statistics
|
||||
|
||||
**Routing** (`sensor/value_getters.py`):
|
||||
|
||||
- Single source of truth mapping 80+ entity keys to calculator methods
|
||||
- Organized by calculation type (Interval, Rolling Hour, Daily Stats, etc.)
|
||||
|
||||
**Calculators** (`sensor/calculators/`):
|
||||
|
||||
- Each calculator inherits from `BaseCalculator` with coordinator access
|
||||
- Focused responsibility: `IntervalCalculator`, `TrendCalculator`, etc.
|
||||
- Complex logic isolated (e.g., `TrendCalculator` has internal caching)
|
||||
|
||||
**Attributes** (`sensor/attributes/`):
|
||||
|
||||
- Separate from business logic, handles state presentation
|
||||
- Builds extra_state_attributes dicts for entity classes
|
||||
- Unified builders: `build_sensor_attributes()`, `build_extra_state_attributes()`
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- Minimal code duplication across 80+ sensors
|
||||
- Clear separation of concerns (calculation vs presentation)
|
||||
- Easy to extend: Add sensor → choose pattern → add to routing
|
||||
|
|
@ -333,12 +340,12 @@ Sensors organized by **calculation method** (refactored Nov 2025):
|
|||
|
||||
### CPU Optimization
|
||||
|
||||
| Optimization | Location | Savings |
|
||||
|--------------|----------|---------|
|
||||
| Config caching | `coordinator/*` | ~50% on config checks |
|
||||
| Period caching | `coordinator/periods.py` | ~70% on period recalculation |
|
||||
| Lazy logging | Throughout | ~15% on log-heavy operations |
|
||||
| Import optimization | Module structure | ~20% faster loading |
|
||||
| Optimization | Location | Savings |
|
||||
| ------------------- | ------------------------ | ---------------------------- |
|
||||
| Config caching | `coordinator/*` | ~50% on config checks |
|
||||
| Period caching | `coordinator/periods.py` | ~70% on period recalculation |
|
||||
| Lazy logging | Throughout | ~15% on log-heavy operations |
|
||||
| Import optimization | Module structure | ~20% faster loading |
|
||||
|
||||
### Memory Usage
|
||||
|
||||
|
|
|
|||
|
|
@ -24,11 +24,13 @@ The integration uses **4 distinct caching layers** with different purposes and l
|
|||
**Purpose:** Reduce API calls to Tibber by caching user data and price data between HA restarts.
|
||||
|
||||
**What is cached:**
|
||||
|
||||
- **Price data** (`price_data`): Day before yesterday/yesterday/today/tomorrow price intervals with enriched fields (384 intervals total)
|
||||
- **User data** (`user_data`): Homes, subscriptions, features from Tibber GraphQL `viewer` query
|
||||
- **Timestamps**: Last update times for validation
|
||||
|
||||
**Lifetime:**
|
||||
|
||||
- **Price data**: Until midnight turnover (cleared daily at 00:00 local time)
|
||||
- **User data**: 24 hours (refreshed daily)
|
||||
- **Survives**: HA restarts via persistent Storage
|
||||
|
|
@ -36,29 +38,31 @@ The integration uses **4 distinct caching layers** with different purposes and l
|
|||
**Invalidation triggers:**
|
||||
|
||||
1. **Midnight turnover** (Timer #2 in coordinator):
|
||||
```python
|
||||
# coordinator/day_transitions.py
|
||||
def _handle_midnight_turnover() -> None:
|
||||
self._cached_price_data = None # Force fresh fetch for new day
|
||||
self._last_price_update = None
|
||||
await self.store_cache()
|
||||
```
|
||||
|
||||
```python
|
||||
# coordinator/day_transitions.py
|
||||
def _handle_midnight_turnover() -> None:
|
||||
self._cached_price_data = None # Force fresh fetch for new day
|
||||
self._last_price_update = None
|
||||
await self.store_cache()
|
||||
```
|
||||
|
||||
2. **Cache validation on load**:
|
||||
```python
|
||||
# coordinator/cache.py
|
||||
def is_cache_valid(cache_data: CacheData) -> bool:
|
||||
# Checks if price data is from a previous day
|
||||
if today_date < local_now.date(): # Yesterday's data
|
||||
return False
|
||||
```
|
||||
|
||||
```python
|
||||
# coordinator/cache.py
|
||||
def is_cache_valid(cache_data: CacheData) -> bool:
|
||||
# Checks if price data is from a previous day
|
||||
if today_date < local_now.date(): # Yesterday's data
|
||||
return False
|
||||
```
|
||||
|
||||
3. **Tomorrow data check** (after 13:00):
|
||||
```python
|
||||
# coordinator/data_fetching.py
|
||||
if tomorrow_missing or tomorrow_invalid:
|
||||
return "tomorrow_check" # Update needed
|
||||
```
|
||||
```python
|
||||
# coordinator/data_fetching.py
|
||||
if tomorrow_missing or tomorrow_invalid:
|
||||
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.
|
||||
|
||||
|
|
@ -71,18 +75,22 @@ The integration uses **4 distinct caching layers** with different purposes and l
|
|||
**Purpose:** Avoid repeated file I/O when accessing entity descriptions, UI strings, etc.
|
||||
|
||||
**What is cached:**
|
||||
|
||||
- **Standard translations** (`/translations/*.json`): Config flow, selector options, entity names
|
||||
- **Custom translations** (`/custom_translations/*.json`): Entity descriptions, usage tips, long descriptions
|
||||
|
||||
**Lifetime:**
|
||||
|
||||
- **Forever** (until HA restart)
|
||||
- No invalidation during runtime
|
||||
|
||||
**When populated:**
|
||||
|
||||
- At integration setup: `async_load_translations(hass, "en")` in `__init__.py`
|
||||
- Lazy loading: If translation missing, attempts file load once
|
||||
|
||||
**Access pattern:**
|
||||
|
||||
```python
|
||||
# Non-blocking synchronous access from cached data
|
||||
description = get_translation("binary_sensor.best_price_period.description", "en")
|
||||
|
|
@ -101,6 +109,7 @@ description = get_translation("binary_sensor.best_price_period.description", "en
|
|||
**What is cached:**
|
||||
|
||||
### DataTransformer Config Cache
|
||||
|
||||
```python
|
||||
{
|
||||
"thresholds": {"low": 15, "high": 35},
|
||||
|
|
@ -110,6 +119,7 @@ description = get_translation("binary_sensor.best_price_period.description", "en
|
|||
```
|
||||
|
||||
### PeriodCalculator Config Cache
|
||||
|
||||
```python
|
||||
{
|
||||
"best": {"flex": 0.15, "min_distance_from_avg": 5.0, "min_period_length": 60},
|
||||
|
|
@ -118,20 +128,23 @@ description = get_translation("binary_sensor.best_price_period.description", "en
|
|||
```
|
||||
|
||||
**Lifetime:**
|
||||
|
||||
- Until `invalidate_config_cache()` is called
|
||||
- Built once on first use per coordinator update cycle
|
||||
|
||||
**Invalidation trigger:**
|
||||
|
||||
- **Options change** (user reconfigures integration):
|
||||
```python
|
||||
# coordinator/core.py
|
||||
async def _handle_options_update(...) -> None:
|
||||
self._data_transformer.invalidate_config_cache()
|
||||
self._period_calculator.invalidate_config_cache()
|
||||
await self.async_request_refresh()
|
||||
```
|
||||
```python
|
||||
# coordinator/core.py
|
||||
async def _handle_options_update(...) -> None:
|
||||
self._data_transformer.invalidate_config_cache()
|
||||
self._period_calculator.invalidate_config_cache()
|
||||
await self.async_request_refresh()
|
||||
```
|
||||
|
||||
**Performance impact:**
|
||||
|
||||
- **Before:** ~30 dict lookups + type conversions per update = ~50μs
|
||||
- **After:** 1 cache check = ~1μs
|
||||
- **Savings:** ~98% (50μs → 1μs per update)
|
||||
|
|
@ -147,6 +160,7 @@ description = get_translation("binary_sensor.best_price_period.description", "en
|
|||
**Purpose:** Avoid expensive period calculations (~100-500ms) when price data and config haven't changed.
|
||||
|
||||
**What is cached:**
|
||||
|
||||
```python
|
||||
{
|
||||
"best_price": {
|
||||
|
|
@ -161,6 +175,7 @@ description = get_translation("binary_sensor.best_price_period.description", "en
|
|||
```
|
||||
|
||||
**Cache key:** Hash of relevant inputs
|
||||
|
||||
```python
|
||||
hash_data = (
|
||||
today_signature, # (startsAt, rating_level) for each interval
|
||||
|
|
@ -172,6 +187,7 @@ hash_data = (
|
|||
```
|
||||
|
||||
**Lifetime:**
|
||||
|
||||
- Until price data changes (today's intervals modified)
|
||||
- Until config changes (flex, thresholds, filters)
|
||||
- Recalculated at midnight (new today data)
|
||||
|
|
@ -179,24 +195,27 @@ hash_data = (
|
|||
**Invalidation triggers:**
|
||||
|
||||
1. **Config change** (explicit):
|
||||
```python
|
||||
def invalidate_config_cache() -> None:
|
||||
self._cached_periods = None
|
||||
self._last_periods_hash = None
|
||||
```
|
||||
|
||||
```python
|
||||
def invalidate_config_cache() -> None:
|
||||
self._cached_periods = None
|
||||
self._last_periods_hash = None
|
||||
```
|
||||
|
||||
2. **Price data change** (automatic via hash mismatch):
|
||||
```python
|
||||
current_hash = self._compute_periods_hash(price_info)
|
||||
if self._last_periods_hash != current_hash:
|
||||
# Cache miss - recalculate
|
||||
```
|
||||
```python
|
||||
current_hash = self._compute_periods_hash(price_info)
|
||||
if self._last_periods_hash != current_hash:
|
||||
# Cache miss - recalculate
|
||||
```
|
||||
|
||||
**Cache hit rate:**
|
||||
|
||||
- **High:** During normal operation (coordinator updates every 15min, price data unchanged)
|
||||
- **Low:** After midnight (new today data) or when tomorrow data arrives (~13:00-14:00)
|
||||
|
||||
**Performance impact:**
|
||||
|
||||
- **Period calculation:** ~100-500ms (depends on interval count, relaxation attempts)
|
||||
- **Cache hit:** `<`1ms (hash comparison + dict lookup)
|
||||
- **Savings:** ~70% of calculation time (most updates hit cache)
|
||||
|
|
@ -212,6 +231,7 @@ hash_data = (
|
|||
**Status:** ✅ **Clean separation** - enrichment only, no redundancy
|
||||
|
||||
**What is cached:**
|
||||
|
||||
```python
|
||||
{
|
||||
"timestamp": ...,
|
||||
|
|
@ -224,14 +244,16 @@ hash_data = (
|
|||
**Purpose:** Avoid re-enriching price data when config unchanged between midnight checks.
|
||||
|
||||
**Current behavior:**
|
||||
|
||||
- Caches **only enriched price data** (price + statistics)
|
||||
- **Does NOT cache periods** (handled by Period Calculation Cache)
|
||||
- Invalidated when:
|
||||
- Config changes (thresholds affect enrichment)
|
||||
- Midnight turnover detected
|
||||
- New update cycle begins
|
||||
- Config changes (thresholds affect enrichment)
|
||||
- Midnight turnover detected
|
||||
- New update cycle begins
|
||||
|
||||
**Architecture:**
|
||||
|
||||
- DataTransformer: Handles price enrichment only
|
||||
- PeriodCalculator: Handles period calculation only (with hash-based cache)
|
||||
- Coordinator: Assembles final data on-demand from both caches
|
||||
|
|
@ -243,6 +265,7 @@ hash_data = (
|
|||
## Cache Invalidation Flow
|
||||
|
||||
### User Changes Options (Config Flow)
|
||||
|
||||
```
|
||||
User saves options
|
||||
↓
|
||||
|
|
@ -267,6 +290,7 @@ Fresh data fetch with new config
|
|||
```
|
||||
|
||||
### Midnight Turnover (Day Transition)
|
||||
|
||||
```
|
||||
Timer #2 fires at 00:00
|
||||
↓
|
||||
|
|
@ -286,6 +310,7 @@ Fresh API fetch for new day
|
|||
```
|
||||
|
||||
### Tomorrow Data Arrives (~13:00)
|
||||
|
||||
```
|
||||
Coordinator update cycle
|
||||
↓
|
||||
|
|
@ -327,12 +352,14 @@ API Data Cache (price_data, user_data)
|
|||
```
|
||||
|
||||
**No cache invalidation cascades:**
|
||||
|
||||
- Config cache invalidation is **explicit** (on options update)
|
||||
- Period cache invalidation is **automatic** (via hash mismatch)
|
||||
- Transformation cache invalidation is **automatic** (on midnight/config change)
|
||||
- Translation cache is **never invalidated** (read-only after load)
|
||||
|
||||
**Thread safety:**
|
||||
|
||||
- All caches are accessed from `MainThread` only (Home Assistant event loop)
|
||||
- No locking needed (single-threaded execution model)
|
||||
|
||||
|
|
@ -341,6 +368,7 @@ API Data Cache (price_data, user_data)
|
|||
## Performance Characteristics
|
||||
|
||||
### Typical Operation (No Changes)
|
||||
|
||||
```
|
||||
Coordinator Update (every 15 min)
|
||||
├─> API fetch: SKIP (cache valid)
|
||||
|
|
@ -353,6 +381,7 @@ Total: ~16ms (down from ~600ms without caching)
|
|||
```
|
||||
|
||||
### After Midnight Turnover
|
||||
|
||||
```
|
||||
Coordinator Update (00:00)
|
||||
├─> API fetch: ~500ms (cache cleared, fetch new day)
|
||||
|
|
@ -365,6 +394,7 @@ Total: ~755ms (expected once per day)
|
|||
```
|
||||
|
||||
### After Config Change
|
||||
|
||||
```
|
||||
Options Update
|
||||
├─> Cache invalidation: `<`1ms
|
||||
|
|
@ -381,23 +411,25 @@ Options Update
|
|||
|
||||
## Summary Table
|
||||
|
||||
| Cache Type | Lifetime | Size | Invalidation | Purpose |
|
||||
|------------|----------|------|--------------|---------|
|
||||
| **API Data** | Hours to 1 day | ~50KB | Midnight, validation | Reduce API calls |
|
||||
| **Translations** | Forever (until HA restart) | ~5KB | Never | Avoid file I/O |
|
||||
| **Config Dicts** | Until options change | `<`1KB | Explicit (options update) | Avoid dict lookups |
|
||||
| **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 |
|
||||
| Cache Type | Lifetime | Size | Invalidation | Purpose |
|
||||
| ---------------------- | ---------------------------- | ------ | ------------------------- | ------------------------------- |
|
||||
| **API Data** | Hours to 1 day | ~50KB | Midnight, validation | Reduce API calls |
|
||||
| **Translations** | Forever (until HA restart) | ~5KB | Never | Avoid file I/O |
|
||||
| **Config Dicts** | Until options change | `<`1KB | Explicit (options update) | Avoid dict lookups |
|
||||
| **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 |
|
||||
|
||||
**Total memory overhead:** ~116KB per coordinator instance (main + subentries)
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- 97% reduction in API calls (from every 15min to once per day)
|
||||
- 70% reduction in period calculation time (cache hits during normal operation)
|
||||
- 98% reduction in config access time (30+ lookups → 1 cache check)
|
||||
- Zero file I/O during runtime (translations cached at startup)
|
||||
|
||||
**Trade-offs:**
|
||||
|
||||
- Memory usage: ~116KB per home (negligible for modern systems)
|
||||
- Code complexity: 5 cache invalidation points (well-tested, documented)
|
||||
- Debugging: Must understand cache lifetime when investigating stale data issues
|
||||
|
|
@ -407,7 +439,9 @@ Options Update
|
|||
## Debugging Cache Issues
|
||||
|
||||
### Symptom: Stale data after config change
|
||||
|
||||
**Check:**
|
||||
|
||||
1. Is `_handle_options_update()` called? (should see "Options updated" log)
|
||||
2. Are `invalidate_config_cache()` methods executed?
|
||||
3. Does `async_request_refresh()` trigger?
|
||||
|
|
@ -415,7 +449,9 @@ Options Update
|
|||
**Fix:** Ensure `config_entry.add_update_listener()` is registered in coordinator init.
|
||||
|
||||
### Symptom: Period calculation not updating
|
||||
|
||||
**Check:**
|
||||
|
||||
1. Verify hash changes when data changes: `_compute_periods_hash()`
|
||||
2. Check `_last_periods_hash` vs `current_hash`
|
||||
3. Look for "Using cached period calculation" vs "Calculating periods" logs
|
||||
|
|
@ -423,7 +459,9 @@ Options Update
|
|||
**Fix:** Hash function may not include all relevant data. Review `_compute_periods_hash()` inputs.
|
||||
|
||||
### Symptom: Yesterday's prices shown as today
|
||||
|
||||
**Check:**
|
||||
|
||||
1. `is_cache_valid()` logic in `coordinator/cache.py`
|
||||
2. Midnight turnover execution (Timer #2)
|
||||
3. Cache clear confirmation in logs
|
||||
|
|
@ -431,7 +469,9 @@ Options Update
|
|||
**Fix:** Timer may not be firing. Check `_schedule_midnight_turnover()` registration.
|
||||
|
||||
### Symptom: Missing translations
|
||||
|
||||
**Check:**
|
||||
|
||||
1. `async_load_translations()` called at startup?
|
||||
2. Translation files exist in `/translations/` and `/custom_translations/`?
|
||||
3. Cache population: `_TRANSLATIONS_CACHE` keys
|
||||
|
|
|
|||
|
|
@ -8,10 +8,10 @@ comments: false
|
|||
|
||||
## Code Style
|
||||
|
||||
- **Formatter/Linter**: Ruff (replaces Black, Flake8, isort)
|
||||
- **Max line length**: 120 characters
|
||||
- **Max complexity**: 25 (McCabe)
|
||||
- **Target**: Python 3.13
|
||||
- **Formatter/Linter**: Ruff (replaces Black, Flake8, isort)
|
||||
- **Max line length**: 120 characters
|
||||
- **Max complexity**: 25 (McCabe)
|
||||
- **Target**: Python 3.13
|
||||
|
||||
Run before committing:
|
||||
|
||||
|
|
@ -41,12 +41,14 @@ class TimeService:
|
|||
```
|
||||
|
||||
**When prefix is required:**
|
||||
|
||||
- Public classes used across multiple modules
|
||||
- All exception classes
|
||||
- All coordinator and entity classes
|
||||
- Data classes (dataclasses, NamedTuples) used as public APIs
|
||||
|
||||
**When prefix can be omitted:**
|
||||
|
||||
- Private helper classes within a single module (prefix with `_` underscore)
|
||||
- Type aliases and callbacks (e.g., `TimeServiceCallback`)
|
||||
- Small internal NamedTuples for function returns
|
||||
|
|
@ -71,6 +73,7 @@ class DataFetcher: # Should be TibberPricesDataFetcher
|
|||
**Current Technical Debt:**
|
||||
|
||||
Many existing classes lack the `TibberPrices` prefix. Before refactoring:
|
||||
|
||||
1. Document the plan in `/planning/class-naming-refactoring.md`
|
||||
2. Use `multi_replace_string_in_file` for bulk renames
|
||||
3. Test thoroughly after each module
|
||||
|
|
|
|||
|
|
@ -14,10 +14,10 @@ Welcome! This guide helps you contribute to the Tibber Prices integration.
|
|||
|
||||
1. Fork the repository on GitHub
|
||||
2. Clone your fork:
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/hass.tibber_prices.git
|
||||
cd hass.tibber_prices
|
||||
```
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/hass.tibber_prices.git
|
||||
cd hass.tibber_prices
|
||||
```
|
||||
3. Open in VS Code
|
||||
4. Click "Reopen in Container" when prompted
|
||||
|
||||
|
|
@ -34,6 +34,7 @@ git checkout -b fix/issue-123-description
|
|||
```
|
||||
|
||||
**Branch naming:**
|
||||
|
||||
- `feature/` - New features
|
||||
- `fix/` - Bug fixes
|
||||
- `docs/` - Documentation only
|
||||
|
|
@ -45,6 +46,7 @@ git checkout -b fix/issue-123-description
|
|||
Edit code, following [Coding Guidelines](coding-guidelines.md).
|
||||
|
||||
**Run checks frequently:**
|
||||
|
||||
```bash
|
||||
./scripts/type-check # Pyright type checking
|
||||
./scripts/lint # Ruff linting (auto-fix)
|
||||
|
|
@ -78,6 +80,7 @@ async def test_your_feature(hass, coordinator):
|
|||
```
|
||||
|
||||
Run your test:
|
||||
|
||||
```bash
|
||||
./scripts/test tests/test_your_feature.py -v
|
||||
```
|
||||
|
|
@ -97,6 +100,7 @@ Impact: Users can predict when prices will stabilize or continue fluctuating."
|
|||
```
|
||||
|
||||
**Commit types:**
|
||||
|
||||
- `feat:` - New feature
|
||||
- `fix:` - Bug fix
|
||||
- `docs:` - Documentation
|
||||
|
|
@ -105,6 +109,7 @@ Impact: Users can predict when prices will stabilize or continue fluctuating."
|
|||
- `chore:` - Maintenance
|
||||
|
||||
**Add scope when relevant:**
|
||||
|
||||
- `feat(sensors):` - Sensor platform
|
||||
- `fix(coordinator):` - Data coordinator
|
||||
- `docs(user):` - User documentation
|
||||
|
|
@ -124,32 +129,40 @@ Then open Pull Request on GitHub.
|
|||
Title: Short, descriptive (50 chars max)
|
||||
|
||||
Description should include:
|
||||
|
||||
```markdown
|
||||
## What
|
||||
|
||||
Brief description of changes
|
||||
|
||||
## Why
|
||||
|
||||
Problem being solved or feature rationale
|
||||
|
||||
## How
|
||||
|
||||
Implementation approach
|
||||
|
||||
## Testing
|
||||
|
||||
- [ ] Manual testing in Home Assistant
|
||||
- [ ] Unit tests added/updated
|
||||
- [ ] Type checking passes
|
||||
- [ ] Linting passes
|
||||
|
||||
## Breaking Changes
|
||||
|
||||
(If any - describe migration path)
|
||||
|
||||
## Related Issues
|
||||
|
||||
Closes #123
|
||||
```
|
||||
|
||||
### PR Checklist
|
||||
|
||||
Before submitting:
|
||||
|
||||
- [ ] Code follows [Coding Guidelines](coding-guidelines.md)
|
||||
- [ ] All tests pass (`./scripts/test`)
|
||||
- [ ] Type checking passes (`./scripts/type-check`)
|
||||
|
|
@ -170,6 +183,7 @@ Before submitting:
|
|||
### What Reviewers Look For
|
||||
|
||||
✅ **Good:**
|
||||
|
||||
- Clear, self-explanatory code
|
||||
- Appropriate comments for complex logic
|
||||
- Tests covering edge cases
|
||||
|
|
@ -177,6 +191,7 @@ Before submitting:
|
|||
- Follows existing patterns
|
||||
|
||||
❌ **Avoid:**
|
||||
|
||||
- Large PRs (>500 lines) - split into smaller ones
|
||||
- Mixing unrelated changes
|
||||
- Missing tests for new features
|
||||
|
|
@ -193,6 +208,7 @@ Before submitting:
|
|||
## Finding Issues to Work On
|
||||
|
||||
Good first issues are labeled:
|
||||
|
||||
- `good first issue` - Beginner-friendly
|
||||
- `help wanted` - Maintainers welcome contributions
|
||||
- `documentation` - Docs improvements
|
||||
|
|
@ -210,6 +226,7 @@ Be respectful, constructive, and patient. We're all volunteers! 🙏
|
|||
---
|
||||
|
||||
💡 **Related:**
|
||||
|
||||
- [Setup Guide](setup.md) - DevContainer setup
|
||||
- [Coding Guidelines](coding-guidelines.md) - Style guide
|
||||
- [Testing](testing.md) - Writing tests
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue