diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 29ccd03f..f06715a0 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -4,7 +4,7 @@ This is a Python based repository providing an application for controlling batte ### Required Before Each Commit -- Only use ASCII characters in code, log messages, and documentation. Avoid non-ASCII characters to ensure compatibility and readability across different environments. +- Only use ASCII characters in source code and log messages. Avoid non-ASCII characters to ensure compatibility and readability across different environments. This rule does not apply to documentation in `docs/`. - Remove excessive whitespaces. - Follow PEP8 standards. Use autopep8 for that. - Check against pylint. Target score is like 9.0-9.5, if you can achieve 10, do it. @@ -44,5 +44,5 @@ This is a Python based repository providing an application for controlling batte 4. Document public APIs and complex logic. Suggest changes to the `docs/` folder when appropriate 5. Lay test scripts for verification and simple testing into the folder `scripts`. 6. Never commit content of `tmp`. -7. If you have new documentation for the wiki, add files to the `docs/` folder. Prefix is `WIKI_`. +7. User documentation lives in the `docs/` folder and is published via MkDocs to https://mastr.github.io/batcontrol/ — add or update pages there and register new pages in `mkdocs.yml`. 8. Ensure compatibility with supported Python versions (3.9 to 3.13) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 00000000..954be35d --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,55 @@ +name: Deploy Documentation + +on: + push: + branches: + - main + paths: + - 'docs/**' + - 'mkdocs.yml' + - '.github/workflows/docs.yml' + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +# Allow only one concurrent deployment, do not cancel in-progress runs. +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install documentation dependencies + run: pip install "mkdocs-material>=9.5,<10" + + - name: Build documentation + run: mkdocs build --strict + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@v3 + with: + path: site + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 6cc32264..528eb10f 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -30,7 +30,7 @@ jobs: run: | uv run pytest tests/ --cov=src/batcontrol --cov-report=xml - name: Upload coverage to Codecov - uses: codecov/codecov-action@v6 + uses: codecov/codecov-action@v7 with: files: ./coverage.xml fail_ci_if_error: false diff --git a/.gitignore b/.gitignore index f321166b..2d098e67 100644 --- a/.gitignore +++ b/.gitignore @@ -186,3 +186,4 @@ bin lib64 lib +site/ diff --git a/CLAUDE.md b/CLAUDE.md index d7f5583d..30e73a68 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,7 +28,7 @@ src/batcontrol/ ## Known Pitfalls -- ASCII-only in all files — no umlauts, special chars, emoji, even in log messages. +- ASCII-only in source code — no umlauts, special chars, emoji, even in log messages. Does not apply to documentation in `docs/`. - Peak shaving config is nested inside calculation parameters (not top-level). - `§14a EnWG` dynamic network fees live in `dynamictariff/network_fees.py`. - `resilient_wrapper.py` wraps inverter calls — test with the wrapper, not the raw backend. diff --git a/README.md b/README.md index 2e295a5d..f56a6fce 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ To integrate batcontrol with Home Assistant, use the following repository: [batc [![Docker Image CI](https://github.com/MaStr/batcontrol/actions/workflows/docker-image.yml/badge.svg?branch=main)](https://github.com/MaStr/batcontrol/actions/workflows/docker-image.yml) -[Wiki/Documentation](https://github.com/MaStr/batcontrol/wiki) +[Documentation](https://mastr.github.io/batcontrol/) ## Prerequisites: @@ -61,8 +61,8 @@ inverter: mqtt_password: secret base_topic: inverter capacity: 10000 # Battery capacity in Wh (required) - min_soc: 10 # Minimum SoC % (default: 10) - max_soc: 95 # Maximum SoC % (default: 95) + min_soc: 5 # Minimum SoC % (default: 5) + max_soc: 100 # Maximum SoC % (default: 100) max_grid_charge_rate: 5000 # Maximum charge rate in W (required) ``` @@ -178,7 +178,7 @@ docker run -d \ --name batcontrol \ -v /path/to/config:/app/config \ -v /path/to/logs:/app/logs \ - muexx/batcontrol:latest + mastr950/batcontrol:latest ``` ### Docker-compose example @@ -190,7 +190,7 @@ version: '3.8' services: batcontrol: - image: muexx/batcontrol:latest + image: mastr950/batcontrol:latest volumes: - ./config:/app/config - ./logs:/app/logs @@ -211,7 +211,7 @@ docker run -d \ -v /path/to/config:/app/config \ -v /path/to/logs:/app/logs \ -e TZ=Europe/Berlin \ - muexx/batcontrol:latest + mastr950/batcontrol:latest ``` #### Docker-compose example @@ -223,7 +223,7 @@ version: '3.8' services: batcontrol: - image: muexx/batcontrol:latest + image: mastr950/batcontrol:latest volumes: - ./config:/app/config - ./logs:/app/logs diff --git a/config/batcontrol_config_dummy.yaml b/config/batcontrol_config_dummy.yaml index d5651b1b..a292f8d1 100644 --- a/config/batcontrol_config_dummy.yaml +++ b/config/batcontrol_config_dummy.yaml @@ -1,6 +1,6 @@ #-------------------------- # Batcontrol Configuration -# See more Details in: https://github.com/MaStr/batcontrol/wiki/Batcontrol-Configuration +# See more Details in: https://mastr.github.io/batcontrol/configuration/batcontrol-configuration/ #-------------------------- timezone: Europe/Berlin #your time zone. not optional. time_resolution_minutes: 60 # Time resolution for forecasts: 15 (quarter-hourly) or 60 (hourly). Default: 60 @@ -23,7 +23,7 @@ battery_control: #-------------------------- # Battery Control Expert Tuning Parameters -# See more Details in: https://github.com/MaStr/batcontrol/wiki/battery_control_expert +# See more Details in: https://mastr.github.io/batcontrol/features/battery-control-expert/ #-------------------------- battery_control_expert: charge_rate_multiplier: 1.1 # Increase (>1) calculated charge rate to compensate charge inefficiencies. @@ -69,7 +69,7 @@ peak_shaving: #-------------------------- # Inverter -# See more Details in: https://github.com/MaStr/batcontrol/wiki/Inverter-Configuration +# See more Details in: https://mastr.github.io/batcontrol/configuration/inverter-configuration/ # # IMPORTANT: This configuration uses a "dummy" inverter for demonstration purposes. # The dummy inverter returns static values to make batcontrol work out of the box. @@ -110,7 +110,7 @@ inverter: #-------------------------- # Dynamic Tariff Provider -# See more Details in: https://github.com/MaStr/batcontrol/wiki/Dynamic-tariff-provider +# See more Details in: https://mastr.github.io/batcontrol/configuration/dynamic-tariff-provider/ #-------------------------- utility: type: awattar_de # [tibber, awattar_at, awattar_de, evcc, energyforecast, tariff_zones] @@ -148,7 +148,7 @@ dynamic_network_fees: #-------------------------- # MQTT API -# See more Details in: https://github.com/MaStr/batcontrol/wiki/MQTT-API +# See more Details in: https://mastr.github.io/batcontrol/integrations/mqtt-api/ #-------------------------- mqtt: enabled: false @@ -170,7 +170,7 @@ mqtt: #-------------------------- # Forecast Solar -# See more Details in: https://github.com/MaStr/batcontrol/wiki/Solar-Forecast +# See more Details in: https://mastr.github.io/batcontrol/configuration/solar-forecast/ # # Supported providers: # - fcsolarapi: Third-party solar forecast API (configured below, default) @@ -205,7 +205,7 @@ pvinstallations: #-------------------------- # Forecast Consumption -# See more Details in: https://github.com/MaStr/batcontrol/wiki/Consumption-forecast +# See more Details in: https://mastr.github.io/batcontrol/configuration/consumption-forecast/ # # Option 1: CSV-based forecast (default) # Option 2: HomeAssistant API-based forecast @@ -232,7 +232,7 @@ consumption_forecast: #-------------------------- # evcc connection # listen to evcc mqtt messages to lock the battery if the car is charging -# See more Details in: https://github.com/MaStr/batcontrol/wiki/evcc-connection +# See more Details in: https://mastr.github.io/batcontrol/integrations/evcc-connection/ #-------------------------- evcc: enabled: false diff --git a/docs/WIKI_peak_shaving.md b/docs/WIKI_peak_shaving.md deleted file mode 100644 index 52116455..00000000 --- a/docs/WIKI_peak_shaving.md +++ /dev/null @@ -1,175 +0,0 @@ -# Peak Shaving - -## Overview - -Peak shaving manages PV battery charging rate so the battery fills up gradually, reaching full capacity by a configurable target hour (`allow_full_battery_after`). This prevents the battery from being full too early in the day. - -**Problem:** All PV systems produce peak power around midday. Most batteries are full by then, causing excess PV to be fed into the grid at a time when grid prices are lowest - and for newer installations, feed-in may not be compensated at all. Peak shaving spreads battery charging over time so the system absorbs as much solar energy as possible. - -## Configuration - -### Enable Peak Shaving - -Peak shaving requires two configuration changes: - -1. Set the logic type to `next` in the `battery_control` section: - -```yaml -battery_control: - type: next # Use 'next' to enable peak shaving logic (default: 'default') -``` - -2. Configure the `peak_shaving` section: - -```yaml -peak_shaving: - enabled: false - mode: combined # 'time' | 'price' | 'combined' - allow_full_battery_after: 14 # Hour (0-23) - battery should be full by this hour - price_limit: 0.05 # Euro/kWh - keep battery empty for slots at or below this price -``` - -### Parameters - -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `enabled` | bool | `false` | Enable/disable peak shaving | -| `mode` | string | `combined` | Algorithm mode: `time`, `price`, or `combined` | -| `allow_full_battery_after` | int | `14` | Target hour (0-23) for the battery to be full | -| `price_limit` | float | `null` | Price threshold (Euro/kWh); required for modes `price` and `combined` | - -**`mode`** selects which algorithm components are active: -- **`time`** - time-based only: spread free capacity evenly until `allow_full_battery_after`. `price_limit` not required. -- **`price`** - price-based only: reserve capacity for cheap-price slots (in-window surplus overflow handled). Requires `price_limit`. -- **`combined`** (default) - both components; stricter limit wins. Requires `price_limit`. - -**`allow_full_battery_after`** controls when the battery is allowed to be 100% full: -- **Before this hour:** PV charge rate may be limited -- **At/after this hour:** No limit for all modes (target-hour check applies globally) - -## How It Works - -### Algorithm - -Peak shaving uses one or two components depending on `mode`. The stricter (lower non-negative) limit wins when both are active. - -**Component 1: Time-Based** (modes `time` and `combined`) - -Uses a **counter-linear ramp** so the allowed charge rate rises as the target hour approaches. Slot 0 (now) gets the smallest allocation; each later slot gets progressively more, reaching its largest value in the last slot before the target. This mirrors actual PV production curves that rise towards midday. - -``` -slots_remaining = n (slots until allow_full_battery_after) -pv_surplus = sum of max(production - consumption, 0) for remaining slots - -if pv_surplus > free_capacity: - # Weight of current slot = 1, weight of slot k = k+1 - # Total weight = n*(n+1)/2 - wh_current = 2 * free_capacity / (n * (n+1)) - charge_limit = wh_current / interval_hours (Wh -> W) -``` - -Example with free_capacity = 2000 Wh and 1 h intervals: - -| Hours to target (n) | Allowed rate | -|---|---| -| 8 | 55 W | -| 4 | 200 W | -| 2 | 666 W | -| 1 | 2000 W (full send) | - -**Component 2: Price-Based** (modes `price` and `combined`) - -Only slots within the **production window** are considered. The production window ends at the first slot where forecast production is zero; nighttime cheap slots beyond that point are ignored because there is no PV to charge from. This prevents reserving capacity for e.g. a cheap slot at 03:00 that would never produce energy. - -Before cheap window - reserves free capacity so cheap-slot PV surplus fills battery completely: - -``` -production_end = index of first slot with production = 0 -cheap_slots = slots where price <= price_limit AND slot < production_end -target_reserve = min(sum of PV surplus in cheap slots, max_capacity) -additional_allowed = free_capacity - target_reserve - -if additional_allowed <= 0: -> block charging (rate = 0) -else: -> spread additional_allowed evenly over slots before window -``` - -Inside cheap window - if total PV surplus in the window exceeds free capacity, the battery cannot fully absorb everything. Charging is spread evenly over the cheap slots so the battery fills gradually instead of hitting 100% in the first slot: - -``` -if total_cheap_surplus > free_capacity: - charge_limit = free_capacity / num_cheap_slots (Wh/slot -> W) -else: - no limit (-1) -``` - -The charge limit is applied using **MODE 8** (`limit_battery_charge_rate`). Peak shaving only applies when discharge is already allowed by the main price-based logic. - -### Skip Conditions - -Peak shaving is automatically skipped when: - -1. **`price_limit` not configured** for mode `price` or `combined` - price component disabled -2. **No PV production** - nighttime, no action needed -3. **Past the target hour** (`allow_full_battery_after`) - applies to all modes; no limit -4. **Battery in always_allow_discharge region** - SOC is already high -5. **Grid charging active (MODE -1)** - force charge takes priority -6. **Discharge not allowed** - battery is being preserved for upcoming high-price hours -7. **evcc is actively charging** - EV consumes the excess PV -8. **EV connected in PV mode** - evcc will absorb PV surplus - -The price-based component also returns no limit when: -- No cheap slots exist in the forecast -- Inside cheap window and total surplus fits in free capacity (absorb freely) - -### evcc Interaction - -When an EV charger is managed by evcc: - -- **EV actively charging** (`charging=true`): Peak shaving is disabled - the EV consumes the excess PV -- **EV connected in PV mode** (`connected=true` AND `mode=pv`): Peak shaving is disabled - evcc will naturally absorb surplus PV when the threshold is reached -- **EV disconnects or mode changes**: Peak shaving is re-enabled - -The evcc integration derives `mode` and `connected` topics automatically from the configured `loadpoint_topic` by replacing `/charging` with `/mode` and `/connected`. - -## MQTT API - -### Published Topics - -| Topic | Type | Retained | Description | -|-------|------|----------|-------------| -| `{base}/peak_shaving/enabled` | bool | Yes | Peak shaving enabled status | -| `{base}/peak_shaving/allow_full_battery_after` | int | Yes | Target hour (0-23) | -| `{base}/peak_shaving/price_limit` | float | Yes | Cheap-slot price limit in EUR/kWh; `-1` published when unset | -| `{base}/peak_shaving/mode` | string | Yes | Active operating mode (`time` / `price` / `combined`) | -| `{base}/peak_shaving/charge_limit` | int | No | Current charge limit in W (-1 if inactive) | - -### Settable Topics - -| Topic | Accepts | Description | -|-------|---------|-------------| -| `{base}/peak_shaving/enabled/set` | `true`/`false` | Enable/disable peak shaving | -| `{base}/peak_shaving/allow_full_battery_after/set` | int 0-23 | Set target hour | -| `{base}/peak_shaving/price_limit/set` | float | Cheap-slot price limit in EUR/kWh; send `-1` to disable the price component (no slot price <= -1 ever exists) | -| `{base}/peak_shaving/mode/set` | `time` / `price` / `combined` | Switch operating mode at runtime | - -Runtime changes are temporary and not persisted to `batcontrol_config.yaml`. - -### Home Assistant Auto-Discovery - -The following HA entities are automatically created: - -- **Peak Shaving Enabled** - switch entity -- **Peak Shaving Allow Full After** - number entity (0-23, step 1) -- **Peak Shaving Price Limit** - number entity (-1.0..1.0 EUR/kWh, step 0.01); this is the HA auto-discovery slider range, not a hard application limit. `peak_shaving.price_limit` itself accepts any numeric EUR/kWh value via the YAML config or the MQTT setter topic. The slider range is intentionally tight: the field is a *cheap-slot* threshold, so values approaching 1 EUR/kWh are already an unusually high cutoff in practice — values above that are accepted, but rarely meaningful for peak shaving. -- **Peak Shaving Mode** - select entity (`time` / `price` / `combined`) -- **Peak Shaving Charge Limit** - sensor entity (unit: W) - -## Known Limitations - -1. **No intra-day adjustment:** If clouds reduce PV significantly, the limit stays as calculated until the next evaluation cycle (every 3 minutes). The counter-linear ramp self-corrects automatically: high free capacity at the next cycle produces a higher allowed rate. - -2. **Code duplication:** `NextLogic` is a copy of `DefaultLogic` with peak shaving added. Once stable, the two could be merged or refactored. - -3. **Persistence:** Runtime changes via MQTT (`enabled`, `allow_full_battery_after`, `price_limit`, `mode`) are not written back to `batcontrol_config.yaml`. After a restart the values from the configuration file take effect again. - -4. **`combined` mode without `price_limit`:** When `mode: combined` is configured but `price_limit` is omitted (or `null`), the price component is skipped and the logic falls back to time-only behaviour. A warning is logged so the fallback is visible. To use the price component, set a numeric `price_limit`; to disable peak shaving entirely, set `enabled: false`. diff --git a/docs/assets/battery_limits_parameter.png b/docs/assets/battery_limits_parameter.png new file mode 100644 index 00000000..6f1c3bcd Binary files /dev/null and b/docs/assets/battery_limits_parameter.png differ diff --git a/docs/assets/charge_rate_multiplier.png b/docs/assets/charge_rate_multiplier.png new file mode 100644 index 00000000..3bb16ae3 Binary files /dev/null and b/docs/assets/charge_rate_multiplier.png differ diff --git a/docs/configuration/batcontrol-configuration.md b/docs/configuration/batcontrol-configuration.md new file mode 100644 index 00000000..c8da253a --- /dev/null +++ b/docs/configuration/batcontrol-configuration.md @@ -0,0 +1,117 @@ +This is the main control logic configuration: + +``` +timezone: Europe/Berlin #your time zone. not optional. +time_resolution_minutes: 60 # Time resolution for forecasts: 15 (quarter-hourly) or 60 (hourly). Default: 60 +loglevel: debug +logfile_enabled: true +log_everything: false # if false debug messages from fronius.auth and urllib3.connectionpool will be suppressed +max_logfile_size: 200 #kB +logfile_path: logs/batcontrol.log +``` + +## Time Resolution (with 0.6.0) +``` +time_resolution_minutes: 60 +``` +This parameter controls the time resolution for all forecasts (solar production, consumption, and electricity prices). Valid values are: +* **60** (default) - Hourly intervals, backward compatible, lower memory usage +* **15** - Quarter-hourly intervals, higher accuracy for dynamic tariffs, 4x more data points + +**Recommendation**: Use **15 minutes** if your dynamic tariff provider offers quarter-hourly prices (e.g., some Tibber or energyforecast.de plans). Use **60 minutes** for standard hourly tariffs or if you want to minimize resource usage. + +**Technical Details**: +- 15-min mode: 192 intervals per 48 hours (~8 KB per forecast) +- 60-min mode: 48 intervals per 48 hours (~2 KB per forecast) +- All forecast providers automatically adapt to the configured resolution +- MQTT topics publish data at the configured interval + +## Timezone +This parameter is used to calculate the correct time for your location, as some datasources deliver UTC based timeslots. +Valid values are [tz based(wikipedia)](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones). + +## Logfile +### Logpath +``` +logfile_path: logs/batcontrol.log +``` +Describes where logfiles are stored. The path can be relative or absolute. + +### Log Level +``` +loglevel: debug +``` +Increases or decreases the verbosity of log messages. Valid entries are + +* error +* warning +* info +* debug + +The recommended settings are `info` and `debug`. +To reduce the noise in the default setup, we introduced +``` +log_everything: false +``` +Setting this to `true`, the logmessage from Fronius authentication logic + HTTP-Requests are visible in the logfile. These are very verbose messages, which is the reason to only enable it for debugging purposes. + + +### Enable / Disable logfile + +``` +logfile_enabled: true +``` + +This parameter is used to enable a pyhsical logfile. Console out is still active if this value is set to `false`. This can be useful in docker-based environments. + +### Logsize + +``` +max_logfile_size: 200 #Kb +``` +Amount of logsize bevore a logswitch is applied. The logs switches from log.1 to log.2 and back. Each file will be the size of `max_logfile_size`. This is used to avoid a filling up disk. + +## Batcontrol alogrithm configuration + +``` +battery_control: + min_price_difference: 0.05 + min_price_difference_rel: 0.10 + always_allow_discharge_limit: 0.90 + max_charging_from_grid_limit: 0.89 + min_grid_charge_soc: 0.55 # optional: grid-charge to this SoC before expensive slots + min_recharge_amount: 100 +``` +Details about the Price configuration can be found on [price difference calculation](../features/price-difference-calculation.md) page. +`always_allow_discharge_limit` & `max_charging_from_grid_limit` is explained [here](../getting-started/how-batcontrol-works.md). + +![Picture of different parameters on battery soc](../assets/battery_limits_parameter.png) + +`min_grid_charge_soc` is optional. When set as a ratio, for example `0.55`, batcontrol grid-charges toward this target when charging is economical. Leave it unset to keep the default behavior. To also preserve this target as reserved energy during cheap/pre-expensive windows, enable the expert option `preserve_min_grid_charge_soc`. + +If `min_grid_charge_soc` is higher than `max_charging_from_grid_limit`, grid charging cannot reach the configured minimum SoC target. Batcontrol will log a warning in this case; increase `max_charging_from_grid_limit` or lower `min_grid_charge_soc` so the settings correlate. + +`min_recharge_amount` controls the minimum amount of Wh is needed to be recharged before batcontrol activates battery charging. + +## Battery Control Expert Tuning Parameters + +``` +battery_control_expert: + charge_rate_multiplier: 1.1 + soften_price_difference_on_charging: false + soften_price_difference_on_charging_factor: 5 + round_price_digits: 4 + production_offset_percent: 1.0 + preserve_min_grid_charge_soc: false +``` + +These expert parameters allow fine-tuning of Batcontrol's behavior. See [Battery Control Expert](../features/battery-control-expert.md) for detailed explanations of each parameter: + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `charge_rate_multiplier` | float | 1.1 | Multiplier for calculated charge rate to compensate for charging inefficiencies | +| `soften_price_difference_on_charging` | boolean | false | Enable earlier charging based on more relaxed price difference calculations | +| `soften_price_difference_on_charging_factor` | integer | 5 | Factor to soften price difference requirements when enabled | +| `round_price_digits` | integer | 4 | Decimal places for price rounding in comparisons | +| `production_offset_percent` | float | 1.0 | Multiplier to adjust solar production forecast (1.0 = no change, 0.8 = 80%, etc.) | +| `preserve_min_grid_charge_soc` | boolean | false | Also preserve `min_grid_charge_soc` as reserved battery energy during cheap/pre-expensive windows | \ No newline at end of file diff --git a/docs/configuration/consumption-forecast.md b/docs/configuration/consumption-forecast.md new file mode 100644 index 00000000..b70cf546 --- /dev/null +++ b/docs/configuration/consumption-forecast.md @@ -0,0 +1,532 @@ +# Consumption Forecast + +Batcontrol uses consumption forecasting to predict your household's energy usage for optimal battery management. This helps the system make smart decisions about when to charge or discharge your battery based on expected consumption patterns. + +## Available Forecast Providers + +Batcontrol supports two methods for consumption forecasting: + +1. **CSV** - Static load profile based on typical consumption patterns +2. **HomeAssistant API** - (Since 0.5.4) Dynamic forecast based on your actual historical consumption data + +--- + +## 1. CSV-Based Forecast + +The CSV method uses a predefined load profile file with typical consumption patterns. This is the default and simplest option. + +### Configuration (Since 0.5.0) + +```yaml +consumption_forecast: + type: csv + csv: + annual_consumption: 4500 # Total consumption in kWh per year + load_profile: load_profile.csv # Name of the load profile file in config folder +``` + +### Configuration (Before 0.5.0) + +```yaml +consumption_forecast: + annual_consumption: 4500 # Total consumption in kWh per year + load_profile: load_profile.csv # Name of the load profile file in config folder +``` + +### CSV File Format + +The CSV file must be placed in the `config/` folder and contain the following fields: + +```csv +month,weekday,hour,energy +``` + +**Field Definitions:** +- `month`: 1-12 (January = 1, December = 12) +- `weekday`: 0-6 (Monday = 0, Sunday = 6) +- `hour`: 0-23 (midnight = 0, 11 PM = 23) +- `energy`: Consumption in Wh (Watt-hours) + +### Example CSV Entry + +```csv +1,0,8,350 +``` +This means: In January, on Monday, at 8 AM, the consumption is 350 Wh. + +### How CSV Scaling Works + +When batcontrol loads the CSV profile, it: + +1. Calculates the total annual consumption from the load profile +2. Compares it to your configured `annual_consumption` +3. Scales all hourly values proportionally to match your actual consumption + +**Example log output:** +``` +INFO [FC Cons] The annual consumption of the applied load profile is 3225.29 kWh +INFO [FC Cons] The hourly values from the load profile are scaled with a factor of 1.40 to match the annual consumption of 4500 kWh +``` + +### Default Load Profile + +If no load profile is specified, batcontrol uses `default_load_profile.csv` as a fallback. + +--- + +## 2. HomeAssistant API-Based Forecast + +The HomeAssistant API method provides **dynamic consumption forecasting** based on your actual historical consumption data. This is the most accurate method as it learns from your real usage patterns. + +### How It Works + +1. **Connects to HomeAssistant** via WebSocket API +2. **Fetches historical data** from configured time periods (e.g., last 7, 14, 21 days) +3. **Calculates weighted averages** for each hour of the week +4. **Generates forecasts** for up to 48 hours ahead +5. **Caches results** to minimize API calls + +### Prerequisites + +- HomeAssistant instance accessible from batcontrol +- Long-term statistics enabled for your consumption sensor +- HomeAssistant Long-Lived Access Token + +### Configuration + +```yaml +consumption_forecast: + type: homeassistant-api + homeassistant_api: + base_url: ws://homeassistant.local:8123 # Your HomeAssistant URL + apitoken: YOUR_LONG_LIVED_ACCESS_TOKEN # Long-Lived Access Token + entity_id: sensor.energy_consumption # Entity ID with consumption data + sensor_unit: auto # Options: 'auto', 'Wh', or 'kWh' (since 0.5.7) + history_days: "-7;-14;-21" # Days to look back (negative values) + history_weights: "1;1;1" # Weight for each history period (1-10) + cache_ttl_hours: 48.0 # Cache duration in hours + multiplier: 1.0 # Forecast adjustment multiplier +``` + +### Configuration Parameters + +#### Required Parameters + +| Parameter | Description | Example | +|-----------|-------------|---------| +| `base_url` | HomeAssistant URL (ws is correct) | `ws://homeassistant.local:8123` | +| `apitoken` | Long-Lived Access Token from HomeAssistant | `eyJ0eXAiOiJKV1Qi...` | +| `entity_id` | Entity ID tracking consumption (must have long-term statistics) | `sensor.energy_consumption` | + +#### Optional Parameters + +| Parameter | Default | Description | +|-----------|---------|-------------| +| `sensor_unit` | `auto` | **Since 0.5.7**: Sensor unit configuration. Options: `'auto'` (auto-detect), `'Wh'`, or `'kWh'`. Set to `'Wh'` or `'kWh'` to skip auto-detection (faster startup, recommended for large HA installations). | +| `history_days` | `"-7;-14;-21"` | List of day offsets to fetch historical data. Negative values = days in the past. | +| `history_weights` | `"1;1;1"` | Weight for each history period (1-10). Higher = more influence. Must match length of `history_days`. | +| `cache_ttl_hours` | `48.0` | How long to cache computed statistics (in hours) | +| `multiplier` | `1.0` | Global multiplier for all forecast values. Use `1.1` for +10%, `0.9` for -10% | + +### Getting a HomeAssistant Access Token + +1. Open HomeAssistant web interface +2. Click on your profile (bottom left) +3. Scroll down to **"Long-Lived Access Tokens"** +4. Click **"Create Token"** +5. Give it a name (e.g., "Batcontrol") +6. Copy the token (you won't be able to see it again!) + +### Sensor Unit Configuration (Since 0.5.7) + +The `sensor_unit` parameter controls how batcontrol detects the unit of measurement for your consumption sensor. + +**Options:** +- `auto` (default) - Automatically detect unit by querying HomeAssistant +- `Wh` - Sensor reports in Watt-hours (no conversion needed) +- `kWh` - Sensor reports in Kilowatt-hours (values multiplied by 1000) + +**When to use explicit configuration (`Wh` or `kWh`):** +- **Large HomeAssistant installations** with many entities (faster startup, avoids "message too big" errors) +- **Performance optimization** - skips the auto-detection query on every startup +- **Consistent behavior** - eliminates the need to fetch all entity states + +**How to check your sensor's unit:** +1. Open HomeAssistant → Developer Tools → States +2. Find your entity (e.g., `sensor.energy_consumption`) +3. Check the `unit_of_measurement` attribute +4. Set `sensor_unit` accordingly in your configuration + +**Example:** +```yaml +consumption_forecast: + type: homeassistant-api + homeassistant_api: + base_url: ws://homeassistant.local:8123 + apitoken: YOUR_TOKEN + entity_id: sensor.energy_consumption + sensor_unit: kWh # Explicit configuration - faster startup! +``` + +**Note:** If you're unsure, leave it as `auto` (default). Batcontrol will automatically detect the correct unit. + +### Entity Requirements + +The entity you specify must: +- Be a **sensor** entity +- Track **cumulative energy consumption** (in Wh) +- Have **long-term statistics enabled** +- Provide **hourly statistics** via the HomeAssistant recorder + +**Good entity examples:** +- `sensor.energy_consumption` +- `sensor.house_energy_total` +- `sensor.grid_import_total` + +**Not suitable:** +- Instantaneous power sensors (W) +- Entities without statistics +- Non-energy entities + +### Example Sensor Configuration Using SunSpec Integration +- install SunSpec via HACS and configure with the IP from your inverter (see the SunSpec documentation if further informations are needed) +- Create a template helper as sensor: Settings -> Devices & Services -> Helper -> Create helper -> Template -> Sensor +- Name: e.g. `sensor.energy_consumption` or `sensor.house_energy_total` +- State: + +```yaml +{{ +states('sensor.smartmeter_ac_meter_total_watt_hours_imported') | float - +states('sensor.smartmeter_ac_meter_total_watt_hours_exported') | float + +(states('sensor.inverter_mppt_module_0_lifetime_energy') | float + +states('sensor.inverter_mppt_module_1_lifetime_energy') | float + +states('sensor.inverter_mppt_module_3_lifetime_energy') | float - +states('sensor.inverter_mppt_module_2_lifetime_energy') | float) +}} +``` +- Unit of measurement: `kWh` +- Device class: `Energy` +- State class: `Total` + + +### How History Weights Work + +The `history_weights` parameter allows you to give more importance to recent data vs. older data. + +**Example 1: Equal weighting** +```yaml +history_days: "-7;-14;-21" +history_weights: "1;1;1" +``` +All three weeks have equal influence (33.3% each). + +**Example 2: Recent data preferred** +```yaml +history_days: "-7;-14;-21" +history_weights: "3;2;1" +``` +- Last week: 50% influence (3/6) +- Two weeks ago: 33% influence (2/6) +- Three weeks ago: 17% influence (1/6) + +**Example 3: Short-term forecast** +```yaml +history_days: "-1;-2;-3" +history_weights: "3;2;1" +``` +Uses only the last 3 days for very dynamic forecasting. + +### Multiplier for Forecast Adjustment + +The `multiplier` parameter allows you to globally adjust all forecast values: + +- `1.0` = No adjustment (default) +- `1.1` = Increase forecast by 10% +- `0.9` = Decrease forecast by 10% +- `1.2` = Increase forecast by 20% + +**Use cases:** +- You know consumption will increase (e.g., guests coming, new appliances) +- You want to be more conservative with battery discharge +- Seasonal adjustments without changing historical data + +### Caching Behavior + +To minimize load on HomeAssistant: +- Computed statistics are **cached** for `cache_ttl_hours` +- Cache stores consumption values per weekday/hour combination +- Cache is automatically refreshed when data is missing +- Cache survives batcontrol restarts (in-memory cache) + +**Cache key format:** `"weekday_hour"` (e.g., `"0_14"` = Monday 14:00) + +### WebSocket Communication + +The HomeAssistant forecaster uses the modern **WebSocket API** for efficient communication: + +1. Establishes WebSocket connection +2. Authenticates with access token +3. Fetches hourly statistics using `recorder/statistics_during_period` +4. Processes and caches results +5. Reuses connection for multiple requests when possible + +This is more efficient than the REST API for frequent data fetches. + +--- + +## Testing Your Configuration + +### Test Script + +Batcontrol includes a test script to verify your HomeAssistant configuration: + +```bash +cd batcontrol/scripts +python test_homeassistant_forecast.py +``` + +Edit the configuration section in the script: + +```python +HOMEASSISTANT_URL = "ws://homeassistant.local:8123" +HOMEASSISTANT_TOKEN = "YOUR_LONG_LIVED_ACCESS_TOKEN" +ENTITY_ID = "sensor.energy_consumption" +HISTORY_DAYS = [-7, -14, -21] +HISTORY_WEIGHTS = [3, 2, 1] +``` + +The script will: +- Connect to HomeAssistant +- Fetch historical data +- Generate a 24-hour forecast +- Display results in a formatted table with statistics + +--- + +## Troubleshooting + +### CSV Method + +**Problem:** "The annual consumption of the applied load profile is X kWh" + +**Solution:** This is just informational. The profile will be automatically scaled to match your `annual_consumption` setting. + +**Problem:** "No load profile specified, using default" + +**Solution:** Specify a valid `load_profile` filename in your configuration. + +### HomeAssistant API Method + +**Problem:** "Authentication failed" + +**Solution:** +- Verify your access token is correct +- Check if the token has been revoked in HomeAssistant +- Create a new Long-Lived Access Token + +**Problem:** "ConnectionClosedError: sent 1009 (message too big)" or "websockets.exceptions.ConnectionClosedError: frame exceeds limit" + +**Solution (Since 0.5.7):** +This error occurs when your HomeAssistant instance has many entities (sensors, lights, automations, etc.) and the response exceeds the WebSocket size limit during auto-detection. + +**Quick Fix:** Set the `sensor_unit` parameter explicitly to skip auto-detection: +```yaml +consumption_forecast: + type: homeassistant-api + homeassistant_api: + base_url: ws://homeassistant.local:8123 + apitoken: YOUR_TOKEN + entity_id: sensor.energy_consumption + sensor_unit: kWh # or 'Wh' depending on your sensor +``` + +**How to determine your sensor unit:** +1. Open HomeAssistant → Developer Tools → States +2. Find your entity (e.g., `sensor.energy_consumption`) +3. Check the `unit_of_measurement` attribute +4. Use `kWh` if it shows "kWh", or `Wh` if it shows "Wh" + +**Technical details:** Batcontrol 0.5.7+ uses a 4MB WebSocket frame limit (up from 1MB) and allows you to skip the auto-detection query entirely by configuring the sensor unit explicitly. + +**Problem:** "No statistics data returned for entity" + +**Solution:** +- Verify the entity exists in HomeAssistant +- Check if long-term statistics are enabled for this entity +- Wait for HomeAssistant to collect at least one hour of statistics +- Check HomeAssistant logs for recorder issues + +**Problem:** "Connection refused" + +**Solution:** +- Verify `base_url` is correct and accessible from batcontrol +- Check if HomeAssistant is running +- Verify network connectivity +- Check firewall rules + +**Problem:** "Length of history_days must match history_weights" + +**Solution:** Ensure both lists have the same number of elements: +```yaml +history_days: "-7;-14;-21" # 3 elements +history_weights: "3;2;1" # 3 elements +``` + +**Problem:** "History weights must be between 1 and 10" + +**Solution:** Use only values from 1 to 10 in `history_weights`. + +**Problem:** Empty or incomplete forecast + +**Solution:** +- Check if HomeAssistant has enough historical data (at least 7 days recommended) +- Verify the entity is recording data continuously +- Check cache TTL - try reducing it temporarily +- Enable DEBUG logging to see detailed fetch information + +**Problem:** Forecast values are too small or too large + +**Solution (Since 0.5.7):** +Batcontrol automatically detects whether your sensor reports in Wh or kWh and applies the correct conversion. If values are incorrect: + +1. **Check auto-detection:** Let batcontrol auto-detect (default `sensor_unit: auto`) +2. **Verify sensor unit:** Check your sensor's `unit_of_measurement` in HomeAssistant +3. **Set explicitly:** If auto-detection fails, set `sensor_unit` manually: + ```yaml + sensor_unit: kWh # if sensor reports in kWh + # or + sensor_unit: Wh # if sensor reports in Wh + ``` + +**Legacy workaround (before 0.5.7):** If auto-detection is not available, use the `multiplier` parameter: +```yaml +multiplier: 1000 # Convert kWh to Wh (if sensor reports in kWh) +``` + +**Note:** Batcontrol expects all consumption values in Wh (Watt-hours) internally. + +--- + +## Comparison: CSV vs. HomeAssistant API + +| Feature | CSV | HomeAssistant API | +|---------|-----|-------------------| +| **Accuracy** | Generic patterns | Based on your actual usage | +| **Setup Complexity** | Simple | Moderate (requires HA setup) | +| **Maintenance** | Manual updates needed | Automatic learning | +| **Dependencies** | None | HomeAssistant + Long-term stats | +| **Flexibility** | Low (static profile) | High (adapts to changes) | +| **Performance** | Fast (local file) | Cached (WebSocket API) | +| **Best For** | Testing, consistent usage | Real-world scenarios | + +--- + +## Recommendations + +- **Start with CSV** for initial testing and setup +- **Switch to HomeAssistant API** once you have historical data for accurate forecasting +- Use **recent history weighting** (e.g., `[3, 2, 1]`) for more responsive forecasts +- Set `cache_ttl_hours` to `24-48` hours for good balance between accuracy and API load +- Use **multiplier** for temporary adjustments rather than changing configuration frequently +- Monitor logs to ensure forecasts are being generated correctly + +--- + +## Advanced Tips + +### Seasonal Adjustments + +For seasonal changes, consider: +- Using shorter `history_days` periods (e.g., `-7, -14` instead of `-7, -14, -21`) +- Adjusting the `multiplier` seasonally +- Creating different CSV profiles for different seasons + +### Multiple Consumption Points + +If you have multiple consumption sensors, you can: +- Create a template sensor in HomeAssistant that combines them +- Use the combined sensor's entity_id in batcontrol configuration + +### Debugging + +Enable DEBUG logging to see detailed information: +```python +logging.basicConfig(level=logging.DEBUG) +``` + +Look for: +- WebSocket connection messages +- Statistics fetch results +- Cache hit/miss events +- Weighted average calculations + +--- + +## Example Configurations + +### Example 1: Simple Setup (CSV) + +```yaml +consumption_forecast: + type: csv + csv: + annual_consumption: 4500 + load_profile: load_profile.csv +``` + +### Example 2: HomeAssistant with Equal Weights + +```yaml +consumption_forecast: + type: homeassistant-api + homeassistant_api: + base_url: ws://192.168.1.100:8123 + apitoken: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9... + entity_id: sensor.house_energy_total + sensor_unit: auto # Auto-detect (default) + history_days: "-7;-14;-21" + history_weights: "1;1;1" + cache_ttl_hours: 48.0 + multiplier: 1.0 +``` + +### Example 3: HomeAssistant with Explicit Unit (Recommended for Large Installations) + +```yaml +consumption_forecast: + type: homeassistant-api + homeassistant_api: + base_url: ws://homeassistant.local:8123 + apitoken: your_token_here + entity_id: sensor.energy_consumption + sensor_unit: kWh # Explicit unit - faster startup, no auto-detection needed + history_days: "-7;-14;-21" + history_weights: "3;2;1" # Recent week has most influence + cache_ttl_hours: 24.0 + multiplier: 1.1 # Increase forecast by 10% +``` + +### Example 4: Short-term Dynamic Forecast + +```yaml +consumption_forecast: + type: homeassistant-api + homeassistant_api: + base_url: ws://homeassistant.local:8123 + apitoken: your_token_here + entity_id: sensor.energy_consumption + sensor_unit: Wh # Explicit unit for optimal performance + history_days: "-1;-2;-3" # Only last 3 days + history_weights: "5;3;2" # Yesterday has most weight + cache_ttl_hours: 12.0 # Shorter cache + multiplier: 1.0 +``` + +--- + +## Related Documentation + +- [Batcontrol Configuration](batcontrol-configuration.md) +- [How Batcontrol Works](../getting-started/how-batcontrol-works.md) +- [MQTT API](../integrations/mqtt-api.md) +- [Solar Forecast](solar-forecast.md) \ No newline at end of file diff --git a/docs/configuration/dynamic-tariff-provider.md b/docs/configuration/dynamic-tariff-provider.md new file mode 100644 index 00000000..e98cd823 --- /dev/null +++ b/docs/configuration/dynamic-tariff-provider.md @@ -0,0 +1,199 @@ +Currently following data providers are available: + +* tibber +* awattar +* Two-Tariff Providers (e.g. Octopus) +* evcc +* energyforecast.de + +You can chose one and need to adjust the configuration. + +## tibber +You need to get an API Key from https://developer.tibber.com/ , which looks like `Zz-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXx`. After obtaining this key, use the following configuration: + +``` +utility: + type: tibber + apikey: YOUR-PASSWORD +``` + + +## awattar +batcontrol provides two different awattar types: + +* `awattar_de` for German aWATTar +* `awattar_at` for Austrian aWATTar + +Please choose the corresponding version. For aWATTar you can use this configuration: + +``` +utility: + type: awattar_de + vat: 0.19 # 19% VAT + fees: 0.015 # Depends on you Netzendgeld + markup: 0.03 # Depends on you aWATTar contract +``` + +The calculation is `( marketprice/1000*(1+markup) + fees ) * (1+vat)` + +## Multi-Zone Tariff Providers (e.g. Octopus, Two-Tariff) (since 0.7.0) + +If your energy provider offers distinct tariff zones (e.g. day/night rates, peak/off-peak), you can configure batcontrol to optimize battery usage accordingly. The `tariff_zones` provider supports up to **3 different price zones**. + +## `tariff_zones` — Static & multi-zone tariff + +The `tariff_zones` provider uses a locally configured, fixed price schedule +instead of an external API. It supports **1, 2, or 3 zones**: + +| Zones configured | Behaviour | +|---------------------------|--------------------------------------------------------| +| **1 zone** (static price) | One flat price for every hour of the day | +| **2 zones** | Classic peak / off-peak split | +| **3 zones** | Peak / shoulder / off-peak | + +> **Since v0.8.0:** `tariff_zone_2` / `zone_2_hours` are **optional**. In +> earlier versions zone 2 was mandatory. Configurations written for the old +> 2-zone-only behaviour keep working unchanged. + +This makes it suitable for: + +- Users **without a dynamic tariff** who still want peak-shaving (see issue + [#318](https://github.com/MaStr/batcontrol/issues/318)). +- Users with a fixed day / night tariff. +- Users with a fixed three-period (HT/NT/shoulder) tariff. + +### Static price mode (single zone) + +> Available since **v0.8.0**. + +Set only `tariff_zone_1`. `zone_1_hours` is optional and defaults to all 24 +hours. + +```yaml +utility: + type: tariff_zones + tariff_zone_1: 0.30 # EUR/kWh incl. VAT/fees, applied to every hour +``` + +### Two zones (peak / off-peak) + +```yaml +utility: + type: tariff_zones + tariff_zone_1: 0.2733 # peak price + zone_1_hours: 7-22 + tariff_zone_2: 0.1734 # off-peak price + zone_2_hours: 0-6,23 +``` + +### Three zones (peak / shoulder / off-peak) + +```yaml +utility: + type: tariff_zones + tariff_zone_1: 0.30 + zone_1_hours: 9-16 + tariff_zone_2: 0.15 + zone_2_hours: 0-6,23 + tariff_zone_3: 0.22 + zone_3_hours: 7-8,17-22 +``` + +### Configuration reference + +| Key | Required | Description | +|------------------|----------------------------------|-------------------------------------------------| +| `type` | yes | Must be `tariff_zones` | +| `tariff_zone_1` | **yes** | Price for zone 1 in EUR/kWh (incl. VAT & fees) | +| `zone_1_hours` | optional in single-zone mode; required otherwise | Hours assigned to zone 1 | +| `tariff_zone_2` | optional since v0.8.0 (paired with `zone_2_hours`) | Price for zone 2 | +| `zone_2_hours` | optional since v0.8.0 (paired with `tariff_zone_2`) | Hours assigned to zone 2 | +| `tariff_zone_3` | optional (paired with `zone_3_hours`) | Price for zone 3 | +| `zone_3_hours` | optional (paired with `tariff_zone_3`) | Hours assigned to zone 3 | + +#### Hour syntax + +`zone_*_hours` accepts flexible formats (may be mixed): + +- Single integer: `5` +- Comma-separated list: `0,1,2,3` +- Inclusive range: `0-5` → `[0, 1, 2, 3, 4, 5]` +- Mixed: `0-5,6,7` +- YAML list with any of the above: `[7, 8, '17-22']` + +#### Validation rules + +- `tariff_zone_1` is always required. All prices must be positive. +- If `tariff_zone_2` is set, `zone_2_hours` **must** also be set (and vice + versa). Same rule applies to zone 3. +- **Every hour 0–23 must be assigned to exactly one zone** — no gaps, no + overlaps. If you configure `zone_1_hours` explicitly in single-zone mode, + it must cover the full day (it is **not** auto-extended); omit it to get + the default `0-23`. +- `zone_1_hours` may only be omitted when zones 2 and 3 are also omitted + (single-zone / static mode, v0.8.0+). + +### Charging behaviour tips + +The charge rate is not evenly distributed across low-price hours by default. + +- For **more even charging** across low-price hours, enable + `soften_price_difference_on_charging` and set `max_grid_charge_rate` to a + modest value (e.g. battery capacity ÷ low-price hours). +- For a **late charging start** (optimise efficiency, keep the battery at + high SOC for less time), disable `soften_price_difference_on_charging`. + +In pure single-zone (static) mode, prices never differ between hours, so +price-based scheduling has no effect — use it together with peak-shaving +(see [#271](https://github.com/MaStr/batcontrol/issues/271)) or fixed +charging-window settings. + +## evcc +If you are running evcc, it can be used to fetch the price information from this endpoint. +The configuration for this is + +``` +utility: + type: evcc + url: http://evcc.local:7070/api/tariff/grid +``` +You may need to adjust hostname + port for your setup. If evcc is running under HomeAssistant, you should use either `http://homeassistant:7070/api/tariff/grid` or `http://:7070/api/tariff/grid` + +## energyforecast.de (0.5.6) +[energyforecast.de](https://www.energyforecast.de) provides a calculated forecast for upcoming prices. Dayahead prices are populated at 14:00 GMT+2, which is after the lunch-drop in prices and prevents a good energy calculation. Based on different values, energyforecast.de calculates a price expectation with a median of 3 cent of. +batcontrol uses the 48h forecast only and it is not possible to activate the 96 hour forecast. You need to setup VAT, markup (+ % on energy price) and fees (Netzentgeld) in the configuration. We are not using the calculation provided by energyforecast.de. +If you like to use this forecast type, please create a login at [energyforecast.de](https://www.energyforecast.de) to aquire an API key. + +``` +utility: + type: energyforecast + apikey: xxxxxxxxx + vat: 0.19 # 19% VAT + fees: 0.15 # Depends on you Netzendgeld + markup: 0.00 # Depends on you aWATTar contract +``` + +To enable the paid 96h forecast, use type: energyforecast_96 + +## Dynamic network fees (§14a EnWG) + +Batcontrol can add time-of-use network fees (NT/ST/HT zones according to §14a EnWG) on top of the energy prices. This only applies to providers that calculate fees locally: **awattar** and **energyforecast**. All-inclusive providers (tibber, evcc, tariff_zones) already deliver final prices and do not need this. + +The fee data (NET prices, excluding VAT) is fetched from [dyn-net.batcontrol.software](https://dyn-net.batcontrol.software/) and added to the raw energy price before VAT is applied. Data is cached for 12 hours, as tariffs change at most quarterly. + +Configure it as a top-level block (next to `utility`): + +```yaml +dynamic_network_fees: + enabled: true # Set to true to activate dynamic network fees + country: de # Country code: de, at, ch + operator: syna # Network operator ID (e.g. syna, westnetz, ggv, ewr-netz) + # url: https://dyn-net.batcontrol.software/api/ # Optional: override for self-hosted instance +``` + +| Key | Required | Description | +|------------|----------|--------------------------------------------------------------------| +| `enabled` | yes | Master switch, defaults to `false` | +| `country` | yes | Country code (`de`, `at`, `ch`) | +| `operator` | yes | Your network operator ID — see [dyn-net.batcontrol.software](https://dyn-net.batcontrol.software/) for available IDs | +| `url` | no | Override the API endpoint, e.g. for a self-hosted instance | \ No newline at end of file diff --git a/docs/configuration/inverter-configuration.md b/docs/configuration/inverter-configuration.md new file mode 100644 index 00000000..04283edb --- /dev/null +++ b/docs/configuration/inverter-configuration.md @@ -0,0 +1,126 @@ +## General options + +### max_grid_charge_rate +This is the upper limit to charge the battery from grid. Value is WATT. This value should not be above the limit of your inverter. + +Default: +``` +max_grid_charge_rate: 5000 +``` + +### max_pv_charge_rate +This limits the used amount of PV to charge the battery. Value is WATT. +With adding `#` in front of the value, this limit is not set and will push all PV into the battery. + +Default: +``` +#max_pv_charge_rate: 3000 (Disabled) +``` + +## Resilient Wrapper Options (since 0.7.0) + +These options enable graceful handling of temporary inverter outages (e.g., during firmware upgrades or network interruptions). + +### enable_resilient_wrapper +Enable or disable the resilient wrapper for graceful outage handling. When enabled, temporary inverter failures are handled gracefully by caching values and applying retry backoff. This helps batcontrol survive brief connection losses without terminating. + +Default: +``` +enable_resilient_wrapper: false +``` + +### outage_tolerance_minutes +The maximum duration (in minutes) to tolerate inverter outages before terminating. This allows batcontrol to survive firmware upgrades or network issues up to the specified time window. After this timeout, batcontrol will give up and exit with an error. + +Default: +``` +outage_tolerance_minutes: 24 # 24 minutes +``` + +### retry_backoff_seconds +The time to wait (in seconds) before retrying after an inverter failure. This prevents hammering an unavailable inverter during the outage period and allows time for recovery. + +Default: +``` +retry_backoff_seconds: 60 # 60 seconds +``` + +## mqtt +This enables the MQTT inverter driver, which allows integration with any battery/inverter system via MQTT topics. This is a generic bridge that works with any external system that can publish battery status and receive control commands over MQTT. + +For detailed documentation, see [MQTT Inverter](../integrations/mqtt-inverter.md). + +```yaml +inverter: + type: mqtt + capacity: 10000 # Battery capacity in Wh (required) + min_soc: 5 # Minimum SoC % (default: 5) + max_soc: 100 # Maximum SoC % (default: 100) + max_grid_charge_rate: 5000 # Maximum charge rate in W (required) + cache_ttl: 120 # Cache TTL for SOC values in seconds (default: 120) +``` + +**Key Features:** +- Generic MQTT-based integration for any inverter +- Uses batcontrol's shared MQTT connection +- Supports Home Assistant auto-discovery +- Real-time status updates and command control +- No vendor-specific protocols required + +## fronius_gen24 +This enables the Fronius GEN24 inverter. + +```yaml +inverter: + type: fronius_gen24 #currently only fronius_gen24 supported + address: 192.168.0.XX # the local IP of your inverter. needs to be reachable from the machine that runs batcontrol + user: customer #customer or technician lowercase only!! + password: YOUR-PASSWORD # + max_grid_charge_rate: 5000 # Watt + fronius_inverter_id: '1' # Optional: ID of the inverter in Fronius API (default: '1') (ab 0.5.6) + fronius_controller_id: '0' # Optional: ID of the controller in Fronius API (default: '0') (ab 0.5.6) +``` + +### Additional Parameters (since 0.5.6) +- **fronius_inverter_id**: Optional parameter to specify the inverter ID in the Fronius API. Default is '1'. +- **fronius_controller_id**: Optional parameter to specify the controller ID in the Fronius API. Default is '0'. + +## fronius-modbus +This enables the Fronius Modbus TCP inverter backend. It controls a Fronius GEN24/BYD battery through SunSpec storage-control registers and does not require inverter web-login credentials. + +Enable Modbus TCP in the Fronius inverter web UI before using this backend. + +```yaml +inverter: + type: fronius-modbus + address: 192.168.0.XX # Local IP/host of your inverter + port: 502 # Optional, default: 502 + unit_id: 1 # Optional, default: 1 + capacity: 10000 # Required: battery capacity in Wh + max_grid_charge_rate: 5000 # Required: maximum grid charge rate in W + min_soc: 5 # Optional, default: 5 + max_soc: 100 # Optional, default: 100 + revert_seconds: 0 # Optional, default: 0 +``` + +### Backup / emergency-power systems +For systems with backup or emergency-power support, batcontrol should run from a UPS/USV-backed power source. If the public grid fails while restrictive Modbus battery flags are active, batcontrol must remain powered so it can react and reset the Modbus flags. + +Optional backup-mode safety settings: + +```yaml + backup_mode_safety_enabled: true + meter_unit_id: 200 # Optional, default: 200 +``` + +With backup-mode safety enabled, restrictive battery-control writes are only sent while the grid is detected as available. If grid status is off-grid, unknown, or unreadable, batcontrol restores allow-discharge mode instead. + +### Notes +- Do not run multiple tools that write Fronius battery-control Modbus registers at the same time. +- If you previously changed battery-control registers with another tool, stop that tool and restart the inverter before running batcontrol. + + +## dummy +This option is for testing purposes only + +*** Sample needs to be added *** \ No newline at end of file diff --git a/docs/configuration/solar-forecast.md b/docs/configuration/solar-forecast.md new file mode 100644 index 00000000..9498b034 --- /dev/null +++ b/docs/configuration/solar-forecast.md @@ -0,0 +1,167 @@ +The following providers are currently available: + +* [Forecast Solar](https://forecast.solar/) +* [Solarprognose.de](https://www.solarprognose.de) - since 0.5.0 +* local evcc instance - since 0.5.3 +* [HomeAssistant Solar Forecast ML](https://zara-toorox.github.io/) - since 0.7.0 + +Multiple Installations can be entered, like this: + +``` +solar_forecast_provider: fcsolarapi +pvinstallations: + - name: Haus #name + lat: 48.4334480 + lon: 8.7654968 + declination: 32 #inclination toward horizon 0..90 0=flat 90=vertical (e.g. wallmounted) + azimuth: -90 # -90:East, 0:South, 90:West -180..180 + kWp: 15.695 # power in kWp + - name: Garage #... further installations + lat: 48.4334480 + lon: 8.7654968 + declination: 32 + azimuth: 87 + kWp: 6.030 +``` +The **name** must be a unique value. + +If a solar forecast provider is not available, batcontrol is running on cached values. It stops working if less then 12 hours of forecast are available. That should be enough to overcome outages. + + +## Forecast.Solar (Default) +[Forecast Solar](https://forecast.solar/) allows a limited amount of free requests with no subscription or account. + +The minimum configuration block is: + +``` + - name: Haus #name + lat: 48.4334480 + lon: 8.7654968 + declination: 32 #inclination toward horizon 0..90 0=flat 90=vertical (e.g. wallmounted) + azimuth: -90 # -90:East, 0:South, 90:West -180..180 + kWp: 15.695 # power in kWp +``` + +In addtion you can register and use an api key with adding: + +``` + - name: Haus #name + lat: 48.4334480 + lon: 8.7654968 + declination: 32 #inclination toward horizon 0..90 0=flat 90=vertical (e.g. wallmounted) + azimuth: -90 # -90:East, 0:South, 90:West -180..180 + kWp: 15.695 # power in kWp + api: ffff-ffff-fff-ffff +``` + +If you have an obstructed horizon, you can add a horizon modifier: + +``` + - name: Haus #name + lat: 48.4334480 + lon: 8.7654968 + declination: 32 #inclination toward horizon 0..90 0=flat 90=vertical (e.g. wallmounted) + azimuth: -90 # -90:East, 0:South, 90:West -180..180 + kWp: 15.695 # power in kWp + horizon: 30,30,30,0,0,0 # leave empty for default PVGIS horizon, only modify if solar array is shaded by trees or houses +``` + +## Solarprognose.de +Solarprognose offers a free tier for installations below 10KW. Currently, larger tiers are available for free, but this may change. The provider is asking for donations. You need to register on their website and enter you installation. With using the provided API key, you can run batcontrol with following configuration: + +``` +solar_forecast_provider: solarprognose +pvinstallations: + - name: Haus #name + apikey: 44k4j5j5j5j5j6j6j6j6j6j6j6j6j6j6j6j6 +``` +This configuration delivers the forecast for the first defined location. The API provider asks to add `project: ` as an additional parameter, that he can contact a person in case of issues. + +In addition you can change the algorithm using: + +``` +pvinstallations: + - name: Haus #name + apikey: 44k4j5j5j5j5j6j6j6j6j6j6j6j6j6j6j6j6 + algorithm: own-v1 # (Default is 'mosmix') +``` + +If you run multiple installations with you account or want to split up forecasts for reasons, you can use the ITEM and ID syntax. + +* item: +* token: + +For further details see: [API description](https://www.solarprognose.de/web/de/solarprediction/page/api) + +## Local evcc instance +evcc is able to collect its own PV forecast, which can be obtained via REST API. batcontrol can make use of that. + + +``` +solar_forecast_provider: evcc-solar +pvinstallations: + - name: Haus #name + url: http://evcc.local:7070/api/tariff/solar + +``` +If evcc is running under HomeAssistant, you should use either `http://homeassistant:7070/api/tariff/solar` or `http://:7070/api/tariff/solar` + +## HomeAssistant Solar Forecast ML +The [HomeAssistant Solar Forecast ML](https://zara-toorox.github.io/) integration (available via HACS) provides machine learning-based solar forecasts directly from your HomeAssistant instance. This provider requires the HACS integration to be installed first. +Use the evcc based sensor, which might be additionally enabled in the SolarML Addon. Sensor name is `sensor.solar_forecast_ml_evcc_solar_prognose` . If you have startup issues, define sensor_unit `Wh`. + +**Minimum Requirements:** +- batcontrol version: 0.7.2 +- HomeAssistant addon minimum version: V16.2.0 + +The minimum configuration is: + +```yaml +solar_forecast_provider: homeassistant-solar-forecast-ml +pvinstallations: + - name: HA Solar ML Forecast + base_url: ws://homeassistant.local:8123 # Your HomeAssistant URL + api_token: eyJ... # Long-lived access token from HA Profile + entity_id: sensor.solar_forecast_ml_evcc_solar_prognose # Forecast sensor entity +``` + +If you're running batcontrol in a HomeAssistant addon, use `ws://homeassistant:8123` as the base_url. For standalone installations, use your HomeAssistant IP or hostname. + +### Optional Parameters + +You can customize the behavior with additional parameters: + +```yaml +pvinstallations: + - name: HA Solar ML Forecast + base_url: ws://homeassistant:8123 + api_token: eyJ... + entity_id: sensor.solar_forecast_ml_prognose_nachste_stunde + sensor_unit: auto # Options: 'auto' (default, auto-detect), 'Wh', or 'kWh' + cache_ttl_hours: 24.0 # Cache duration in hours (default: 24.0) +``` + +The `sensor_unit` parameter: +- `auto` (default): Automatically detects the unit from the sensor +- `Wh`: If you know your sensor reports in Wh +- `kWh`: If you know your sensor reports in kWh + +Setting the explicit unit (`Wh` or `kWh`) can speed up startup by skipping auto-detection. + +## Adjusting Production Forecasts + +### production_offset_percent + +If your actual solar production systematically differs from the forecast (e.g., winter snow coverage, panel degradation, or consistently higher performance), you can adjust the entire forecast using the `production_offset_percent` parameter in the `battery_control_expert` section: + +```yaml +battery_control_expert: + production_offset_percent: 0.8 # Use 80% of the forecast (20% reduction) +``` + +This multiplier is applied to all forecasted values: +- `1.0` = no adjustment (default) +- `0.8` = 80% of forecast (useful for winter/snow conditions) +- `1.1` = 110% of forecast (for systems that consistently outperform) + +For detailed information, see [Battery Control Expert - production_offset_percent](../features/battery-control-expert.md#adjust-solar-production-forecast). diff --git a/docs/15-min-transform.md b/docs/development/15-min-transform.md similarity index 100% rename from docs/15-min-transform.md rename to docs/development/15-min-transform.md diff --git a/docs/features/battery-control-expert.md b/docs/features/battery-control-expert.md new file mode 100644 index 00000000..7fee116e --- /dev/null +++ b/docs/features/battery-control-expert.md @@ -0,0 +1,167 @@ +# Adjust the charging rate on a charging event +* Config option: `charge_rate_multiplier` +* Default: 1.1 + +When Batcontrol reaches the lowest point in the price curve, it determines which future hours exceed a minimum price threshold. For those hours, it retrieves the required Wh from the load profile, and those Wh are then recharged. + +``` +charge_rate = required_recharge_energy/remaining_time +charge_rate * charge_rate_multiplier = final_charge_rate +if final_charge_rate < 500W then charge_rate = 500W +``` + +Example: + +We are currently at a price of €0.30. We plan to recharge for two hours during which the price will exceed €0.35, and we will need 600 Wh and 400 Wh respectively. The battery is empty, so we recharge a total of 1,000 Wh. Because there are conversion losses, we use the charge_rate_multiplier to increase the charging power: + +```1,000 Wh * 1.1 = 1,100 W``` + +With a multiplier value of 1.0, you may observe that the charging power ramps up over the course of the hour because the setpoints are reached too slowly. At a value of 1.5, the target is reached more quickly and then charging slows steadily as the hour progresses. + +![Example pic for different charge_rate_multiplier](../assets/charge_rate_multiplier.png) + +# Adjust Charging pricepoint +* Config options: + * `soften_price_difference_on_charging` ; true / false - Enable / Disable + * `soften_price_difference_on_charging_factor` ; 5 +* Default: disabled + +By default, Batcontrol checks: +```python +found_lower_price = future_price <= current_price +``` +This means that any future price that is lower or equal to the current price (current_price) is considered cheaper, and Batcontrol adjusts its evaluation period accordingly. The result is, that **only** at the lowest point, batcontrol searches for possible price-targets, which needs to be covered by energy stored in the battery. + +### How the Soften Mechanism Works + +When you enable `soften_price_difference_on_charging` (by setting it to True), Batcontrol modifies the current price by a fraction ( `soften_price_difference_on_charging_factor` ) of `min_price_difference`: + +```python +modified_price = current_price - min_price_difference / self.soften_price_difference_on_charging_factor +found_lower_price = future_price <= modified_price +``` +**Key idea:** The code requires the future price to be lower than a slightly reduced threshold (modified_price) before treating it as “cheaper.” In other words, batcontrol will only stop evaluating (the current price window) if the future price is truly lower by some margin—rather than just marginally lower. + +### Example + +Let's assume: + +* `current_price = 0.30 €/kWh` +* `min_price_difference = 0.02 €/kWh` +* `soften_price_difference_on_charging = True` +* `soften_price_difference_on_charging_factor = 2.0` + +Then +``` +modified_price = current_price - min_price_difference / self.soften_price_difference_on_charging_factor + = 0.30 - (0.02 / 2.0) + = 0.30 - 0.01 + = 0.29 +``` +Now, a future price will only be considered “cheaper” if it's **less than or equal** to `€0.29/kWh`. If the next hour’s price is `€0.295/kWh` (which is indeed lower than `€0.30/kWh`), Batcontrol **will not** count it as cheap enough to interrupt the current evaluation period—because `€0.295` is still higher than `€0.29. + +Without `soften_price_difference_on_charging`, Batcontrol would see €0.295 as cheaper than €0.30, so it does not evaluate for chasing a marginally lower price. By introducing this “softening” factor, we allow batcontrol to decide earlier how much energy needs to be charged. This helps in scenarios where the **battery can not be charged to maximum within one hour**. It ensures Batcontrol waits for a more significant price drop before adjusting its strategy, too. + +The **downside** is, that not each cent of saving is achieved. + +# Granularity in price calculations: +* Config option: `round_price_digits` +* Default: 4 + +## round_price_digits + +**Config option**: `round_price_digits` +**Default**: `4` + +This option defines how many decimal places the algorithm uses when comparing electricity prices. By default, Batcontrol rounds prices to 4 decimal places (e.g., `0.30345` becomes `0.3035`). Since Batcontrol relies on precise comparisons (e.g., "greater than" or "less than") to determine when to charge or discharge the battery, the number of decimal places can significantly affect how it perceives small price differences. + +### Why Does It Matter? + +- **Higher Precision (more decimal places)** + If you increase `round_price_digits`, Batcontrol will consider smaller price differences. This can lead to more frequent adjustments if tiny price variations cause the algorithm to switch between charging and not charging. + +- **Lower Precision (fewer decimal places)** + If you decrease `round_price_digits`, Batcontrol ignores subtle fluctuations below that rounding threshold. As a result, the algorithm behaves more conservatively and won't react to very small price movements. + +### Example + +- **4 decimal places (default)** + - A price of `0.30345 €/kWh` is rounded to `0.3035 €/kWh`. + - A price of `0.30344 €/kWh` is rounded to `0.3034 €/kWh`. + - The difference (`0.3035 - 0.3034`) is `0.0001 €/kWh`. + +- **2 decimal places** + - Both `0.30345 €/kWh` and `0.30344 €/kWh` become `0.30 €/kWh`. + - The difference disappears, so Batcontrol sees them as the same price. + +By adjusting `round_price_digits`, you can fine-tune how sensitive Batcontrol is to minor variations in the price curve. If your tariff data is very precise and you want every tiny fluctuation to matter, use more decimal places. If you prefer a more stable, less reactive charging strategy, you can reduce the decimal precision. + +# Adjust Solar Production Forecast +* Config option: `production_offset_percent` +* Default: 1.0 + +## production_offset_percent (since 0.6.1) + +**Config option**: `production_offset_percent` +**Default**: `1.0` + +This option allows you to adjust the solar production forecast by a percentage multiplier. This is particularly useful for scenarios where your actual solar production differs systematically from the forecast—such as during winter months when solar panels may be partially covered with snow, or when dust/dirt affects panel efficiency. + +### How It Works + +The `production_offset_percent` parameter multiplies the entire solar production forecast. For example: + +- `1.0` = 100% of the forecast (no adjustment, default behavior) +- `0.8` = 80% of the forecast (20% reduction, useful for winter/snow conditions) +- `1.2` = 120% of the forecast (20% increase) +- `0.5` = 50% of the forecast (50% reduction) + +The adjusted forecast is then used throughout Batcontrol's decision-making logic, affecting: +- Charging recommendations based on solar production +- Battery discharge decisions +- Grid charging evaluations + +### Use Cases + +**Winter Mode (Snow Coverage)** +```yaml +battery_control_expert: + production_offset_percent: 0.7 # Reduce forecast to 70% during winter +``` +If panels are covered with snow, actual production may be 20-30% lower than the forecast. Using `0.7` helps Batcontrol make more conservative charging decisions and avoid over-discharging the battery when the predicted solar energy doesn't materialize. + +**Panel Degradation or Dirt** +```yaml +battery_control_expert: + production_offset_percent: 0.95 # 5% reduction for degraded panels +``` +Over time, solar panels degrade slightly. If your panels are particularly dirty or aged, you can apply a small reduction factor. + +**High-Efficiency Summer** +```yaml +battery_control_expert: + production_offset_percent: 1.05 # 5% increase during peak summer +``` +In optimal conditions, your system might consistently outperform forecasts. A slight increase can help maximize battery charging from available solar energy. + +### Example Impact + +Assume: +- Forecasted solar production: 5,000 W +- `production_offset_percent: 0.8` (winter mode) + +Then: +- **Adjusted forecast**: 5,000 W × 0.8 = 4,000 W +- Batcontrol will base its decisions on 4,000 W instead of 5,000 W + +This conservative approach prevents Batcontrol from over-planning battery discharge based on overly optimistic solar forecasts. + +### Logging + +When `production_offset_percent` differs from `1.0`, Batcontrol logs the adjustment: + +``` +Production forecast adjusted by 80% (factor: 0.8) +``` + +This helps you verify that your offset setting is being applied correctly. diff --git a/docs/features/forecast-metrics.md b/docs/features/forecast-metrics.md new file mode 100644 index 00000000..693f959e --- /dev/null +++ b/docs/features/forecast-metrics.md @@ -0,0 +1,213 @@ +# Forecast Metrics + +## Overview + +Batcontrol derives a set of **forecast metrics** from the production/consumption +forecast arrays and the current battery state. These indicators are published +via [MQTT](../integrations/mqtt-api.md) and are intended to drive downstream +automation decisions such as *"should I run the heat pump now, or save the +battery for tomorrow's solar charge?"* + +Batcontrol itself does not act on these values -- they exist so that **external +automations** (Home Assistant, Node-RED, evcc scripts, ...) can make smarter +decisions about flexible loads without re-implementing the forecast simulation. + +All values are updated once per evaluation cycle (every 3 minutes by default) +and are based on the same forecast window that the main optimizer uses, so +their horizon is the shortest of the available forecasts (prices, solar +production, consumption). + +The metrics are computed by the `ForecastMetrics` class in +`src/batcontrol/forecast_metrics.py`. + +## The Metrics + +### `solar_active` -- Solar Currently Producing + +**MQTT topic:** `{base}/solar_active` + +Boolean flag: `true` if solar production is greater than zero in the current +time slot. Useful as a cheap day/night discriminator for automations that +should only react during (or outside of) production hours. + +### `solar_surplus_wh` -- Expected Solar Overflow + +**MQTT topic:** `{base}/solar_surplus_wh` + +Expected energy in Wh that the solar production of the **next (or ongoing) +production window** will generate above what the battery can absorb. + +- **`solar_active = true` (slot 0 is producing):** surplus is the net solar + production of the ongoing window minus the remaining free battery capacity. +- **`solar_active = false` (nighttime or a break before the next window):** + the overnight discharge is accounted for first -- consumption will drain the + battery before solar restarts, which creates additional room. `surplus > 0` + means even after that extra room is created the next solar window will still + overflow. + +A value of `0` means the battery can absorb everything the upcoming solar +window produces. A value `> 0` means some PV will inevitably be exported to +the grid; running flexible loads now is "free" in terms of battery state, +because the energy they consume would have been exported anyway. + +### `pv_start_battery_wh` -- Battery Level at the Next Charging Point + +**MQTT topic:** `{base}/pv_start_battery_wh` + +Battery level in Wh (above MIN_SOC) at the moment when solar production first +**exceeds** household consumption (`net_consumption < 0`). That crossover is +the point where the battery transitions from discharging to charging, which +makes it the most meaningful reference for overnight planning. + +- Simulated slot-by-slot from the current moment forward. +- `0` if the battery hits MIN_SOC before solar takes over. +- `0` if no net-charging slot exists in the forecast at all. +- If slot 0 is already a net-charging slot (solar already exceeds + consumption), the value equals the current stored usable energy. + +!!! note "Net-charging, not sunrise" + `pv_start_battery_wh` depends on `net_consumption < 0`, not just on + `production > 0`. The battery does not start charging at sunrise -- it + starts charging when solar output exceeds household consumption. On a + partly-cloudy morning that crossover can happen hours after sunrise. + +### `forecast_min_battery_wh` -- Forecast Minimum Battery Level + +**MQTT topic:** `{base}/forecast_min_battery_wh` + +The lowest battery level in Wh (above MIN_SOC) reached at **any** point during +the entire forecast horizon, based on a slot-by-slot simulation with proper +floor/ceiling clamping. + +- `0` means the battery is expected to hit MIN_SOC at some point in the + forecast -- the system will be energy-constrained. +- The simulation respects both the floor (MIN_SOC = 0 usable Wh) and the + ceiling (MAX_SOC = stored usable energy + free capacity), so multi-day + charge/discharge cycles are tracked correctly. A simple net-sum over the + slots would overestimate the available energy because it ignores that the + battery can neither go below MIN_SOC nor above MAX_SOC. +- The horizon covers the full forecast window (same as the optimizer), not + just the next 24 hours. + +## Use Cases + +The metrics answer three different questions about the *future* battery state. +Picking the right one for an automation matters: + +| Question | Metric | +|----------|--------| +| "Can I run a load right now without losing stored energy?" | `solar_surplus_wh` | +| "How much charge is left when the battery starts refilling?" | `pv_start_battery_wh` | +| "Will the battery run empty at any point in the planning horizon?" | `forecast_min_battery_wh` | + +### Use case 1: Heat pump / hot water on PV surplus + +*"Is running the heat pump now free in terms of grid cost?"* + +If `solar_surplus_wh >= estimated_heat_pump_wh`, the heat pump can run without +net additional grid draw over the forecast horizon -- the energy it consumes +would otherwise have been exported. Because the nighttime variant of the +calculation already accounts for the overnight bridge discharge, this also +works for "pre-heat in the early morning before a sunny day" scenarios. + +Example Home Assistant template condition: + +```yaml +condition: + - condition: numeric_state + entity_id: sensor.batcontrol_solar_surplus + above: 2000 # expected heat pump consumption in Wh +``` + +### Use case 2: EV charging beyond evcc's PV mode + +evcc reacts to *current* surplus power. `solar_surplus_wh` adds the *forecast* +dimension: if a large surplus is expected for the rest of the production +window, charging can be started earlier (or with more than the minimum +current) without sacrificing battery charge. Conversely, `solar_surplus_wh == +0` tells the automation that every Wh sent to the car competes directly with +the home battery. + +### Use case 3: Overnight flexible loads (dishwasher, washing machine) + +*"Can I run the dishwasher tonight from the battery, or will that leave the +house on grid power before sunrise?"* + +`pv_start_battery_wh` is the projected battery level at the moment the battery +starts charging again. A comfortable value (e.g. `> 1500 Wh`) means overnight +consumption will not deplete the battery and the load can run from stored +energy. A value near `0` means the battery will be flat before solar takes +over -- the load either waits for the next solar window or knowingly runs on +grid power (which may still be fine in a cheap price slot). + +### Use case 4: Shortage guard for multi-day planning + +*"Is the system energy-constrained anywhere in the planning horizon?"* + +`forecast_min_battery_wh == 0` signals that batcontrol expects the battery to +hit MIN_SOC at some point -- typically before a cloudy day. Automations should +be conservative with flexible loads, and pre-heating/pre-cooling strategies +can shift consumption into cheap or surplus slots instead. If the value is +comfortably above zero there is a buffer across the whole horizon and flexible +loads can run freely. + +### Decision matrix + +The metrics combine into a simple decision space for flexible load control: + +| `solar_surplus_wh` | `forecast_min_battery_wh` | Recommended action | +|--------------------|---------------------------|--------------------| +| `> 0` | `> 0` | Run flexible loads freely -- PV covers them and the battery stays healthy | +| `> 0` | `= 0` | PV surplus exists but the battery will be short later -- run light loads only | +| `= 0` | `> 0` | No surplus, but the battery is OK -- use `pv_start_battery_wh` to judge night loads | +| `= 0` | `= 0` | Constrained -- block flexible loads, preserve the battery | + +`pv_start_battery_wh` refines the third row: if it is high, the overnight +discharge is gentle and a moderate flexible load (e.g. one heat pump cycle) +is fine. If it is near zero, defer the load to the next solar window. + +## MQTT Topics + +| Topic | Unit | Retained | Description | +|-------|------|----------|-------------| +| `{base}/solar_active` | bool | No | `true` if solar is producing in the current slot | +| `{base}/solar_surplus_wh` | Wh | No | Expected PV overflow that cannot be stored in the battery | +| `{base}/pv_start_battery_wh` | Wh | No | Battery level at the next net-charging crossover | +| `{base}/forecast_min_battery_wh` | Wh | No | Minimum battery level over the entire forecast horizon | + +All values are published after each evaluation cycle, together with the +inverter control decision. + +### Home Assistant Auto-Discovery + +The following entities are created automatically when +`auto_discover_enable: true` is configured (see +[MQTT API](../integrations/mqtt-api.md#home-assistant-auto-discovery)): + +- **Solar Surplus** -- sensor (energy, Wh) +- **Solar Active** -- binary sensor (diagnostic) +- **PV Start Battery** -- sensor (energy, Wh) +- **Forecast Min Battery** -- sensor (energy, Wh) + +!!! info "Migration from `night_surplus_wh`" + Earlier development versions published a single `night_surplus_wh` topic. + It was replaced by the two clearer metrics `pv_start_battery_wh` (its + direct successor) and `forecast_min_battery_wh`. The retained Home + Assistant discovery entry for the old sensor is cleaned up automatically. + +## Implementation Notes + +- All values are computed in `src/batcontrol/forecast_metrics.py` by the + stateless `ForecastMetrics` class. +- Forecast arrays contain energy per interval (Wh per slot); see + [15-Minute Interval Transformation](../development/15-min-transform.md). +- Slot 0 is time-adjusted: the elapsed fraction of the current interval is + subtracted, so a slot that is already 80% elapsed only contributes 20% of + its forecast energy. +- `net_consumption = consumption - production`; negative values mean the + battery is charging (or energy is fed in), positive values mean the battery + is discharging (or energy is drawn from the grid). +- `stored_usable_energy` is the energy above MIN_SOC; `free_capacity` is the + space between the current level and MAX_SOC. +- The slot-by-slot simulation clamps at both ends: + `battery = max(0, min(stored_usable + free_capacity, battery - net))`. diff --git a/docs/features/peak-shaving.md b/docs/features/peak-shaving.md new file mode 100644 index 00000000..33bccb42 --- /dev/null +++ b/docs/features/peak-shaving.md @@ -0,0 +1,197 @@ +# Peak Shaving + +## Why Peak Shaving? + +Most PV systems produce peak power around midday. Without any intervention the battery charges as fast as possible and is full well before the afternoon. Once the battery is full, all surplus PV energy is fed into the grid -- often at the lowest grid prices of the day. For newer installations feed-in compensation may be very small or zero, so this exported energy is essentially wasted. + +Peak shaving solves this by **limiting the PV-to-battery charge rate** so the battery fills gradually over the course of the day. The goal is to reach full capacity only by a configurable target hour (e.g. 14:00). This way the battery absorbs as much solar energy as possible and grid feed-in during midday peaks is minimised. + +## Status + +The algorithm is in the status "experimental", which is the reason why it is only available in the logic type `next`. After collecting enough experience with that feature, it will move into `default` eventually. + +## Prerequisites + +Peak shaving was introduced with 0.8.0 and is only available with the **`next` logic type**. Set this in the `battery_control` section of your configuration: + +```yaml +battery_control: + type: next # Required -- 'default' does not include peak shaving +``` + +The `default` logic type does not support peak shaving at all. Enabling peak shaving without switching to `next` has no effect. + +## Configuration + +Add a `peak_shaving` block at the **top level** of your configuration file (not nested under `battery_control`): + +```yaml +peak_shaving: + enabled: false + mode: combined # 'time' | 'price' | 'combined' + allow_full_battery_after: 14 # Hour (0-23) -- battery should be full by this hour + price_limit: 0.05 # Euro/kWh -- slots at or below this price are "cheap" +``` + +### Parameter Reference + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `enabled` | bool | `false` | Master switch for peak shaving | +| `mode` | string | `combined` | Algorithm mode (see below) | +| `allow_full_battery_after` | int | `14` | Target hour (0-23) by which the battery should be full | +| `price_limit` | float | *none* | Price threshold in Euro/kWh. Required for modes `price` and `combined` | + +### MQTT Runtime Control + +All four parameters can be changed at runtime via MQTT without restarting batcontrol: + +| Topic | Accepts | Description | +|-------|---------|-------------| +| `{base}/peak_shaving/enabled/set` | `true` / `false` | Enable or disable peak shaving | +| `{base}/peak_shaving/allow_full_battery_after/set` | int 0-23 | Change the target hour | +| `{base}/peak_shaving/mode/set` | `time` / `price` / `combined` | Change the algorithm mode | +| `{base}/peak_shaving/price_limit/set` | float | Change the price threshold in EUR/kWh; send `-1` to disable the price component | + +Runtime changes are temporary and are not written back to the configuration file. + +## The `allow_full_battery_after` Target Hour + +This parameter controls when the battery is **allowed** to be 100% full: + +- **Before this hour:** the PV charge rate may be limited (depending on mode). +- **At or after this hour:** no limit is applied -- the battery charges as fast as possible. + +The target hour applies globally to **all three modes**. Set it to the hour by which your PV system typically produces enough to fill the battery. For many Central European systems `14` (2 PM) is a good starting point; adjust based on your panel orientation and local conditions. + +## Modes + +Peak shaving offers three modes that control which algorithm components are active: + +### `time` -- Time-Based Only + +Distributes the remaining free battery capacity evenly over the slots between now and `allow_full_battery_after`, using a **counter-linear ramp**. The allowed charge rate starts low and increases as the target hour approaches, which mirrors the typical PV generation curve that rises towards midday. + +`price_limit` is **not required** for this mode. + +**Formula:** + +``` +slots_remaining = n (slots until allow_full_battery_after) +pv_surplus = sum of max(production - consumption, 0) per remaining slot + +If pv_surplus > free_capacity: + wh_current_slot = 2 * free_capacity / (n * (n + 1)) + charge_limit = wh_current_slot / interval_hours +``` + +**Example** (free capacity = 2000 Wh, 1 h intervals): + +| Hours to target | Allowed charge rate | +|-----------------|---------------------| +| 8 | 55 W | +| 4 | 200 W | +| 2 | 666 W | +| 1 | 2000 W (full rate) | + +If the expected PV surplus does not exceed the free capacity, no limit is applied -- the battery can absorb everything anyway. + +### `price` -- Price-Based Only + +Reserves free battery capacity for upcoming **cheap-price** slots where PV is still producing. A slot is "cheap" when its price is at or below `price_limit`. + +`price_limit` is **required** for this mode. + +Only slots within the **production window** are considered. The production window ends at the first forecast slot where PV production is zero. This prevents reserving capacity for a cheap slot at e.g. 03:00 that would never produce any solar energy. + +**Before the cheap window:** +1. Sum the expected PV surplus during cheap slots to get the target reserve. +2. Calculate how much additional charging is allowed: `additional_allowed = free_capacity - target_reserve`. +3. If `additional_allowed <= 0`: block PV charging entirely (rate = 0). +4. Otherwise: spread `additional_allowed` evenly over the slots before the cheap window. + +**Inside the cheap window:** +- If total PV surplus during cheap slots exceeds free capacity, spread `free_capacity` evenly over cheap slots so the battery fills gradually. +- If surplus fits in free capacity, no limit is applied. + +### `combined` -- Both Active (Default) + +Both the time-based and price-based components run in parallel. The **stricter (lower non-negative) limit wins**. This is the most conservative and generally recommended mode. + +`price_limit` is **required** for the price component. If `price_limit` is not set, the price component is disabled and `combined` falls back to **time-only** behaviour — batcontrol logs a warning at startup in this case. Set a numeric `price_limit` or change the mode to `time` to silence the warning. + +## Charge Limit and Minimum Charge Rate + +The calculated charge limit is applied via **Mode 8** (`LIMIT_BATTERY_CHARGE_RATE`). In this mode the inverter caps PV-to-battery charging at the given wattage while still allowing the battery to discharge normally. + +A minimum charge rate of **500 W** is enforced: any computed limit between 1 W and 499 W is raised to 500 W to avoid inefficient low-power charging. A limit of exactly **0 W** (block charging completely) is kept as-is and is not raised. + +The charge limit is published via MQTT: + +| Topic | Type | Retained | Description | +|-------|------|----------|-------------| +| `{base}/peak_shaving/charge_limit` | int | No | Current charge limit in W (-1 = inactive / no limit) | + +## When Peak Shaving is Skipped + +Peak shaving is automatically bypassed in the following situations: + +| Condition | Reason | +|-----------|--------| +| No PV production (nighttime) | Nothing to limit | +| Past `allow_full_battery_after` hour | Target reached, charge freely | +| Battery in `always_allow_discharge` region (high SOC) | Battery is nearly full anyway | +| Force-charge from grid active (Mode -1) | Grid charging takes priority | +| Discharge not allowed | Battery is being preserved for expensive hours -- limiting PV would be counterproductive | +| evcc is actively charging the EV | The EV already consumes excess PV | +| EV connected in PV mode (evcc) | evcc will absorb surplus PV when its threshold is reached | +| `price_limit` not configured | Price component cannot operate; `combined` falls back to time-only, `price` is effectively inactive | + +## evcc Interaction + +When an EV charger is managed by [evcc](../integrations/evcc-connection.md): + +- **EV actively charging** (`charging=true`): peak shaving is disabled because the EV is already consuming excess PV energy. +- **EV connected in PV mode** (`connected=true` AND `mode=pv`): peak shaving is disabled because evcc will naturally absorb surplus PV once its threshold is reached. +- **EV disconnects or mode changes**: peak shaving is automatically re-enabled. + +## Home Assistant Auto-Discovery + +When MQTT auto-discovery is enabled, the following Home Assistant entities are created automatically: + +| Entity | Type | Description | +|--------|------|-------------| +| Peak Shaving Enabled | Switch | Enable/disable peak shaving | +| Peak Shaving Allow Full After | Number (0-23) | Set the target hour | +| Peak Shaving Charge Limit | Sensor (W) | Current calculated charge limit | + +## Self-Correction + +The charge limit is recalculated every evaluation cycle (typically every 3 minutes). If clouds reduce PV production significantly, the free capacity stays higher at the next cycle and the counter-linear ramp automatically produces a higher allowed rate. This means the system self-corrects without manual intervention, though there is no intra-cycle adjustment. + +## Quick-Start Examples + +**Simple time-based setup** -- spread charging until 14:00, no price awareness: + +```yaml +battery_control: + type: next + +peak_shaving: + enabled: true + mode: time + allow_full_battery_after: 14 +``` + +**Price-aware combined setup** -- reserve capacity for cheap slots below 5 ct/kWh: + +```yaml +battery_control: + type: next + +peak_shaving: + enabled: true + mode: combined + allow_full_battery_after: 14 + price_limit: 0.05 +``` diff --git a/docs/features/price-difference-calculation.md b/docs/features/price-difference-calculation.md new file mode 100644 index 00000000..86157e78 --- /dev/null +++ b/docs/features/price-difference-calculation.md @@ -0,0 +1,38 @@ +## Understanding `min_price_difference` and the Relative Approach + +In [Issue #84](https://github.com/muexxl/batcontrol/issues/84), the idea arose to make the parameter `min_price_difference` more flexible. Originally, it was a fixed absolute value, like `€0.03`, meaning Batcontrol would only consider future prices “high enough” if they exceeded the current price by at least `€0.03`. However, when prices vary a lot (e.g., from `€0.20` to `€1.00`), a fixed amount may not always make sense. + +This feature is introduced in version 0.4.0, but defaults to `0%` (disabled). + +### How It Works + +1. **Absolute threshold** (`min_price_difference`) + - Example: `€0.05` + - Batcontrol checks if the future price is at least `€0.05` higher than the current price. + +2. **Relative threshold** (`min_price_difference_rel`) + - Example: `0.10` (i.e., 10% of the current price) + - Batcontrol multiplies the current price by `0.10`. + - If `current_price = €0.50`, the difference is `€0.05`. + - If `current_price = €1.00`, the difference becomes `€0.10`. + +Batcontrol then takes **whichever value is larger**—the absolute amount or the relative amount. If your price is low, you might be governed mostly by the fixed value. If your price is high, the relative difference might exceed the absolute difference, so Batcontrol won’t trigger too soon. + +### Example Scenarios + +1. **Prices around `€0.30`:** + - Absolute threshold: `€0.05` + - Relative threshold (`10%`): `€0.30 * 0.10 = €0.03` + - In this case, the absolute threshold (`€0.05`) is larger, so Batcontrol waits for at least a `€0.05` jump. + +2. **Prices around `€1.00`:** + - Absolute threshold: `€0.05` + - Relative threshold (`10%`): `€1.00 * 0.10 = €0.10` + - Now the relative threshold (`€0.10`) is bigger than `€0.05`, so Batcontrol won’t start charging unless the future price is at least `€0.10` higher. + +### Why This Matters + +- **Fixed Absolute Value Only**: Easy to configure, but may be too small when prices get very high, or too large when prices are very low. +- **Relative Plus Absolute**: Scales automatically with the current price, ensuring Batcontrol’s logic remains balanced across a wide range of price levels. + +This change helps prevent overreacting at high prices (where a small amount like `€0.03` is negligible) and avoids unnecessary waiting at low prices (where a large absolute amount might never occur). diff --git a/docs/getting-started/how-batcontrol-works.md b/docs/getting-started/how-batcontrol-works.md new file mode 100644 index 00000000..39f7d66d --- /dev/null +++ b/docs/getting-started/how-batcontrol-works.md @@ -0,0 +1,167 @@ +# How Batcontrol Works + +Batcontrol is an intelligent battery management system that optimizes your home energy storage by predicting energy prices, solar production, and consumption patterns. It automatically controls your battery inverter to minimize electricity costs. + +## Core Principle + +The fundamental idea is simple: **charge your battery when electricity is cheap and use stored energy when electricity is expensive**. However, the implementation requires sophisticated forecasting and decision-making logic. + +## System Architecture + +### Main Components + +1. **Forecasting Engines**: Gather predictions for prices, solar production, and consumption +2. **Logic Engine**: Calculates optimal battery control decisions +3. **Inverter Interface**: Controls your battery inverter +4. **External APIs**: Integrates with MQTT, evcc, and Home Assistant + +### Operation Cycle + +Batcontrol runs in a continuous loop, evaluating conditions **every 3 minutes**: + +``` +1. Fetch Forecasts → 2. Calculate Optimal Strategy → 3. Control Inverter → 4. Wait 3 Minutes → Repeat +``` + +## Forecasting System + +Batcontrol combines three critical forecasts to make intelligent decisions: + +### 1. Electricity Price Forecast +- **Source**: Dynamic tariff providers (Tibber, aWATTar, evcc, energyforecast.de, static tariff zones) — see [Dynamic Tariff Provider](../configuration/dynamic-tariff-provider.md) +- **Data**: Hourly electricity prices for the next 24-48 hours +- **Purpose**: Identifies when electricity is cheapest for charging + +### 2. Solar Production Forecast +- **Source**: Solar forecast APIs (Forecast.Solar, Solarprognose, evcc, HomeAssistant Solar Forecast ML) — see [Solar Forecast](../configuration/solar-forecast.md) +- **Data**: Expected PV generation based on weather predictions +- **Configuration**: Your PV system specifications (kWp, orientation, location) +- **Purpose**: Predicts available solar energy + +### 3. Consumption Forecast +- **Source**: Load profile CSV file scaled to your annual consumption, or your actual historical data via the HomeAssistant API — see [Consumption Forecast](../configuration/consumption-forecast.md) +- **Data**: Expected household energy usage patterns +- **Purpose**: Estimates energy demand throughout the day + +### Net Consumption Calculation + +The key metric is **Net Consumption = Consumption - Solar Production**: +- **Positive values**: You need energy from grid/battery +- **Negative values**: You have surplus solar energy + +## Decision Logic + +Based on the forecasts and current battery state, batcontrol puts your inverter into one of four modes: + +### Mode 10: DISCHARGE ALLOWED (Normal Operation) +- **When**: Energy is currently expensive OR battery is sufficiently charged +- **Behavior**: + - Battery can discharge to meet household demand + - Surplus solar energy charges battery (respects `max_pv_charge_rate`) + - Excess energy feeds into grid +- **Always Active**: When SOC > `always_allow_discharge_limit` (typically 90%) + +### Mode 8: LIMIT BATTERY CHARGE RATE (Peak Shaving) +- **Requires**: version 0.8.0+ , Logic type `next` must be selected in `battery_control.type` +- **When**: Peak shaving is enabled, PV is producing, and the battery should not fill up too quickly +- **Behavior**: + - Battery **discharge is allowed** (handles household demand normally) + - PV charging is **rate-limited** so the battery fills gradually instead of hitting 100% early in the day + - Excess PV energy that cannot be stored at the limited rate feeds into the grid +- **Purpose**: Preserve free battery capacity so the battery can absorb maximal solar energy later in the day, avoiding unnecessary grid feed-in during midday PV peaks + +### Mode 0: AVOID DISCHARGE (Energy Saving) +- **When**: Prices are rising and stored energy will be more valuable later +- **Behavior**: + - Battery does not discharge + - Grid provides additional energy needed + - Direct solar consumption continues normally +- **Purpose**: Preserve battery energy for expensive periods + +### Mode -1: CHARGE FROM GRID (Active Charging) +- **When**: Current prices are significantly lower than future prices +- **Behavior**: + - Battery charges from grid at configured rate (`max_grid_charge_rate`) + - Charging stops at `max_charging_from_grid_limit` (typically 89%) +- **Calculation**: Estimates required energy for upcoming expensive hours +- **Efficiency**: Accounts for 10-20% charge/discharge losses + +## Price-Based Decision Making + +### Key Parameters + +**`min_price_difference`** (absolute): Minimum price difference in Euro to justify grid charging +- Example: 0.05 means current price must be ≥5 cents lower than future price + +**`min_price_difference_rel`** (relative): Percentage-based price difference threshold +- Example: 0.10 means current price must be ≥10% lower than future price +- Helps avoid inefficient charging during high-price periods + +**Final threshold**: `max(min_price_difference, current_price × min_price_difference_rel)` + +### Advanced Price Logic + +**Expert Mode** offers additional refinements: +- **Price Rounding**: Configurable precision for price comparisons +- **Softened Charging**: Earlier charging with relaxed price requirements +- **Charge Rate Multiplier**: Compensates for charging inefficiencies + +## Battery Management + +### SOC (State of Charge) Limits + +**`always_allow_discharge_limit`** (default: 90%) +- Above this SOC, battery always discharges freely +- Prevents over-charging and ensures availability + +**`max_charging_from_grid_limit`** (default: 80%) +- Maximum SOC for grid charging +- Must be lower than discharge limit to prevent oscillation + +**`min_recharge_amount`** (default: 100Wh) (since 0.5.3) +- Minimum amount of energy that is needed to be recharged before starting to recharge. +- That prevents ditching between discharge + recharge on an increasing price situation. + +### Safety Constraints + +The system validates configuration to prevent problematic behavior: +- `always_allow_discharge_limit` must be > `max_charging_from_grid_limit` +- If violated, `max_charging_from_grid_limit` is automatically lowered + +## External Integrations + +### EVCC Integration +- **Purpose**: Coordinate with electric vehicle charging +- **Function**: Can lock battery discharge when car is charging +- **Configuration**: Monitors charging status and adjusts battery limits + +### MQTT/Home Assistant +- **Real-time Data**: Battery state, prices, forecasts +- **Remote Control**: Override modes and parameters +- **Auto-Discovery**: Automatic Home Assistant entity creation + +## Error Handling + +### Forecast Failures +- **Timeout**: If APIs fail for >10 minutes, defaults to discharge mode +- **Fallback Strategy**: Ensures battery remains usable during outages +- **Recovery**: Automatically resumes normal operation when APIs return + +### Configuration Validation +- **Runtime Checks**: Validates parameter relationships +- **Automatic Corrections**: Adjusts conflicting settings +- **Comprehensive Logging**: Detailed operation logs for troubleshooting + +## Configuration Flow + +1. **Hardware Setup**: Configure inverter connection and credentials +2. **Location Setup**: PV system specifications and geographic location +3. **Consumption Profile**: Annual usage and load pattern +4. **Price Provider**: Dynamic tariff API configuration +5. **Battery Limits**: Discharge and charging thresholds +6. **Fine-Tuning**: Expert parameters and external integrations + +## Battery Configuration Visualization +![Battery Limits Parameter](../assets/battery_limits_parameter.png) + +This diagram illustrates the relationship between the key battery SOC limits and how they control charging/discharging behavior. \ No newline at end of file diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 00000000..d8053cd3 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,95 @@ +# Welcome to Batcontrol Documentation! 🔋 + +Batcontrol is an intelligent battery management system that optimizes your home battery usage based on dynamic electricity pricing, solar forecasts, and consumption patterns. This documentation will guide you through setup, configuration, and integration with your home energy system. + +## 🚀 Getting Started + +### New to Batcontrol? +- **[How Batcontrol Works](getting-started/how-batcontrol-works.md)** - Understand the system architecture and logic +- **[Main Configuration](configuration/batcontrol-configuration.md)** - Basic system settings and logging + +### Installation +- **Docker / docker-compose**: See the [project README](https://github.com/MaStr/batcontrol#readme) for container setup instructions +- **Home Assistant Add-on**: Install via the [batcontrol_ha_addon repository](https://github.com/MaStr/batcontrol_ha_addon) + +### Quick Setup Guide +1. Start with the [Main Configuration](configuration/batcontrol-configuration.md) for basic system settings +2. Configure your [Inverter](configuration/inverter-configuration.md) (Fronius GEN24, Fronius Modbus, or MQTT supported) +3. Set up [Dynamic Tariff Provider](configuration/dynamic-tariff-provider.md) for pricing data +4. Configure [Solar Forecast](configuration/solar-forecast.md) for PV predictions +5. Set up [Consumption Forecast](configuration/consumption-forecast.md) for load predictions + +## ⚙️ Core Configuration + +### Essential Components +| Component | Description | Documentation | +|-----------|-------------|---------------| +| **Inverter** | Connect to your battery inverter | [Inverter Configuration](configuration/inverter-configuration.md) | +| **Dynamic Tariff** | Get real-time electricity prices | [Dynamic Tariff Provider](configuration/dynamic-tariff-provider.md) | +| **Solar Forecast** | Predict solar energy production | [Solar Forecast](configuration/solar-forecast.md) | +| **Consumption Forecast** | Predict energy consumption | [Consumption Forecast](configuration/consumption-forecast.md) | + +### Battery Control Logic +| Feature | Description | Documentation | +|---------|-------------|---------------| +| **Basic Control** | Simple price-based charging/discharging | [Main Configuration](configuration/batcontrol-configuration.md) | +| **Expert Mode** | Advanced control with custom logic | [Battery Control Expert](features/battery-control-expert.md) | +| **Peak Shaving** | Spread PV battery charging over the day | [Peak Shaving](features/peak-shaving.md) | +| **Price Calculations** | How price differences are calculated | [Price Difference Calculation](features/price-difference-calculation.md) | + +## 🔌 Integrations + +### External Systems +| Integration | Purpose | Documentation | +|-------------|---------|---------------| +| **EVCC** | Electric vehicle charging coordination | [EVCC Connection](integrations/evcc-connection.md) | +| **MQTT/Home Assistant** | Home automation integration | [MQTT API](integrations/mqtt-api.md) | +| **MQTT Inverter** | Integrate any battery system via MQTT | [MQTT Inverter](integrations/mqtt-inverter.md) | + +## 📋 Configuration Reference + +### Supported Hardware +- **Inverters**: Fronius GEN24 series, Fronius Modbus TCP, MQTT inverter bridge +- **Dynamic Tariff Providers**: aWATTar, Tibber, EVCC integration, 2 Tariff Providers like Octopus +- **Solar Forecast**: Forecast.Solar, Solar-Prognose.de, EVCC integration +- **Consumption Forecast**: CSV-based load profiles + +### File Structure +``` +config/ +├── batcontrol_config.yaml # Main configuration file +├── load_profile.csv # Consumption patterns (optional) +└── grafana-overview.json # Grafana dashboard (optional) +``` + +## 🛠️ Advanced Topics + +### Expert Features +- **[Battery Control Expert](features/battery-control-expert.md)** - Advanced control algorithms +- **[Price Difference Calculation](features/price-difference-calculation.md)** - Custom pricing logic +- **[MQTT API](integrations/mqtt-api.md)** - Complete API reference for home automation + +### Monitoring & Debugging +- **[Main Configuration](configuration/batcontrol-configuration.md)** - Logging and debugging options +- **MQTT Topics** - Real-time monitoring via MQTT + +## 💡 Tips for Success + +1. **Start Simple**: Begin with basic configuration and add integrations gradually +2. **Monitor Logs**: Enable debug logging during initial setup +3. **Test Incrementally**: Verify each component before adding the next +4. **Check Compatibility**: Ensure your inverter model is supported +5. **Backup Settings**: Keep copies of working configurations + +## 🆘 Need Help? + +- Check the specific configuration page for your component +- Enable debug logging to troubleshoot issues +- Verify network connectivity for external API services +- Ensure correct timezone settings for accurate time-based operations + +--- + +📝 **Documentation Status**: This documentation lives in the [`docs/` folder of the batcontrol repository](https://github.com/MaStr/batcontrol/tree/main/docs). If you find outdated information or need additional details, please open an issue or pull request. + +🔗 **Project Repository**: [GitHub - Batcontrol](https://github.com/MaStr/batcontrol) diff --git a/docs/integrations/evcc-connection.md b/docs/integrations/evcc-connection.md new file mode 100644 index 00000000..5811af36 --- /dev/null +++ b/docs/integrations/evcc-connection.md @@ -0,0 +1,242 @@ +# EVCC Integration + +Batcontrol can integrate with [evcc (Electric Vehicle Charging Controller)](https://evcc.io/) to intelligently manage battery usage during electric vehicle charging. This integration helps prevent unnecessary battery discharge while your EV is charging, optimizing your overall energy management. + +## How It Works + +When evcc is charging your electric vehicle, batcontrol can automatically: + +1. **Block battery discharge** to prevent the home battery from being used while the EV charges +2. **Temporarily adjust discharge limits** based on evcc's buffer SOC settings +3. **Monitor multiple charging loadpoints** for comprehensive EV charging detection +4. **Restore original settings** when charging stops + +## Basic Configuration + +```yaml +evcc: + enabled: true + broker: localhost + port: 1883 + status_topic: evcc/status + loadpoint_topic: + - evcc/loadpoints/1/charging + - evcc/loadpoints/2/charging + block_battery_while_charging: true +``` + +### Required Parameters + +| Parameter | Type | Description | +|-----------|------|-------------| +| `enabled` | boolean | Enable or disable evcc integration | +| `broker` | string | MQTT broker hostname or IP address (same as evcc uses) | +| `port` | integer | MQTT broker port (typically 1883 or 8883 for TLS) | +| `status_topic` | string | MQTT topic for evcc online/offline status | +| `loadpoint_topic` | list/string | MQTT topic(s) for loadpoint charging status | + +### Basic Parameters Explained + +- **`status_topic`**: Usually `evcc/status` - monitors if evcc is online/offline +- **`loadpoint_topic`**: Can be a single string or list of topics like: + - `evcc/loadpoints/1/charging` (for loadpoint 1) + - `evcc/loadpoints/2/charging` (for loadpoint 2) + - Add more loadpoints as needed for your setup + +## Advanced Configuration + +### Authentication + +```yaml +evcc: + username: mqtt_user + password: mqtt_password +``` + +### TLS/SSL Support + +> ⚠️ **Note**: TLS/SSL support is currently **untested and known to be non-functional** (same limitation as in the [MQTT API](mqtt-api.md)). Keep MQTT traffic on a trusted local network. + +### Battery Management Options + +```yaml +evcc: + block_battery_while_charging: true + battery_halt_topic: evcc/site/bufferSoc +``` + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `block_battery_while_charging` | boolean | `true` | If `true`: Block battery discharge while EV is charging. If `false`: Battery discharge follows normal batcontrol algorithm regardless of EV charging status | +| `battery_halt_topic` | string | *unset (disabled)* | Topic for dynamic discharge limit control, e.g. `evcc/site/bufferSoc` | + +## Battery Halt Topic (Advanced) + +The `battery_halt_topic` enables dynamic battery discharge limit management based on evcc's buffer SOC setting. + +### How It Works + +1. **Normal Operation**: Batcontrol uses your configured `always_allow_discharge_limit` +2. **EV Charging Starts**: + - Batcontrol saves current discharge limit + - Sets new limit based on evcc's `bufferSoc` value +3. **EV Charging Stops**: + - Restores original discharge limit + - Returns to normal battery management + +### Example Scenario + +- Your normal `always_allow_discharge_limit`: `0.20` (20%) +- EVCC `bufferSoc` setting: `50` (50%) +- **Result**: While EV charges, battery discharge is blocked above 50% SOC instead of 20% + +## MQTT Topics Monitored + +Batcontrol subscribes to the following evcc MQTT topics: + +### Status Monitoring +- `evcc/status` - evcc online/offline status (`online`/`offline`) + +### Charging Detection +- `evcc/loadpoints/1/charging` - Loadpoint 1 charging status (`true`/`false`) +- `evcc/loadpoints/2/charging` - Loadpoint 2 charging status (`true`/`false`) +- Additional loadpoints as configured + +### Loadpoint Mode and Connection State (derived automatically) +For every configured `.../charging` topic, batcontrol additionally subscribes to the sibling topics: + +- `evcc/loadpoints/1/mode` - Loadpoint charging mode (`pv`, `now`, `minpv`, `off`) +- `evcc/loadpoints/1/connected` - Whether an EV is connected (`true`/`false`) + +These are used by [peak shaving](../features/peak-shaving.md): peak shaving is automatically disabled while evcc is actively charging or while an EV is connected in PV mode, and re-enabled when the EV disconnects or the mode changes. + +### Buffer SOC (Optional) +- `evcc/site/bufferSoc` - Dynamic discharge threshold (integer 0-100) + +## Behavior During EV Charging + +### When Charging Starts +1. **Battery Blocking**: If `block_battery_while_charging: true`, battery discharge is blocked. If `false`, battery discharge continues according to normal batcontrol algorithm +2. **Limit Adjustment**: If `battery_halt_topic` configured, discharge limit is temporarily set to buffer SOC +3. **Logging**: Batcontrol logs: `"evcc is charging, set block"` (only if blocking enabled) + +### When Charging Stops +1. **Battery Unblocking**: Battery discharge blocking is removed +2. **Limit Restoration**: Original discharge limit is restored +3. **Logging**: Batcontrol logs: `"evcc is not charging, remove block"` + +### When EVCC Goes Offline +1. **Safety Mechanism**: If evcc goes offline while charging, blocks are automatically removed +2. **Limit Restoration**: Original settings are restored +3. **Logging**: Batcontrol logs: `"evcc went offline"` and `"evcc was charging, remove block"` + +## Example Configurations + +### Single Loadpoint Setup +```yaml +evcc: + enabled: true + broker: 192.168.1.100 + port: 1883 + status_topic: evcc/status + loadpoint_topic: evcc/loadpoints/1/charging + block_battery_while_charging: true +``` + +### Multiple Loadpoints with Authentication +```yaml +evcc: + enabled: true + broker: evcc.local + port: 1883 + status_topic: evcc/status + loadpoint_topic: + - evcc/loadpoints/1/charging + - evcc/loadpoints/2/charging + block_battery_while_charging: true + username: batcontrol + password: secure_password +``` + +### Advanced Setup with Buffer SOC +```yaml +evcc: + enabled: true + broker: mqtt.home.local + port: 1883 + status_topic: evcc/status + loadpoint_topic: + - evcc/loadpoints/1/charging + block_battery_while_charging: true + battery_halt_topic: evcc/site/bufferSoc + username: mqtt_user + password: mqtt_pass +``` + +### Monitoring Only (No Battery Blocking) +```yaml +evcc: + enabled: true + broker: localhost + port: 1883 + status_topic: evcc/status + loadpoint_topic: + - evcc/loadpoints/1/charging + block_battery_while_charging: false # Battery discharge follows normal batcontrol algorithm +``` + +**Use Case**: This configuration allows you to monitor EV charging status without affecting battery discharge behavior. The battery will charge/discharge according to batcontrol's normal price-based algorithm, regardless of whether the EV is charging. + +## Troubleshooting + +### Common Issues + +1. **Connection Failed** + - Verify evcc MQTT broker settings match batcontrol configuration + - Check network connectivity between batcontrol and MQTT broker + - Ensure MQTT credentials are correct + +2. **Charging Not Detected** + - Verify loadpoint topic names match your evcc configuration + - Check evcc MQTT API is enabled and publishing messages + - Use MQTT client to monitor topics: `mosquitto_sub -h localhost -t evcc/+/+` + +3. **Buffer SOC Not Working** + - Ensure `battery_halt_topic` matches evcc's bufferSoc topic + - Verify evcc is publishing bufferSoc values + - Check logs for: `"Enabling battery threshold management"` + +### Debug Logging + +Enable detailed logging for troubleshooting: + +```yaml +evcc: + enabled: true + # ... other config ... + logger: true # Enable MQTT debug logging +``` + +### Log Messages to Watch For + +- `"evcc is online"` - evcc status detection working +- `"Loadpoint evcc/loadpoints/1/charging is charging"` - charging detection +- `"evcc is charging, set block"` - battery blocking activated +- `"Enabling battery threshold management"` - buffer SOC feature active +- `"New battery_halt value: 50"` - buffer SOC updated + +## Integration with Home Assistant + +When using both batcontrol and evcc with Home Assistant: + +1. Use the same MQTT broker for all three systems +2. Configure evcc auto-discovery: `homeassistant` topic +3. Configure batcontrol MQTT auto-discovery for the same topic +4. Both systems will create entities in Home Assistant automatically + +## Security Considerations + +- Use authentication for production MQTT brokers +- TLS encryption is currently not functional (see above) — keep MQTT traffic on a trusted local network +- Ensure MQTT user has appropriate topic permissions +- Keep MQTT credentials secure and unique \ No newline at end of file diff --git a/docs/integrations/mqtt-api.md b/docs/integrations/mqtt-api.md new file mode 100644 index 00000000..7ac368e5 --- /dev/null +++ b/docs/integrations/mqtt-api.md @@ -0,0 +1,281 @@ +# MQTT API Configuration + +Batcontrol provides an MQTT API that allows you to monitor and integrate your battery control system with other home automation platforms like Home Assistant. The MQTT interface publishes battery status, pricing information, and control states to configurable topics. + +## Basic Configuration + +```yaml +mqtt: + enabled: true + logger: false + broker: localhost + port: 1883 + topic: house/batcontrol + username: user + password: password +``` + +### Basic Parameters + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `enabled` | boolean | `false` | Enable or disable the MQTT API | +| `logger` | boolean | `false` | Enable MQTT logging for debugging | +| `broker` | string | `localhost` | MQTT broker hostname or IP address | +| `port` | integer | `1883` | MQTT broker port (1883 for unencrypted, 8883 for TLS) | +| `topic` | string | `house/batcontrol` | Base topic for all batcontrol MQTT messages | +| `username` | string | `user` | MQTT broker username (if authentication required) | +| `password` | string | `password` | MQTT broker password (if authentication required) | + +## Advanced Configuration + +### Connection Reliability + +```yaml +mqtt: + retry_attempts: 5 # Number of connection retry attempts + retry_delay: 10 # Delay in seconds between retry attempts +``` + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `retry_attempts` | integer | `5` | Number of times to retry connection on failure | +| `retry_delay` | integer | `10` | Seconds to wait between retry attempts | + +### TLS/SSL Configuration + +> ⚠️ **Note**: TLS/SSL support is currently **untested and known to be non-functional**: the implementation expects the certificate options nested below `tls`, while the enable check expects a boolean — these requirements contradict each other. Track progress or report your use case in the project issues before relying on TLS. + +## Home Assistant Auto-Discovery + +Batcontrol supports Home Assistant's MQTT auto-discovery feature, which automatically creates entities in Home Assistant without manual configuration. + +```yaml +mqtt: + auto_discover_enable: true + auto_discover_topic: homeassistant +``` + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `auto_discover_enable` | boolean | `true` | Enable Home Assistant auto-discovery | +| `auto_discover_topic` | string | `homeassistant` | Base topic for auto-discovery messages | + +When enabled, batcontrol will publish device and entity configuration messages to topics like: +- `homeassistant/sensor/batcontrol/battery_soc/config` +- `homeassistant/sensor/batcontrol/current_price/config` +- `homeassistant/binary_sensor/batcontrol/charging_active/config` + +## Published Topics + +Batcontrol publishes data to the following topic structure (assuming base topic `house/batcontrol`): + +### System Status +- `house/batcontrol/status` - System status (`online`/`offline`) +- `house/batcontrol/last_evaluation` - Timestamp of last evaluation (Unix timestamp) +- `house/batcontrol/evaluation_intervall` - Evaluation interval in seconds + +### Control & Mode +- `house/batcontrol/mode` - Current operational mode: + - `-1` = Charge from Grid + - `0` = Avoid Discharge + - `8` = Limit Battery Charge Rate ([peak shaving](../features/peak-shaving.md)) + - `10` = Discharge Allowed +- `house/batcontrol/charge_rate` - Current charge rate in W +- `house/batcontrol/limit_battery_charge_rate` - Dynamic battery charge rate limit in W +- `house/batcontrol/discharge_blocked` - Whether discharge is blocked (`true`/`false`) +- `house/batcontrol/api_override_active` - Whether a temporary external/API override is active (`true`/`false`) +- `house/batcontrol/control_source` - Source that last selected the current control state (`api` or `optimizer`) + +### Battery Information +- `house/batcontrol/SOC` - State of Charge in % (two decimal places, e.g., `69.00`) +- `house/batcontrol/max_energy_capacity` - Maximum battery capacity in Wh +- `house/batcontrol/stored_energy_capacity` - Energy stored in battery in Wh +- `house/batcontrol/stored_usable_energy_capacity` - Usable energy stored in battery in Wh (considering min SOC) +- `house/batcontrol/reserved_energy_capacity` - Energy reserved for discharge in Wh + +### Forecast Metrics +See [Forecast Metrics](../features/forecast-metrics.md) for details and use cases: + +- `house/batcontrol/solar_surplus_wh` - Expected solar surplus energy in Wh (>0 means usable surplus available) +- `house/batcontrol/solar_active` - Whether solar is currently producing (`true`/`false`) +- `house/batcontrol/pv_start_battery_wh` - Battery level in Wh (above min SOC) at the next net-charging point (when PV first exceeds consumption) +- `house/batcontrol/forecast_min_battery_wh` - Minimum battery level in Wh (above min SOC) over the entire forecast horizon (`0` = shortage expected) + +### Configuration Limits +- `house/batcontrol/always_allow_discharge_limit` - Always discharge limit (0.0-1.0) +- `house/batcontrol/always_allow_discharge_limit_percent` - Always discharge limit in % +- `house/batcontrol/always_allow_discharge_limit_capacity` - Always discharge limit in Wh +- `house/batcontrol/max_charging_from_grid_limit` - Max charging from grid limit (0.0-1.0) +- `house/batcontrol/max_charging_from_grid_limit_percent` - Max charging from grid limit in % +- `house/batcontrol/min_grid_charge_soc` - Optional minimum grid-charge target (0.0-1.0) +- `house/batcontrol/min_grid_charge_soc_percent` - Optional minimum grid-charge target in % +- `house/batcontrol/production_offset` - Production offset multiplier (`1.0` = 100%, `0.8` = 80%, etc.) + +### Peak Shaving +See [Peak Shaving](../features/peak-shaving.md) for details: + +- `house/batcontrol/peak_shaving/enabled` - Whether peak shaving is enabled (`true`/`false`) +- `house/batcontrol/peak_shaving/mode` - Active mode (`time`, `price`, or `combined`) +- `house/batcontrol/peak_shaving/allow_full_battery_after` - Target hour (0-23) +- `house/batcontrol/peak_shaving/charge_limit` - Current charge limit in W (`-1` = inactive / no limit) +- `house/batcontrol/peak_shaving/price_limit` - Price threshold in EUR/kWh + +### Price Information +- `house/batcontrol/min_price_difference` - Minimum price difference in EUR (e.g., `0.050`) +- `house/batcontrol/min_price_difference_rel` - Relative minimum price difference (e.g., `0.100`) +- `house/batcontrol/min_dynamic_price_difference` - Dynamic price difference limit in EUR + +### Forecasts (JSON Arrays) +- `house/batcontrol/FCST/production` - Forecasted solar production in W +- `house/batcontrol/FCST/consumption` - Forecasted consumption in W +- `house/batcontrol/FCST/prices` - Forecasted electricity prices in EUR +- `house/batcontrol/FCST/net_consumption` - Forecasted net consumption in W + +### Inverter-Specific Topics (per inverter, e.g., inverter 0) +- `house/batcontrol/inverters/0/SOC` - Inverter SOC in % +- `house/batcontrol/inverters/0/stored_energy` - Stored energy in Wh +- `house/batcontrol/inverters/0/free_capacity` - Free capacity in Wh +- `house/batcontrol/inverters/0/max_capacity` - Maximum capacity in Wh +- `house/batcontrol/inverters/0/usable_capacity` - Usable capacity in Wh +- `house/batcontrol/inverters/0/max_grid_charge_rate` - Max grid charge rate in W +- `house/batcontrol/inverters/0/max_pv_charge_rate` - Max PV charge rate in W +- `house/batcontrol/inverters/0/min_soc` - Minimum SOC setting +- `house/batcontrol/inverters/0/max_soc` - Maximum SOC setting +- `house/batcontrol/inverters/0/capacity` - Total capacity in Wh +- `house/batcontrol/inverters/0/em_mode` - Energy Manager mode (Fronius specific) +- `house/batcontrol/inverters/0/em_power` - Energy Manager power setting in W (Fronius specific) + +## Command Topics (Input API) + +Batcontrol listens to the following `/set` topics for remote control: + +### Main Control +- `house/batcontrol/mode/set` - Set operational mode (send `-1`, `0`, `8`, or `10`) +- `house/batcontrol/charge_rate/set` - Set charge rate in W (automatically sets mode to `-1`) +- `house/batcontrol/limit_battery_charge_rate/set` - Set dynamic battery charge rate limit in W + +### Configuration +- `house/batcontrol/always_allow_discharge_limit/set` - Set always discharge limit (0.0-1.0) +- `house/batcontrol/max_charging_from_grid_limit/set` - Set max charging from grid limit (0.0-1.0) +- `house/batcontrol/min_price_difference/set` - Set minimum price difference in EUR +- `house/batcontrol/min_price_difference_rel/set` - Set relative minimum price difference (e.g. `0.10` for 10%) +- `house/batcontrol/production_offset/set` - Set production offset multiplier (0.0-2.0) + +### Peak Shaving +- `house/batcontrol/peak_shaving/enabled/set` - Enable or disable peak shaving (`true`/`false`) +- `house/batcontrol/peak_shaving/mode/set` - Set mode (`time`, `price`, or `combined`) +- `house/batcontrol/peak_shaving/allow_full_battery_after/set` - Set target hour (0-23) +- `house/batcontrol/peak_shaving/price_limit/set` - Set price threshold in EUR/kWh (`-1` disables the price component) + +All `/set` changes are temporary runtime overrides and are not written back to the configuration file. + +### Inverter Control (per inverter, e.g., inverter 0) +- `house/batcontrol/inverters/0/max_grid_charge_rate/set` - Set max grid charge rate in W +- `house/batcontrol/inverters/0/max_pv_charge_rate/set` - Set max PV charge rate in W +- `house/batcontrol/inverters/0/em_mode/set` - Set Energy Manager mode (Fronius: 0-2) +- `house/batcontrol/inverters/0/em_power/set` - Set Energy Manager power in W (Fronius specific) + +### Testdriver/Dummy Inverter (for testing) +- `house/batcontrol/inverters/0/SOC/set` - Set SOC manually (0-100, testdriver only) + +## Forecast Data Format + +The forecast topics (`/FCST/*`) publish JSON data with the following structure: + +```json +{ + "data": [ + { + "time_start": 1696435200, + "value": 2500.5, + "time_end": 1696438800 + }, + { + "time_start": 1696438800, + "value": 3200.0, + "time_end": 1696442400 + } + ] +} +``` + +Where: +- `time_start` - Unix timestamp for start of hour +- `time_end` - Unix timestamp for end of hour +- `value` - Forecasted value (W for production/consumption, EUR for prices) + +## Example Configurations + +### Basic Setup (No Authentication) +```yaml +mqtt: + enabled: true + broker: 192.168.1.100 + port: 1883 + topic: energy/batcontrol +``` + +### With Authentication +```yaml +mqtt: + enabled: true + broker: mqtt.example.com + port: 1883 + topic: home/energy/batcontrol + username: batcontrol_user + password: secure_password_here + retry_attempts: 3 + retry_delay: 5 +``` + +### Home Assistant Integration +```yaml +mqtt: + enabled: true + broker: homeassistant.local + port: 1883 + topic: batcontrol + username: mqtt_user + password: mqtt_password + auto_discover_enable: true + auto_discover_topic: homeassistant +``` + +## Troubleshooting + +### Common Issues + +1. **Connection Failed** + - Check broker hostname/IP and port + - Verify network connectivity + - Check username/password if authentication is enabled + +2. **Messages Not Appearing** + - Verify the topic configuration + - Check broker logs for rejected messages + - Ensure proper permissions for the MQTT user + +3. **Home Assistant Auto-Discovery Not Working** + - Verify `auto_discover_enable: true` + - Check that Home Assistant MQTT integration is configured + - Ensure the discovery topic matches Home Assistant configuration + +### Debug Logging + +Enable MQTT logging for troubleshooting: + +```yaml +mqtt: + enabled: true + logger: true # Enable debug logging +``` + +This will provide detailed information about MQTT connections, published messages, and any errors in the batcontrol log files. + +## Security Considerations + +- Always use authentication (`username`/`password`) in production +- TLS encryption is currently not functional (see above) — keep MQTT traffic on a trusted local network +- Limit MQTT user permissions to only necessary topics +- Use strong, unique passwords for MQTT authentication \ No newline at end of file diff --git a/docs/integrations/mqtt-inverter.md b/docs/integrations/mqtt-inverter.md new file mode 100644 index 00000000..3786f666 --- /dev/null +++ b/docs/integrations/mqtt-inverter.md @@ -0,0 +1,277 @@ +# MQTT Inverter + +The MQTT Inverter driver enables batcontrol to integrate with any battery/inverter system via MQTT topics. It acts as a generic bridge, allowing external systems to provide battery state information and receive control commands over MQTT. + +## Architecture Overview + +The MQTT inverter driver uses batcontrol's shared MQTT connection (configured in the main batcontrol MQTT API section). It does NOT create a separate MQTT client. This design ensures: + +- Single MQTT connection per batcontrol instance +- Consistent MQTT broker configuration +- Shared connection pool and resources +- Unified logging and error handling + +## Topic Structure + +All topics follow the pattern: `/inverters/$num/` + +Where: +- `` is the MQTT base topic from your main MQTT configuration (the `topic` key in the `mqtt` section) +- `$num` is the inverter number (e.g., 0, 1, 2), **not** the literal string "$num" +- `` is the specific status or command topic + +**Example:** If your base topic is "batcontrol" and inverter number is 0, topics would be: +- `batcontrol/inverters/0/status/soc` +- `batcontrol/inverters/0/command/mode` + +## Status Topics (Inverter → batcontrol) + +These topics **MUST be published as RETAINED** by your external inverter/bridge system: + +| Topic | Description | Type | Retention | +|-------|-------------|------|-----------| +| `/status/capacity` | Battery capacity in Wh | float | **RETAINED** (required) | +| `/status/min_soc` | Minimum SoC limit in % (0-100) | float | **RETAINED** (optional) | +| `/status/max_soc` | Maximum SoC limit in % (0-100) | float | **RETAINED** (optional) | +| `/status/max_charge_rate` | Maximum charge rate in W | float | **RETAINED** (optional) | + +These topics should be **updated at least every 2 minutes** to ensure fresh data: + +| Topic | Description | Type | Retention | +|-------|-------------|------|-----------| +| `/status/soc` | Current State of Charge in % (0-100) | float | Non-retained (updated frequently) | + +## Command Topics (batcontrol → Inverter) + +These topics are published by batcontrol and **MUST NOT be retained**: + +| Topic | Description | Values | +|-------|-------------|--------| +| `/command/mode` | Set operating mode | `force_charge`, `allow_discharge`, `avoid_discharge` | +| `/command/charge_rate` | Set charge rate in W | float | + +## Why Retention Matters + +⚠️ **Critical for proper operation:** + +- **Status topics MUST be RETAINED** so batcontrol can read the current state immediately on startup +- **Command topics MUST NOT be retained** to avoid re-executing stale commands on reconnect +- If command topics are retained, the inverter may execute old commands after restart, causing unexpected behavior + +## Configuration + +### Main MQTT Connection + +Configure the MQTT connection in batcontrol's main MQTT API section (not in the inverter configuration): + +```yaml +mqtt: + broker: 192.168.1.100 + port: 1883 + username: batcontrol + password: secret + topic: batcontrol # Base topic for all MQTT messages +``` + +### Inverter Configuration + +```yaml +inverter: + type: mqtt + capacity: 10000 # Battery capacity in Wh (required) + min_soc: 5 # Minimum SoC % (default: 5) + max_soc: 100 # Maximum SoC % (default: 100) + max_grid_charge_rate: 5000 # Maximum charge rate in W (required) + cache_ttl: 120 # Cache TTL for SOC values in seconds (default: 120) + base_topic: batcontrol/inverters/0 # Optional: override default topic structure +``` + +### Configuration Parameters + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `type` | Yes | - | Must be `mqtt` | +| `capacity` | Yes | - | Battery capacity in Wh | +| `max_grid_charge_rate` | Yes | - | Maximum charge rate from grid in W | +| `min_soc` | No | 5 | Minimum State of Charge in % | +| `max_soc` | No | 100 | Maximum State of Charge in % | +| `cache_ttl` | No | 120 | Cache TTL for SOC values in seconds | +| `base_topic` | No | `/inverters/` | Custom base topic for inverter MQTT messages | + +## External Bridge Requirements + +Your external system (inverter bridge script, inverter firmware, etc.) must: + +1. **Publish battery status as RETAINED messages:** + - Battery capacity in Wh (required) + - Optional: min_soc, max_soc, max_charge_rate + +2. **Publish current SOC regularly (at least every 2 minutes):** + - Current State of Charge as a normal message (can be retained or non-retained) + +3. **Subscribe to command topics (non-retained):** + - Mode changes (`force_charge`, `allow_discharge`, `avoid_discharge`) + - Charge rate adjustments + +4. **Handle reconnection gracefully:** + - Re-publish all status topics as RETAINED on reconnect + - Don't retain command topics to avoid stale command execution + +## Example Bridge Implementation + +Here's a simple Python example using paho-mqtt to bridge your inverter to batcontrol: + +```python +import paho.mqtt.client as mqtt +import time + +# Configuration +MQTT_BROKER = "192.168.1.100" +MQTT_PORT = 1883 +MQTT_USER = "batcontrol" +MQTT_PASSWORD = "secret" +BASE_TOPIC = "batcontrol/inverters/0" + +def on_connect(client, userdata, flags, rc): + """Called when connected to MQTT broker""" + print(f"Connected with result code {rc}") + + # Publish initial state (RETAINED) + client.publish(f"{BASE_TOPIC}/status/capacity", "10000", retain=True) + client.publish(f"{BASE_TOPIC}/status/min_soc", "5", retain=True) + client.publish(f"{BASE_TOPIC}/status/max_soc", "100", retain=True) + client.publish(f"{BASE_TOPIC}/status/max_charge_rate", "5000", retain=True) + + # Subscribe to commands + client.subscribe(f"{BASE_TOPIC}/command/#") + print(f"Subscribed to {BASE_TOPIC}/command/#") + +def on_message(client, userdata, message): + """Handle incoming commands from batcontrol""" + topic = message.topic + value = message.payload.decode() + + print(f"Received: {topic} = {value}") + + if topic == f"{BASE_TOPIC}/command/mode": + print(f"Setting mode to: {value}") + # TODO: Implement your inverter control here + # Examples: + # - force_charge: Enable grid charging + # - allow_discharge: Normal operation + # - avoid_discharge: Prevent battery discharge + + elif topic == f"{BASE_TOPIC}/command/charge_rate": + print(f"Setting charge rate to: {value}W") + # TODO: Implement your charge rate control here + +def publish_soc(client, soc_value): + """Publish current State of Charge""" + client.publish(f"{BASE_TOPIC}/status/soc", str(soc_value)) + +# Create MQTT client +client = mqtt.Client() +client.username_pw_set(MQTT_USER, MQTT_PASSWORD) +client.on_connect = on_connect +client.on_message = on_message + +# Connect to broker +client.connect(MQTT_BROKER, MQTT_PORT, 60) + +# Start network loop in background +client.loop_start() + +# Main loop: Periodically publish SOC +try: + while True: + # TODO: Read actual SOC from your inverter + soc = 65.5 # Example value + publish_soc(client, soc) + + time.sleep(60) # Update every 60 seconds + +except KeyboardInterrupt: + print("Shutting down...") + client.loop_stop() + client.disconnect() +``` + +## Operating Modes + +The MQTT inverter supports three operating modes: + +### force_charge +Forces the battery to charge from grid at the specified rate. Used during low-price periods. + +``` +Topic: /command/mode +Payload: force_charge + +Topic: /command/charge_rate +Payload: 5000 # Charge at 5000W +``` + +### allow_discharge +Normal operation mode. Battery can charge from PV and discharge to supply loads. + +``` +Topic: /command/mode +Payload: allow_discharge +``` + +### avoid_discharge +Prevents battery discharge. Battery can still charge from PV but won't discharge to supply loads. Used to preserve battery for later use. + +``` +Topic: /command/mode +Payload: avoid_discharge +``` + +## Home Assistant Integration + +The MQTT inverter automatically publishes Home Assistant MQTT Discovery messages for all status and command topics. This allows you to monitor your inverter's status and commands in Home Assistant without manual configuration. + +Discovered entities include: +- MQTT Inverter Status SOC +- MQTT Inverter Status Capacity +- MQTT Inverter Status Min SOC +- MQTT Inverter Status Max SOC +- MQTT Inverter Status Max Charge Rate +- MQTT Inverter Command Mode +- MQTT Inverter Command Charge Rate + + +## Limitations + +- **No bidirectional acknowledgment:** batcontrol assumes commands succeed immediately +- **No auto-discovery:** All topics must follow the documented structure exactly +- **Network dependency:** MQTT broker must be reliable and accessible +- **Initial state required:** Status topics must be available at batcontrol startup +- **Clock synchronization:** Ensure time is synchronized between batcontrol and your inverter system for accurate scheduling +- **QoS 1 for commands:** Guarantees delivery but not exactly-once semantics (commands may be delivered multiple times) + +## Advanced Configuration + +### Custom Topic Structure + +By default, the MQTT inverter uses the topic structure `/inverters/`, where `` is the base topic from the main MQTT configuration. You can override this: + +```yaml +inverter: + type: mqtt + base_topic: custom/battery/system # Use custom topic structure + capacity: 10000 + max_grid_charge_rate: 5000 +``` + +This would result in topics like: +- `custom/battery/system/status/soc` +- `custom/battery/system/command/mode` + +Each inverter will have its own set of MQTT topics. + +## See Also + +- [Inverter Configuration](../configuration/inverter-configuration.md) - General inverter configuration options +- [MQTT API](mqtt-api.md) - Main MQTT API configuration and topics +- [How batcontrol works](../getting-started/how-batcontrol-works.md) - Understanding batcontrol's operation diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 00000000..0a3d6481 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,76 @@ +site_name: Batcontrol Documentation +site_description: >- + Optimize your electricity costs by re-charging your PV battery when + electricity is cheap and there is not enough solar power available. +site_url: https://mastr.github.io/batcontrol/ +repo_url: https://github.com/MaStr/batcontrol +repo_name: MaStr/batcontrol +edit_uri: edit/main/docs/ + +theme: + name: material + icon: + logo: material/battery-charging + palette: + - media: "(prefers-color-scheme: light)" + scheme: default + primary: green + accent: light green + toggle: + icon: material/brightness-7 + name: Switch to dark mode + - media: "(prefers-color-scheme: dark)" + scheme: slate + primary: green + accent: light green + toggle: + icon: material/brightness-4 + name: Switch to light mode + features: + - navigation.sections + - navigation.top + - navigation.footer + - content.action.edit + - content.code.copy + - search.suggest + - search.highlight + +markdown_extensions: + - admonition + - attr_list + - tables + - toc: + permalink: true + - pymdownx.highlight + - pymdownx.superfences + - pymdownx.details + +plugins: + - search + +nav: + - Home: index.md + - Getting Started: + - How Batcontrol Works: getting-started/how-batcontrol-works.md + - Configuration: + - Main Configuration: configuration/batcontrol-configuration.md + - Inverter: configuration/inverter-configuration.md + - Dynamic Tariff Provider: configuration/dynamic-tariff-provider.md + - Solar Forecast: configuration/solar-forecast.md + - Consumption Forecast: configuration/consumption-forecast.md + - Features: + - Battery Control Expert: features/battery-control-expert.md + - Peak Shaving: features/peak-shaving.md + - Price Difference Calculation: features/price-difference-calculation.md + - Forecast Metrics: features/forecast-metrics.md + - Integrations: + - MQTT API: integrations/mqtt-api.md + - MQTT Inverter: integrations/mqtt-inverter.md + - EVCC Connection: integrations/evcc-connection.md + - Development: + - 15-Minute Interval Transformation: development/15-min-transform.md + +validation: + links: + not_found: warn + unrecognized_links: warn diff --git a/pyproject.toml b/pyproject.toml index 3f4c9db3..886d8671 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,6 +53,9 @@ test = [ "pytest-asyncio>=0.21.0", "pytest-mock>=3.0.0", ] +docs = [ + "mkdocs-material>=9.5,<10", +] # Bump Version [tool.bumpversion] diff --git a/src/batcontrol/core.py b/src/batcontrol/core.py index 221dad19..894f2a43 100644 --- a/src/batcontrol/core.py +++ b/src/batcontrol/core.py @@ -36,6 +36,7 @@ from .forecastsolar import ForecastSolar as solar_factory from .forecastconsumption import Consumption as consumption_factory +from .forecast_metrics import ForecastMetrics ERROR_IGNORE_TIME = 600 # 10 Minutes EVALUATIONS_EVERY_MINUTES = 3 # Every x minutes on the clock @@ -604,6 +605,7 @@ def run(self): # Factorize [0] to account for elapsed time production[0] *= (1 - elapsed_in_current) consumption[0] *= (1 - elapsed_in_current) + net_consumption = consumption - production logger.debug( 'Current interval factorization: elapsed=%.3f, remaining=%.3f', @@ -683,16 +685,21 @@ def run(self): if self.mqtt_api is not None: self.mqtt_api.publish_min_dynamic_price_diff( calc_output.min_dynamic_price_difference) - solar_active, surplus_wh = self._compute_solar_active_and_surplus( + solar_active, surplus_wh = ForecastMetrics.solar_active_and_surplus( production, consumption, calc_input.free_capacity ) self.mqtt_api.publish_solar_active(solar_active) self.mqtt_api.publish_solar_surplus(surplus_wh) - night_surplus_wh = self._compute_night_surplus( - production, consumption, + pv_start_wh = ForecastMetrics.pv_start_battery( + net_consumption, + calc_input.stored_usable_energy, calc_input.free_capacity + ) + self.mqtt_api.publish_pv_start_battery(pv_start_wh) + forecast_min_wh = ForecastMetrics.forecast_min_battery( + net_consumption, calc_input.stored_usable_energy, calc_input.free_capacity ) - self.mqtt_api.publish_night_surplus(night_surplus_wh) + self.mqtt_api.publish_forecast_min_battery(forecast_min_wh) if self.discharge_blocked and not \ self.general_logic.is_discharge_always_allowed_soc(self.get_SOC()): @@ -876,124 +883,6 @@ def get_reserved_energy(self) -> float: """ Returns the reserved energy in Wh from last calculation """ return self.last_reserved_energy - def _compute_solar_active_and_surplus( - self, - production: np.ndarray, - consumption: np.ndarray, - free_capacity: float) -> tuple: - """Compute solar-active flag and expected surplus energy. - - Returns: - solar_active (bool): True iff solar is producing in slot 0 - surplus_wh (float): Expected solar overflow in Wh (>0 = WP can run) - - When solar is active, surplus is the overflow in the current production - window. Otherwise, surplus is the expected overflow at the end of the - next production window after the battery has bridged consumption until - solar restarts. - """ - net_consumption = consumption - production - - # Find start and end of the FIRST production window only - production_start: Optional[int] = None - production_end_current: Optional[int] = None - for i, p in enumerate(production): - if p > 0: - if production_start is None: - production_start = i - production_end_current = i - elif production_start is not None: - break - - solar_active = production_start == 0 - - if production_start is None: - surplus_wh = 0.0 - else: - bridge_wh = max(0.0, float(np.sum(net_consumption[:production_start]))) - end_idx = (production_end_current + 1) if production_end_current is not None \ - else production_start + 1 - solar_net_wh = float(-np.sum(net_consumption[production_start:end_idx])) - surplus_wh = max(0.0, solar_net_wh - free_capacity - bridge_wh) - - logger.debug( - 'Solar active: %s, surplus: %.1f Wh (free_cap=%.1f Wh)', - solar_active, surplus_wh, free_capacity - ) - return solar_active, surplus_wh - - def _compute_night_surplus( - self, - production: np.ndarray, - consumption: np.ndarray, - stored_usable_energy: float, - free_capacity: float) -> float: - """Compute expected battery surplus at the start of the next production window. - - Answers the question: after tonight's discharge, how much charge will remain - in the battery when tomorrow's solar production starts? - - The calculation intentionally projects through the entire first production - window (including any solar charging) to obtain the battery level at production - end. From there it subtracts overnight consumption to arrive at the battery - level at the next morning's production start: - - battery_at_production_end - night_consumption - - When solar is currently inactive (e.g. early morning), this means net_delta - covers the bridge discharge AND the upcoming solar charging. This is deliberate: - stopping at production_start would give the battery level at dawn of today, not - at dusk — which is the wrong baseline for the overnight calculation. - - If no second production window exists within the forecast horizon, - night_consumption covers the remaining forecast slots (best available proxy). - - Returns 0.0 if no solar production window exists in the forecast at all. - """ - net_consumption = consumption - production - - # Find start and end of the first production window - production_start: Optional[int] = None - production_end: Optional[int] = None - for i, p in enumerate(production): - if p > 0: - if production_start is None: - production_start = i - production_end = i - elif production_start is not None: - break - - if production_start is None: - return 0.0 - - end_idx = production_end + 1 # type: ignore[operator] - - # Project battery level at end of first production window (clamped to [0, max]) - net_delta = float(-np.sum(net_consumption[0:end_idx])) - battery_at_end = stored_usable_energy + min( - free_capacity, max(-stored_usable_energy, net_delta) - ) - - # Find the start of the next (second) production window after the night gap - next_production_start: Optional[int] = None - for i in range(end_idx, len(production)): - if production[i] > 0: - next_production_start = i - break - night_end = next_production_start if next_production_start is not None \ - else len(production) - - night_consumption_wh = max(0.0, float(np.sum(net_consumption[end_idx:night_end]))) - - night_surplus_wh = max(0.0, battery_at_end - night_consumption_wh) - - logger.debug( - 'Night surplus: %.1f Wh (battery_at_production_end=%.1f Wh,' - ' night_consumption=%.1f Wh, night_slots=%d)', - night_surplus_wh, battery_at_end, night_consumption_wh, night_end - end_idx - ) - return night_surplus_wh - def set_stored_energy(self, stored_energy) -> None: """ Set the stored energy in Wh """ self.last_stored_energy = stored_energy diff --git a/src/batcontrol/forecast_metrics.py b/src/batcontrol/forecast_metrics.py new file mode 100644 index 00000000..045f2e60 --- /dev/null +++ b/src/batcontrol/forecast_metrics.py @@ -0,0 +1,117 @@ +""" +Forecast-derived battery metrics for state estimation and load-control decisions. + +ForecastMetrics computes indicators from production/consumption forecast arrays +and current battery state. All methods are stateless with respect to object +state; they emit debug log messages but do not mutate any shared state. + +Metrics: + solar_active_and_surplus -- solar-active flag + expected PV overflow (Wh) + pv_start_battery -- battery level (Wh) at next net-charging point + forecast_min_battery -- minimum battery level (Wh) over forecast horizon +""" +import logging +from typing import Optional, Tuple + +import numpy as np + +logger = logging.getLogger(__name__) + + +class ForecastMetrics: + """Pure-function metrics derived from forecast arrays and battery state.""" + + @staticmethod + def solar_active_and_surplus( + production: np.ndarray, + consumption: np.ndarray, + free_capacity: float) -> Tuple[bool, float]: + """Compute solar-active flag and expected surplus energy. + + Returns: + solar_active (bool): True iff solar is producing in slot 0 + surplus_wh (float): Expected solar overflow in Wh (>0 = WP can run) + + When solar is active, surplus is the overflow in the current production + window. Otherwise, surplus is the expected overflow at the end of the + next production window after the battery has bridged consumption until + solar restarts. + """ + net_consumption = consumption - production + + production_start: Optional[int] = None + production_end_current: Optional[int] = None + for i, p in enumerate(production): + if p > 0: + if production_start is None: + production_start = i + production_end_current = i + elif production_start is not None: + break + + solar_active = production_start == 0 + + if production_start is None: + surplus_wh = 0.0 + else: + bridge_wh = max(0.0, float(np.sum(net_consumption[:production_start]))) + end_idx = (production_end_current + 1) if production_end_current is not None \ + else production_start + 1 + solar_net_wh = float(-np.sum(net_consumption[production_start:end_idx])) + surplus_wh = max(0.0, solar_net_wh - free_capacity - bridge_wh) + + logger.debug( + 'Solar active: %s, surplus: %.1f Wh (free_cap=%.1f Wh)', + solar_active, surplus_wh, free_capacity + ) + return solar_active, surplus_wh + + @staticmethod + def pv_start_battery( + net_consumption: np.ndarray, + stored_usable_energy: float, + free_capacity: float) -> float: + """Battery level (Wh above MIN_SOC) at the start of the next net-charging window. + + Simulates slot-by-slot discharge until the first slot where + net_consumption < 0 (solar production exceeds household consumption). + That crossing point is when the battery transitions from discharging to + charging and is the most meaningful reference for overnight planning. + + Returns 0.0 if the battery reaches MIN_SOC before that point, or if no + net-charging slot exists in the forecast at all. + """ + battery = stored_usable_energy + max_battery = stored_usable_energy + free_capacity + for net in net_consumption: + if net < 0: + return battery + battery = max(0.0, min(max_battery, battery - net)) + return 0.0 + + @staticmethod + def forecast_min_battery( + net_consumption: np.ndarray, + stored_usable_energy: float, + free_capacity: float) -> float: + """Minimum battery level (Wh above MIN_SOC) over the entire forecast horizon. + + Simulates slot-by-slot with proper floor (MIN_SOC = 0 usable) and ceiling + (MAX_SOC = stored_usable + free_capacity) clamping at each step. + Returns the lowest point reached during the simulation. + + A value of 0 means the battery is expected to hit MIN_SOC at some point + in the forecast -- a signal to be conservative with flexible loads. + """ + battery = stored_usable_energy + max_battery = stored_usable_energy + free_capacity + min_battery = stored_usable_energy + for net in net_consumption: + battery = max(0.0, min(max_battery, battery - net)) + if battery < min_battery: + min_battery = battery + logger.debug( + 'Forecast min battery: %.1f Wh (stored=%.1f Wh, slots=%d)', + min_battery, stored_usable_energy, len(net_consumption) + ) + return min_battery diff --git a/src/batcontrol/mqtt_api.py b/src/batcontrol/mqtt_api.py index b4c511dd..82426eba 100644 --- a/src/batcontrol/mqtt_api.py +++ b/src/batcontrol/mqtt_api.py @@ -28,7 +28,8 @@ - /control_source: source that last selected the current control state (api or optimizer) - /solar_surplus_wh: expected solar surplus energy in Wh (>0 means usable surplus available) - /solar_active: bool indicating whether solar is currently producing (slot 0 > 0) -- /night_surplus_wh: expected battery surplus in Wh at start of next production window (>0 means leftover charge after overnight discharge) +- /pv_start_battery_wh: battery level in Wh (above MIN_SOC) at the next net-charging point (when PV first exceeds consumption) +- /forecast_min_battery_wh: minimum battery level in Wh (above MIN_SOC) over the entire forecast horizon (0 = shortage expected) The following statistical arrays are published as JSON arrays: - /FCST/production: forecasted production in W @@ -499,16 +500,28 @@ def publish_solar_surplus(self, surplus_wh: float) -> None: f'{surplus_wh:.1f}' ) - def publish_night_surplus(self, surplus_wh: float) -> None: - """ Publish the expected battery surplus at the start of the next production window. - /night_surplus_wh - Positive values mean the battery will still hold charge (above MIN_SOC) - when solar production resumes the next morning. + def publish_pv_start_battery(self, battery_wh: float) -> None: + """ Publish the battery level at the next net-charging point. + /pv_start_battery_wh + Energy in Wh above MIN_SOC at the moment PV production first exceeds + household consumption. 0 if battery hits MIN_SOC before that point. """ if self.client.is_connected(): self.client.publish( - self.base_topic + '/night_surplus_wh', - f'{surplus_wh:.1f}' + self.base_topic + '/pv_start_battery_wh', + f'{battery_wh:.1f}' + ) + + def publish_forecast_min_battery(self, battery_wh: float) -> None: + """ Publish the minimum battery level over the entire forecast horizon. + /forecast_min_battery_wh + Energy in Wh above MIN_SOC at the trough of the slot-by-slot simulation. + 0 means the battery is expected to hit MIN_SOC at some point. + """ + if self.client.is_connected(): + self.client.publish( + self.base_topic + '/forecast_min_battery_wh', + f'{battery_wh:.1f}' ) def publish_solar_active(self, active: bool) -> None: @@ -922,12 +935,20 @@ def send_mqtt_discovery_messages(self) -> None: self.base_topic + "/solar_surplus_wh") self.publish_mqtt_discovery_message( - "Night Surplus", - "batcontrol_night_surplus_wh", + "PV Start Battery", + "batcontrol_pv_start_battery_wh", + "sensor", + "energy", + "Wh", + self.base_topic + "/pv_start_battery_wh") + + self.publish_mqtt_discovery_message( + "Forecast Min Battery", + "batcontrol_forecast_min_battery_wh", "sensor", "energy", "Wh", - self.base_topic + "/night_surplus_wh") + self.base_topic + "/forecast_min_battery_wh") self.publish_mqtt_discovery_message( "Solar Active", @@ -939,6 +960,16 @@ def send_mqtt_discovery_messages(self) -> None: entity_category="diagnostic", value_template="{% if value == 'true' %}ON{% else %}OFF{% endif %}") + # TODO(0.9.1): remove this tombstone block once brokers have been cleaned up. + # Remove legacy retained discovery config for the renamed metric. + # An empty retained payload deletes the HA entity from existing brokers. + if self.client.is_connected(): + self.client.publish( + self.auto_discover_topic + + '/sensor/batcontrol/batcontrol_night_surplus_wh/config', + '', + retain=True) + def send_mqtt_discovery_for_mode(self) -> None: """ Publish Home Assistant MQTT Auto Discovery message for mode""" val_templ = ( diff --git a/tests/batcontrol/test_mqtt_api.py b/tests/batcontrol/test_mqtt_api.py index b4f4e0ca..897282ef 100644 --- a/tests/batcontrol/test_mqtt_api.py +++ b/tests/batcontrol/test_mqtt_api.py @@ -40,6 +40,8 @@ def _make_discovery_stub(): api.send_mqtt_discovery_messages = ( MqttApi.send_mqtt_discovery_messages.__get__(api, MqttApi) ) + api.client = MagicMock() + api.auto_discover_topic = 'homeassistant' return api diff --git a/tests/batcontrol/test_night_surplus.py b/tests/batcontrol/test_night_surplus.py deleted file mode 100644 index 82d2b946..00000000 --- a/tests/batcontrol/test_night_surplus.py +++ /dev/null @@ -1,137 +0,0 @@ -"""Tests for Batcontrol._compute_night_surplus.""" -import numpy as np -import pytest -from unittest.mock import MagicMock - -from batcontrol.core import Batcontrol - - -def _make_core(time_resolution=60): - stub = MagicMock(spec=Batcontrol) - stub.time_resolution = time_resolution - stub._compute_night_surplus = ( - Batcontrol._compute_night_surplus.__get__(stub, Batcontrol) - ) - return stub - - -def _call(stub, production, consumption, stored_usable=0.0, free_cap=0.0): - return stub._compute_night_surplus( - np.array(production, dtype=float), - np.array(consumption, dtype=float), - stored_usable, - free_cap, - ) - - -class TestNightSurplusNoProduction: - def test_zero_when_no_production_in_forecast(self): - stub = _make_core() - result = _call(stub, [0, 0, 0, 0], [300, 300, 300, 300]) - assert result == pytest.approx(0.0) - - -class TestNightSurplusSolarActive: - def test_full_battery_exceeds_night_consumption(self): - # Solar active (slot 0), production window slots 0-1 - # net_delta = -(500-1500 + 500-1500) = 2000 Wh net gain - # stored_usable=2000, free_cap=3000 -> battery_at_end = min(2000+3000, 2000+2000) = 4000 - # night: slots 2-3, consumption=500 each -> night_consumption=1000 - # surplus = 4000 - 1000 = 3000 - stub = _make_core() - production = [1500, 1500, 0, 0] - consumption = [500, 500, 500, 500] - result = _call(stub, production, consumption, stored_usable=2000.0, free_cap=3000.0) - assert result == pytest.approx(3000.0) - - def test_battery_just_empty_by_morning(self): - # Solar active, net_delta=2000, stored=0, free=2000 -> battery_at_end=2000 - # night consumption = 2000 -> surplus = 0 - stub = _make_core() - production = [1500, 1500, 0, 0] - consumption = [500, 500, 1000, 1000] - result = _call(stub, production, consumption, stored_usable=0.0, free_cap=2000.0) - assert result == pytest.approx(0.0) - - def test_surplus_never_negative(self): - # Battery drains completely during night - stub = _make_core() - production = [500, 0, 0, 0] - consumption = [400, 1000, 1000, 1000] - result = _call(stub, production, consumption, stored_usable=100.0, free_cap=5000.0) - assert result == 0.0 - - def test_uses_only_first_production_window_not_second_day(self): - # Today solar (slots 0-1), night (slots 2-5), tomorrow solar (slots 6-7) - # battery_at_end should be computed at slot 1, night ends at slot 6 (next production) - stub = _make_core() - today = [1500, 1500] - night = [0] * 4 # 4 slots at 200 Wh each = 800 night consumption - tomorrow = [1500, 1500] - production = today + night + tomorrow - consumption = [200] * len(production) - # net_delta during slots 0-1: -((200-1500)+(200-1500)) = 2600 - # stored=1000, free=2000 -> battery_at_end = 1000 + min(2000, 2600) = 3000 - # night consumption slots 2-5: 4*200=800 - # surplus = 3000 - 800 = 2200 - result = _call(stub, production, consumption, stored_usable=1000.0, free_cap=2000.0) - assert result == pytest.approx(2200.0) - - -class TestNightSurplusSolarInactive: - def test_solar_tomorrow_enough_to_cover_night(self): - # slots 0-1: bridge (200 Wh each = 400 Wh discharge) - # slots 2-3: solar production (net +800 Wh each = 1600 Wh) - # end_idx=4, night slots 4-5: 200 Wh each = 400 Wh night consumption - # net_delta 0-3: -(200+200 - (1000-200) - (1000-200)) = -(400-1600) = 1200 - # stored=500, free=1500 -> battery_at_end = 500 + min(1500, 1200) = 1700 - # surplus = 1700 - 400 = 1300 - stub = _make_core() - production = [0, 0, 1000, 1000, 0, 0] - consumption = [200, 200, 200, 200, 200, 200] - result = _call(stub, production, consumption, stored_usable=500.0, free_cap=1500.0) - assert result == pytest.approx(1300.0) - - def test_no_forecast_after_production_end(self): - # Forecast ends right after production window, no night slots - stub = _make_core() - production = [0, 0, 1000, 1000] - consumption = [200, 200, 200, 200] - # net_delta 0-3: -(200+200-800-800) = 1200 - # stored=500, free=1500 -> battery_at_end=1700 - # night_end = len(production) = 4, no slots after production -> consumption=0 - # surplus = 1700 - result = _call(stub, production, consumption, stored_usable=500.0, free_cap=1500.0) - assert result == pytest.approx(1700.0) - - def test_free_cap_limits_charging(self): - # Large production but very little free capacity - # net_delta would be 3000, but free_cap=100 -> battery_at_end = 300+100 = 400 - stub = _make_core() - production = [0, 2000, 2000, 0, 0] - consumption = [100, 100, 100, 200, 200] - # net_delta 0-2: -(100 + (100-2000) + (100-2000)) = -(100-1900-1900) = 3700 - # stored=300, free=100 -> battery_at_end = 300+min(100, 3700) = 400 - # night slots 3-4: 200+200=400 -> surplus = 0 - result = _call(stub, production, consumption, stored_usable=300.0, free_cap=100.0) - assert result == pytest.approx(0.0) - - def test_works_with_15min_resolution(self): - stub = _make_core(time_resolution=15) - # 4 night slots then 4 solar slots then 4 more night slots - production = [0, 0, 0, 0, 500, 500, 500, 500, 0, 0, 0, 0] - consumption = [100] * 12 - # net_delta slots 0-7: -(4*100 + 4*(100-500)) = -(400 - 1600) = 1200 - # stored=500, free=2000 -> battery_at_end = 500 + min(2000, 1200) = 1700 - # night end at slot 8 (no second production), night_end=12 - # night consumption slots 8-11: 4*100=400 - # surplus = 1700 - 400 = 1300 - result = _call(stub, production, consumption, stored_usable=500.0, free_cap=2000.0) - assert result == pytest.approx(1300.0) - - def test_surplus_never_negative_when_consumption_huge(self): - stub = _make_core() - production = [0, 0, 100, 0] - consumption = [500, 500, 500, 5000] - result = _call(stub, production, consumption, stored_usable=100.0, free_cap=10000.0) - assert result == 0.0 diff --git a/tests/batcontrol/test_pv_battery_metrics.py b/tests/batcontrol/test_pv_battery_metrics.py new file mode 100644 index 00000000..5975461a --- /dev/null +++ b/tests/batcontrol/test_pv_battery_metrics.py @@ -0,0 +1,103 @@ +"""Tests for ForecastMetrics.pv_start_battery and ForecastMetrics.forecast_min_battery.""" +import numpy as np +import pytest + +from batcontrol.forecast_metrics import ForecastMetrics + + +def _net(production, consumption): + return np.array(consumption, dtype=float) - np.array(production, dtype=float) + + +# --------------------------------------------------------------------------- +# pv_start_battery +# --------------------------------------------------------------------------- + +class TestPvStartBattery: + def test_returns_battery_just_before_first_net_charging_slot(self): + # 2 discharge slots (net=+300), then net charging starts + # stored=2000, discharge 2x300=600 -> battery=1400 at pv start + net = _net([0, 0, 1000], [300, 300, 200]) + result = ForecastMetrics.pv_start_battery(net, stored_usable_energy=2000.0, free_capacity=3000.0) + assert result == pytest.approx(1400.0) + + def test_returns_zero_when_no_net_charging_in_forecast(self): + net = _net([0, 0, 0], [300, 300, 300]) + result = ForecastMetrics.pv_start_battery(net, stored_usable_energy=1000.0, free_capacity=3000.0) + assert result == 0.0 + + def test_returns_zero_when_battery_depleted_before_pv_start(self): + # stored=500, 2x300 discharge exhausts it before net<0 + net = _net([0, 0, 1000], [300, 300, 200]) + result = ForecastMetrics.pv_start_battery(net, stored_usable_energy=500.0, free_capacity=3000.0) + assert result == 0.0 + + def test_returns_stored_when_first_slot_already_net_charging(self): + # slot 0 already net<0 (solar active with surplus) + net = _net([1000, 500, 0], [200, 600, 300]) + result = ForecastMetrics.pv_start_battery(net, stored_usable_energy=3000.0, free_capacity=2000.0) + assert result == pytest.approx(3000.0) + + def test_floor_clamp_at_zero(self): + # battery drains to 0, stays there, then net charging starts + net = _net([0, 0, 0, 1000], [300, 300, 300, 200]) + result = ForecastMetrics.pv_start_battery(net, stored_usable_energy=500.0, free_capacity=3000.0) + assert result == 0.0 + + def test_works_with_15min_resolution(self): + # 4 night slots at 100 Wh each, then net charging + net = _net([0, 0, 0, 0, 600], [100, 100, 100, 100, 100]) + result = ForecastMetrics.pv_start_battery(net, stored_usable_energy=1000.0, free_capacity=2000.0) + assert result == pytest.approx(600.0) + + +# --------------------------------------------------------------------------- +# forecast_min_battery +# --------------------------------------------------------------------------- + +class TestForecastMinBattery: + def test_returns_stored_when_always_charging(self): + # All slots net charging: battery only grows, minimum = stored + net = _net([1000, 1000, 1000], [200, 200, 200]) + result = ForecastMetrics.forecast_min_battery(net, stored_usable_energy=2000.0, free_capacity=3000.0) + assert result == pytest.approx(2000.0) + + def test_returns_zero_when_battery_depleted(self): + net = _net([0, 0, 0, 0], [500, 500, 500, 500]) + result = ForecastMetrics.forecast_min_battery(net, stored_usable_energy=1000.0, free_capacity=3000.0) + assert result == 0.0 + + def test_tracks_trough_not_final_value(self): + # Discharge to trough, then solar recharges above trough + # stored=3000, 4x400 discharge -> trough=1400, then solar restores + net = _net([0, 0, 0, 0, 2000, 2000], [400, 400, 400, 400, 200, 200]) + result = ForecastMetrics.forecast_min_battery(net, stored_usable_energy=3000.0, free_capacity=5000.0) + assert result == pytest.approx(1400.0) + + def test_cap_limits_charging(self): + # Minimum is during initial discharge; cap is irrelevant for the trough + net = _net([0, 0, 3000, 3000, 0, 0], [300, 300, 200, 200, 300, 300]) + # stored=3000, trough after 2x discharge: 3000-300-300=2400 + result = ForecastMetrics.forecast_min_battery( + net, stored_usable_energy=3000.0, free_capacity=2000.0) + assert result == pytest.approx(2400.0) + + def test_multi_day_tracks_deepest_trough(self): + # Night1 discharges 1000, Solar1 recharges, Night2 discharges 3200 (deeper) + production = [0, 0, 1500, 1500, 0, 0, 0, 0] + consumption = [500, 500, 200, 200, 800, 800, 800, 800] + net = _net(production, consumption) + # stored=4000: 4000-500-500=3000, +1300+1300=5600, -800x4=2400 (deepest) + result = ForecastMetrics.forecast_min_battery(net, stored_usable_energy=4000.0, free_capacity=4000.0) + assert result == pytest.approx(2400.0) + + def test_returns_zero_not_negative(self): + net = _net([0], [10000]) + result = ForecastMetrics.forecast_min_battery(net, stored_usable_energy=1000.0, free_capacity=500.0) + assert result == 0.0 + + def test_initial_stored_counts_as_potential_minimum(self): + # stored=0: minimum starts at 0, solar later does not change that + net = _net([1000, 1000], [200, 200]) + result = ForecastMetrics.forecast_min_battery(net, stored_usable_energy=0.0, free_capacity=5000.0) + assert result == 0.0 diff --git a/tests/batcontrol/test_solar_surplus.py b/tests/batcontrol/test_solar_surplus.py index 34ba09d2..66231cd9 100644 --- a/tests/batcontrol/test_solar_surplus.py +++ b/tests/batcontrol/test_solar_surplus.py @@ -1,23 +1,12 @@ -"""Tests for Batcontrol._compute_solar_active_and_surplus.""" +"""Tests for ForecastMetrics.solar_active_and_surplus.""" import numpy as np import pytest -from unittest.mock import MagicMock -from batcontrol.core import Batcontrol +from batcontrol.forecast_metrics import ForecastMetrics -def _make_core(time_resolution=60): - """Return a minimal stub with only the attributes used by the method.""" - stub = MagicMock(spec=Batcontrol) - stub.time_resolution = time_resolution - stub._compute_solar_active_and_surplus = ( - Batcontrol._compute_solar_active_and_surplus.__get__(stub, Batcontrol) - ) - return stub - - -def _call(stub, production, consumption, free_cap=0.0): - return stub._compute_solar_active_and_surplus( +def _call(production, consumption, free_cap=0.0): + return ForecastMetrics.solar_active_and_surplus( np.array(production, dtype=float), np.array(consumption, dtype=float), free_cap, @@ -26,120 +15,104 @@ def _call(stub, production, consumption, free_cap=0.0): class TestSolarActive: def test_active_when_production_starts_at_slot0(self): - stub = _make_core() - active, _ = _call(stub, [1000, 1500, 500], [400, 400, 400]) + active, _ = _call([1000, 1500, 500], [400, 400, 400]) assert active is True def test_inactive_when_production_starts_later(self): - stub = _make_core() - active, _ = _call(stub, [0, 0, 800, 1200, 0], [300, 300, 300, 300, 300]) + active, _ = _call([0, 0, 800, 1200, 0], [300, 300, 300, 300, 300]) assert active is False def test_inactive_when_no_production_at_all(self): - stub = _make_core() - active, _ = _call(stub, [0, 0, 0, 0], [300, 400, 350, 300]) + active, _ = _call([0, 0, 0, 0], [300, 400, 350, 300]) assert active is False def test_active_when_slot0_producing_even_with_gap_after(self): - stub = _make_core() - active, _ = _call(stub, [800, 0, 600, 0], [300, 300, 300, 300]) + active, _ = _call([800, 0, 600, 0], [300, 300, 300, 300]) assert active is True class TestSurplusActive: def test_surplus_zero_when_net_production_fits_in_battery(self): # net = 1000+1000 = 2000 Wh, free_cap=3000 -> fits, no surplus - stub = _make_core() - _, surplus = _call(stub, [1500, 1500, 0], [500, 500, 500], free_cap=3000.0) + _, surplus = _call([1500, 1500, 0], [500, 500, 500], free_cap=3000.0) assert surplus == pytest.approx(0.0) def test_surplus_positive_when_net_production_exceeds_free_capacity(self): # net = 1000+1000 = 2000 Wh, free_cap=1200 -> surplus=800 - stub = _make_core() - _, surplus = _call(stub, [1500, 1500, 0], [500, 500, 0], free_cap=1200.0) + _, surplus = _call([1500, 1500, 0], [500, 500, 0], free_cap=1200.0) assert surplus == pytest.approx(800.0) def test_surplus_accounts_for_consumption_in_window(self): # slot0: +500 net, slot1: -500 net -> total=0, no surplus - stub = _make_core() - _, surplus = _call(stub, [2000, 2000, 0], [1500, 2500, 400], free_cap=0.0) + _, surplus = _call([2000, 2000, 0], [1500, 2500, 400], free_cap=0.0) assert surplus == pytest.approx(0.0) def test_surplus_never_negative(self): - stub = _make_core() - _, surplus = _call(stub, [100, 100, 0], [800, 800, 800], free_cap=10000.0) + _, surplus = _call([100, 100, 0], [800, 800, 800], free_cap=10000.0) assert surplus == 0.0 def test_active_uses_only_first_production_window(self): # 48h forecast: today's solar then a long break then tomorrow's solar # 'during' must NOT include tomorrow's solar (production_end stops at first zero) - stub = _make_core() today_solar = [1500, 1500] # 2000 Wh net production night = [0] * 12 tomorrow_solar = [1500, 1500] production = today_solar + night + tomorrow_solar consumption = [500] * len(production) # solar_net = -(500-1500 + 500-1500) = 2000 Wh, free_cap=1200 -> surplus=800 - _, surplus = _call(stub, production, consumption, free_cap=1200.0) + _, surplus = _call(production, consumption, free_cap=1200.0) assert surplus == pytest.approx(800.0) class TestSurplusInactive: def test_surplus_zero_when_no_solar_in_forecast(self): - stub = _make_core() - _, surplus = _call(stub, [0, 0, 0, 0], [500, 500, 500, 500], free_cap=0.0) + _, surplus = _call([0, 0, 0, 0], [500, 500, 500, 500], free_cap=0.0) assert surplus == pytest.approx(0.0) def test_surplus_positive_when_solar_overflows(self): # bridge: 2 slots * 200 Wh = 400 Wh # solar_net: 2 slots * (1000-200) = 1600 Wh # surplus = max(0, 1600 - 500 - 400) = 700 Wh - stub = _make_core() production = [0, 0, 1000, 1000, 0] consumption = [200, 200, 200, 200, 200] - _, surplus = _call(stub, production, consumption, free_cap=500.0) + _, surplus = _call(production, consumption, free_cap=500.0) assert surplus == pytest.approx(700.0) def test_surplus_zero_when_solar_fits_in_battery_after_night_discharge(self): # bridge=400, solar_net=800, free_cap=2000 -> 800-2000-400 < 0 -> surplus=0 - stub = _make_core() production = [0, 0, 1000, 0] consumption = [200, 200, 200, 200] - _, surplus = _call(stub, production, consumption, free_cap=2000.0) + _, surplus = _call(production, consumption, free_cap=2000.0) assert surplus == pytest.approx(0.0) def test_night_discharge_creates_room_for_solar(self): # slot0: cons=500 (bridge=500, opens battery room) # slots1-2: 2000W prod, 500W cons -> solar_net=3000 Wh # surplus = max(0, 3000 - 2000 - 500) = 500 - stub = _make_core() production = [0, 2000, 2000, 0] consumption = [500, 500, 500, 500] - _, surplus = _call(stub, production, consumption, free_cap=2000.0) + _, surplus = _call(production, consumption, free_cap=2000.0) assert surplus == pytest.approx(500.0) def test_surplus_never_negative(self): - stub = _make_core() - _, surplus = _call(stub, [0, 0, 100, 0], [500, 500, 500, 500], free_cap=10000.0) + _, surplus = _call([0, 0, 100, 0], [500, 500, 500, 500], free_cap=10000.0) assert surplus == 0.0 def test_inactive_only_uses_first_production_window(self): # 48h: night, tomorrow solar window (slots 2-3), second night, day-after solar - stub = _make_core() production = [0, 0, 1000, 1000, 0, 0, 0, 0, 1000, 1000] consumption = [200] * 10 # bridge=400 (slots 0-1), solar_net=1600 (slots 2-3), free_cap=500 # surplus = max(0, 1600-500-400) = 700 (day-after ignored) - _, surplus = _call(stub, production, consumption, free_cap=500.0) + _, surplus = _call(production, consumption, free_cap=500.0) assert surplus == pytest.approx(700.0) def test_works_with_15min_resolution(self): # Arrays are already Wh/slot independent of resolution - stub = _make_core(time_resolution=15) # 4 slots night (200 Wh each) = 800 bridge # 4 slots solar 500 Wh prod, 200 Wh cons each = 4 * 300 = 1200 Wh solar_net # free_cap=0 -> surplus = max(0, 1200 - 0 - 800) = 400 production = [0, 0, 0, 0, 500, 500, 500, 500, 0] consumption = [200] * 9 - _, surplus = _call(stub, production, consumption, free_cap=0.0) + _, surplus = _call(production, consumption, free_cap=0.0) assert surplus == pytest.approx(400.0)