Skip to content

Commit

Permalink
readme split & link references, added tests for app, argparse & main
Browse files Browse the repository at this point in the history
  • Loading branch information
PeterBrain committed Oct 27, 2024
1 parent 135e838 commit ca3e6ba
Show file tree
Hide file tree
Showing 11 changed files with 326 additions and 107 deletions.
2 changes: 0 additions & 2 deletions .github/workflows/coverage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@ on:
workflow_dispatch:

push:
#tags:
# - '*'

jobs:
test:
Expand Down
94 changes: 24 additions & 70 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
# Boilr

[![codecov](https://codecov.io/gh/PeterBrain/boilr/graph/badge.svg?token=NQDML8H7QA)](https://codecov.io/gh/PeterBrain/boilr)
[![latest release][github-release-shield]][github-release-link]
[![coverage][codecov-shield]][codecov-coverage]

Water boiler automation with a Fronius pv inverter on a Raspberry Pi.

The objective was to harness the surplus generated by the PV system and enhance daytime self-consumption through water heating. This approach not only reduces the energy fed back to the grid but also curtails the need for heating resources like pellets, oil, or other fuels.

![sufficiency over one day](./docs/sufficiency.jpg)
![sufficiency over one day][fronius-dashboard-graph]
The yellow area illustrates the self-consumed energy after using Boilr (this program) to increase self-sufficiency. The blue line is the overall energy consumption. The gray region represents the surplus energy directed into the grid. The green line corresponds to the battery charge level, depicted as a percentage, while the green segment represents the surplus energy channeled into the battery.

![self-sufficiency example](./docs/fronius.jpg)
![self-sufficiency example][fronius-dashboard]
Here, it's evident that all the energy generated by the PV system serves either to charge the battery or for direct consumption (inclusive of electrical devices and the water heater's heating element).

## Features
Expand All @@ -24,42 +25,9 @@ Here, it's evident that all the energy generated by the PV system serves either

## Setup

### Containerised in Docker (recommended)
Refer to this for setup instructions: [Setup.md][setup]

#### Manually building & run

```bash
docker-compose build
docker-compose up -d
```

#### From Docker Hub (armv6 only)

```bash
docker run --privileged -v ./config.yaml:/etc/boilr/config.yaml --device /dev/gpiomem:/dev/gpiomem peterbrain/boilr:latest
```

> [!NOTE]
> In order to install and use Docker on a Raspberry Pi 1 Model B, I had to set `sysctl vm.overcommit_memory=1` and restart after the installation.
### Manually build and install package

```bash
pip install .
```

Create config folder and copy sample config file to config directory.

```bash
mkdir /etc/boilr
cp config.yaml /etc/boilr/
```

Edit the config file to your needs

```bash
vi /etc/boilr/config.yaml
```
Check out this [Sample configuration][config-reference] for reference.

## Usage

Expand Down Expand Up @@ -114,45 +82,31 @@ https://github.com/PeterBrain/boilr
## Requirements

### Software

- Raspberry Pi with operating system (tested with Model 1B and headless Raspbian)
- Python 3 (tested with 3.10)
- Some python packages
- Docker (optional, but recommended)
- MQTT Broker (optional, but recommended)
Check out [Requirements][requirements] for more details.

### Hardware

List of all parts I used:
## Weaknesses

- Distribution box
- Circuit breaker (3-phase AC)
- Contactor to switch the three phases AC
- 5x1.5mm2 copper stranded cable
- Ferrules (for stranded wires)
- Raspberry Pi 1B + sd card
- Relay for the pi
- Network switch & Ethernet cable
- Fronius PV inverter
- Fronius PV Battery (optional - Boilr will work just fine without a battery)
The existing design exhibits a significant limitation: in contrast to Ohmpilot[^1], a comparable Fronius product that boasts notably higher efficiency due to its use of PWM (Pulse Width Modulation), my setup operates solely in two states. It's either fully activated, providing maximum power to the heating coil, or completely deactivated. The optimal efficiency advantage is sacrificed on days when PV production falls just short of meeting both the household's current consumption and the water heating requirements.

Inside | Outside
:---:|:---:
![inside view](./docs/inside.JPG) | ![outside view](./docs/outside.JPG)
## Additional resources

Unfortunately, the lid cannot be closed when there's something plugged into the power outlet on the hut rail. Thats why there is a second power outlet inside the distribution box.
- Fronius official API documentation: [Documentation - Fronius Solar API V1][fronius-api-documentation]
- Postman request collection: [Postman Collection - Fronius Solar API V1][fronius-api-collection]

> [!WARNING]
> If dealing with the electrical aspect isn't within your comfort zone, it's advisable to seek assistance from an electrician, as mishandling it can pose serious risks.
[^1]: <https://www.fronius.com/en/solar-energy/installers-partners/infocentre/news-row/ohmpilot-hot-water-with-solar-energy> and <https://www.fronius.com/~/downloads/Solar%20Energy/Brochures/SE_BRO_Fronius_Ohmpilot_B2C_EN_MEACA.pdf>

## Weaknesses

The existing design exhibits a significant limitation: in contrast to Ohmpilot[^1], a comparable Fronius product that boasts notably higher efficiency due to its use of PWM (Pulse Width Modulation), my setup operates solely in two states. It's either fully activated, providing maximum power to the heating coil, or completely deactivated. The optimal efficiency advantage is sacrificed on days when PV production falls just short of meeting both the household's current consumption and the water heating requirements.
[github-release-link]: https://github.com/peterbrain/boilr/releases
[github-release-shield]: https://img.shields.io/github/v/release/peterbrain/boilr
[codecov-coverage]: https://codecov.io/gh/PeterBrain/boilr
[codecov-shield]: https://codecov.io/gh/PeterBrain/boilr/graph/badge.svg?token=NQDML8H7QA

## Additional resources
[fronius-dashboard-graph]: ./docs/sufficiency.jpg
[fronius-dashboard]: ./docs/fronius.jpg

- Fronius official API documentation: [Documentation - Fronius Solar API V1](https://www.fronius.com/~/downloads/Solar%20Energy/Operating%20Instructions/42%2C0410%2C2012.pdf)
- Postman request collection: [Postman Collection - Fronius Solar API V1](https://www.getpostman.com/collections/27c663306206d7fbf502)
[setup]: ./docs/Setup.md
[requirements]: ./docs/Requirements.md
[config-reference]: ./config.yaml

[^1]: <https://www.fronius.com/en/solar-energy/installers-partners/infocentre/news-row/ohmpilot-hot-water-with-solar-energy> and <https://www.fronius.com/~/downloads/Solar%20Energy/Brochures/SE_BRO_Fronius_Ohmpilot_B2C_EN_MEACA.pdf>
[fronius-api-documentation]: https://www.fronius.com/~/downloads/Solar%20Energy/Operating%20Instructions/42%2C0410%2C2012.pdf
[fronius-api-collection]: https://www.getpostman.com/collections/27c663306206d7fbf502
2 changes: 1 addition & 1 deletion boilr/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ def main():

logger = logging.getLogger(__name__)

if hasattr(args, 'callback'):
if hasattr(args, 'callback') and callable(args.callback):
logger.debug("Executing command callback: %s", args.callback)
args.callback(args)
else:
Expand Down
18 changes: 0 additions & 18 deletions boilr/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,24 +217,6 @@ def daemon_status(args):

# Status variables disabled due to memory separation in daemon
# suggestion: multiprocessing.Manager for shared state (shared_dict)
'''boilr = app.boilr
(status, status_timestamp) = boilr.status
(status_prev, status_timestamp_prev) = boilr.status
msg += f"\nContactor status: {status}"
msg += f"\nContactor last changed: {status_timestamp}"
msg += f"\nContactor {'closed' if status else 'open'} for " \
f"{round((status_timestamp - status_timestamp_prev).total_seconds())} " \
f"seconds, Previously {status_prev}"
logger.info("Power load deque: %s", list(boilr.pload))
logger.info("Power pv deque: %s", list(boilr.ppv))
latest_load = boilr.pload[-1] if boilr.pload else 0
msg += f"\nPower load: {latest_load} W, Median: {boilr.pload_median} W"
latest_pv = boilr.ppv[-1] if boilr.ppv else 0
msg += f"\nPower pv: {latest_pv} W, Median: {boilr.ppv_median} W" '''

print(msg)
else:
Expand Down
39 changes: 39 additions & 0 deletions docs/Requirements.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Requirements

## Software

- Raspberry Pi with operating system (tested with Model 1B and headless Raspbian)
- Python 3 (tested with 3.10)
- Some python packages
- Docker (optional, but recommended)
- MQTT Broker (optional, but recommended)

## Hardware

List of all parts I used:

- Distribution box
- Circuit breaker (3-phase AC)
- Contactor to switch the three phases AC
- 5x1.5mm2 copper stranded cable
- Ferrules (for stranded wires)
- Raspberry Pi 1B + sd card
- Relay for the pi
- Network switch & Ethernet cable
- Fronius PV inverter
- Fronius PV Battery (optional - Boilr will work just fine without a battery)

### Electrical installation example

Inside | Outside
:---:|:---:
![inside view][installation-example-inside] | ![outside view][installation-example-outside]

Unfortunately, the lid cannot be closed when there's something plugged into the power outlet on the hut rail. Thats why there is a second power outlet inside the distribution box.

> [!WARNING]
> If dealing with the electrical aspect isn't within your comfort zone, it's advisable to seek assistance from an electrician, as mishandling it can pose serious risks.

[installation-example-inside]: ../docs/inside.JPG
[installation-example-outside]: ../docs/outside.JPG
53 changes: 53 additions & 0 deletions docs/Setup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Boilr Setup

- [Boilr Setup](#boilr-setup)
- [Containerised in Docker (recommended)](#containerised-in-docker-recommended)
- [Manually building \& run container](#manually-building--run-container)
- [Docker Hub (ARMv6 only)](#docker-hub-armv6-only)
- [PyPI - Python Package Index](#pypi---python-package-index)
- [Manually build and install package](#manually-build-and-install-package)


## Containerised in Docker (recommended)

### Manually building & run container

```bash
docker-compose build
docker-compose up -d
```

### Docker Hub (ARMv6 only)

```bash
docker run --privileged -v ./config.yaml:/etc/boilr/config.yaml --device /dev/gpiomem:/dev/gpiomem peterbrain/boilr:latest
```

> [!NOTE]
> In order to install and use Docker on a Raspberry Pi 1 Model B, I had to set `sysctl vm.overcommit_memory=1` and restart after the installation.
## PyPI - Python Package Index

Boilr is not yet available on Python Package Index. Check back later

## Manually build and install package

```bash
pip install .
```

Create config folder and copy sample config file to config directory.

```bash
mkdir /etc/boilr
cp config.yaml /etc/boilr/
```

Edit the config file to your needs. Check out this [Sample configuration][config-reference] for reference.

```bash
vi /etc/boilr/config.yaml
```


[config-reference]: ../config.yaml
66 changes: 52 additions & 14 deletions tests/test_app.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'''tests for app'''
from datetime import datetime
"""Tests for app"""
from datetime import datetime, timedelta
from collections import deque
from unittest.mock import patch
from unittest.mock import patch, ANY
import statistics
import pytest
from requests.exceptions import ConnectionError
Expand Down Expand Up @@ -34,11 +34,47 @@ def test_boilr_initialization():
assert boilr.ppv.maxlen == config.SystemConfig.moving_median_list_size


@patch('boilr.mqtt.publish_mqtt')
def test_update_medians(mock_publish):
"""Test updating medians"""
boilr = Boilr()
@patch('boilr.app.publish_mqtt')
def test_update_status(mock_publish, boilr_instance):
"""Test updating status"""
result = boilr_instance.update_status(True)

assert result is True
# Verify boilr contact status
assert boilr_instance.status[0] is True
# Verify that publish_mqtt was called once with the following arguments
mock_publish.assert_called_once_with("contactor/state", True)


@patch("boilr.app.datetime")
@patch('boilr.app.publish_mqtt')
def test_update_status_updates_timestamp(
mock_publish,
mock_datetime,
boilr_instance
):
"""Test that update_status updates timestamp correctly"""
initial_time = datetime(2024, 10, 27, 12, 0, 0)
later_time = initial_time + timedelta(hours=1)

mock_datetime.now.return_value = initial_time
boilr_instance.update_status(False)

# Verify boilr contact status with correct time
assert boilr_instance.status == (False, initial_time)

mock_datetime.now.return_value = later_time # Mock time one hour later
boilr_instance.update_status(True)

# Verify boilr contact status with correct time
assert boilr_instance.status == (True, later_time)
# Verify that publish_mqtt was called twice with the following arguments
mock_publish.assert_called_with("contactor/state", True)
assert mock_publish.call_count == 2


def test_update_medians():
"""Test updating medians"""
powerflow_pload = 50
powerflow_ppv = 100
result = boilr.update_medians(powerflow_pload, powerflow_ppv)
Expand All @@ -51,33 +87,35 @@ def test_update_medians_handles_error(mock_logger_error, boilr_instance):
"""Test update_medians gracefully handles errors during calculation"""
result = boilr_instance.update_medians(None, 200)

mock_logger_error.assert_called_with(
"Error in median calculation: %s",
ANY
)
assert result is False


@patch('boilr.mqtt.publish_mqtt')
def test_update_medians_calculation(mock_publish, boilr_instance):
def test_update_medians_calculation(boilr_instance):
"""Test that median calculation works correctly with valid inputs"""
powerflow_ploads = [10, 20, 30, 40, 50]
powerflow_ppvs = [100, 150, 200, 250, 300]

for pload, ppv in zip(powerflow_ploads, powerflow_ppvs):
boilr_instance.update_medians(pload, ppv)

# Validate that the medians were calculated correctly
# Verify that the medians were calculated correctly
assert boilr_instance.pload_median == statistics.median(powerflow_ploads)
assert boilr_instance.ppv_median == statistics.median(powerflow_ppvs)


@patch('boilr.mqtt.publish_mqtt')
def test_update_medians_calculation_partial_data(mock_publish, boilr_instance):
def test_update_medians_calculation_partial_data(boilr_instance):
"""Test update_medians with fewer than maxlen data points"""
powerflow_ploads = [10, 20]
powerflow_ppvs = [100, 150]

for pload, ppv in zip(powerflow_ploads, powerflow_ppvs):
boilr_instance.update_medians(pload, ppv)

# Check if the medians are calculated correctly with partial data
# Verify that medians are calculated correctly with partial data
assert boilr_instance.pload_median == statistics.median(powerflow_ploads)
assert boilr_instance.ppv_median == statistics.median(powerflow_ppvs)

Expand All @@ -88,7 +126,7 @@ def test_update_medians_empty_lists(boilr_instance):
boilr_instance.pload = deque(maxlen=5)
boilr_instance.ppv = deque(maxlen=5)

# Test update_medians with no data
# Test update_medians without data
result = boilr_instance.update_medians(None, None)

# Should return False as there is no data for calculation
Expand Down
Loading

0 comments on commit ca3e6ba

Please sign in to comment.