Skip to content

Commit

Permalink
Visualisation of Site Energy Flow (evcc-io#1148)
Browse files Browse the repository at this point in the history
added energy flow visualisation
  • Loading branch information
naltatis authored Aug 4, 2021
1 parent 383d450 commit bb910a5
Show file tree
Hide file tree
Showing 33 changed files with 14,775 additions and 8,188 deletions.
7 changes: 5 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: default all clean install install-ui ui assets lint lint-ui test build test-release release
.PHONY: default all clean install install-ui ui assets lint test-ui lint-ui test build test-release release
.PHONY: docker publish-testing publish-latest publish-images
.PHONY: prepare-image image-rootfs image-update
.PHONY: soc stamps
Expand All @@ -24,7 +24,7 @@ IMAGE_OPTIONS := -hostname evcc -http_port 8080 github.com/gokrazy/serial-busybo

default: build

all: clean install install-ui ui assets lint lint-ui test build
all: clean install install-ui ui assets lint test-ui lint-ui test build

clean:
rm -rf dist/
Expand All @@ -47,6 +47,9 @@ lint:
lint-ui:
npm run lint

test-ui:
npm run test

test:
@echo "Running testsuite"
go test ./...
Expand Down
38 changes: 16 additions & 22 deletions assets/css/app.css
Original file line number Diff line number Diff line change
@@ -1,29 +1,23 @@
:root {
--evcc-green: #66d85a;
--evcc-dark-green: #3aba2c;
--evcc-yellow: #ffe000;

--evcc-grid: var(--bs-gray-dark);
--evcc-self: var(--evcc-dark-green);
--evcc-export: var(--evcc-yellow);

--bs-primary: var(--evcc-dark-green);
}

.bg-primary {
background-color: var(--evcc-dark-green) !important;
}

/* reverse loading animation */
.progress-bar-animated {
animation-direction: reverse;
}
.btn.caption {
opacity: 1;
}
.btn.first {
border-top-left-radius: 0.2rem;
border-bottom-left-radius: 0.2rem;
}
code {
font-size: 87.5%;
color: #e83e8c;
word-wrap: break-word;
}
.value,
input[type="radio"],
label.btn {
white-space: nowrap !important;
}
.text-muted a,
.text-muted a:hover {
color: #6c757d !important;
text-decoration: none;
}
.bg-muted {
opacity: 0.25;
}
5 changes: 4 additions & 1 deletion assets/js/app.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import "../css/app.css";
import "bootstrap/dist/css/bootstrap.min.css";
import "bootstrap";
import "../css/app.css";
import Vue from "vue";
import VueMeta from "vue-meta";
import axios from "axios";
import App from "./views/App";
import router from "./router";
import i18n from "./i18n";
import store from "./store";

Vue.use(VueMeta);

const loc = window.location;
axios.defaults.baseURL =
loc.protocol + "//" + loc.hostname + (loc.port ? ":" + loc.port : "") + loc.pathname + "api";
Expand Down
58 changes: 58 additions & 0 deletions assets/js/components/Energyflow/BatteryIcon.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<template>
<div :class="{ 'power--in': charge, 'power--out': discharge }">
<fa-icon class="battery" :icon="batteryIcon"></fa-icon
><fa-icon class="arrow" icon="angle-double-right"></fa-icon>
</div>
</template>

<script>
import "../../icons";
export default {
name: "BatteryIcon",
props: {
discharge: { type: Boolean },
charge: { type: Boolean },
soc: { type: Number, default: 0 },
},
computed: {
batteryIcon: function () {
if (this.soc > 80) return "battery-full";
if (this.soc > 60) return "battery-three-quarters";
if (this.soc > 40) return "battery-half";
if (this.soc > 20) return "battery-quarter";
return "battery-empty";
},
},
};
</script>
<style scoped>
.battery {
transform: translateX(0.35rem) rotate(-90deg);
transition-property: transform;
transition-duration: 250ms;
transition-timing-function: ease;
}
.power--in .battery {
transform: translateX(0.7rem) rotate(-90deg);
}
.power--out .battery {
transform: translateX(0) rotate(-90deg);
}
.arrow {
margin-left: -0.2rem;
opacity: 0;
transform: translateX(-0.5rem);
transition-property: opacity, transform;
transition-duration: 250ms;
transition-timing-function: ease;
}
.power--in .arrow {
opacity: 1;
transform: translateX(-1rem) scaleX(1);
}
.power--out .arrow {
opacity: 1;
transform: translateX(0rem) scaleX(1);
}
</style>
162 changes: 162 additions & 0 deletions assets/js/components/Energyflow/Energyflow.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
/* globals describe, it, expect */
import { shallowMount } from "@vue/test-utils";
import Energyflow from "./Energyflow.vue";

describe("Energyflow.vue", () => {
const defaultProps = {
gridConfigured: true,
gridPower: 0,
pvConfigured: true,
pvPower: 0,
batteryConfigured: false,
batteryPower: 0,
batterySoC: 0,
};

it("using pv and grid power", async () => {
const wrapper = shallowMount(Energyflow, {
mocks: { $t: (x) => x },
propsData: { ...defaultProps, gridPower: 1000, pvPower: 4000 },
});
await wrapper.find(".energyflow").trigger("click");

expect(wrapper.find("[data-test-grid-import]").text()).toMatch("1.0 kW");
expect(wrapper.find("[data-test-self-consumption]").text()).toMatch("4.0 kW");
expect(wrapper.find("[data-test-pv-export]").text()).toMatch("0.0 kW");

expect(wrapper.find("[data-test-house-consumption]").text()).toMatch("5.0 kW");
expect(wrapper.find("[data-test-pv-production]").text()).toMatch("4.0 kW");
expect(wrapper.find("[data-test-battery]").exists()).toBe(false);
});

it("exporting all pv power, no usage", async () => {
const wrapper = shallowMount(Energyflow, {
mocks: { $t: (x) => x },
propsData: { ...defaultProps, gridPower: -4000, pvPower: 4000 },
});

await wrapper.find(".energyflow").trigger("click");

expect(wrapper.find("[data-test-grid-import]").text()).toMatch("0.0 kW");
expect(wrapper.find("[data-test-self-consumption]").text()).toMatch("0.0 kW");
expect(wrapper.find("[data-test-pv-export]").text()).toMatch("4.0 kW");

expect(wrapper.find("[data-test-house-consumption]").text()).toMatch("0.0 kW");
expect(wrapper.find("[data-test-pv-production]").text()).toMatch("4.0 kW");
expect(wrapper.find("[data-test-battery]").exists()).toBe(false);
});

it("more grid export than pv, grid value wins (invalid state)", async () => {
const wrapper = shallowMount(Energyflow, {
mocks: { $t: (x) => x },
propsData: { ...defaultProps, gridPower: -4000, pvPower: 3000 },
});

await wrapper.find(".energyflow").trigger("click");

expect(wrapper.find("[data-test-grid-import]").text()).toMatch("0.0 kW");
expect(wrapper.find("[data-test-self-consumption]").text()).toMatch("0.0 kW");
expect(wrapper.find("[data-test-pv-export]").text()).toMatch("4.0 kW");

expect(wrapper.find("[data-test-house-consumption]").text()).toMatch("0.0 kW");
expect(wrapper.find("[data-test-pv-production]").text()).toMatch("3.0 kW");
expect(wrapper.find("[data-test-battery]").exists()).toBe(false);
});

it("only grid usage, no pv, idleBattery", async () => {
const wrapper = shallowMount(Energyflow, {
mocks: { $t: (x) => x },
propsData: {
...defaultProps,
gridPower: 360,
pvPower: 0,
batteryConfigured: true,
batteryPower: 0,
},
});

await wrapper.find(".energyflow").trigger("click");

expect(wrapper.find("[data-test-grid-import]").text()).toMatch("0.4 kW");
expect(wrapper.find("[data-test-self-consumption]").text()).toMatch("0.0 kW");
expect(wrapper.find("[data-test-pv-export]").text()).toMatch("0.0 kW");

expect(wrapper.find("[data-test-house-consumption]").text()).toMatch("0.4 kW");
expect(wrapper.find("[data-test-pv-production]").text()).toMatch("0.0 kW");
expect(wrapper.find("[data-test-battery]").text()).toMatch("main.energyflow.battery");
});

it("grid and battery usage, no pv", async () => {
const wrapper = shallowMount(Energyflow, {
mocks: { $t: (x) => x },
propsData: {
...defaultProps,
gridPower: 300,
batteryConfigured: true,
batteryPower: 200,
batterySoC: 77,
pvPower: 0,
},
});

await wrapper.find(".energyflow").trigger("click");

expect(wrapper.find("[data-test-grid-import]").text()).toMatch("0.3 kW");
expect(wrapper.find("[data-test-self-consumption]").text()).toMatch("0.2 kW");
expect(wrapper.find("[data-test-pv-export]").text()).toMatch("0.0 kW");

expect(wrapper.find("[data-test-house-consumption]").text()).toMatch("0.5 kW");
expect(wrapper.find("[data-test-pv-production]").text()).toMatch("0.0 kW");
expect(wrapper.find("[data-test-battery]").text()).toMatch("0.2 kW");
expect(wrapper.find("[data-test-battery]").text()).toMatch("77%");
expect(wrapper.find("[data-test-battery]").text()).toMatch("main.energyflow.batteryDischarge");
});

it("battery charge, pv export", async () => {
const wrapper = shallowMount(Energyflow, {
mocks: { $t: (x) => x },
propsData: {
...defaultProps,
gridPower: -2500,
batteryConfigured: true,
batteryPower: -1700,
pvPower: 9000,
},
});

await wrapper.find(".energyflow").trigger("click");

expect(wrapper.find("[data-test-grid-import]").text()).toMatch("0.0 kW");
expect(wrapper.find("[data-test-self-consumption]").text()).toMatch("6.5 kW");
expect(wrapper.find("[data-test-pv-export]").text()).toMatch("2.5 kW");

expect(wrapper.find("[data-test-house-consumption]").text()).toMatch("4.8 kW");
expect(wrapper.find("[data-test-pv-production]").text()).toMatch("9.0 kW");
expect(wrapper.find("[data-test-battery]").text()).toMatch("1.7 kW");
expect(wrapper.find("[data-test-battery]").text()).toMatch("main.energyflow.batteryCharge");
});

it("thresholds", async () => {
const wrapper = shallowMount(Energyflow, {
mocks: { $t: (x) => x },
propsData: {
...defaultProps,
gridPower: 5555,
batteryConfigured: true,
batteryPower: 1234,
pvPower: 378,
},
});

await wrapper.find(".energyflow").trigger("click");

expect(wrapper.find("[data-test-grid-import]").text()).toMatch("5.6 kW");
expect(wrapper.find("[data-test-self-consumption]").text()).toMatch("1.6 kW");
expect(wrapper.find("[data-test-pv-export]").text()).toMatch("0.0 kW");

expect(wrapper.find("[data-test-house-consumption]").text()).toMatch("7.2 kW");
expect(wrapper.find("[data-test-pv-production]").text()).toMatch("0.4 kW");
expect(wrapper.find("[data-test-battery]").text()).toMatch("1.2 kW");
expect(wrapper.find("[data-test-battery]").text()).toMatch("main.energyflow.batteryDischarge");
});
});
Loading

0 comments on commit bb910a5

Please sign in to comment.