node-red-contrib-power-saver 4.1.2 → 4.1.4
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/CHANGELOG.md +1 -1
- package/docs/.vuepress/client.js +7 -0
- package/docs/.vuepress/clientAppEnhance.js +1 -1
- package/docs/.vuepress/components/AdsenseAdd.vue +7 -1
- package/docs/.vuepress/config.js +31 -30
- package/docs/README.md +6 -0
- package/docs/changelog/README.md +8 -0
- package/docs/contribute/README.md +1 -1
- package/docs/examples/example-grid-tariff-capacity-part.md +35 -7
- package/docs/guide/README.md +5 -1
- package/docs/privacy.md +17 -0
- package/examples/example-grid-tariff-capacity-flow.json +4 -4
- package/package.json +19 -18
- package/src/handle-output.js +1 -1
- package/src/schedule-merger-functions.js +4 -3
- package/src/schedule-merger.js +1 -1
- package/src/strategy-functions.js +1 -1
- package/src/strategy-heat-capacitor-functions.js +38 -4
- package/src/strategy-heat-capacitor.html +2 -2
- package/src/strategy-heat-capacitor.js +94 -63
- package/test/commands-input-best-save.test.js +15 -15
- package/test/commands-input-lowest-price.test.js +15 -15
- package/test/commands-input-schedule-merger.test.js +11 -11
- package/test/data/heat-capacitor-prices-NaN-test.json +197 -0
- package/test/elvia.test.js +2 -2
- package/test/general-add-tariff-functions.test.js +6 -6
- package/test/general-add-tariff.test.js +7 -7
- package/test/mostSavedStrategy.test.js +17 -17
- package/test/receive-price-functions.test.js +9 -9
- package/test/receive-price.test.js +11 -11
- package/test/schedule-merger-functions.test.js +18 -28
- package/test/schedule-merger.test.js +17 -17
- package/test/send-config-input.test.js +13 -13
- package/test/strategy-best-save-overlap.test.js +2 -2
- package/test/strategy-best-save.test.js +28 -28
- package/test/strategy-fixed-schedule.test.js +5 -5
- package/test/strategy-heat-capacitor-node.test.js +115 -16
- package/test/strategy-heat-capacitor.test.js +8 -6
- package/test/strategy-lowest-price-3days.test.js +34 -3
- package/test/strategy-lowest-price-functions.test.js +13 -13
- package/test/strategy-lowest-price.test.js +34 -34
- package/test/utils.test.js +46 -45
package/CHANGELOG.md
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
[The changelog has moved here](
|
|
1
|
+
[The changelog has moved here](https://powersaver.no/changelog/#change-log)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div>
|
|
3
3
|
<ins
|
|
4
|
-
v-if="øverst"
|
|
4
|
+
v-if="type === 'øverst'"
|
|
5
5
|
class="adsbygoogle"
|
|
6
6
|
style="display: inline-block; width: 740px; height: 90px"
|
|
7
7
|
data-ad-client="ca-pub-9857859182772006"
|
|
@@ -38,3 +38,9 @@ onMounted(() => {
|
|
|
38
38
|
(adsbygoogle = window.adsbygoogle || []).push({});
|
|
39
39
|
});
|
|
40
40
|
</script>
|
|
41
|
+
|
|
42
|
+
<style scoped>
|
|
43
|
+
ins {
|
|
44
|
+
margin-bottom: 30px;
|
|
45
|
+
}
|
|
46
|
+
</style>
|
package/docs/.vuepress/config.js
CHANGED
|
@@ -1,12 +1,34 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
import navbar from "./navbar";
|
|
2
|
+
import { path } from "@vuepress/utils";
|
|
3
|
+
import { registerComponentsPlugin } from "@vuepress/plugin-register-components";
|
|
4
|
+
import { searchPlugin } from "@vuepress/plugin-search";
|
|
5
|
+
import { googleAnalyticsPlugin } from "@vuepress/plugin-google-analytics";
|
|
3
6
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
description: "A Node-RED node collection to save money on hourly changing power prices",
|
|
7
|
+
import { defaultTheme, defineUserConfig } from "vuepress";
|
|
8
|
+
|
|
9
|
+
export default defineUserConfig({
|
|
8
10
|
base: "/",
|
|
9
|
-
|
|
11
|
+
description: "A Node-RED node collection to save money on hourly changing power prices",
|
|
12
|
+
head: [
|
|
13
|
+
["link", { rel: "shortcut icon", type: "image/x-icon", href: "euro.png" }],
|
|
14
|
+
[
|
|
15
|
+
"script",
|
|
16
|
+
{
|
|
17
|
+
async: true,
|
|
18
|
+
crossorigin: "anonymous",
|
|
19
|
+
src: "https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-9857859182772006",
|
|
20
|
+
},
|
|
21
|
+
],
|
|
22
|
+
],
|
|
23
|
+
lang: "en-US",
|
|
24
|
+
plugins: [
|
|
25
|
+
registerComponentsPlugin({ componentsDir: path.resolve(__dirname, "./components") }),
|
|
26
|
+
searchPlugin({}),
|
|
27
|
+
googleAnalyticsPlugin({
|
|
28
|
+
id: "G-Z2QNNCDQZG",
|
|
29
|
+
}),
|
|
30
|
+
],
|
|
31
|
+
theme: defaultTheme({
|
|
10
32
|
contributors: false,
|
|
11
33
|
logo: "/Ukraine-heart-shape-flag.png",
|
|
12
34
|
navbar,
|
|
@@ -55,26 +77,5 @@ module.exports = {
|
|
|
55
77
|
"/contribute/": [{ text: "Contribute", children: ["/contribute/README.md"] }],
|
|
56
78
|
"/changelog/": [{ text: "Changelog", children: ["/changelog/README.md"] }],
|
|
57
79
|
},
|
|
58
|
-
},
|
|
59
|
-
|
|
60
|
-
["link", { rel: "shortcut icon", type: "image/x-icon", href: "euro.png" }],
|
|
61
|
-
[
|
|
62
|
-
"script",
|
|
63
|
-
{
|
|
64
|
-
async: true,
|
|
65
|
-
crossorigin: "anonymous",
|
|
66
|
-
src: "https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-9857859182772006",
|
|
67
|
-
},
|
|
68
|
-
],
|
|
69
|
-
],
|
|
70
|
-
plugins: [
|
|
71
|
-
[
|
|
72
|
-
"@vuepress/register-components",
|
|
73
|
-
{
|
|
74
|
-
componentsDir: path.resolve(__dirname, "./components"),
|
|
75
|
-
},
|
|
76
|
-
],
|
|
77
|
-
["@vuepress/plugin-search"],
|
|
78
|
-
["@vuepress/google-analytics", { id: "G-Z2QNNCDQZG" }],
|
|
79
|
-
],
|
|
80
|
-
};
|
|
80
|
+
}),
|
|
81
|
+
});
|
package/docs/README.md
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
home: true
|
|
3
3
|
heroImage: /logo.png
|
|
4
|
+
heroHeight: 112
|
|
4
5
|
heroAlt: Power Saver
|
|
5
6
|
heroText: node-red-contrib-power-saver
|
|
6
7
|
tagline: A collection of nodes to Node-RED that automates saving money on variable electricity prices
|
|
@@ -36,4 +37,9 @@ footerHtml: true
|
|
|
36
37
|
|
|
37
38
|
This is a collection of nodes for the popular [Node-RED](https://nodered.org/) that you can use to save money on variable electricity prices. Node-RED is a widely used low-code programming tool that can be used together with many smart home solutions to create automations.
|
|
38
39
|
|
|
40
|
+
Please remember to take a look at our [privacy rules](./privacy.md).
|
|
41
|
+
<br/>
|
|
42
|
+
<br/>
|
|
43
|
+
<br/>
|
|
44
|
+
|
|
39
45
|
<DonateButtons/>
|
package/docs/changelog/README.md
CHANGED
|
@@ -7,6 +7,14 @@ sidebarDepth: 1
|
|
|
7
7
|
|
|
8
8
|
List the most significant changes.
|
|
9
9
|
|
|
10
|
+
## 4.1.4
|
|
11
|
+
|
|
12
|
+
- Update dependencies.
|
|
13
|
+
|
|
14
|
+
## 4.1.3
|
|
15
|
+
|
|
16
|
+
- Fix bug that saved some data in wrong context storage.
|
|
17
|
+
|
|
10
18
|
## 4.1.2
|
|
11
19
|
|
|
12
20
|
- Fix so configured values for output are sent, not only true/false.
|
|
@@ -49,7 +49,7 @@ Main developer: [Otto Paulsen](https://github.com/ottopaulsen)
|
|
|
49
49
|
|
|
50
50
|
Heat Capacitor developer: [Arne Klaveness](https://github.com/TomTorger)
|
|
51
51
|
|
|
52
|
-
Example contributors: [Stefan](https://github.com/oakhill87/node-red-contrib-power-saver)
|
|
52
|
+
Example contributors: [Stefan](https://github.com/oakhill87/node-red-contrib-power-saver), [Kim Storøy](https://www.facebook.com/kim.storoy)
|
|
53
53
|
|
|
54
54
|
###
|
|
55
55
|
|
|
@@ -426,6 +426,8 @@ context.set("buffer", []);
|
|
|
426
426
|
```js
|
|
427
427
|
// Number of minutes used to calculate assumed consumption:
|
|
428
428
|
const ESTIMATION_TIME_MINUTES = 1;
|
|
429
|
+
// Allows records to deviate from maxAgeMs
|
|
430
|
+
const DELAY_TIME_MS_ALLOWED = 3 * 1000;
|
|
429
431
|
|
|
430
432
|
const buffer = context.get("buffer") || [];
|
|
431
433
|
|
|
@@ -441,7 +443,7 @@ currentHour.setMinutes(0);
|
|
|
441
443
|
currentHour.setSeconds(0);
|
|
442
444
|
|
|
443
445
|
// Remove too old records from buffer
|
|
444
|
-
const maxAgeMs = ESTIMATION_TIME_MINUTES * 60 * 1000;
|
|
446
|
+
const maxAgeMs = (ESTIMATION_TIME_MINUTES * 60 * 1000) + DELAY_TIME_MS_ALLOWED;
|
|
445
447
|
let oldest = buffer[0];
|
|
446
448
|
while (timeMs - oldest.timeMs > maxAgeMs) {
|
|
447
449
|
buffer.splice(0, 1);
|
|
@@ -451,10 +453,16 @@ context.set("buffer", buffer);
|
|
|
451
453
|
|
|
452
454
|
// Calculate buffer
|
|
453
455
|
const periodMs = buffer[buffer.length - 1].timeMs - buffer[0].timeMs;
|
|
454
|
-
|
|
456
|
+
let consumptionInPeriod = buffer[buffer.length - 1].accumulatedConsumption - buffer[0].accumulatedConsumption;
|
|
457
|
+
if (consumptionInPeriod < 0) {
|
|
458
|
+
consumptionInPeriod = 0;
|
|
459
|
+
}
|
|
455
460
|
if (periodMs === 0) {
|
|
456
|
-
|
|
461
|
+
//Should only occur during startup
|
|
462
|
+
node.status({ fill: "red", shape: "dot", text: "First item in buffer" });
|
|
463
|
+
return; // Stopping rest of the flow for this message
|
|
457
464
|
}
|
|
465
|
+
node.status({ fill: "green", shape: "dot", text: "Working" });
|
|
458
466
|
|
|
459
467
|
// Estimate remaining of current hour
|
|
460
468
|
const timeLeftMs = 60 * 60 * 1000 - (time.getMinutes() * 60000 + time.getSeconds() * 1000 + time.getMilliseconds());
|
|
@@ -546,6 +554,7 @@ const MAX_COUNTING = 3; // Number of days to calculate month average of
|
|
|
546
554
|
const BUFFER = 0.5; // kWh - Closer to limit increases alarm level
|
|
547
555
|
const SAFE_SONE = 2; // kWh - Further from limit reduces level
|
|
548
556
|
const ALARM = 8; // Min level that causes status to be alarm
|
|
557
|
+
const MIN_TIMELEFT = 30; //Min level for time left (30 seconds)
|
|
549
558
|
```
|
|
550
559
|
|
|
551
560
|
The `HA_NAME` must be set to the name you have given your Home Assistant. One place to find this is in Node-RED,
|
|
@@ -572,9 +581,11 @@ const MAX_COUNTING = 3; // Number of days to calculate month
|
|
|
572
581
|
const BUFFER = 0.5; // Closer to limit increases level
|
|
573
582
|
const SAFE_ZONE = 2; // Further from limit reduces level
|
|
574
583
|
const ALARM = 8; // Min level that causes status to be alarm
|
|
584
|
+
const MIN_TIMELEFT = 3 * 60; //Min level for time left
|
|
575
585
|
|
|
576
586
|
const ha = global.get("homeassistant")[HA_NAME];
|
|
577
587
|
if (!ha.isConnected) {
|
|
588
|
+
node.status({ fill: "red", shape: "dot", text: "Ha not connected" });
|
|
578
589
|
return;
|
|
579
590
|
}
|
|
580
591
|
|
|
@@ -640,6 +651,7 @@ const averageConsumptionNow = msg.payload.averageConsumptionNow;
|
|
|
640
651
|
const currentHour = msg.payload.currentHour;
|
|
641
652
|
|
|
642
653
|
if (timeLeftSec === 0) {
|
|
654
|
+
node.status({ fill: "red", shape: "dot", text: "Time Left 0" });
|
|
643
655
|
return null;
|
|
644
656
|
}
|
|
645
657
|
|
|
@@ -687,17 +699,19 @@ const alarmLevel = calculateLevel(hourEstimate, currentHourRanking, currentMonth
|
|
|
687
699
|
// Evaluate status
|
|
688
700
|
const status = alarmLevel >= ALARM ? "Alarm" : alarmLevel > 0 ? "Warning" : "Ok";
|
|
689
701
|
|
|
702
|
+
// Avoid calculations to increase too much when timeLeftSec is approaching zero
|
|
703
|
+
const minTimeLeftSec = Math.max(timeLeftSec, MIN_TIMELEFT);
|
|
690
704
|
// Calculate reduction
|
|
691
705
|
const reductionRequired =
|
|
692
706
|
alarmLevel < ALARM
|
|
693
707
|
? 0
|
|
694
|
-
: (Math.max((currentMonthlyEstimate - currentStep) * highestCounting.length, 0) * 3600) /
|
|
708
|
+
: (Math.max((currentMonthlyEstimate - currentStep) * highestCounting.length, 0) * 3600) / minTimeLeftSec;
|
|
695
709
|
const reductionRecommended =
|
|
696
|
-
alarmLevel < 3 ? 0 : (Math.max(hourEstimate + SAFE_ZONE - currentStep, 0) * 3600) /
|
|
710
|
+
alarmLevel < 3 ? 0 : (Math.max(hourEstimate + SAFE_ZONE - currentStep, 0) * 3600) / minTimeLeftSec;
|
|
697
711
|
|
|
698
712
|
// Calculate increase possible
|
|
699
713
|
const increasePossible =
|
|
700
|
-
alarmLevel >= 3 ? 0 : (Math.max(currentStep - hourEstimate - SAFE_ZONE, 0) * 3600) /
|
|
714
|
+
alarmLevel >= 3 ? 0 : (Math.max(currentStep - hourEstimate - SAFE_ZONE, 0) * 3600) / minTimeLeftSec;
|
|
701
715
|
|
|
702
716
|
// Create output
|
|
703
717
|
const fill = status === "Ok" ? "green" : status === "Alarm" ? "red" : "yellow";
|
|
@@ -895,6 +909,8 @@ If you don't want the actions, or you want to control actions another way,
|
|
|
895
909
|
you can omit the action-related nodes and only use the nodes creating the sensors.
|
|
896
910
|
:::
|
|
897
911
|
|
|
912
|
+
The `MIN_MINUTES_INTO_HOUR_TO_TAKE_ACTION` constant in the `On Message` code sets a period (of default 5 minutes) in the beginning of the hour when no reduction action is taken. This is to avoid that a high consumption at the end of the previous hour causes reduction actions to be taken as soon as the hour changes.
|
|
913
|
+
|
|
898
914
|
::: details Code
|
|
899
915
|
|
|
900
916
|
<CodeGroup>
|
|
@@ -1008,6 +1024,7 @@ flow.set("actions", actions);
|
|
|
1008
1024
|
|
|
1009
1025
|
```js
|
|
1010
1026
|
const MIN_CONSUMPTION_TO_CARE = 0.05; // Do not reduce unless at least 50W
|
|
1027
|
+
const MIN_MINUTES_INTO_HOUR_TO_TAKE_ACTION = 5;
|
|
1011
1028
|
|
|
1012
1029
|
const actions = flow.get("actions");
|
|
1013
1030
|
const ha = global.get("homeassistant").homeAssistant;
|
|
@@ -1015,10 +1032,21 @@ const ha = global.get("homeassistant").homeAssistant;
|
|
|
1015
1032
|
let reductionRequired = msg.payload.reductionRequired;
|
|
1016
1033
|
let reductionRecommended = msg.payload.reductionRecommended;
|
|
1017
1034
|
|
|
1035
|
+
node.status({});
|
|
1036
|
+
|
|
1018
1037
|
if (reductionRecommended <= 0) {
|
|
1019
1038
|
return null;
|
|
1020
1039
|
}
|
|
1021
1040
|
|
|
1041
|
+
if (3600 - msg.payload.timeLeftSec < MIN_MINUTES_INTO_HOUR_TO_TAKE_ACTION * 60) {
|
|
1042
|
+
node.status({
|
|
1043
|
+
fill: "yellow",
|
|
1044
|
+
shape: "ring",
|
|
1045
|
+
text: "No action during first " + MIN_MINUTES_INTO_HOUR_TO_TAKE_ACTION + " minutes",
|
|
1046
|
+
});
|
|
1047
|
+
return;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1022
1050
|
function takeAction(action, consumption) {
|
|
1023
1051
|
const info = {
|
|
1024
1052
|
time: new Date().toISOString(),
|
|
@@ -1105,7 +1133,7 @@ function resetAction(action) {
|
|
|
1105
1133
|
const info = {
|
|
1106
1134
|
time: new Date().toISOString(),
|
|
1107
1135
|
name: "Reset action",
|
|
1108
|
-
data: msg.
|
|
1136
|
+
data: msg.payload,
|
|
1109
1137
|
action,
|
|
1110
1138
|
};
|
|
1111
1139
|
const output1 = action.payloadToResetAction ? { payload: action.payloadToResetAction } : null;
|
package/docs/guide/README.md
CHANGED
|
@@ -239,4 +239,8 @@ You may directly replace the `Power Saver` node by two of the new nodes (`ps-rec
|
|
|
239
239
|
|
|
240
240
|
See more details in the [documentation for the `ps-strategy-best-save`](../nodes/ps-strategy-best-save.md) node.
|
|
241
241
|
|
|
242
|
-
|
|
242
|
+
## Disclaimer
|
|
243
|
+
|
|
244
|
+
This software is offered for free as open source. You use it totally on your own risk. The developers take no responsibility of any consequences caused by use or misuse of this software.
|
|
245
|
+
|
|
246
|
+
It is not recommended to reduce the temperature of the water heater or similar over longer periods, due to the risk of legionella. Please read the recommendations of [FHI](https://www.fhi.no/sv/smittsomme-sykdommer/legionella/) about this. You do this at your own risk.
|
package/docs/privacy.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Privacy Policy
|
|
2
|
+
|
|
3
|
+
We use Google Adsense to display advertisements on our website.
|
|
4
|
+
|
|
5
|
+
Google Adsense collects information about your website visits, including your IP address, to display relevant advertisements.
|
|
6
|
+
|
|
7
|
+
We have no control over and are not responsible for the information collection performed by Google Adsense.
|
|
8
|
+
|
|
9
|
+
Please refer to Google's privacy policy for more information on how they handle your personal information: [policies.google.com/privacy](https://policies.google.com/privacy).
|
|
10
|
+
|
|
11
|
+
If you choose to donate, we are very happy for your contribution. Due to documentation rules we will record
|
|
12
|
+
name, email, phone, donation service and amount for all donations. Each donation service may record
|
|
13
|
+
additional information. Please refer to the privacy rules for the service you use for more information
|
|
14
|
+
|
|
15
|
+
By visiting our website, you consent to these privacy rules, the use of cookies and advertising by Google Adsense.
|
|
16
|
+
|
|
17
|
+
We will update this privacy policy if necessary. Please check regularly for updates.
|
|
@@ -82,7 +82,7 @@
|
|
|
82
82
|
"type": "function",
|
|
83
83
|
"z": "d938c47f.3398f8",
|
|
84
84
|
"name": "Collect estimate for hour",
|
|
85
|
-
"func": "\n// Number of minutes used to calculate assumed consumption:\nconst ESTIMATION_TIME_MINUTES = 1\n\nconst buffer = context.get(\"buffer\") || []\n\n// Add new record to buffer\nconst time = new Date(msg.payload.timestamp)\nconst timeMs = time.getTime()\nconst accumulatedConsumption = msg.payload.accumulatedConsumption\nconst accumulatedConsumptionLastHour = msg.payload.accumulatedConsumptionLastHour\nbuffer.push({timeMs, accumulatedConsumption})\n\nconst currentHour = new Date(msg.payload.timestamp)\ncurrentHour.setMinutes(0)\ncurrentHour.setSeconds(0)\n\n// Remove too old records from buffer\nconst maxAgeMs = ESTIMATION_TIME_MINUTES * 60 * 1000\nlet oldest = buffer[0]\nwhile ((timeMs - oldest.timeMs) > maxAgeMs) {\n buffer.splice(0, 1)\n oldest = buffer[0]\n}\ncontext.set(\"buffer\", buffer)\n\n// Calculate buffer\nconst periodMs = buffer[buffer.length - 1].timeMs - buffer[0].timeMs\
|
|
85
|
+
"func": "\n// Number of minutes used to calculate assumed consumption:\nconst ESTIMATION_TIME_MINUTES = 1\n// Allows records to deviate from maxAgeMs\nconst DELAY_TIME_MS_ALLOWED = 3 * 1000\n\nconst buffer = context.get(\"buffer\") || []\n\n// Add new record to buffer\nconst time = new Date(msg.payload.timestamp)\nconst timeMs = time.getTime()\nconst accumulatedConsumption = msg.payload.accumulatedConsumption\nconst accumulatedConsumptionLastHour = msg.payload.accumulatedConsumptionLastHour\nbuffer.push({timeMs, accumulatedConsumption})\n\nconst currentHour = new Date(msg.payload.timestamp)\ncurrentHour.setMinutes(0)\ncurrentHour.setSeconds(0)\n\n// Remove too old records from buffer\nconst maxAgeMs = (ESTIMATION_TIME_MINUTES * 60 * 1000) + DELAY_TIME_MS_ALLOWED\nlet oldest = buffer[0]\nwhile ((timeMs - oldest.timeMs) > maxAgeMs) {\n buffer.splice(0, 1)\n oldest = buffer[0]\n}\ncontext.set(\"buffer\", buffer)\n\n// Calculate buffer\nconst periodMs = buffer[buffer.length - 1].timeMs - buffer[0].timeMs\nlet consumptionInPeriod = buffer[buffer.length - 1].accumulatedConsumption - buffer[0].accumulatedConsumption\nif (consumptionInPeriod < 0) {\nconsumptionInPeriod = 0\n}\nif (periodMs === 0) {\n //Should only occur during startup\n node.status({ fill: \"red\", shape: \"dot\", text: \"First item in buffer\" })\n return // Stopping rest of the flow for this message\n}\nnode.status({ fill: \"green\", shape: \"dot\", text: \"Working\" })\n\n// Estimate remaining of current hour\nconst timeLeftMs = (60 * 60 * 1000) - (time.getMinutes() * 60000 + time.getSeconds() * 1000 + time.getMilliseconds())\nconst consumptionLeft = consumptionInPeriod / periodMs * timeLeftMs\nconst averageConsumptionNow = consumptionInPeriod / periodMs * 60 * 60 * 1000\n\n// Estimate total hour\nconst hourEstimate = accumulatedConsumptionLastHour + consumptionLeft + 0 // Change for testing\n\nmsg.payload = {\n accumulatedConsumption,\n accumulatedConsumptionLastHour,\n periodMs,\n consumptionInPeriod,\n averageConsumptionNow,\n timeLeftMs,\n consumptionLeft,\n hourEstimate,\n currentHour\n}\n\nreturn msg;",
|
|
86
86
|
"outputs": 1,
|
|
87
87
|
"noerr": 0,
|
|
88
88
|
"initialize": "// Code added here will be run once\n// whenever the node is started.\ncontext.set(\"buffer\", [])",
|
|
@@ -97,7 +97,7 @@
|
|
|
97
97
|
"type": "function",
|
|
98
98
|
"z": "d938c47f.3398f8",
|
|
99
99
|
"name": "Calculate values",
|
|
100
|
-
"func": "const HA_NAME = \"homeAssistant\"; // Your HA name\nconst STEPS = [10, 15, 20]\nconst MAX_COUNTING = 3 // Number of days to calculate month\nconst BUFFER = 0.5 // Closer to limit increases level\nconst SAFE_ZONE = 2 // Further from limit reduces level\nconst ALARM = 8 // Min level that causes status to be alarm\n\nconst ha = global.get(\"homeassistant\")[HA_NAME];\nif(!ha.isConnected) {\n
|
|
100
|
+
"func": "const HA_NAME = \"homeAssistant\"; // Your HA name\nconst STEPS = [10, 15, 20]\nconst MAX_COUNTING = 3 // Number of days to calculate month\nconst BUFFER = 0.5 // Closer to limit increases level\nconst SAFE_ZONE = 2 // Further from limit reduces level\nconst ALARM = 8 // Min level that causes status to be alarm\nconst MIN_TIMELEFT = 30 //Min level for time left (30 seconds)\n\nconst ha = global.get(\"homeassistant\")[HA_NAME];\nif (!ha.isConnected) {\n node.status({ fill: \"red\", shape: \"dot\", text: \"Ha not connected\" })\n return;\n}\n\nfunction isNull(value) {\n return value === null || value === undefined\n}\n\nfunction calculateLevel(hourEstimate,\n currentHourRanking,\n highestCountingAverageWithCurrent,\n nextStep) {\n if (currentHourRanking === 0) {\n return 0\n }\n if (highestCountingAverageWithCurrent > nextStep) {\n return 9\n }\n if (highestCountingAverageWithCurrent > (nextStep - BUFFER)) {\n return 8\n }\n if (hourEstimate > nextStep) {\n return 7\n }\n if (hourEstimate > (nextStep - BUFFER)) {\n return 6\n }\n if (currentHourRanking === 1 && (nextStep - hourEstimate) < SAFE_ZONE) {\n return 5\n }\n if (currentHourRanking === 2 && (nextStep - hourEstimate) < SAFE_ZONE) {\n return 4\n }\n if (currentHourRanking === 3 && (nextStep - hourEstimate) < SAFE_ZONE) {\n return 3\n }\n if (currentHourRanking === 1) {\n return 2\n }\n if (currentHourRanking === 2) {\n return 1\n }\n return 0\n}\n\n\nif (msg.payload.highestPerDay) {\n context.set(\"highestPerDay\", msg.payload.highestPerDay)\n context.set(\"highestCounting\", msg.payload.highestCounting)\n context.set(\"highestToday\", msg.payload.highestToday)\n context.set(\"currentMonthlyMaxAverage\", msg.payload.currentMonthlyMaxAverage)\n node.status({ fill: \"green\", shape: \"ring\", text: \"Got ranking\" });\n return\n}\n\nconst highestPerDay = context.get(\"highestPerDay\")\nconst highestCounting = context.get(\"highestCounting\")\nconst highestToday = context.get(\"highestToday\")\nconst currentMonthlyMaxAverage = context.get(\"currentMonthlyMaxAverage\")\nconst hourEstimate = msg.payload.hourEstimate\nconst timeLeftMs = msg.payload.timeLeftMs\nconst timeLeftSec = timeLeftMs / 1000\nconst periodMs = msg.payload.periodMs\nconst accumulatedConsumption = msg.payload.accumulatedConsumption\nconst accumulatedConsumptionLastHour = msg.payload.accumulatedConsumptionLastHour\nconst consumptionLeft = msg.payload.consumptionLeft\nconst averageConsumptionNow = msg.payload.averageConsumptionNow\nconst currentHour = msg.payload.currentHour\n\nif (timeLeftSec === 0) {\n node.status({ fill: \"red\", shape: \"dot\", text: \"Time Left 0\" });\n return null;\n}\n\nif (isNull(highestPerDay)) {\n node.status({ fill: \"red\", shape: \"dot\", text: \"No highest per day\" });\n return\n}\nif (isNull(highestToday)) {\n node.status({ fill: \"red\", shape: \"dot\", text: \"No highest today\" });\n return\n}\nif (isNull(hourEstimate)) {\n node.status({ fill: \"red\", shape: \"dot\", text: \"No estimate\" });\n return\n}\n\nconst currentStep = STEPS.reduceRight((prev, val) => val > currentMonthlyMaxAverage ? val : prev, STEPS[STEPS.length - 1])\n\n// Set currentHourRanking\nlet currentHourRanking = MAX_COUNTING + 1\nfor (let i = highestCounting.length - 1; i >= 0; i--) {\n if (hourEstimate > highestCounting[i].consumption) {\n currentHourRanking = i + 1\n }\n}\nif (hourEstimate < highestToday.consumption) {\n currentHourRanking = 0\n}\n\nconst current = { from: currentHour, consumption: hourEstimate }\nconst highestCountingWithCurrent = [...highestCounting, current].sort((a, b) => b.consumption - a.consumption).slice(0, highestCounting.length)\nconst currentMonthlyEstimate = highestCountingWithCurrent.length === 0 ? 0 : highestCountingWithCurrent.reduce((prev, val) => prev + val.consumption, 0) / highestCountingWithCurrent.length\n\n// Set alarm level\nconst alarmLevel = calculateLevel(\n hourEstimate,\n currentHourRanking,\n currentMonthlyEstimate,\n currentStep)\n\n// Evaluate status\nconst status = alarmLevel >= ALARM ? \"Alarm\" : alarmLevel > 0 ? \"Warning\" : \"Ok\"\n\n// Avoid calculations to increase too much when timeLeftSec is approaching zero\nconst minTimeLeftSec = Math.max(timeLeftSec, MIN_TIMELEFT);\n// Calculate reduction\nconst reductionRequired = alarmLevel < ALARM ? 0 :\n Math.max((currentMonthlyEstimate - currentStep) * highestCounting.length, 0)\n * 3600 / minTimeLeftSec;\nconst reductionRecommended = alarmLevel < 3 ? 0 :\n Math.max(hourEstimate + SAFE_ZONE - currentStep, 0)\n * 3600 / minTimeLeftSec;\n\n// Calculate increase possible\nconst increasePossible = alarmLevel >= 3 ? 0 :\n Math.max(currentStep - hourEstimate - SAFE_ZONE, 0)\n * 3600 / minTimeLeftSec;\n\n// Create output\nconst fill = status === \"Ok\" ? \"green\" : status === \"Alarm\" ? \"red\" : \"yellow\";\nnode.status({ fill, shape: \"dot\", text: \"Working\" });\n\nconst RESOLUTION = 1000\n\nconst payload = {\n status, // Ok, Warning, Alarm\n statusOk: status === \"Ok\",\n statusWarning: status === \"Warning\",\n statusAlarm: status === \"Alarm\",\n alarmLevel,\n highestPerDay,\n highestCounting,\n highestCountingWithCurrent,\n highestToday,\n highestTodayConsumption: highestToday.consumption,\n highestTodayFrom: highestToday.from,\n currentMonthlyEstimate: Math.round(currentMonthlyEstimate * RESOLUTION) / RESOLUTION,\n accumulatedConsumptionLastHour: Math.round(accumulatedConsumptionLastHour * RESOLUTION) / RESOLUTION,\n consumptionLeft: Math.round(consumptionLeft * RESOLUTION) / RESOLUTION,\n hourEstimate: Math.round(hourEstimate * RESOLUTION) / RESOLUTION,\n averageConsumptionNow: Math.round(averageConsumptionNow * RESOLUTION) / RESOLUTION,\n reductionRequired: Math.round(reductionRequired * RESOLUTION) / RESOLUTION,\n reductionRecommended: Math.round(reductionRecommended * RESOLUTION) / RESOLUTION,\n increasePossible: Math.round(increasePossible * RESOLUTION) / RESOLUTION,\n currentStep,\n currentHourRanking,\n timeLeftSec,\n periodMs,\n accumulatedConsumption\n}\n\nmsg.payload = payload\n\nreturn msg;",
|
|
101
101
|
"outputs": 1,
|
|
102
102
|
"noerr": 0,
|
|
103
103
|
"initialize": "",
|
|
@@ -112,7 +112,7 @@
|
|
|
112
112
|
"type": "function",
|
|
113
113
|
"z": "d938c47f.3398f8",
|
|
114
114
|
"name": "Reduction Actions",
|
|
115
|
-
"func": "const MIN_CONSUMPTION_TO_CARE = 0.05 // Do not reduce unless at least 50W\n\nconst actions = flow.get(\"actions\")\nconst ha = global.get(\"homeassistant\").homeAssistant\n\nlet reductionRequired = msg.payload.reductionRequired\nlet reductionRecommended = msg.payload.reductionRecommended\n\nif(reductionRecommended <= 0) {\n return null\n}\n\nfunction takeAction(action, consumption ) {\n const info = {\n time: new Date().toISOString(),\n name: \"Reduction action\",\n data: msg.payload,\n action\n }\n\n // output1 is for actions\n const output1 = action.payloadToTakeAction ? { payload: action.payloadToTakeAction } : null\n // output 2 is for overriding PS strategies\n const output2 = action.nameOfStrategyToOverride ? { payload: { config: { override: \"off\" }, name: action.nameOfStrategyToOverride} } : null\n // output 3 is for logging\n const output3 = { payload: info }\n\n node.send([output1, output2, output3])\n reductionRequired = Math.max(0, reductionRequired - consumption)\n reductionRecommended = Math.max(0, reductionRecommended - consumption)\n action.actionTaken = true\n action.actionTime = Date.now()\n action.savedConsumption = consumption\n flow.set(\"actions\", actions)\n}\n\nfunction getConsumption(consumption) {\n if(typeof consumption === \"string\") {\n const sensor = ha.states[consumption]\n return sensor.state / 1000\n } else if (typeof consumption === \"number\") {\n return consumption\n } else if(typeof consumption === \"function\") {\n return consumption()\n } else {\n node.warn(\"Config error: consumption has illegal type: \" + typeof consumption)\n return 0\n }\n}\n\nactions\n.filter(a => msg.payload.alarmLevel >= a.minAlarmLevel && !a.actionTaken)\n.forEach(a => {\n const consumption = getConsumption(a.consumption)\n if (consumption < MIN_CONSUMPTION_TO_CARE) {\n return\n }\n if (reductionRequired > 0 || (reductionRecommended > 0 && a.reduceWhenRecommended)) {\n takeAction(a, consumption)\n }\n})\n \n",
|
|
115
|
+
"func": "const MIN_CONSUMPTION_TO_CARE = 0.05 // Do not reduce unless at least 50W\nconst MIN_MINUTES_INTO_HOUR_TO_TAKE_ACTION = 5\n\nconst actions = flow.get(\"actions\")\nconst ha = global.get(\"homeassistant\").homeAssistant\n\nlet reductionRequired = msg.payload.reductionRequired\nlet reductionRecommended = msg.payload.reductionRecommended\n\nnode.status({})\n\nif(reductionRecommended <= 0 ) {\n return null\n}\n\nif (3600 - msg.payload.timeLeftSec < MIN_MINUTES_INTO_HOUR_TO_TAKE_ACTION * 60) {\n node.status({ fill: \"yellow\", shape: \"ring\", text: \"No action during first \" + MIN_MINUTES_INTO_HOUR_TO_TAKE_ACTION + \" minutes\"});\n return\n}\n\nfunction takeAction(action, consumption ) {\n const info = {\n time: new Date().toISOString(),\n name: \"Reduction action\",\n data: msg.payload,\n action\n }\n\n // output1 is for actions\n const output1 = action.payloadToTakeAction ? { payload: action.payloadToTakeAction } : null\n // output 2 is for overriding PS strategies\n const output2 = action.nameOfStrategyToOverride ? { payload: { config: { override: \"off\" }, name: action.nameOfStrategyToOverride} } : null\n // output 3 is for logging\n const output3 = { payload: info }\n\n node.send([output1, output2, output3])\n reductionRequired = Math.max(0, reductionRequired - consumption)\n reductionRecommended = Math.max(0, reductionRecommended - consumption)\n action.actionTaken = true\n action.actionTime = Date.now()\n action.savedConsumption = consumption\n flow.set(\"actions\", actions)\n}\n\nfunction getConsumption(consumption) {\n if(typeof consumption === \"string\") {\n const sensor = ha.states[consumption]\n return sensor.state / 1000\n } else if (typeof consumption === \"number\") {\n return consumption\n } else if(typeof consumption === \"function\") {\n return consumption()\n } else {\n node.warn(\"Config error: consumption has illegal type: \" + typeof consumption)\n return 0\n }\n}\n\nactions\n.filter(a => msg.payload.alarmLevel >= a.minAlarmLevel && !a.actionTaken)\n.forEach(a => {\n const consumption = getConsumption(a.consumption)\n if (consumption < MIN_CONSUMPTION_TO_CARE) {\n return\n }\n if (reductionRequired > 0 || (reductionRecommended > 0 && a.reduceWhenRecommended)) {\n takeAction(a, consumption)\n }\n})\n \n",
|
|
116
116
|
"outputs": 3,
|
|
117
117
|
"noerr": 0,
|
|
118
118
|
"initialize": "// You MUST edit the actions array with your own actions.\n\nconst actions = [\n { \n consumption: \"sensor.varmtvannsbereder_electric_consumption_w\",\n name: \"Varmtvannsbereder\",\n id: \"vvb\",\n minAlarmLevel: 3,\n reduceWhenRecommended: true,\n minTimeOffSec: 300,\n nameOfStrategyToOverride: \"Best Save\",\n },\n { \n consumption: \"sensor.varme_gulv_bad_electric_consumption_w_2\",\n name: \"Varme gulv bad 1. etg.\",\n id: \"gulvbad\",\n minAlarmLevel: 3,\n reduceWhenRecommended: true,\n minTimeOffSec: 300,\n payloadToTakeAction: {\n domain: \"climate\",\n service: \"turn_off\",\n target: {\n entity_id: [\"climate.varme_gulv_bad_2\"]\n }\n },\n payloadToResetAction: {\n domain: \"climate\",\n service: \"turn_on\",\n target: {\n entity_id: [\"climate.varme_gulv_bad_2\"]\n }\n }\n },\n { \n consumption: \"sensor.varme_gulv_gang_electric_consumption_w\",\n name: \"Varme gulv gang 1. etg.\",\n id: \"gulvgang\",\n minAlarmLevel: 3,\n reduceWhenRecommended: true,\n minTimeOffSec: 300,\n payloadToTakeAction: {\n domain: \"climate\",\n service: \"turn_off\",\n target: {\n entity_id: [\"climate.varme_gulv_gang\"]\n }\n },\n payloadToResetAction: {\n domain: \"climate\",\n service: \"turn_on\",\n target: {\n entity_id: [\"climate.varme_gulv_gang\"]\n }\n }\n },\n {\n consumption: \"sensor.varme_gulv_kjellerstue_electric_consumption_w\",\n name: \"Varme gulv kjellerstue\",\n id: \"gulvkjeller\",\n minAlarmLevel: 3,\n reduceWhenRecommended: true,\n minTimeOffSec: 300,\n payloadToTakeAction: {\n domain: \"climate\",\n service: \"turn_off\",\n target: {\n entity_id: [\"climate.varme_gulv_kjellerstue\"]\n }\n },\n payloadToResetAction: {\n domain: \"climate\",\n service: \"turn_on\",\n target: {\n entity_id: [\"climate.varme_gulv_kjellerstue\"]\n }\n }\n }\n]\n// End of actions array\n\n// DO NOT DELETE THE CODE BELOW\n\n// Set default values for all actions\nactions.forEach(a => {\n a.actionTaken = false\n a.savedConsumption = 0\n})\n\nflow.set(\"actions\", actions)\n",
|
|
@@ -127,7 +127,7 @@
|
|
|
127
127
|
"type": "function",
|
|
128
128
|
"z": "d938c47f.3398f8",
|
|
129
129
|
"name": "Reset Actions",
|
|
130
|
-
"func": "\nconst actions = flow.get(\"actions\")\nconst ha = global.get(\"homeassistant\").homeAssistant\n\nconst BUFFER_TO_RESET = 1 // Must have 1kW extra to perform reset\n\nlet increasePossible = msg.payload.increasePossible\n\nif (increasePossible <= 0) {\n return null\n}\n\nfunction resetAction(action) {\n const info = {\n time: new Date().toISOString(),\n name: \"Reset action\",\n data: msg.
|
|
130
|
+
"func": "\nconst actions = flow.get(\"actions\")\nconst ha = global.get(\"homeassistant\").homeAssistant\n\nconst BUFFER_TO_RESET = 1 // Must have 1kW extra to perform reset\n\nlet increasePossible = msg.payload.increasePossible\n\nif (increasePossible <= 0) {\n return null\n}\n\nfunction resetAction(action) {\n const info = {\n time: new Date().toISOString(),\n name: \"Reset action\",\n data: msg.payload,\n action\n }\n const output1 = action.payloadToResetAction ? { payload: action.payloadToResetAction } : null\n const output2 = action.nameOfStrategyToOverride ? { payload: { config: { override: \"auto\" }, name: action.nameOfStrategyToOverride } } : null\n const output3 = { payload: info }\n\n node.send([output1, output2, output3])\n increasePossible -= action.savedConsumption\n action.actionTaken = false\n action.savedConsumption = 0\n flow.set(\"actions\", actions)\n}\n\nactions\n .filter(a => a.actionTaken\n && (a.savedConsumption + BUFFER_TO_RESET) <= increasePossible\n && (Date.now() - a.actionTime > a.minTimeOffSec * 1000)\n ).forEach(a => resetAction(a))\n",
|
|
131
131
|
"outputs": 3,
|
|
132
132
|
"noerr": 0,
|
|
133
133
|
"initialize": "",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "node-red-contrib-power-saver",
|
|
3
|
-
"version": "4.1.
|
|
3
|
+
"version": "4.1.4",
|
|
4
4
|
"description": "A module for Node-RED that you can use to turn on and off a switch based on power prices",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -46,24 +46,25 @@
|
|
|
46
46
|
"url": "https://github.com/ottopaulsen/node-red-contrib-power-saver.git"
|
|
47
47
|
},
|
|
48
48
|
"devDependencies": {
|
|
49
|
-
"@vuepress/bundler-vite": "
|
|
50
|
-
"@vuepress/plugin-google-analytics": "
|
|
51
|
-
"@vuepress/plugin-register-components": "
|
|
52
|
-
"@vuepress/plugin-search": "
|
|
53
|
-
"@vuepress/utils": "
|
|
54
|
-
"
|
|
55
|
-
"
|
|
56
|
-
"
|
|
57
|
-
"
|
|
58
|
-
"node-red
|
|
59
|
-
"
|
|
60
|
-
"
|
|
49
|
+
"@vuepress/bundler-vite": "2.0.0-beta.67",
|
|
50
|
+
"@vuepress/plugin-google-analytics": "2.0.0-beta.67",
|
|
51
|
+
"@vuepress/plugin-register-components": "2.0.0-beta.67",
|
|
52
|
+
"@vuepress/plugin-search": "2.0.0-beta.67",
|
|
53
|
+
"@vuepress/utils": "2.0.0-beta.67",
|
|
54
|
+
"chai": "^4.3.10",
|
|
55
|
+
"eslint": "8.52.0",
|
|
56
|
+
"expect": "29.7.0",
|
|
57
|
+
"mocha": "^10.2.0",
|
|
58
|
+
"node-red": "^3.1.0",
|
|
59
|
+
"node-red-node-test-helper": "0.3.2",
|
|
60
|
+
"sass-loader": "^13.3.2",
|
|
61
|
+
"vuepress": "2.0.0-beta.67"
|
|
61
62
|
},
|
|
62
63
|
"dependencies": {
|
|
63
|
-
"floating-vue": "
|
|
64
|
-
"lodash.clonedeep": "
|
|
65
|
-
"luxon": "
|
|
66
|
-
"nano-time": "
|
|
67
|
-
"node-fetch": "
|
|
64
|
+
"floating-vue": "2.0.0-beta.24",
|
|
65
|
+
"lodash.clonedeep": "4.5.0",
|
|
66
|
+
"luxon": "3.4.3",
|
|
67
|
+
"nano-time": "1.0.0",
|
|
68
|
+
"node-fetch": "2.6.7"
|
|
68
69
|
}
|
|
69
70
|
}
|
package/src/handle-output.js
CHANGED
|
@@ -62,7 +62,7 @@ function sendSwitch(node, onOff) {
|
|
|
62
62
|
const output1 = onOff ? { payload: node.outputValueForOn } : null;
|
|
63
63
|
const output2 = onOff ? null : { payload: node.outputValueForOff };
|
|
64
64
|
node.send([output1, output2, null]);
|
|
65
|
-
node.context().set("currentOutput", onOff);
|
|
65
|
+
node.context().set("currentOutput", onOff, node.contextStorage);
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
function runSchedule(node, schedule, time, currentSent = false) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
|
+
const cloneDeep = require("lodash.clonedeep");
|
|
3
4
|
const { msgHasConfig } = require("./utils.js");
|
|
4
5
|
|
|
5
6
|
function msgHasSchedule(msg) {
|
|
@@ -11,7 +12,7 @@ function validateSchedule(msg) {
|
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
function saveSchedule(node, msg) {
|
|
14
|
-
let savedSchedules = node.context().get("savedSchedules") || {};
|
|
15
|
+
let savedSchedules = node.context().get("savedSchedules", node.contextStorage) || {};
|
|
15
16
|
|
|
16
17
|
// If the saved schedule has a different start period, delete them
|
|
17
18
|
const ids = Object.keys(savedSchedules);
|
|
@@ -28,14 +29,14 @@ function saveSchedule(node, msg) {
|
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
const id = msg.payload.strategyNodeId;
|
|
31
|
-
savedSchedules[id] = msg.payload;
|
|
32
|
+
savedSchedules[id] = cloneDeep(msg.payload);
|
|
32
33
|
node.context().set("savedSchedules", savedSchedules);
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
function mergeSchedules(node, logicFunction) {
|
|
36
37
|
// Transpose all schedules
|
|
37
38
|
const transposed = {};
|
|
38
|
-
const savedSchedules = node.context().get("savedSchedules");
|
|
39
|
+
const savedSchedules = node.context().get("savedSchedules", node.contextStorage);
|
|
39
40
|
if (!savedSchedules) {
|
|
40
41
|
const msg = "No schedules";
|
|
41
42
|
node.warn(msg);
|
package/src/schedule-merger.js
CHANGED
|
@@ -87,7 +87,7 @@ module.exports = function (RED) {
|
|
|
87
87
|
};
|
|
88
88
|
|
|
89
89
|
const planFromTime = msg.payload.time ? DateTime.fromISO(msg.payload.time) : DateTime.now();
|
|
90
|
-
const currentOutput = node.context().get("currentOutput");
|
|
90
|
+
const currentOutput = node.context().get("currentOutput", node.contextStorage);
|
|
91
91
|
const plannedOutputNow = getOutputForTime(plan.schedule, planFromTime, node.outputIfNoSchedule);
|
|
92
92
|
|
|
93
93
|
const outputCommands = {
|
|
@@ -12,7 +12,7 @@ function strategyOnInput(node, msg, doPlanning, calcSavings) {
|
|
|
12
12
|
const { plan, commands } = handleStrategyInput(node, msg, config, doPlanning, calcSavings);
|
|
13
13
|
if (plan) {
|
|
14
14
|
const planFromTime = msg.payload.time ? DateTime.fromISO(msg.payload.time) : DateTime.now();
|
|
15
|
-
const currentOutput = node.context().get("currentOutput");
|
|
15
|
+
const currentOutput = node.context().get("currentOutput", node.contextStorage);
|
|
16
16
|
const plannedOutputNow =
|
|
17
17
|
node.override === "auto"
|
|
18
18
|
? getOutputForTime(plan.schedule, planFromTime, node.outputIfNoSchedule)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
const { DateTime } = require("luxon");
|
|
3
|
-
const { roundPrice } = require("./utils");
|
|
3
|
+
const { roundPrice, getDiffToNextOn } = require("./utils");
|
|
4
4
|
|
|
5
5
|
function calculateOpportunities(prices, pattern, amount) {
|
|
6
6
|
//creating a price vector with minute granularity
|
|
@@ -121,6 +121,7 @@ function calculateSchedule(
|
|
|
121
121
|
buySellStackedArray,
|
|
122
122
|
buyPrices,
|
|
123
123
|
sellPrices,
|
|
124
|
+
setpoint,
|
|
124
125
|
maxTempAdjustment,
|
|
125
126
|
boostTempHeat,
|
|
126
127
|
boostTempCool,
|
|
@@ -137,10 +138,25 @@ function calculateSchedule(
|
|
|
137
138
|
boostTempCool: boostTempCool,
|
|
138
139
|
heatingDuration: buyDuration,
|
|
139
140
|
coolingDuration: sellDuration,
|
|
141
|
+
minimalSchedule: [], //array of dicts with date as key and temperature as value
|
|
140
142
|
};
|
|
141
143
|
|
|
144
|
+
function pushTempChange(startDate, minutes, tempAdj, sp) {
|
|
145
|
+
if (
|
|
146
|
+
schedule.minimalSchedule.length > 0 &&
|
|
147
|
+
schedule.minimalSchedule[schedule.minimalSchedule.length - 1].adjustment == tempAdj
|
|
148
|
+
)
|
|
149
|
+
return;
|
|
150
|
+
schedule.minimalSchedule.push({
|
|
151
|
+
startAt: startDate.plus({ minutes: minutes }).toISO(),
|
|
152
|
+
setpoint: sp + tempAdj,
|
|
153
|
+
adjustment: tempAdj,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
142
157
|
if (buySellStackedArray[0].length === 0) {
|
|
143
158
|
//No procurements or sales scheduled
|
|
159
|
+
schedule.minimalSchedule.push({ startDate: -maxTempAdjustment });
|
|
144
160
|
schedule.temperatures.fill(-maxTempAdjustment, 0, arrayLength);
|
|
145
161
|
} else {
|
|
146
162
|
let lastBuyIndex = 0;
|
|
@@ -155,27 +171,32 @@ function calculateSchedule(
|
|
|
155
171
|
lastBuyIndex == 0 ? (boostCool = 0) : (boostCool = boostTempCool);
|
|
156
172
|
|
|
157
173
|
//Cooling period. Adding boosted cooling temperature for the period of divestment
|
|
174
|
+
pushTempChange(startDate, lastBuyIndex, -maxTempAdjustment - boostCool, setpoint);
|
|
158
175
|
if (sellIndex - lastBuyIndex <= sellDuration) {
|
|
159
176
|
schedule.temperatures.fill(-maxTempAdjustment - boostCool, lastBuyIndex, sellIndex);
|
|
160
177
|
} else {
|
|
178
|
+
pushTempChange(startDate, lastBuyIndex + sellDuration, -maxTempAdjustment, setpoint);
|
|
161
179
|
schedule.temperatures.fill(-maxTempAdjustment - boostCool, lastBuyIndex, lastBuyIndex + sellDuration);
|
|
162
180
|
schedule.temperatures.fill(-maxTempAdjustment, lastBuyIndex + sellDuration, sellIndex);
|
|
163
181
|
}
|
|
164
182
|
//Heating period. Adding boosted heating temperature for the period of procurement
|
|
183
|
+
pushTempChange(startDate, sellIndex, maxTempAdjustment + boostHeat, setpoint);
|
|
165
184
|
if (buyIndex - sellIndex <= buyDuration) {
|
|
166
185
|
schedule.temperatures.fill(maxTempAdjustment + boostHeat, sellIndex, buyIndex);
|
|
167
186
|
} else {
|
|
187
|
+
pushTempChange(startDate, sellIndex + buyDuration, maxTempAdjustment, setpoint);
|
|
168
188
|
schedule.temperatures.fill(maxTempAdjustment + boostHeat, sellIndex, sellIndex + buyDuration);
|
|
169
189
|
schedule.temperatures.fill(maxTempAdjustment, sellIndex + buyDuration, buyIndex);
|
|
170
190
|
}
|
|
171
|
-
|
|
172
191
|
lastBuyIndex = buyIndex;
|
|
173
192
|
}
|
|
174
193
|
|
|
175
194
|
//final fill
|
|
195
|
+
pushTempChange(startDate, lastBuyIndex, -maxTempAdjustment - boostCool, setpoint);
|
|
176
196
|
if (arrayLength - lastBuyIndex <= sellDuration) {
|
|
177
197
|
schedule.temperatures.fill(-maxTempAdjustment - boostCool, lastBuyIndex, arrayLength);
|
|
178
198
|
} else {
|
|
199
|
+
pushTempChange(startDate, lastBuyIndex + sellDuration, -maxTempAdjustment, setpoint);
|
|
179
200
|
schedule.temperatures.fill(-maxTempAdjustment - boostCool, lastBuyIndex, lastBuyIndex + sellDuration);
|
|
180
201
|
schedule.temperatures.fill(-maxTempAdjustment, lastBuyIndex + sellDuration, arrayLength);
|
|
181
202
|
}
|
|
@@ -186,14 +207,26 @@ function calculateSchedule(
|
|
|
186
207
|
}
|
|
187
208
|
|
|
188
209
|
function findTemp(date, schedule) {
|
|
189
|
-
let
|
|
190
|
-
|
|
210
|
+
let closestDate = null;
|
|
211
|
+
let temp = null;
|
|
212
|
+
schedule.minimalSchedule.forEach((e) => {
|
|
213
|
+
const testDate = DateTime.fromISO(e.startAt);
|
|
214
|
+
if (date < testDate) return;
|
|
215
|
+
if (closestDate !== null) {
|
|
216
|
+
if (closestDate > testDate) return; //
|
|
217
|
+
}
|
|
218
|
+
closestDate = testDate;
|
|
219
|
+
temp = e.adjustment;
|
|
220
|
+
});
|
|
221
|
+
if (temp == null) temp = 0;
|
|
222
|
+
return temp;
|
|
191
223
|
}
|
|
192
224
|
|
|
193
225
|
function runBuySellAlgorithm(
|
|
194
226
|
priceData,
|
|
195
227
|
timeHeat1C,
|
|
196
228
|
timeCool1C,
|
|
229
|
+
setpoint,
|
|
197
230
|
boostTempHeat,
|
|
198
231
|
boostTempCool,
|
|
199
232
|
maxTempAdjustment,
|
|
@@ -225,6 +258,7 @@ function runBuySellAlgorithm(
|
|
|
225
258
|
buySellCleaned,
|
|
226
259
|
buyPrices,
|
|
227
260
|
sellPrices,
|
|
261
|
+
setpoint,
|
|
228
262
|
maxTempAdjustment,
|
|
229
263
|
boostTempHeat,
|
|
230
264
|
boostTempCool,
|