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.
Files changed (177) hide show
  1. package/.github/FUNDING.yml +12 -0
  2. package/CHANGELOG.md +1 -37
  3. package/README.md +3 -191
  4. package/docs/.vuepress/config.js +67 -0
  5. package/docs/.vuepress/dist/404.html +15 -0
  6. package/docs/.vuepress/dist/assets/css/styles.e835bef6.css +8 -0
  7. package/docs/.vuepress/dist/assets/img/back-to-top.8b37f773.svg +1 -0
  8. package/docs/.vuepress/dist/assets/img/elvia-config-no-config.b4bb972c.png +0 -0
  9. package/docs/.vuepress/dist/assets/img/elvia-config-no-tariff.3f89aba8.png +0 -0
  10. package/docs/.vuepress/dist/assets/img/elvia-config-select-tariff.0f73fd56.png +0 -0
  11. package/docs/.vuepress/dist/assets/img/elvia-config-subscription-key.8be8ab8a.png +0 -0
  12. package/docs/.vuepress/dist/assets/img/elvia-flow.bae2a4d5.png +0 -0
  13. package/docs/.vuepress/dist/assets/img/example-flow-1.3ff3e23f.png +0 -0
  14. package/docs/.vuepress/dist/assets/img/example-flow-2.b653b58d.png +0 -0
  15. package/docs/.vuepress/dist/assets/img/migrate-best-save.f73420f6.png +0 -0
  16. package/docs/.vuepress/dist/assets/img/migrate-power-saver.aae13f9d.png +0 -0
  17. package/docs/.vuepress/dist/assets/img/node-power-saver.51ff2e5d.png +0 -0
  18. package/docs/.vuepress/dist/assets/img/node-ps-elvia-add-tariff.94ea2b09.png +0 -0
  19. package/docs/.vuepress/dist/assets/img/node-ps-receive-price.76eaa418.png +0 -0
  20. package/docs/.vuepress/dist/assets/img/node-ps-strategy-best-save.392292d5.png +0 -0
  21. package/docs/.vuepress/dist/assets/img/node-ps-strategy-lowest-price.3a4ad347.png +0 -0
  22. package/docs/.vuepress/dist/assets/img/power-saver-nordpool-current-state.bf14afde.png +0 -0
  23. package/docs/.vuepress/dist/assets/img/power-saver-nordpool-events-state.8c392507.png +0 -0
  24. package/docs/.vuepress/dist/assets/img/power-saver-tibber-mqtt.16891dd2.png +0 -0
  25. package/docs/.vuepress/dist/assets/js/293.5e967839.js +1 -0
  26. package/docs/.vuepress/dist/assets/js/491.c183eba3.js +1 -0
  27. package/docs/.vuepress/dist/assets/js/812.79dad458.js +2 -0
  28. package/docs/.vuepress/dist/assets/js/812.79dad458.js.LICENSE.txt +8 -0
  29. package/docs/.vuepress/dist/assets/js/app.4ee3384b.js +1 -0
  30. package/docs/.vuepress/dist/assets/js/runtime~app.cafd6537.js +1 -0
  31. package/docs/.vuepress/dist/assets/js/v-08683c60.07fe8291.js +1 -0
  32. package/docs/.vuepress/dist/assets/js/v-0aca7ba6.aec5ba75.js +1 -0
  33. package/docs/.vuepress/dist/assets/js/v-0b5e3c8c.d008d8bc.js +1 -0
  34. package/docs/.vuepress/dist/assets/js/v-1ad821fa.85407071.js +1 -0
  35. package/docs/.vuepress/dist/assets/js/v-30acb564.73b8e29f.js +1 -0
  36. package/docs/.vuepress/dist/assets/js/v-3706649a.d7f73384.js +1 -0
  37. package/docs/.vuepress/dist/assets/js/v-4637f9e4.22ab9413.js +1 -0
  38. package/docs/.vuepress/dist/assets/js/v-510ed0d4.204a09ec.js +1 -0
  39. package/docs/.vuepress/dist/assets/js/v-5954bcb2.be07962c.js +1 -0
  40. package/docs/.vuepress/dist/assets/js/v-5db8da3a.ac192f35.js +1 -0
  41. package/docs/.vuepress/dist/assets/js/v-61f728ca.802ab15e.js +1 -0
  42. package/docs/.vuepress/dist/assets/js/v-677dfaed.9bbbd037.js +1 -0
  43. package/docs/.vuepress/dist/assets/js/v-7c87f26e.457a1a60.js +1 -0
  44. package/docs/.vuepress/dist/assets/js/v-8daa1a0e.db8b59c6.js +1 -0
  45. package/docs/.vuepress/dist/assets/js/v-b4a42144.6e0c5aa0.js +1 -0
  46. package/docs/.vuepress/dist/assets/js/v-e8c55052.5f85b6cd.js +1 -0
  47. package/docs/.vuepress/dist/assets/js/v-fffb8e28.e815e852.js +1 -0
  48. package/docs/.vuepress/dist/changelog/index.html +15 -0
  49. package/docs/.vuepress/dist/contribute/index.html +15 -0
  50. package/docs/.vuepress/dist/euro.png +0 -0
  51. package/docs/.vuepress/dist/examples/example-nordpool-current-state.html +169 -0
  52. package/docs/.vuepress/dist/examples/example-nordpool-events-state.html +173 -0
  53. package/docs/.vuepress/dist/examples/example-tibber-mqtt.html +182 -0
  54. package/docs/.vuepress/dist/examples/index.html +15 -0
  55. package/docs/.vuepress/dist/guide/index.html +52 -0
  56. package/docs/.vuepress/dist/index.html +15 -0
  57. package/docs/.vuepress/dist/logo.png +0 -0
  58. package/docs/.vuepress/dist/nodes/index.html +15 -0
  59. package/docs/.vuepress/dist/nodes/old-power-saver-doc.html +97 -0
  60. package/docs/.vuepress/dist/nodes/power-saver.html +15 -0
  61. package/docs/.vuepress/dist/nodes/ps-elvia-add-tariff.html +15 -0
  62. package/docs/.vuepress/dist/nodes/ps-receive-price.html +80 -0
  63. package/docs/.vuepress/dist/nodes/ps-strategy-best-save.html +65 -0
  64. package/docs/.vuepress/dist/nodes/ps-strategy-lowest-price.html +89 -0
  65. package/docs/.vuepress/dist/nodes/strategy-input.html +40 -0
  66. package/docs/.vuepress/public/euro.png +0 -0
  67. package/docs/.vuepress/public/logo.png +0 -0
  68. package/docs/README.md +32 -0
  69. package/docs/changelog/README.md +65 -0
  70. package/docs/contribute/README.md +39 -0
  71. package/docs/examples/README.md +5 -0
  72. package/docs/examples/example-nordpool-current-state.md +166 -0
  73. package/docs/examples/example-nordpool-events-state.md +170 -0
  74. package/docs/examples/example-tibber-mqtt.md +179 -0
  75. package/docs/guide/README.md +202 -0
  76. package/docs/images/all-nodes.png +0 -0
  77. package/docs/images/best-save-config.png +0 -0
  78. package/docs/images/elvia-add-tariff-node-used.png +0 -0
  79. package/docs/images/elvia-config-no-config.png +0 -0
  80. package/docs/images/elvia-config-no-tariff.png +0 -0
  81. package/docs/images/elvia-config-select-tariff.png +0 -0
  82. package/docs/images/elvia-config-subscription-key.png +0 -0
  83. package/docs/images/elvia-flow.png +0 -0
  84. package/docs/images/elvia-tariff-config.png +0 -0
  85. package/docs/images/euro.png +0 -0
  86. package/docs/images/example-flow-1.png +0 -0
  87. package/docs/images/example-flow-2.png +0 -0
  88. package/docs/images/logo.png +0 -0
  89. package/docs/images/lowest-price-config.png +0 -0
  90. package/docs/images/migrate-best-save.png +0 -0
  91. package/docs/images/migrate-power-saver.png +0 -0
  92. package/docs/images/node-power-saver.png +0 -0
  93. package/docs/images/node-ps-elvia-add-tariff.png +0 -0
  94. package/docs/images/node-ps-elvia-tariff-types.png +0 -0
  95. package/docs/images/node-ps-elvia-tariff.png +0 -0
  96. package/docs/images/node-ps-receive-price.png +0 -0
  97. package/docs/images/node-ps-strategy-best-save.png +0 -0
  98. package/docs/images/node-ps-strategy-lowest-price.png +0 -0
  99. package/{doc → docs/images}/node-red-contrib-power-saver-flow.png +0 -0
  100. package/docs/images/power-saver-nordpool-current-state.png +0 -0
  101. package/docs/images/power-saver-nordpool-events-state.png +0 -0
  102. package/docs/images/power-saver-tibber-mqtt.png +0 -0
  103. package/docs/nodes/README.md +53 -0
  104. package/docs/nodes/old-power-saver-doc.md +231 -0
  105. package/docs/nodes/power-saver.md +23 -0
  106. package/docs/nodes/ps-elvia-add-tariff.md +52 -0
  107. package/docs/nodes/ps-receive-price.md +153 -0
  108. package/docs/nodes/ps-strategy-best-save.md +142 -0
  109. package/docs/nodes/ps-strategy-lowest-price.md +165 -0
  110. package/docs/nodes/strategy-input.md +39 -0
  111. package/package.json +19 -4
  112. package/src/elvia/elvia-add-tariff.html +70 -0
  113. package/src/elvia/elvia-add-tariff.js +47 -0
  114. package/src/elvia/elvia-api.js +61 -0
  115. package/src/elvia/elvia-config.html +46 -0
  116. package/src/elvia/elvia-config.js +19 -0
  117. package/src/elvia/elvia-tariff-types.html +34 -0
  118. package/src/elvia/elvia-tariff-types.js +25 -0
  119. package/src/elvia/elvia-tariff.html +89 -0
  120. package/src/elvia/elvia-tariff.js +22 -0
  121. package/src/elvia/icons/elvia_hvite.svg +4 -0
  122. package/src/elvia/icons/elvia_positive_4 copy.svg +4 -0
  123. package/src/handle-input.js +162 -0
  124. package/src/power-saver.html +116 -0
  125. package/{power-saver.js → src/power-saver.js} +90 -72
  126. package/src/receive-price-functions.js +99 -0
  127. package/src/receive-price.html +30 -0
  128. package/src/receive-price.js +21 -0
  129. package/src/strategy-best-save-functions.js +110 -0
  130. package/src/strategy-best-save.html +116 -0
  131. package/src/strategy-best-save.js +95 -0
  132. package/src/strategy-lowest-price-functions.js +35 -0
  133. package/src/strategy-lowest-price.html +168 -0
  134. package/src/strategy-lowest-price.js +125 -0
  135. package/{utils.js → src/utils.js} +59 -104
  136. package/test/data/adjustedResult.js +302 -0
  137. package/test/data/adjustedResult_old.js +154 -0
  138. package/test/data/best-save-result.json +357 -0
  139. package/test/data/converted-prices.json +196 -0
  140. package/test/data/elvia-input-grid-tariff.json +760 -0
  141. package/test/data/elvia-input-power-prices.json +194 -0
  142. package/test/data/elvia-output-add-tariff.json +290 -0
  143. package/test/data/lowest-price-result-cont.json +18 -0
  144. package/test/data/lowest-price-result-split-allday.json +21 -0
  145. package/test/data/lowest-price-result-split-allday10.json +20 -0
  146. package/test/data/lowest-price-result-split.json +20 -0
  147. package/test/data/nordpool-current-state-prices.json +283 -0
  148. package/test/data/nordpool-event-prices.json +574 -0
  149. package/test/data/reconfigResult.js +315 -0
  150. package/test/data/reconfigResult_old.js +141 -0
  151. package/test/data/result.js +1 -0
  152. package/test/data/tibber-prices-single-home.json +64 -0
  153. package/test/data/tibber-prices.json +124 -0
  154. package/test/data/{tibber_result.json → tibber-result.json} +2 -1
  155. package/test/elvia.test.js +26 -0
  156. package/test/mostSavedStrategy.test.js +22 -55
  157. package/test/power-saver.test.js +4 -38
  158. package/test/receive-price-functions.test.js +153 -0
  159. package/test/receive-price.test.js +122 -0
  160. package/test/send-config-input.test.js +121 -0
  161. package/test/strategy-best-save-test-utils.js +32 -0
  162. package/test/strategy-best-save.test.js +103 -0
  163. package/test/strategy-lowest-price-functions.test.js +40 -0
  164. package/test/strategy-lowest-price.test.js +472 -0
  165. package/test/test-utils.js +106 -0
  166. package/test/utils.test.js +53 -163
  167. package/doc/example-nordpool-current-state.md +0 -166
  168. package/doc/example-nordpool-events-state.md +0 -153
  169. package/doc/example-tibber-mqtt.md +0 -189
  170. package/doc/power-saver-nordpool-current-state.png +0 -0
  171. package/doc/power-saver-nordpool-events-state.png +0 -0
  172. package/doc/power-saver-tibber-mqtt.png +0 -0
  173. package/mostSavedStrategy.js +0 -84
  174. package/mostSavedStrategy_v2.js +0 -68
  175. package/power-saver.html +0 -259
  176. package/test/data/tibber_data.json +0 -412
  177. 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
- * Get today and tomorrow data out of the input message.
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 Math.round((values[i] - values[n]) * 10000) / 10000;
47
+ return getDiff(values[i], values[n]);
98
48
  }
99
49
  }
100
- return Math.round((p - nextOnValue) * 10000) / 10000;
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
- DateTime.fromISO(date1).toISODate() === DateTime.fromISO(date2).toISODate()
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
- sortedIndex,
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
  };