Skip to content

Commit

Permalink
feat: Added ability to apply weightings to rates from external source…
Browse files Browse the repository at this point in the history
…s for use with target rate and rolling target rate sensors (4 hours 30 minutes dev time)
  • Loading branch information
BottlecapDave authored Dec 24, 2024
1 parent 8b94c7d commit 9350c3f
Show file tree
Hide file tree
Showing 22 changed files with 667 additions and 25 deletions.
14 changes: 14 additions & 0 deletions .github/actions/setup/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
name: Setup dependencies
description: Sets up required dependencies
runs:
using: composite
steps:
- name: Install dependencies
run: sudo apt install libffi-dev libncurses5-dev zlib1g zlib1g-dev libssl-dev libreadline-dev libbz2-dev libsqlite3-dev
shell: bash
- name: asdf_install
uses: asdf-vm/actions/install@v3
- name: Install Python modules
run: |
pip install -r requirements.test.txt
shell: bash
4 changes: 4 additions & 0 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ on:
- 'mkdocs.yml'
- '_docs/**'

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Expand Down
19 changes: 9 additions & 10 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ on:
paths-ignore:
- 'mkdocs.yml'
- '_docs/**'

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
validate:
if: ${{ github.event_name != 'schedule' || github.repository_owner == 'BottlecapDave' }}
Expand All @@ -37,11 +42,8 @@ jobs:
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: asdf_install
uses: asdf-vm/actions/install@v3
- name: Install Python modules
run: |
pip install -r requirements.test.txt
- name: Setup
uses: ./.github/actions/setup
- name: Run unit tests
run: |
python -m pytest tests/unit
Expand All @@ -55,11 +57,8 @@ jobs:
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: asdf_install
uses: asdf-vm/actions/install@v3
- name: Install Python modules
run: |
pip install -r requirements.test.txt
- name: Setup
uses: ./.github/actions/setup
- name: Run integration tests
run: |
python -m pytest tests/integration
Expand Down
34 changes: 33 additions & 1 deletion _docs/services.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,4 +235,36 @@ Allows you to adjust the consumption for any given period recorded by a [cost tr
| ------------------------ | -------- | --------------------------------------------------------------------------------------------------------------------- |
| `target.entity_id` | `no` | The name of the cost tracker sensor(s) that should be updated (e.g. `sensor.octopus_energy_cost_tracker_{{COST_TRACKER_NAME}}`). |
| `data.date` | `no` | The date of the data within the cost tracker to be adjusted. |
| `data.consumption` | `no` | The new consumption recorded against the specified date. |
| `data.consumption` | `no` | The new consumption recorded against the specified date. |

## octopus_energy.register_rate_weightings

Allows you to configure weightings against rates at given times using factors external to the integration. These are applied when calculating [target rates](./setup/target_rate.md#external-rate-weightings) or [rolling target rates](./setup/rolling_target_rate.md#external-rate-weightings).

Rate weightings are added to any existing rate weightings that have been previously configured. Any rate weightings that are more than 24 hours old are removed. Any rate weightings for periods that have been previously configured are overridden.

| Attribute | Optional | Description |
| ------------------------ | -------- | --------------------------------------------------------------------------------------------------------------------- |
| `target.entity_id` | `no` | The name of the electricity current rate sensor for the rates the weighting should be applied to (e.g. `sensor.octopus_energy_electricity_{{METER_SERIAL_NUMBER}}_{{MPAN_NUMBER}}_current_rate`). |
| `data.weightings` | `no` | The collection of weightings to add. Each item in the array should represent a given 30 minute period. Example array is `[{ "start": "2025-01-01T00:00:00Z", "end": "2025-01-01T00:30:00Z", "weighting": 0.1 }]` |

### Automation Example

This automation adds weightings based on the national grids carbon intensity, as provided by [Octopus Energy Carbon Intensity](https://github.com/BottlecapDave/HomeAssistant-CarbonIntensity).

```yaml
- alias: Carbon Intensity Rate Weightings
triggers:
- platform: state
entity_id: event.carbon_intensity_national_current_day_rates
actions:
- action: octopus_energy.register_rate_weightings
target:
entity_id: sensor.octopus_energy_electricity_xxx_xxx_current_rate
data:
weightings: >
{% set forecast = state_attr('event.carbon_intensity_national_current_day_rates', 'rates') + state_attr('event.carbon_intensity_national_next_day_rates', 'rates') %}
{% set ns = namespace(list = []) %} {%- for a in forecast -%}
{%- set ns.list = ns.list + [{ "start": a.from.strftime('%Y-%m-%dT%H:%M:%SZ'), "end": a.to.strftime('%Y-%m-%dT%H:%M:%SZ'), "weighting": a.intensity_forecast | float }] -%}
{%- endfor -%} {{ ns.list }}
```
6 changes: 6 additions & 0 deletions _docs/setup/rolling_target_rate.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,12 @@ If we had a target rate sensor of 1 hour, the following would occur with the fol
| 0.2 | `2024-11-26 11:00:00`-`2024-11-26 12:00:00` | Cheapest period would be 0.1p, free electricity period would be 0.02p. |
| 0 | `2024-11-26 11:00:00`-`2024-11-26 12:00:00` | Cheapest period would be 0.1p, free electricity period would be 0p. This will always go for free electricity sessions if available. |

## External Rate Weightings

There may be times when you want to calculate the best times using factors that are external to data available via the integration, like grid carbon intensity or solar forecasts. This is where external rate weightings come in. Using the [Register Rate Weightings service](../services.md#octopus_energyregister_rate_weightings), you can configured weightings against given rates which are then multiplied against the associated rate. For example if you have a weighting of `2` set and a rate of `0.20`, then the rate will be interpreted as `0.40` during calculation.

These weightings are used in addition to any [weightings](#weighting) configured against the sensor and [free electricity weightings](#free-electricity-weighting). For example if you have rate weight of `2`, a rate of `0.20`, a sensor weight of `3` and free electricity weight of `0.5`, then rate will be interpreted as `0.6` (2 * 0.20 * 3 * 0.5).

## Attributes

The following attributes are available on each sensor
Expand Down
6 changes: 6 additions & 0 deletions _docs/setup/target_rate.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,12 @@ If we had a target rate sensor of 1 hour, the following would occur with the fol
| 0.2 | `2024-11-26 11:00:00`-`2024-11-26 12:00:00` | Cheapest period would be 0.1p, free electricity period would be 0.02p. |
| 0 | `2024-11-26 11:00:00`-`2024-11-26 12:00:00` | Cheapest period would be 0.1p, free electricity period would be 0p. This will always go for free electricity sessions if available. |

## External Rate Weightings

There may be times when you want to calculate the best times using factors that are external to data available via the integration, like grid carbon intensity or solar forecasts. This is where external rate weightings come in. Using the [Register Rate Weightings service](../services.md#octopus_energyregister_rate_weightings), you can configured weightings against given rates which are then multiplied against the associated rate. For example if you have a weighting of `2` set and a rate of `0.20`, then the rate will be interpreted as `0.40` during calculation.

These weightings are used in addition to any [weightings](#weighting) configured against the sensor and [free electricity weightings](#free-electricity-weighting). For example if you have rate weight of `2`, a rate of `0.20`, a sensor weight of `3` and free electricity weight of `0.5`, then rate will be interpreted as `0.6` (2 * 0.20 * 3 * 0.5).

## Attributes

The following attributes are available on each sensor
Expand Down
7 changes: 7 additions & 0 deletions custom_components/octopus_energy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
from .utils.error import api_exception_to_string
from .storage.account import async_load_cached_account, async_save_cached_account
from .storage.intelligent_device import async_load_cached_intelligent_device, async_save_cached_intelligent_device
from .storage.rate_weightings import async_load_cached_rate_weightings

from .const import (
CONFIG_FAVOUR_DIRECT_DEBIT_RATES,
Expand All @@ -44,6 +45,7 @@
CONFIG_MAIN_HOME_PRO_API_KEY,
CONFIG_MAIN_OLD_API_KEY,
CONFIG_VERSION,
DATA_CUSTOM_RATE_WEIGHTINGS_KEY,
DATA_HOME_PRO_CLIENT,
DATA_INTELLIGENT_DEVICE,
DATA_INTELLIGENT_MPAN,
Expand Down Expand Up @@ -350,6 +352,11 @@ async def async_setup_dependencies(hass, config):
mpan = point["mpan"]
electricity_tariff = get_active_tariff(now, point["agreements"])

rate_weightings = await async_load_cached_rate_weightings(hass, mpan)
if rate_weightings is not None:
key = DATA_CUSTOM_RATE_WEIGHTINGS_KEY.format(mpan)
hass.data[DOMAIN][account_id][key] = rate_weightings

for meter in point["meters"]:
serial_number = meter["serial_number"]

Expand Down
1 change: 1 addition & 0 deletions custom_components/octopus_energy/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@
DATA_HOME_PRO_CURRENT_CONSUMPTION_KEY = "HOME_PRO_CURRENT_CONSUMPTION_{}"
DATA_FREE_ELECTRICITY_SESSIONS = "FREE_ELECTRICITY_SESSIONS"
DATA_FREE_ELECTRICITY_SESSIONS_COORDINATOR = "FREE_ELECTRICITY_SESSIONS_COORDINATOR"
DATA_CUSTOM_RATE_WEIGHTINGS_KEY = "DATA_CUSTOM_RATE_WEIGHTINGS_{}"

DATA_SAVING_SESSIONS_FORCE_UPDATE = "SAVING_SESSIONS_FORCE_UPDATE"

Expand Down
1 change: 1 addition & 0 deletions custom_components/octopus_energy/electricity/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ def __init__(self, hass: HomeAssistant, meter, point, entity_domain = "sensor"):
"""Init sensor"""
self._point = point
self._meter = meter
self._hass = hass

self._mpan = point["mpan"]
self._serial_number = meter["serial_number"]
Expand Down
38 changes: 35 additions & 3 deletions custom_components/octopus_energy/electricity/current_rate.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from datetime import timedelta
import logging

from custom_components.octopus_energy.storage.rate_weightings import async_save_cached_rate_weightings
from homeassistant.exceptions import ServiceValidationError

from homeassistant.const import (
STATE_UNAVAILABLE,
STATE_UNKNOWN,
Expand All @@ -20,6 +22,8 @@
from .base import (OctopusEnergyElectricitySensor)
from ..utils.attributes import dict_to_typed_dict
from ..coordinators.electricity_rates import ElectricityRatesCoordinatorResult
from ..utils.weightings import merge_weightings, validate_rate_weightings
from ..const import DATA_CUSTOM_RATE_WEIGHTINGS_KEY, DOMAIN

from ..utils.rate_information import (get_current_rate_information)

Expand All @@ -28,7 +32,7 @@
class OctopusEnergyElectricityCurrentRate(CoordinatorEntity, OctopusEnergyElectricitySensor, RestoreSensor):
"""Sensor for displaying the current rate."""

def __init__(self, hass: HomeAssistant, coordinator, meter, point, electricity_price_cap):
def __init__(self, hass: HomeAssistant, coordinator, meter, point, electricity_price_cap, account_id: str):
"""Init sensor."""
# Pass coordinator to base class
CoordinatorEntity.__init__(self, coordinator)
Expand All @@ -37,6 +41,7 @@ def __init__(self, hass: HomeAssistant, coordinator, meter, point, electricity_p
self._state = None
self._last_updated = None
self._electricity_price_cap = electricity_price_cap
self._account_id = account_id

self._attributes = {
"mpan": self._mpan,
Expand Down Expand Up @@ -155,4 +160,31 @@ async def async_added_to_hass(self):
if state is not None and self._state is None:
self._state = None if state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN) else state.state
self._attributes = dict_to_typed_dict(state.attributes, ['all_rates', 'applicable_rates'])
_LOGGER.debug(f'Restored OctopusEnergyElectricityCurrentRate state: {self._state}')
_LOGGER.debug(f'Restored OctopusEnergyElectricityCurrentRate state: {self._state}')

@callback
async def async_register_rate_weightings(self, weightings):
"""Apply rate weightings"""
result = validate_rate_weightings(weightings)
if result.success == False:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_rate_weightings",
translation_placeholders={
"error": result.error_message,
},
)

key = DATA_CUSTOM_RATE_WEIGHTINGS_KEY.format(self._mpan)
weightings = result.weightings
weightings = merge_weightings(
now(),
weightings,
self._hass.data[DOMAIN][self._account_id][key]
if key in self._hass.data[DOMAIN][self._account_id]
else []
)

self._hass.data[DOMAIN][self._account_id][key] = weightings

await async_save_cached_rate_weightings(self._hass, self._mpan, result.weightings)
1 change: 1 addition & 0 deletions custom_components/octopus_energy/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"homekit": {},
"iot_class": "cloud_polling",
"issue_tracker": "https://github.com/BottlecapDave/HomeAssistant-OctopusEnergy/issues",
"requirements": ["pydantic"],
"ssdp": [],
"version": "13.3.0",
"zeroconf": []
Expand Down
26 changes: 24 additions & 2 deletions custom_components/octopus_energy/sensor.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from datetime import timedelta
from datetime import datetime, timedelta
from custom_components.octopus_energy.api_client.intelligent_device import IntelligentDevice
import voluptuous as vol
import logging
Expand Down Expand Up @@ -189,6 +189,28 @@ async def async_setup_entry(hass, entry, async_add_entities):
"async_redeem_points_into_account_credit",
# supports_response=SupportsResponse.OPTIONAL
)

platform.async_register_entity_service(
"register_rate_weightings",
vol.All(
cv.make_entity_service_schema(
{
vol.Required("weightings"): vol.All(
cv.ensure_list,
[
{
vol.Required("start"): str,
vol.Required("end"): str,
vol.Required("weighting"): float
}
],
),
},
extra=vol.ALLOW_EXTRA,
),
),
"async_register_rate_weightings",
)
elif config[CONFIG_KIND] == CONFIG_KIND_COST_TRACKER:
await async_setup_cost_sensors(hass, entry, config, async_add_entities)

Expand Down Expand Up @@ -314,7 +336,7 @@ async def async_setup_default_sensors(hass: HomeAssistant, config, async_add_ent
electricity_rate_coordinator = hass.data[DOMAIN][account_id][DATA_ELECTRICITY_RATES_COORDINATOR_KEY.format(mpan, serial_number)]
electricity_standing_charges_coordinator = await async_setup_electricity_standing_charges_coordinator(hass, account_id, mpan, serial_number)

entities.append(OctopusEnergyElectricityCurrentRate(hass, electricity_rate_coordinator, meter, point, electricity_price_cap))
entities.append(OctopusEnergyElectricityCurrentRate(hass, electricity_rate_coordinator, meter, point, electricity_price_cap, account_id))
entities.append(OctopusEnergyElectricityPreviousRate(hass, electricity_rate_coordinator, meter, point))
entities.append(OctopusEnergyElectricityNextRate(hass, electricity_rate_coordinator, meter, point))
entities.append(OctopusEnergyElectricityCurrentStandingCharge(hass, electricity_standing_charges_coordinator, meter, point))
Expand Down
24 changes: 23 additions & 1 deletion custom_components/octopus_energy/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -234,4 +234,26 @@ adjust_cost_tracker:

diagnose_heatpump_apis:
name: Diagnose heatpump APIs
description: Diagnose available heatpump APIs
description: Diagnose available heatpump APIs

register_rate_weightings:
name: Register rate weightings
description: Registers external weightings against rates, for use with target rate sensors when calculating target periods.
target:
entity:
integration: octopus_energy
domain: sensor
fields:
weightings:
name: Weightings
description: The collection of time periods and associated weightings to apply.
example: >-
[
{
"start": "2025-01-01T00:00:00Z",
"end": "2025-01-01T00:30:00Z",
"weighting": 0.1
}
]
selector:
object:
28 changes: 28 additions & 0 deletions custom_components/octopus_energy/storage/rate_weightings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import logging
from homeassistant.helpers import storage

from pydantic import BaseModel

from ..utils.weightings import RateWeighting

_LOGGER = logging.getLogger(__name__)

class RateWeightings(BaseModel):
weightings: list[RateWeighting]

async def async_load_cached_rate_weightings(hass, mpan: str) -> list[RateWeighting]:
store = storage.Store(hass, "1", f"octopus_energy.{mpan}_rate_weightings")

try:
data = await store.async_load()
if data is not None:
_LOGGER.debug(f"Loaded cached rate weightings for {mpan}")
return RateWeightings.parse_obj(data).weightings
except:
return None

async def async_save_cached_rate_weightings(hass, mpan: str, weightings: list[RateWeighting]):
if weightings is not None:
store = storage.Store(hass, "1", f"octopus_energy.{mpan}_rate_weightings")
await store.async_save(RateWeightings(weightings=weightings).dict())
_LOGGER.debug(f"Saved rate weightings data for {mpan}")
Loading

0 comments on commit 9350c3f

Please sign in to comment.