From bb910a5cd55e6e7ed8c839ed427e3e516dbee9d2 Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Wed, 4 Aug 2021 14:53:51 +0200 Subject: [PATCH] Visualisation of Site Energy Flow (#1148) added energy flow visualisation --- Makefile | 7 +- assets/css/app.css | 38 +- assets/js/app.js | 5 +- .../js/components/Energyflow/BatteryIcon.vue | 58 + .../components/Energyflow/Energyflow.spec.js | 162 + .../Energyflow/Energyflow.stories.js | 102 + .../js/components/Energyflow/Energyflow.vue | 202 + .../components/Energyflow/Visualization.vue | 277 + assets/js/components/Energyflow/index.js | 3 + assets/js/components/Mode.vue | 8 +- assets/js/components/Site.stories.js | 24 +- assets/js/components/Site.vue | 19 +- assets/js/components/SiteDetails.stories.js | 34 - assets/js/components/SiteDetails.vue | 126 - assets/js/i18n/de.js | 16 +- assets/js/i18n/en.js | 16 +- assets/js/i18n/it.js | 16 +- assets/js/icons.js | 36 +- assets/js/views/App.vue | 19 +- dist/css/index.d2c9ca1b.css | 1 + dist/css/index.f1264e7c.css | 1 - dist/index.html | 4 +- ....8d9f525d.js => chunk-vendors.1a7ce110.js} | 14 +- dist/js/chunk-vendors.1a7ce110.js.map | 1 + dist/js/chunk-vendors.8d9f525d.js.map | 1 - dist/js/index.3e99d31f.js | 2 - dist/js/index.3e99d31f.js.map | 1 - dist/js/index.6e9352a0.js | 2 + dist/js/index.6e9352a0.js.map | 1 + docker/bin/docker-btmgmt | 0 jest.config.js | 4 + package-lock.json | 5948 ++++++++++++++++- package.json | 77 +- 33 files changed, 6906 insertions(+), 319 deletions(-) create mode 100644 assets/js/components/Energyflow/BatteryIcon.vue create mode 100644 assets/js/components/Energyflow/Energyflow.spec.js create mode 100644 assets/js/components/Energyflow/Energyflow.stories.js create mode 100644 assets/js/components/Energyflow/Energyflow.vue create mode 100644 assets/js/components/Energyflow/Visualization.vue create mode 100644 assets/js/components/Energyflow/index.js delete mode 100644 assets/js/components/SiteDetails.stories.js delete mode 100644 assets/js/components/SiteDetails.vue create mode 100644 dist/css/index.d2c9ca1b.css delete mode 100644 dist/css/index.f1264e7c.css rename dist/js/{chunk-vendors.8d9f525d.js => chunk-vendors.1a7ce110.js} (51%) create mode 100644 dist/js/chunk-vendors.1a7ce110.js.map delete mode 100644 dist/js/chunk-vendors.8d9f525d.js.map delete mode 100644 dist/js/index.3e99d31f.js delete mode 100644 dist/js/index.3e99d31f.js.map create mode 100644 dist/js/index.6e9352a0.js create mode 100644 dist/js/index.6e9352a0.js.map mode change 100755 => 100644 docker/bin/docker-btmgmt create mode 100644 jest.config.js diff --git a/Makefile b/Makefile index a11220f93..b6d421b72 100644 --- a/Makefile +++ b/Makefile @@ -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 @@ -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/ @@ -47,6 +47,9 @@ lint: lint-ui: npm run lint +test-ui: + npm run test + test: @echo "Running testsuite" go test ./... diff --git a/assets/css/app.css b/assets/css/app.css index 6fdde5d8e..df0fbee7b 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -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; } diff --git a/assets/js/app.js b/assets/js/app.js index b3013f5d7..d2098e4ad 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -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"; diff --git a/assets/js/components/Energyflow/BatteryIcon.vue b/assets/js/components/Energyflow/BatteryIcon.vue new file mode 100644 index 000000000..ca7fa5c53 --- /dev/null +++ b/assets/js/components/Energyflow/BatteryIcon.vue @@ -0,0 +1,58 @@ + + + + diff --git a/assets/js/components/Energyflow/Energyflow.spec.js b/assets/js/components/Energyflow/Energyflow.spec.js new file mode 100644 index 000000000..54983d952 --- /dev/null +++ b/assets/js/components/Energyflow/Energyflow.spec.js @@ -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"); + }); +}); diff --git a/assets/js/components/Energyflow/Energyflow.stories.js b/assets/js/components/Energyflow/Energyflow.stories.js new file mode 100644 index 000000000..f133aef91 --- /dev/null +++ b/assets/js/components/Energyflow/Energyflow.stories.js @@ -0,0 +1,102 @@ +import Energyflow from "./"; +import i18n from "../../i18n"; + +export default { + title: "Main/Energyflow", + component: Energyflow, + argTypes: { + gridPower: { control: { type: "range", min: -5000, max: 20000, step: 100 } }, + pvPower: { control: { type: "range", min: 0, max: 10000, step: 100 } }, + batteryPower: { control: { type: "range", min: -4000, max: 4000, step: 100 } }, + batterySoC: { control: { type: "range", min: 0, max: 100, step: 1 } }, + }, +}; + +const Template = (args, { argTypes }) => ({ + i18n, + props: Object.keys(argTypes), + components: { Energyflow }, + template: '', +}); + +export const GridAndPV = Template.bind({}); +GridAndPV.args = { + gridConfigured: true, + pvConfigured: true, + gridPower: -2300, + pvPower: 7320, + batteryConfigured: false, + siteTitle: "Home", +}; + +export const BatteryAndGrid = Template.bind({}); +BatteryAndGrid.args = { + gridConfigured: true, + pvConfigured: true, + batteryConfigured: true, + gridPower: 1200, + pvPower: 0, + batteryPower: 800, + batterySoC: 77, + siteTitle: "Home", +}; + +export const BatteryCharging = Template.bind({}); +BatteryCharging.args = { + gridConfigured: true, + pvConfigured: true, + batteryConfigured: true, + gridPower: -1300, + pvPower: 5000, + batteryPower: -1500, + batterySoC: 75, + siteTitle: "Home", +}; + +export const GridPvAndBattery = Template.bind({}); +GridPvAndBattery.args = { + gridConfigured: true, + pvConfigured: true, + batteryConfigured: true, + gridPower: 700, + pvPower: 1000, + batteryPower: 1500, + batterySoC: 30, + siteTitle: "Home", +}; + +export const BatteryThresholds = Template.bind({}); +BatteryThresholds.args = { + gridConfigured: true, + pvConfigured: true, + batteryConfigured: true, + gridPower: -510, + pvPower: 8740, + batteryPower: -100, + batterySoC: 95, + siteTitle: "Home", +}; + +export const PvThresholds = Template.bind({}); +PvThresholds.args = { + gridConfigured: true, + pvConfigured: true, + batteryConfigured: true, + batteryPower: 800, + gridPower: 5555, + pvPower: 300, + batterySoC: 76, + siteTitle: "Home", +}; + +export const GridOnly = Template.bind({}); +GridOnly.args = { + gridConfigured: true, + pvConfigured: false, + batteryConfigured: false, + gridPower: -6230, + pvPower: 0, + batteryPower: 0, + batterySoC: 0, + siteTitle: "Home", +}; diff --git a/assets/js/components/Energyflow/Energyflow.vue b/assets/js/components/Energyflow/Energyflow.vue new file mode 100644 index 000000000..61888d82a --- /dev/null +++ b/assets/js/components/Energyflow/Energyflow.vue @@ -0,0 +1,202 @@ + + + + diff --git a/assets/js/components/Energyflow/Visualization.vue b/assets/js/components/Energyflow/Visualization.vue new file mode 100644 index 000000000..534e51903 --- /dev/null +++ b/assets/js/components/Energyflow/Visualization.vue @@ -0,0 +1,277 @@ + + + + diff --git a/assets/js/components/Energyflow/index.js b/assets/js/components/Energyflow/index.js new file mode 100644 index 000000000..057959fd0 --- /dev/null +++ b/assets/js/components/Energyflow/index.js @@ -0,0 +1,3 @@ +import Energyflow from "./Energyflow.vue"; + +export default Energyflow; diff --git a/assets/js/components/Mode.vue b/assets/js/components/Mode.vue index f747efcbe..21108a8e0 100644 --- a/assets/js/components/Mode.vue +++ b/assets/js/components/Mode.vue @@ -4,7 +4,7 @@