Inital commit
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.idea
|
||||
69
README.rst
Normal file
69
README.rst
Normal file
@@ -0,0 +1,69 @@
|
||||
Octograph
|
||||
---------
|
||||
|
||||
Python tool for downloading energy consumption data from the
|
||||
`Octopus Energy API`_ and loading it into `InfluxDB`_.
|
||||
|
||||
In the process, additional metrics will be generated and stored for unit rates
|
||||
and costs as configured by the user. Suitable for two-rate electricity tariffs
|
||||
like `Octopus Energy Go`_. Single rate gas readings are also retrieved and
|
||||
stored.
|
||||
|
||||
The secondary unit rate is specified with start and end times, and a timezone
|
||||
which is useful for the Go tariff where the discount rate changes with
|
||||
daylight savings time.
|
||||
|
||||
Included is an example `Grafana`_ dashboard to visualise the captured data.
|
||||
|
||||
An example Docker Compose file is included for easily running InfluxDB and
|
||||
Grafana.
|
||||
|
||||
.. image:: grafana-dashboard.png
|
||||
:width: 800
|
||||
|
||||
Installation
|
||||
============
|
||||
|
||||
Tested on macOS with Docker for Mac and Python 3.6. A Python virtualenv is
|
||||
recommended.
|
||||
|
||||
Install the Python requirements with ``pip``
|
||||
|
||||
.. code:: bash
|
||||
|
||||
pip install -r app/requirements.txt
|
||||
|
||||
|
||||
Usage
|
||||
=====
|
||||
|
||||
Create a configuration file ``octograph.ini`` customised with your Octopus
|
||||
API key, meter details and energy rate information. This file should be in the
|
||||
working directory where you run the ``octopus_to_influxdb.py`` command, or
|
||||
can be passed as an argument.
|
||||
|
||||
.. code:: bash
|
||||
|
||||
python app/octopus_to_influxdb.py --help
|
||||
|
||||
By default, energy data for the previous day will be collected. Optional from
|
||||
and to ranges may be specified to retrieve larger datasets. It is anticipated
|
||||
that the script will be run daily by a cron job.
|
||||
|
||||
.. code:: bash
|
||||
|
||||
docker-compose up -d # start InfluxDB and Grafana in Docker
|
||||
python app/octopus_to_infuxdb.py --from-date=2018-10-20
|
||||
open http://localhost:3000
|
||||
|
||||
The default login credentials for Grafana are admin/admin, and you will be
|
||||
prompted to set a new password on first login. You should then proceed to add
|
||||
InfluxDB as a datasource with URL ``http://influxdb:8086`` and database
|
||||
``energy`` if using the Docker version provided. The dashboard provided can
|
||||
then be imported to review the data.
|
||||
|
||||
|
||||
.. _Octopus Energy API: https://developer.octopus.energy/docs/api/
|
||||
.. _Octopus Energy Go: https://octopus.energy/go/
|
||||
.. _InfluxDB: https://www.influxdata.com/time-series-platform/influxdb/
|
||||
.. _Grafana: https://grafana.com
|
||||
1
app/.dockerignore
Normal file
1
app/.dockerignore
Normal file
@@ -0,0 +1 @@
|
||||
octograph.ini
|
||||
1
app/.gitignore
vendored
Normal file
1
app/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
octograph.ini
|
||||
12
app/Dockerfile
Normal file
12
app/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
||||
FROM python:3.6
|
||||
|
||||
LABEL maintainer="Stephen Newey <github@s-n.me>"
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
COPY requirements.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY octopus_to_influxdb.py ./
|
||||
|
||||
ENTRYPOINT [ "python", "./octopus_to_influxdb.py" ]
|
||||
25
app/example-octograph.ini
Normal file
25
app/example-octograph.ini
Normal file
@@ -0,0 +1,25 @@
|
||||
[influxdb]
|
||||
host = localhost
|
||||
port = 8086
|
||||
user = user
|
||||
password = password
|
||||
database = energy
|
||||
|
||||
[octopus]
|
||||
api_key = sk_live_1234
|
||||
|
||||
[electricity]
|
||||
mpan = 12345
|
||||
serial_number = 12A3456
|
||||
standing_charge = 25.00
|
||||
unit_rate_high = 14.12
|
||||
unit_rate_low = 5.00
|
||||
unit_rate_low_start = 00:30
|
||||
unit_rate_low_end = 04:30
|
||||
unit_rate_low_zone = Europe/London
|
||||
|
||||
[gas]
|
||||
mpan = 12345
|
||||
serial_number = 12A3456
|
||||
standing_charge = 16.80
|
||||
unit_rate = 3.03
|
||||
187
app/octopus_to_influxdb.py
Normal file
187
app/octopus_to_influxdb.py
Normal file
@@ -0,0 +1,187 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
from configparser import ConfigParser
|
||||
from urllib import parse
|
||||
|
||||
import click
|
||||
import maya
|
||||
import requests
|
||||
from influxdb import InfluxDBClient
|
||||
|
||||
|
||||
def retrieve_paginated_consumption(
|
||||
api_key, url, from_date, to_date, page=None
|
||||
):
|
||||
args = {
|
||||
'period_from': from_date,
|
||||
'period_to': to_date,
|
||||
}
|
||||
if page:
|
||||
args['page'] = page
|
||||
response = requests.get(url, params=args, auth=(api_key, ''))
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
results = data.get('results', [])
|
||||
if data['next']:
|
||||
url_query = parse.urlparse(data['next']).query
|
||||
next_page = parse.parse_qs(url_query)['page'][0]
|
||||
results += retrieve_paginated_consumption(
|
||||
api_key, url, from_date, to_date, next_page
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
def store_series(connection, series, metrics, rate_data):
|
||||
|
||||
def active_rate_field(measurement):
|
||||
if series == 'gas':
|
||||
return 'unit_rate'
|
||||
elif not rate_data['unit_rate_low_zone']: # no low rate
|
||||
return 'unit_rate_high'
|
||||
|
||||
low_start_str = rate_data['unit_rate_low_start']
|
||||
low_end_str = rate_data['unit_rate_low_end']
|
||||
low_zone = rate_data['unit_rate_low_zone']
|
||||
|
||||
measurement_at = maya.parse(measurement['interval_start'])
|
||||
|
||||
low_start = maya.when(
|
||||
measurement_at.datetime().strftime(f'%Y-%m-%dT{low_start_str}'),
|
||||
timezone=low_zone
|
||||
)
|
||||
low_end = maya.when(
|
||||
measurement_at.datetime().strftime(f'%Y-%m-%dT{low_end_str}'),
|
||||
timezone=low_zone
|
||||
)
|
||||
low_period = maya.MayaInterval(low_start, low_end)
|
||||
|
||||
return \
|
||||
'unit_rate_low' if measurement_at in low_period \
|
||||
else 'unit_rate_high'
|
||||
|
||||
def fields_for_measurement(measurement):
|
||||
consumption = measurement['consumption']
|
||||
rate = active_rate_field(measurement)
|
||||
rate_cost = rate_data[rate]
|
||||
cost = consumption * rate_cost
|
||||
standing_charge = rate_data['standing_charge'] / 48 # 30 minute reads
|
||||
return {
|
||||
'consumption': consumption,
|
||||
'cost': cost,
|
||||
'total_cost': cost + standing_charge,
|
||||
}
|
||||
|
||||
def tags_for_measurement(measurement):
|
||||
period = maya.parse(measurement['interval_end'])
|
||||
time = period.datetime().strftime('%H:%M')
|
||||
return {
|
||||
'active_rate': active_rate_field(measurement),
|
||||
'time_of_day': time,
|
||||
}
|
||||
|
||||
measurements = [
|
||||
{
|
||||
'measurement': series,
|
||||
'tags': tags_for_measurement(measurement),
|
||||
'time': measurement['interval_end'],
|
||||
'fields': fields_for_measurement(measurement),
|
||||
}
|
||||
for measurement in metrics
|
||||
]
|
||||
connection.write_points(measurements)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
'--config-file',
|
||||
default="octograph.ini",
|
||||
type=click.Path(exists=True, dir_okay=True, readable=True),
|
||||
)
|
||||
@click.option('--from-date', default='yesterday midnight', type=click.STRING)
|
||||
@click.option('--to-date', default='today midnight', type=click.STRING)
|
||||
def cmd(config_file, from_date, to_date):
|
||||
|
||||
config = ConfigParser()
|
||||
config.read(config_file)
|
||||
|
||||
influx = InfluxDBClient(
|
||||
host=config.get('influxdb', 'host', fallback="localhost"),
|
||||
port=config.getint('influxdb', 'port', fallback=8086),
|
||||
username=config.get('influxdb', 'user', fallback=""),
|
||||
password=config.get('influxdb', 'password', fallback=""),
|
||||
database=config.get('influxdb', 'database', fallback="energy"),
|
||||
)
|
||||
|
||||
api_key = config.get('octopus', 'api_key')
|
||||
if not api_key:
|
||||
raise click.ClickException('No Octopus API key set')
|
||||
|
||||
e_mpan = config.get('electricity', 'mpan', fallback=None)
|
||||
e_serial = config.get('electricity', 'serial_number', fallback=None)
|
||||
if not e_mpan or not e_serial:
|
||||
raise click.ClickException('No electricity meter identifiers')
|
||||
e_url = 'https://api.octopus.energy/v1/electricity-meter-points/' \
|
||||
f'{e_mpan}/meters/{e_serial}/consumption/'
|
||||
|
||||
g_mpan = config.get('gas', 'mpan', fallback=None)
|
||||
g_serial = config.get('gas', 'serial_number', fallback=None)
|
||||
if not g_mpan or not g_serial:
|
||||
raise click.ClickException('No gas meter identifiers')
|
||||
g_url = 'https://api.octopus.energy/v1/gas-meter-points/' \
|
||||
f'{g_mpan}/meters/{g_serial}/consumption/'
|
||||
|
||||
rate_data = {
|
||||
'electricity': {
|
||||
'standing_charge': config.getfloat(
|
||||
'electricity', 'standing_charge', fallback=0.0
|
||||
),
|
||||
'unit_rate_high': config.getfloat(
|
||||
'electricity', 'unit_rate_high', fallback=0.0
|
||||
),
|
||||
'unit_rate_low': config.getfloat(
|
||||
'electricity', 'unit_rate_low', fallback=0.0
|
||||
),
|
||||
'unit_rate_low_start': config.get(
|
||||
'electricity', 'unit_rate_low_start', fallback="00:00"
|
||||
),
|
||||
'unit_rate_low_end': config.get(
|
||||
'electricity', 'unit_rate_low_end', fallback="00:00"
|
||||
),
|
||||
'unit_rate_low_zone': config.get(
|
||||
'electricity', 'unit_rate_low_zone', fallback=None
|
||||
),
|
||||
},
|
||||
'gas': {
|
||||
'standing_charge': config.getfloat(
|
||||
'gas', 'standing_charge', fallback=0.0
|
||||
),
|
||||
'unit_rate': config.getfloat('gas', 'unit_rate', fallback=0.0),
|
||||
}
|
||||
}
|
||||
|
||||
from_iso = maya.when(from_date).iso8601()
|
||||
to_iso = maya.when(to_date).iso8601()
|
||||
|
||||
click.echo(
|
||||
f'Retrieving electricity data for {from_iso} until {to_iso}...',
|
||||
nl=False
|
||||
)
|
||||
e_consumption = retrieve_paginated_consumption(
|
||||
api_key, e_url, from_iso, to_iso
|
||||
)
|
||||
click.echo(f' {len(e_consumption)} readings.')
|
||||
store_series(influx, 'electricity', e_consumption, rate_data['electricity'])
|
||||
|
||||
click.echo(
|
||||
f'Retrieving gas data for {from_iso} until {to_iso}...',
|
||||
nl=False
|
||||
)
|
||||
g_consumption = retrieve_paginated_consumption(
|
||||
api_key, g_url, from_iso, to_iso
|
||||
)
|
||||
click.echo(f' {len(g_consumption)} readings.')
|
||||
store_series(influx, 'gas', g_consumption, rate_data['gas'])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
cmd()
|
||||
4
app/requirements.txt
Normal file
4
app/requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
requests >= 2.20.0
|
||||
influxdb == 5.2.0
|
||||
Click == 7.0
|
||||
maya == 0.5.0
|
||||
14
docker-compose.yml
Normal file
14
docker-compose.yml
Normal file
@@ -0,0 +1,14 @@
|
||||
version: '3'
|
||||
services:
|
||||
influxdb:
|
||||
image: influxdb
|
||||
environment:
|
||||
- INFLUXDB_DB=energy
|
||||
ports:
|
||||
- "8086:8086"
|
||||
grafana:
|
||||
image: grafana/grafana
|
||||
ports:
|
||||
- "3000:3000"
|
||||
depends_on:
|
||||
- influxdb
|
||||
1102
energy_dashboard.json
Normal file
1102
energy_dashboard.json
Normal file
File diff suppressed because it is too large
Load Diff
BIN
grafana-dashboard.png
Normal file
BIN
grafana-dashboard.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
Reference in New Issue
Block a user