node-red-contrib-power-saver 2.0.5 → 3.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/FUNDING.yml +12 -0
- package/CHANGELOG.md +1 -37
- package/README.md +3 -191
- package/docs/.vuepress/config.js +67 -0
- package/docs/.vuepress/dist/404.html +15 -0
- package/docs/.vuepress/dist/assets/css/styles.e835bef6.css +8 -0
- package/docs/.vuepress/dist/assets/img/back-to-top.8b37f773.svg +1 -0
- package/docs/.vuepress/dist/assets/img/elvia-config-no-config.b4bb972c.png +0 -0
- package/docs/.vuepress/dist/assets/img/elvia-config-no-tariff.3f89aba8.png +0 -0
- package/docs/.vuepress/dist/assets/img/elvia-config-select-tariff.0f73fd56.png +0 -0
- package/docs/.vuepress/dist/assets/img/elvia-config-subscription-key.8be8ab8a.png +0 -0
- package/docs/.vuepress/dist/assets/img/elvia-flow.bae2a4d5.png +0 -0
- package/docs/.vuepress/dist/assets/img/example-flow-1.3ff3e23f.png +0 -0
- package/docs/.vuepress/dist/assets/img/example-flow-2.b653b58d.png +0 -0
- package/docs/.vuepress/dist/assets/img/migrate-best-save.f73420f6.png +0 -0
- package/docs/.vuepress/dist/assets/img/migrate-power-saver.aae13f9d.png +0 -0
- package/docs/.vuepress/dist/assets/img/node-power-saver.51ff2e5d.png +0 -0
- package/docs/.vuepress/dist/assets/img/node-ps-elvia-add-tariff.94ea2b09.png +0 -0
- package/docs/.vuepress/dist/assets/img/node-ps-receive-price.76eaa418.png +0 -0
- package/docs/.vuepress/dist/assets/img/node-ps-strategy-best-save.392292d5.png +0 -0
- package/docs/.vuepress/dist/assets/img/node-ps-strategy-lowest-price.3a4ad347.png +0 -0
- package/docs/.vuepress/dist/assets/img/power-saver-nordpool-current-state.bf14afde.png +0 -0
- package/docs/.vuepress/dist/assets/img/power-saver-nordpool-events-state.8c392507.png +0 -0
- package/docs/.vuepress/dist/assets/img/power-saver-tibber-mqtt.16891dd2.png +0 -0
- package/docs/.vuepress/dist/assets/js/293.5e967839.js +1 -0
- package/docs/.vuepress/dist/assets/js/491.c183eba3.js +1 -0
- package/docs/.vuepress/dist/assets/js/812.79dad458.js +2 -0
- package/docs/.vuepress/dist/assets/js/812.79dad458.js.LICENSE.txt +8 -0
- package/docs/.vuepress/dist/assets/js/app.4ee3384b.js +1 -0
- package/docs/.vuepress/dist/assets/js/runtime~app.cafd6537.js +1 -0
- package/docs/.vuepress/dist/assets/js/v-08683c60.07fe8291.js +1 -0
- package/docs/.vuepress/dist/assets/js/v-0aca7ba6.aec5ba75.js +1 -0
- package/docs/.vuepress/dist/assets/js/v-0b5e3c8c.d008d8bc.js +1 -0
- package/docs/.vuepress/dist/assets/js/v-1ad821fa.85407071.js +1 -0
- package/docs/.vuepress/dist/assets/js/v-30acb564.73b8e29f.js +1 -0
- package/docs/.vuepress/dist/assets/js/v-3706649a.d7f73384.js +1 -0
- package/docs/.vuepress/dist/assets/js/v-4637f9e4.22ab9413.js +1 -0
- package/docs/.vuepress/dist/assets/js/v-510ed0d4.204a09ec.js +1 -0
- package/docs/.vuepress/dist/assets/js/v-5954bcb2.be07962c.js +1 -0
- package/docs/.vuepress/dist/assets/js/v-5db8da3a.ac192f35.js +1 -0
- package/docs/.vuepress/dist/assets/js/v-61f728ca.802ab15e.js +1 -0
- package/docs/.vuepress/dist/assets/js/v-677dfaed.9bbbd037.js +1 -0
- package/docs/.vuepress/dist/assets/js/v-7c87f26e.457a1a60.js +1 -0
- package/docs/.vuepress/dist/assets/js/v-8daa1a0e.db8b59c6.js +1 -0
- package/docs/.vuepress/dist/assets/js/v-b4a42144.6e0c5aa0.js +1 -0
- package/docs/.vuepress/dist/assets/js/v-e8c55052.5f85b6cd.js +1 -0
- package/docs/.vuepress/dist/assets/js/v-fffb8e28.e815e852.js +1 -0
- package/docs/.vuepress/dist/changelog/index.html +15 -0
- package/docs/.vuepress/dist/contribute/index.html +15 -0
- package/docs/.vuepress/dist/euro.png +0 -0
- package/docs/.vuepress/dist/examples/example-nordpool-current-state.html +169 -0
- package/docs/.vuepress/dist/examples/example-nordpool-events-state.html +173 -0
- package/docs/.vuepress/dist/examples/example-tibber-mqtt.html +182 -0
- package/docs/.vuepress/dist/examples/index.html +15 -0
- package/docs/.vuepress/dist/guide/index.html +52 -0
- package/docs/.vuepress/dist/index.html +15 -0
- package/docs/.vuepress/dist/logo.png +0 -0
- package/docs/.vuepress/dist/nodes/index.html +15 -0
- package/docs/.vuepress/dist/nodes/old-power-saver-doc.html +97 -0
- package/docs/.vuepress/dist/nodes/power-saver.html +15 -0
- package/docs/.vuepress/dist/nodes/ps-elvia-add-tariff.html +15 -0
- package/docs/.vuepress/dist/nodes/ps-receive-price.html +80 -0
- package/docs/.vuepress/dist/nodes/ps-strategy-best-save.html +65 -0
- package/docs/.vuepress/dist/nodes/ps-strategy-lowest-price.html +89 -0
- package/docs/.vuepress/dist/nodes/strategy-input.html +40 -0
- package/docs/.vuepress/public/euro.png +0 -0
- package/docs/.vuepress/public/logo.png +0 -0
- package/docs/README.md +32 -0
- package/docs/changelog/README.md +65 -0
- package/docs/contribute/README.md +39 -0
- package/docs/examples/README.md +5 -0
- package/docs/examples/example-nordpool-current-state.md +166 -0
- package/docs/examples/example-nordpool-events-state.md +170 -0
- package/docs/examples/example-tibber-mqtt.md +179 -0
- package/docs/guide/README.md +202 -0
- package/docs/images/all-nodes.png +0 -0
- package/docs/images/best-save-config.png +0 -0
- package/docs/images/elvia-add-tariff-node-used.png +0 -0
- package/docs/images/elvia-config-no-config.png +0 -0
- package/docs/images/elvia-config-no-tariff.png +0 -0
- package/docs/images/elvia-config-select-tariff.png +0 -0
- package/docs/images/elvia-config-subscription-key.png +0 -0
- package/docs/images/elvia-flow.png +0 -0
- package/docs/images/elvia-tariff-config.png +0 -0
- package/docs/images/euro.png +0 -0
- package/docs/images/example-flow-1.png +0 -0
- package/docs/images/example-flow-2.png +0 -0
- package/docs/images/logo.png +0 -0
- package/docs/images/lowest-price-config.png +0 -0
- package/docs/images/migrate-best-save.png +0 -0
- package/docs/images/migrate-power-saver.png +0 -0
- package/docs/images/node-power-saver.png +0 -0
- package/docs/images/node-ps-elvia-add-tariff.png +0 -0
- package/docs/images/node-ps-elvia-tariff-types.png +0 -0
- package/docs/images/node-ps-elvia-tariff.png +0 -0
- package/docs/images/node-ps-receive-price.png +0 -0
- package/docs/images/node-ps-strategy-best-save.png +0 -0
- package/docs/images/node-ps-strategy-lowest-price.png +0 -0
- package/{doc → docs/images}/node-red-contrib-power-saver-flow.png +0 -0
- package/docs/images/power-saver-nordpool-current-state.png +0 -0
- package/docs/images/power-saver-nordpool-events-state.png +0 -0
- package/docs/images/power-saver-tibber-mqtt.png +0 -0
- package/docs/nodes/README.md +53 -0
- package/docs/nodes/old-power-saver-doc.md +231 -0
- package/docs/nodes/power-saver.md +23 -0
- package/docs/nodes/ps-elvia-add-tariff.md +52 -0
- package/docs/nodes/ps-receive-price.md +153 -0
- package/docs/nodes/ps-strategy-best-save.md +142 -0
- package/docs/nodes/ps-strategy-lowest-price.md +165 -0
- package/docs/nodes/strategy-input.md +39 -0
- package/package.json +19 -4
- package/src/elvia/elvia-add-tariff.html +70 -0
- package/src/elvia/elvia-add-tariff.js +47 -0
- package/src/elvia/elvia-api.js +61 -0
- package/src/elvia/elvia-config.html +46 -0
- package/src/elvia/elvia-config.js +19 -0
- package/src/elvia/elvia-tariff-types.html +34 -0
- package/src/elvia/elvia-tariff-types.js +25 -0
- package/src/elvia/elvia-tariff.html +89 -0
- package/src/elvia/elvia-tariff.js +22 -0
- package/src/elvia/icons/elvia_hvite.svg +4 -0
- package/src/elvia/icons/elvia_positive_4 copy.svg +4 -0
- package/src/handle-input.js +162 -0
- package/src/power-saver.html +116 -0
- package/{power-saver.js → src/power-saver.js} +90 -72
- package/src/receive-price-functions.js +99 -0
- package/src/receive-price.html +30 -0
- package/src/receive-price.js +21 -0
- package/src/strategy-best-save-functions.js +110 -0
- package/src/strategy-best-save.html +116 -0
- package/src/strategy-best-save.js +95 -0
- package/src/strategy-lowest-price-functions.js +35 -0
- package/src/strategy-lowest-price.html +168 -0
- package/src/strategy-lowest-price.js +125 -0
- package/{utils.js → src/utils.js} +59 -104
- package/test/data/adjustedResult.js +302 -0
- package/test/data/adjustedResult_old.js +154 -0
- package/test/data/best-save-result.json +357 -0
- package/test/data/converted-prices.json +196 -0
- package/test/data/elvia-input-grid-tariff.json +760 -0
- package/test/data/elvia-input-power-prices.json +194 -0
- package/test/data/elvia-output-add-tariff.json +290 -0
- package/test/data/lowest-price-result-cont.json +18 -0
- package/test/data/lowest-price-result-split-allday.json +21 -0
- package/test/data/lowest-price-result-split-allday10.json +20 -0
- package/test/data/lowest-price-result-split.json +20 -0
- package/test/data/nordpool-current-state-prices.json +283 -0
- package/test/data/nordpool-event-prices.json +574 -0
- package/test/data/reconfigResult.js +315 -0
- package/test/data/reconfigResult_old.js +141 -0
- package/test/data/result.js +1 -0
- package/test/data/tibber-prices-single-home.json +64 -0
- package/test/data/tibber-prices.json +124 -0
- package/test/data/{tibber_result.json → tibber-result.json} +2 -1
- package/test/elvia.test.js +26 -0
- package/test/mostSavedStrategy.test.js +22 -55
- package/test/power-saver.test.js +4 -38
- package/test/receive-price-functions.test.js +153 -0
- package/test/receive-price.test.js +122 -0
- package/test/send-config-input.test.js +121 -0
- package/test/strategy-best-save-test-utils.js +32 -0
- package/test/strategy-best-save.test.js +103 -0
- package/test/strategy-lowest-price-functions.test.js +40 -0
- package/test/strategy-lowest-price.test.js +472 -0
- package/test/test-utils.js +106 -0
- package/test/utils.test.js +53 -163
- package/doc/example-nordpool-current-state.md +0 -166
- package/doc/example-nordpool-events-state.md +0 -153
- package/doc/example-tibber-mqtt.md +0 -189
- package/doc/power-saver-nordpool-current-state.png +0 -0
- package/doc/power-saver-nordpool-events-state.png +0 -0
- package/doc/power-saver-tibber-mqtt.png +0 -0
- package/mostSavedStrategy.js +0 -84
- package/mostSavedStrategy_v2.js +0 -68
- package/power-saver.html +0 -259
- package/test/data/tibber_data.json +0 -412
- package/test/data/tibber_prices.json +0 -412
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
const { countAtEnd, makeSchedule, getSavings, getStartAtIndex, getDiff } = require("./utils");
|
|
2
|
+
const { handleStrategyInput } = require("./handle-input");
|
|
3
|
+
const { loadDayData } = require("./utils");
|
|
4
|
+
|
|
5
|
+
const mostSavedStrategy = require("./strategy-best-save-functions");
|
|
6
|
+
|
|
7
|
+
module.exports = function (RED) {
|
|
8
|
+
function StrategyBestSaveNode(config) {
|
|
9
|
+
RED.nodes.createNode(this, config);
|
|
10
|
+
const node = this;
|
|
11
|
+
|
|
12
|
+
const originalConfig = {
|
|
13
|
+
maxHoursToSaveInSequence: config.maxHoursToSaveInSequence,
|
|
14
|
+
minHoursOnAfterMaxSequenceSaved: config.minHoursOnAfterMaxSequenceSaved,
|
|
15
|
+
minSaving: parseFloat(config.minSaving),
|
|
16
|
+
sendCurrentValueWhenRescheduling: config.sendCurrentValueWhenRescheduling,
|
|
17
|
+
outputIfNoSchedule: config.outputIfNoSchedule === "true",
|
|
18
|
+
scheduleOnlyFromCurrentTime: config.scheduleOnlyFromCurrentTime === "true",
|
|
19
|
+
};
|
|
20
|
+
node.context().set("config", originalConfig);
|
|
21
|
+
|
|
22
|
+
node.on("close", function () {
|
|
23
|
+
clearTimeout(node.schedulingTimeout);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
node.on("input", function (msg) {
|
|
27
|
+
handleStrategyInput(node, msg, doPlanning);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
RED.nodes.registerType("ps-strategy-best-save", StrategyBestSaveNode);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function adjustSavingsPassedHours(plan, includeFromLastPlanHours) {
|
|
34
|
+
const firstOnIndex = plan.hours.findIndex((h) => h.onOff);
|
|
35
|
+
if (firstOnIndex < 0) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const nextOnValue = plan.hours[firstOnIndex].price;
|
|
39
|
+
let adjustIndex = includeFromLastPlanHours.length - 1;
|
|
40
|
+
while (adjustIndex >= 0 && !includeFromLastPlanHours[adjustIndex].onOff) {
|
|
41
|
+
includeFromLastPlanHours[adjustIndex].saving = getDiff(includeFromLastPlanHours[adjustIndex].price, nextOnValue);
|
|
42
|
+
adjustIndex--;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function loadDataJustBefore(node, dateDayBefore, dateToday, startAtIndex) {
|
|
47
|
+
const dataDayBefore = loadDayData(node, dateDayBefore);
|
|
48
|
+
const dataToday = loadDayData(node, dateToday);
|
|
49
|
+
return {
|
|
50
|
+
schedule: [...dataDayBefore.schedule, ...dataToday.schedule.slice(0, startAtIndex)],
|
|
51
|
+
hours: [...dataDayBefore.hours, ...dataToday.hours.slice(0, startAtIndex)],
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function doPlanning(node, effectiveConfig, priceData, planFromTime, dateDayBefore, dateToday) {
|
|
56
|
+
const startAtIndex = getStartAtIndex(effectiveConfig, priceData, planFromTime);
|
|
57
|
+
const dataJustBefore = loadDataJustBefore(node, dateDayBefore, dateToday, startAtIndex);
|
|
58
|
+
const values = priceData.map((d) => d.value).slice(startAtIndex);
|
|
59
|
+
const startTimes = priceData.map((d) => d.start).slice(startAtIndex);
|
|
60
|
+
const onOffBefore = dataJustBefore.hours.map((h) => h.onOff);
|
|
61
|
+
const lastPlanHours = node.context().get("lastPlan")?.hours ?? [];
|
|
62
|
+
const plan = makePlan(node, values, startTimes, onOffBefore);
|
|
63
|
+
const includeFromLastPlanHours = lastPlanHours.filter(
|
|
64
|
+
(h) => h.start < plan.hours[0].start && h.start >= priceData[0].start
|
|
65
|
+
);
|
|
66
|
+
adjustSavingsPassedHours(plan, includeFromLastPlanHours);
|
|
67
|
+
plan.hours.splice(0, 0, ...includeFromLastPlanHours);
|
|
68
|
+
return plan;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function makePlan(node, values, startTimes, onOffBefore, firstValueNextDay) {
|
|
72
|
+
const lastValueDayBefore = onOffBefore[onOffBefore.length - 1];
|
|
73
|
+
const lastCountDayBefore = countAtEnd(onOffBefore, lastValueDayBefore);
|
|
74
|
+
const onOff = mostSavedStrategy.calculate(
|
|
75
|
+
values,
|
|
76
|
+
node.maxHoursToSaveInSequence,
|
|
77
|
+
node.minHoursOnAfterMaxSequenceSaved,
|
|
78
|
+
node.minSaving,
|
|
79
|
+
lastValueDayBefore,
|
|
80
|
+
lastCountDayBefore
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
const schedule = makeSchedule(onOff, startTimes, lastValueDayBefore);
|
|
84
|
+
const savings = getSavings(values, onOff, firstValueNextDay);
|
|
85
|
+
const hours = values.map((v, i) => ({
|
|
86
|
+
price: v,
|
|
87
|
+
onOff: onOff[i],
|
|
88
|
+
start: startTimes[i],
|
|
89
|
+
saving: savings[i],
|
|
90
|
+
}));
|
|
91
|
+
return {
|
|
92
|
+
hours,
|
|
93
|
+
schedule,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
const cloneDeep = require("lodash.clonedeep");
|
|
2
|
+
const { sortedIndex } = require("./utils");
|
|
3
|
+
|
|
4
|
+
function getBestContinuous(values, count) {
|
|
5
|
+
let min = values.reduce((p, v) => p + v, 0);
|
|
6
|
+
let minIndex = 0;
|
|
7
|
+
for (let i = 0; i <= values.length - count; i++) {
|
|
8
|
+
let sum = 0;
|
|
9
|
+
for (let j = 0; j < count; j++) {
|
|
10
|
+
sum += values[i + j];
|
|
11
|
+
}
|
|
12
|
+
if (sum < min) {
|
|
13
|
+
min = sum;
|
|
14
|
+
minIndex = i;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
const onOff = cloneDeep(values)
|
|
18
|
+
.fill(false)
|
|
19
|
+
.fill(true, minIndex, minIndex + count);
|
|
20
|
+
return onOff;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function getBestX(values, count) {
|
|
24
|
+
const sorted = sortedIndex(values);
|
|
25
|
+
const onOff = cloneDeep(values).fill(true);
|
|
26
|
+
for (let i = 0; i < sorted.length - count; i++) {
|
|
27
|
+
onOff[sorted[i]] = false;
|
|
28
|
+
}
|
|
29
|
+
return onOff;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
module.exports = {
|
|
33
|
+
getBestContinuous,
|
|
34
|
+
getBestX,
|
|
35
|
+
};
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
<script type="text/javascript">
|
|
2
|
+
const hours = [
|
|
3
|
+
"00",
|
|
4
|
+
"01",
|
|
5
|
+
"02",
|
|
6
|
+
"03",
|
|
7
|
+
"04",
|
|
8
|
+
"05",
|
|
9
|
+
"06",
|
|
10
|
+
"07",
|
|
11
|
+
"08",
|
|
12
|
+
"09",
|
|
13
|
+
"10",
|
|
14
|
+
"11",
|
|
15
|
+
"12",
|
|
16
|
+
"13",
|
|
17
|
+
"14",
|
|
18
|
+
"15",
|
|
19
|
+
"16",
|
|
20
|
+
"17",
|
|
21
|
+
"18",
|
|
22
|
+
"19",
|
|
23
|
+
"20",
|
|
24
|
+
"21",
|
|
25
|
+
"22",
|
|
26
|
+
"23",
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
RED.nodes.registerType("ps-strategy-lowest-price", {
|
|
30
|
+
category: "Power Saver",
|
|
31
|
+
color: "#a6bbcf",
|
|
32
|
+
defaults: {
|
|
33
|
+
name: { value: "Lowest Price" },
|
|
34
|
+
fromTime: {
|
|
35
|
+
value: "00",
|
|
36
|
+
required: true,
|
|
37
|
+
},
|
|
38
|
+
toTime: {
|
|
39
|
+
value: "00",
|
|
40
|
+
required: true,
|
|
41
|
+
},
|
|
42
|
+
hoursOn: {
|
|
43
|
+
value: "12",
|
|
44
|
+
required: true,
|
|
45
|
+
},
|
|
46
|
+
doNotSplit: {
|
|
47
|
+
value: "false",
|
|
48
|
+
required: true,
|
|
49
|
+
align: "left",
|
|
50
|
+
},
|
|
51
|
+
sendCurrentValueWhenRescheduling: {
|
|
52
|
+
value: "true",
|
|
53
|
+
required: true,
|
|
54
|
+
align: "left",
|
|
55
|
+
},
|
|
56
|
+
outputIfNoSchedule: { value: "true", required: true, align: "left" },
|
|
57
|
+
outputOutsidePeriod: { value: "false", required: true, align: "left" },
|
|
58
|
+
},
|
|
59
|
+
inputs: 1,
|
|
60
|
+
outputs: 3,
|
|
61
|
+
icon: "font-awesome/fa-bar-chart",
|
|
62
|
+
color: "#FFCC66",
|
|
63
|
+
label: function () {
|
|
64
|
+
return this.name || "Lowest Price";
|
|
65
|
+
},
|
|
66
|
+
outputLabels: ["on", "off", "schedule"],
|
|
67
|
+
oneditprepare: function () {
|
|
68
|
+
$("#node-input-outputIfNoSchedule").typedInput({
|
|
69
|
+
types: [
|
|
70
|
+
{
|
|
71
|
+
value: "onoff",
|
|
72
|
+
options: [
|
|
73
|
+
{ value: "true", label: "On" },
|
|
74
|
+
{ value: "false", label: "Off" },
|
|
75
|
+
],
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
});
|
|
79
|
+
$("#node-input-outputOutsidePeriod").typedInput({
|
|
80
|
+
types: [
|
|
81
|
+
{
|
|
82
|
+
value: "onoff",
|
|
83
|
+
options: [
|
|
84
|
+
{ value: "true", label: "On" },
|
|
85
|
+
{ value: "false", label: "Off" },
|
|
86
|
+
],
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
});
|
|
90
|
+
$("#node-input-fromTime").typedInput({
|
|
91
|
+
types: [
|
|
92
|
+
{
|
|
93
|
+
value: "fromtime",
|
|
94
|
+
options: hours.map((h) => ({ value: h, label: h + ":00" })),
|
|
95
|
+
},
|
|
96
|
+
],
|
|
97
|
+
});
|
|
98
|
+
$("#node-input-toTime").typedInput({
|
|
99
|
+
types: [
|
|
100
|
+
{
|
|
101
|
+
value: "totime",
|
|
102
|
+
options: hours.map((h) => ({ value: h, label: h + ":00" })),
|
|
103
|
+
},
|
|
104
|
+
],
|
|
105
|
+
});
|
|
106
|
+
$("#node-input-hoursOn").typedInput({
|
|
107
|
+
types: [
|
|
108
|
+
{
|
|
109
|
+
value: "hourson",
|
|
110
|
+
options: (() => {
|
|
111
|
+
const res = hours.map((h) => ({ value: h, label: h }));
|
|
112
|
+
res.push({ value: "24", label: "24" });
|
|
113
|
+
return res;
|
|
114
|
+
})(),
|
|
115
|
+
},
|
|
116
|
+
],
|
|
117
|
+
});
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
</script>
|
|
121
|
+
|
|
122
|
+
<script type="text/html" data-template-name="ps-strategy-lowest-price">
|
|
123
|
+
<div class="form-row">
|
|
124
|
+
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
|
|
125
|
+
<input type="text" id="node-input-name" placeholder="Name" style="width: 240px">
|
|
126
|
+
</div>
|
|
127
|
+
<div class="form-row">
|
|
128
|
+
<label for="node-input-fromTime"><i class="fa fa-arrows-h"></i> From time</label>
|
|
129
|
+
<input type="text" id="node-input-fromTime" style="width: 80px">
|
|
130
|
+
</div>
|
|
131
|
+
<div class="form-row">
|
|
132
|
+
<label for="node-input-toTime"><i class="fa fa-arrows-h"></i> To time</label>
|
|
133
|
+
<input type="text" id="node-input-toTime" style="width: 80px">
|
|
134
|
+
</div>
|
|
135
|
+
<div class="form-row">
|
|
136
|
+
<label for="node-input-hoursOn"><i class="fa fa-arrows-h"></i> Hours on</label>
|
|
137
|
+
<input type="text" id="node-input-hoursOn" style="width: 80px">
|
|
138
|
+
</div>
|
|
139
|
+
<div class="form-row">
|
|
140
|
+
<label for="node-input-doNotSplit">Consecutive on-period</label>
|
|
141
|
+
<input type="checkbox" id="node-input-doNotSplit" style="display:inline-block; width:22px; vertical-align:top;">
|
|
142
|
+
</label>
|
|
143
|
+
</div>
|
|
144
|
+
<div class="form-row">
|
|
145
|
+
<label for="node-input-sendCurrentValueWhenRescheduling" style="width:240px">
|
|
146
|
+
<input type="checkbox"
|
|
147
|
+
id="node-input-sendCurrentValueWhenRescheduling"
|
|
148
|
+
style="display:inline-block; width:22px; vertical-align:top;"
|
|
149
|
+
autocomplete="off"><span>Send when rescheduling</span>
|
|
150
|
+
</label>
|
|
151
|
+
</div>
|
|
152
|
+
<div class="form-row">
|
|
153
|
+
<label for="node-input-outputIfNoSchedule">If no schedule, send</label>
|
|
154
|
+
<input type="text" id="node-input-outputIfNoSchedule" style="width: 80px">
|
|
155
|
+
</label>
|
|
156
|
+
</div>
|
|
157
|
+
<div class="form-row">
|
|
158
|
+
<label for="node-input-outputIfNoSchedule">Outside period, send</label>
|
|
159
|
+
<input type="text" id="node-input-outputOutsidePeriod" style="width: 80px">
|
|
160
|
+
</label>
|
|
161
|
+
</div>
|
|
162
|
+
</script>
|
|
163
|
+
|
|
164
|
+
<script type="text/markdown" data-help-name="ps-strategy-lowest-price">
|
|
165
|
+
A node to turn on a switch the hours when the price is lowest.
|
|
166
|
+
|
|
167
|
+
Please read more in the [node documentation](https://ottopaulsen.github.io/node-red-contrib-power-saver/nodes/ps-strategy-lowest-price)
|
|
168
|
+
</script>
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
const { DateTime } = require("luxon");
|
|
2
|
+
const { booleanConfig, makeSchedule, loadDayData } = require("./utils");
|
|
3
|
+
const { handleStrategyInput } = require("./handle-input");
|
|
4
|
+
const { getBestContinuous, getBestX } = require("./strategy-lowest-price-functions");
|
|
5
|
+
|
|
6
|
+
module.exports = function (RED) {
|
|
7
|
+
function StrategyLowestPriceNode(config) {
|
|
8
|
+
RED.nodes.createNode(this, config);
|
|
9
|
+
const node = this;
|
|
10
|
+
|
|
11
|
+
const originalConfig = {
|
|
12
|
+
fromTime: config.fromTime,
|
|
13
|
+
toTime: config.toTime,
|
|
14
|
+
hoursOn: parseInt(config.hoursOn),
|
|
15
|
+
doNotSplit: booleanConfig(config.doNotSplit),
|
|
16
|
+
sendCurrentValueWhenRescheduling: booleanConfig(config.sendCurrentValueWhenRescheduling),
|
|
17
|
+
outputIfNoSchedule: booleanConfig(config.outputIfNoSchedule),
|
|
18
|
+
outputOutsidePeriod: booleanConfig(config.outputOutsidePeriod),
|
|
19
|
+
};
|
|
20
|
+
node.context().set("config", originalConfig);
|
|
21
|
+
|
|
22
|
+
node.on("close", function () {
|
|
23
|
+
clearTimeout(node.schedulingTimeout);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
node.on("input", function (msg) {
|
|
27
|
+
handleStrategyInput(node, msg, doPlanning);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
RED.nodes.registerType("ps-strategy-lowest-price", StrategyLowestPriceNode);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function doPlanning(node, _, priceData, _, dateDayBefore, _) {
|
|
35
|
+
const dataDayBefore = loadDayData(node, dateDayBefore);
|
|
36
|
+
const values = [...dataDayBefore.hours.map((h) => h.price), ...priceData.map((pd) => pd.value)];
|
|
37
|
+
const startTimes = [...dataDayBefore.hours.map((h) => h.start), ...priceData.map((pd) => pd.start)];
|
|
38
|
+
|
|
39
|
+
const from = parseInt(node.fromTime);
|
|
40
|
+
const to = parseInt(node.toTime);
|
|
41
|
+
const periodStatus = [];
|
|
42
|
+
const startIndexes = [];
|
|
43
|
+
const endIndexes = [];
|
|
44
|
+
let currentStatus = from < to ? "Outside" : "StartMissing";
|
|
45
|
+
let hour;
|
|
46
|
+
priceData.forEach((pd, i) => {
|
|
47
|
+
hour = DateTime.fromISO(pd.start).hour;
|
|
48
|
+
if (hour === to && to === from && currentStatus === "Inside") {
|
|
49
|
+
endIndexes.push(i - 1);
|
|
50
|
+
}
|
|
51
|
+
if (hour === to && to !== from) {
|
|
52
|
+
currentStatus = "Outside";
|
|
53
|
+
endIndexes.push(i - 1);
|
|
54
|
+
}
|
|
55
|
+
if (hour === from) {
|
|
56
|
+
currentStatus = "Inside";
|
|
57
|
+
startIndexes.push(i);
|
|
58
|
+
}
|
|
59
|
+
periodStatus[i] = currentStatus;
|
|
60
|
+
});
|
|
61
|
+
if (currentStatus === "Inside" && hour !== (to === 0 ? 23 : to - 1)) {
|
|
62
|
+
// Last period incomplete
|
|
63
|
+
let i = periodStatus.length - 1;
|
|
64
|
+
do {
|
|
65
|
+
periodStatus[i] = "EndMissing";
|
|
66
|
+
hour = DateTime.fromISO(priceData[i].start).hour;
|
|
67
|
+
i--;
|
|
68
|
+
} while (periodStatus[i] === "Inside" && hour !== from);
|
|
69
|
+
}
|
|
70
|
+
if (hour === (to === 0 ? 23 : to - 1)) {
|
|
71
|
+
endIndexes.push(priceData.length - 1);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const onOff = [];
|
|
75
|
+
|
|
76
|
+
// Fill in data from previous plan for StartMissing
|
|
77
|
+
const lastStartMissing = periodStatus.lastIndexOf((s) => s === "StartMissing");
|
|
78
|
+
if (lastStartMissing >= 0 && dataDayBefore?.hours?.length > 0) {
|
|
79
|
+
const lastBefore = DateTime.fromISO(dataDayBefore.hours[dataDayBefore.hours.length - 1].start);
|
|
80
|
+
if (lastBefore >= DateTime.fromISO(priceData[lastStartMissing].start)) {
|
|
81
|
+
for (let i = 0; i <= lastStartMissing; i++) {
|
|
82
|
+
onOff[i] = dataDayBefore.hours.find((h) => h.start === priceData[i].start);
|
|
83
|
+
periodStatus[i] = "Backfilled";
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Set onOff for hours that will not be planned
|
|
89
|
+
periodStatus.forEach((s, i) => {
|
|
90
|
+
onOff[i] =
|
|
91
|
+
s === "Outside"
|
|
92
|
+
? node.outputOutsidePeriod
|
|
93
|
+
: s === "StartMissing" || s === "EndMissing"
|
|
94
|
+
? node.outputIfNoSchedule
|
|
95
|
+
: null;
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
startIndexes.forEach((s, i) => {
|
|
99
|
+
makePlan(node, values, onOff, s, endIndexes[i]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const schedule = makeSchedule(onOff, startTimes, !onOff[0]);
|
|
103
|
+
|
|
104
|
+
const hours = values.map((v, i) => ({
|
|
105
|
+
price: v,
|
|
106
|
+
onOff: onOff[i],
|
|
107
|
+
start: startTimes[i],
|
|
108
|
+
saving: null,
|
|
109
|
+
}));
|
|
110
|
+
return {
|
|
111
|
+
hours,
|
|
112
|
+
schedule,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function makePlan(node, values, onOff, fromIndex, toIndex) {
|
|
117
|
+
const valuesInPeriod = values.slice(fromIndex, toIndex + 1);
|
|
118
|
+
const res = node.doNotSplit
|
|
119
|
+
? getBestContinuous(valuesInPeriod, node.hoursOn)
|
|
120
|
+
: getBestX(valuesInPeriod, node.hoursOn);
|
|
121
|
+
res.forEach((v, i) => {
|
|
122
|
+
onOff[fromIndex + i] = v;
|
|
123
|
+
});
|
|
124
|
+
return onOff;
|
|
125
|
+
}
|
|
@@ -1,57 +1,7 @@
|
|
|
1
1
|
const { DateTime } = require("luxon");
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
* Can accept 3 types of messages: Tibber, Nordpool or plain payload with data already converted.
|
|
6
|
-
* @param {*} msg
|
|
7
|
-
*/
|
|
8
|
-
function convertMsg(msg) {
|
|
9
|
-
let today = [];
|
|
10
|
-
let tomorrow = [];
|
|
11
|
-
let source = "Unknown";
|
|
12
|
-
|
|
13
|
-
if (msg.payload?.viewer?.homes[0]?.currentSubscription?.priceInfo?.today) {
|
|
14
|
-
source = "Tibber";
|
|
15
|
-
today = msg.payload.viewer.homes[0].currentSubscription.priceInfo.today.map(
|
|
16
|
-
(v) => ({ value: v.total, start: v.startsAt })
|
|
17
|
-
);
|
|
18
|
-
} else if (msg.data?.new_state?.attributes?.raw_today) {
|
|
19
|
-
source = "Nordpool";
|
|
20
|
-
today = msg.data.new_state.attributes.raw_today.map((v) => ({
|
|
21
|
-
value: v.value,
|
|
22
|
-
start: v.start,
|
|
23
|
-
}));
|
|
24
|
-
} else if (msg.data?.attributes?.raw_today) {
|
|
25
|
-
source = "Nordpool";
|
|
26
|
-
today = msg.data.attributes.raw_today.map((v) => ({
|
|
27
|
-
value: v.value,
|
|
28
|
-
start: v.start,
|
|
29
|
-
}));
|
|
30
|
-
} else {
|
|
31
|
-
source = "Other";
|
|
32
|
-
today = msg.payload?.today || [];
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
if (msg.payload?.viewer?.homes[0]?.currentSubscription?.priceInfo?.tomorrow) {
|
|
36
|
-
tomorrow =
|
|
37
|
-
msg.payload.viewer.homes[0].currentSubscription.priceInfo.tomorrow.map(
|
|
38
|
-
(v) => ({ value: v.total, start: v.startsAt })
|
|
39
|
-
);
|
|
40
|
-
} else if (msg.data?.new_state?.attributes?.raw_tomorrow) {
|
|
41
|
-
tomorrow = msg.data.new_state.attributes.raw_tomorrow.map((v) => ({
|
|
42
|
-
value: v.value,
|
|
43
|
-
start: v.start,
|
|
44
|
-
}));
|
|
45
|
-
} else if (msg.data?.attributes?.raw_tomorrow) {
|
|
46
|
-
tomorrow = msg.data.attributes.raw_tomorrow.map((v) => ({
|
|
47
|
-
value: v.value,
|
|
48
|
-
start: v.start,
|
|
49
|
-
}));
|
|
50
|
-
} else {
|
|
51
|
-
tomorrow = msg.payload?.tomorrow || [];
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
return { today, tomorrow, source };
|
|
3
|
+
function booleanConfig(value) {
|
|
4
|
+
return value === "true" || value === true;
|
|
55
5
|
}
|
|
56
6
|
|
|
57
7
|
/**
|
|
@@ -94,14 +44,47 @@ function getDiffToNextOn(values, onOff, nextOn = null) {
|
|
|
94
44
|
const res = values.map((p, i, a) => {
|
|
95
45
|
for (let n = i + 1; n < a.length; n++) {
|
|
96
46
|
if (onOff[n]) {
|
|
97
|
-
return
|
|
47
|
+
return getDiff(values[i], values[n]);
|
|
98
48
|
}
|
|
99
49
|
}
|
|
100
|
-
return
|
|
50
|
+
return getDiff(p, nextOnValue);
|
|
101
51
|
});
|
|
102
52
|
return res;
|
|
103
53
|
}
|
|
104
54
|
|
|
55
|
+
function getDiff(large, small) {
|
|
56
|
+
return roundPrice(large - small);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function getEffectiveConfig(node, msg) {
|
|
60
|
+
const res = node.context().get("config");
|
|
61
|
+
const isConfigMsg = !!msg?.payload?.config;
|
|
62
|
+
if (isConfigMsg) {
|
|
63
|
+
const inputConfig = msg.payload.config;
|
|
64
|
+
Object.keys(inputConfig).forEach((key) => {
|
|
65
|
+
res[key] = inputConfig[key];
|
|
66
|
+
});
|
|
67
|
+
node.context().set("config", res);
|
|
68
|
+
}
|
|
69
|
+
return res;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function loadDayData(node, date) {
|
|
73
|
+
// Load saved schedule for the date (YYYY-MM-DD)
|
|
74
|
+
// Return null if not found
|
|
75
|
+
const key = date.toISODate();
|
|
76
|
+
const saved = node.context().get(key);
|
|
77
|
+
const res = saved ?? {
|
|
78
|
+
schedule: [],
|
|
79
|
+
hours: [],
|
|
80
|
+
};
|
|
81
|
+
return res;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function roundPrice(value) {
|
|
85
|
+
return Math.round(value * 10000) / 10000;
|
|
86
|
+
}
|
|
87
|
+
|
|
105
88
|
/**
|
|
106
89
|
*
|
|
107
90
|
* @param {*} values Array of prices
|
|
@@ -111,9 +94,7 @@ function getDiffToNextOn(values, onOff, nextOn = null) {
|
|
|
111
94
|
* @returns Array with how much you save on the off-hours, null on the others.
|
|
112
95
|
*/
|
|
113
96
|
function getSavings(values, onOff, nextOn = null) {
|
|
114
|
-
return getDiffToNextOn(values, onOff, nextOn).map((v, i) =>
|
|
115
|
-
onOff[i] ? null : v
|
|
116
|
-
);
|
|
97
|
+
return getDiffToNextOn(values, onOff, nextOn).map((v, i) => (onOff[i] ? null : v));
|
|
117
98
|
}
|
|
118
99
|
|
|
119
100
|
/**
|
|
@@ -126,42 +107,6 @@ function firstOn(values, onOff, defaultValue = 0) {
|
|
|
126
107
|
return [...values, defaultValue][[...onOff, true].findIndex((e) => e)];
|
|
127
108
|
}
|
|
128
109
|
|
|
129
|
-
/**
|
|
130
|
-
* Takes an array of true/false values where true means on and false means off.
|
|
131
|
-
* Evaluates of the on/off sequences are valid according to other arguments.
|
|
132
|
-
*
|
|
133
|
-
* @param {*} onOff Array of on/off values
|
|
134
|
-
* @param {*} maxOff Max number of values that can be off in a sequence
|
|
135
|
-
* @param {*} minOnAfterOff Min number of values that must be on after maxOff is reached
|
|
136
|
-
* @returns
|
|
137
|
-
*/
|
|
138
|
-
function isOnOffSequencesOk(onOff, maxOff, minOnAfterOff) {
|
|
139
|
-
let offCount = 0;
|
|
140
|
-
let onCount = 0;
|
|
141
|
-
let reachedMaxOff = false;
|
|
142
|
-
for (let i = 0; i < onOff.length; i++) {
|
|
143
|
-
if (!onOff[i]) {
|
|
144
|
-
if (maxOff === 0 || reachedMaxOff) {
|
|
145
|
-
return false;
|
|
146
|
-
}
|
|
147
|
-
offCount++;
|
|
148
|
-
onCount = 0;
|
|
149
|
-
if (offCount >= maxOff) {
|
|
150
|
-
reachedMaxOff = true;
|
|
151
|
-
}
|
|
152
|
-
} else {
|
|
153
|
-
if (reachedMaxOff) {
|
|
154
|
-
onCount++;
|
|
155
|
-
if (onCount >= minOnAfterOff) {
|
|
156
|
-
reachedMaxOff = false;
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
offCount = 0;
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
return true;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
110
|
/**
|
|
166
111
|
* Count number of the given value at the end of the given array
|
|
167
112
|
* @param {*} arr
|
|
@@ -219,21 +164,31 @@ function extractPlanForDate(plan, day) {
|
|
|
219
164
|
}
|
|
220
165
|
|
|
221
166
|
function isSameDate(date1, date2) {
|
|
222
|
-
return (
|
|
223
|
-
|
|
224
|
-
|
|
167
|
+
return DateTime.fromISO(date1).toISODate() === DateTime.fromISO(date2).toISODate();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function getStartAtIndex(effectiveConfig, priceData, time) {
|
|
171
|
+
if (effectiveConfig.scheduleOnlyFromCurrentTime) {
|
|
172
|
+
return priceData.map((p) => DateTime.fromISO(p.start)).filter((t) => t < time).length;
|
|
173
|
+
} else {
|
|
174
|
+
return 0;
|
|
175
|
+
}
|
|
225
176
|
}
|
|
226
177
|
|
|
227
178
|
module.exports = {
|
|
228
|
-
|
|
229
|
-
getDiffToNextOn,
|
|
230
|
-
firstOn,
|
|
231
|
-
isOnOffSequencesOk,
|
|
232
|
-
getSavings,
|
|
179
|
+
booleanConfig,
|
|
233
180
|
countAtEnd,
|
|
234
|
-
makeSchedule,
|
|
235
|
-
fillArray,
|
|
236
|
-
convertMsg,
|
|
237
181
|
extractPlanForDate,
|
|
182
|
+
fillArray,
|
|
183
|
+
firstOn,
|
|
184
|
+
getDiff,
|
|
185
|
+
getDiffToNextOn,
|
|
186
|
+
getEffectiveConfig,
|
|
187
|
+
getSavings,
|
|
188
|
+
getStartAtIndex,
|
|
238
189
|
isSameDate,
|
|
190
|
+
loadDayData,
|
|
191
|
+
makeSchedule,
|
|
192
|
+
roundPrice,
|
|
193
|
+
sortedIndex,
|
|
239
194
|
};
|