node-red-contrib-power-saver 4.1.3 → 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 +1 -1
- package/docs/.vuepress/config.js +31 -30
- package/docs/README.md +6 -0
- package/docs/changelog/README.md +4 -0
- package/docs/examples/example-grid-tariff-capacity-part.md +16 -5
- package/docs/privacy.md +17 -0
- package/examples/example-grid-tariff-capacity-flow.json +2 -2
- package/package.json +19 -18
- package/src/schedule-merger-functions.js +2 -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 +4 -4
- 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)
|
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
|
@@ -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);
|
|
@@ -456,8 +458,11 @@ if (consumptionInPeriod < 0) {
|
|
|
456
458
|
consumptionInPeriod = 0;
|
|
457
459
|
}
|
|
458
460
|
if (periodMs === 0) {
|
|
459
|
-
|
|
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
|
|
460
464
|
}
|
|
465
|
+
node.status({ fill: "green", shape: "dot", text: "Working" });
|
|
461
466
|
|
|
462
467
|
// Estimate remaining of current hour
|
|
463
468
|
const timeLeftMs = 60 * 60 * 1000 - (time.getMinutes() * 60000 + time.getSeconds() * 1000 + time.getMilliseconds());
|
|
@@ -549,6 +554,7 @@ const MAX_COUNTING = 3; // Number of days to calculate month average of
|
|
|
549
554
|
const BUFFER = 0.5; // kWh - Closer to limit increases alarm level
|
|
550
555
|
const SAFE_SONE = 2; // kWh - Further from limit reduces level
|
|
551
556
|
const ALARM = 8; // Min level that causes status to be alarm
|
|
557
|
+
const MIN_TIMELEFT = 30; //Min level for time left (30 seconds)
|
|
552
558
|
```
|
|
553
559
|
|
|
554
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,
|
|
@@ -575,9 +581,11 @@ const MAX_COUNTING = 3; // Number of days to calculate month
|
|
|
575
581
|
const BUFFER = 0.5; // Closer to limit increases level
|
|
576
582
|
const SAFE_ZONE = 2; // Further from limit reduces level
|
|
577
583
|
const ALARM = 8; // Min level that causes status to be alarm
|
|
584
|
+
const MIN_TIMELEFT = 3 * 60; //Min level for time left
|
|
578
585
|
|
|
579
586
|
const ha = global.get("homeassistant")[HA_NAME];
|
|
580
587
|
if (!ha.isConnected) {
|
|
588
|
+
node.status({ fill: "red", shape: "dot", text: "Ha not connected" });
|
|
581
589
|
return;
|
|
582
590
|
}
|
|
583
591
|
|
|
@@ -643,6 +651,7 @@ const averageConsumptionNow = msg.payload.averageConsumptionNow;
|
|
|
643
651
|
const currentHour = msg.payload.currentHour;
|
|
644
652
|
|
|
645
653
|
if (timeLeftSec === 0) {
|
|
654
|
+
node.status({ fill: "red", shape: "dot", text: "Time Left 0" });
|
|
646
655
|
return null;
|
|
647
656
|
}
|
|
648
657
|
|
|
@@ -690,17 +699,19 @@ const alarmLevel = calculateLevel(hourEstimate, currentHourRanking, currentMonth
|
|
|
690
699
|
// Evaluate status
|
|
691
700
|
const status = alarmLevel >= ALARM ? "Alarm" : alarmLevel > 0 ? "Warning" : "Ok";
|
|
692
701
|
|
|
702
|
+
// Avoid calculations to increase too much when timeLeftSec is approaching zero
|
|
703
|
+
const minTimeLeftSec = Math.max(timeLeftSec, MIN_TIMELEFT);
|
|
693
704
|
// Calculate reduction
|
|
694
705
|
const reductionRequired =
|
|
695
706
|
alarmLevel < ALARM
|
|
696
707
|
? 0
|
|
697
|
-
: (Math.max((currentMonthlyEstimate - currentStep) * highestCounting.length, 0) * 3600) /
|
|
708
|
+
: (Math.max((currentMonthlyEstimate - currentStep) * highestCounting.length, 0) * 3600) / minTimeLeftSec;
|
|
698
709
|
const reductionRecommended =
|
|
699
|
-
alarmLevel < 3 ? 0 : (Math.max(hourEstimate + SAFE_ZONE - currentStep, 0) * 3600) /
|
|
710
|
+
alarmLevel < 3 ? 0 : (Math.max(hourEstimate + SAFE_ZONE - currentStep, 0) * 3600) / minTimeLeftSec;
|
|
700
711
|
|
|
701
712
|
// Calculate increase possible
|
|
702
713
|
const increasePossible =
|
|
703
|
-
alarmLevel >= 3 ? 0 : (Math.max(currentStep - hourEstimate - SAFE_ZONE, 0) * 3600) /
|
|
714
|
+
alarmLevel >= 3 ? 0 : (Math.max(currentStep - hourEstimate - SAFE_ZONE, 0) * 3600) / minTimeLeftSec;
|
|
704
715
|
|
|
705
716
|
// Create output
|
|
706
717
|
const fill = status === "Ok" ? "green" : status === "Alarm" ? "red" : "yellow";
|
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\nlet consumptionInPeriod = buffer[buffer.length - 1].accumulatedConsumption - buffer[0].accumulatedConsumption\nif (consumptionInPeriod < 0) {\nconsumptionInPeriod = 0\n}\nif (periodMs === 0) {\n
|
|
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": "",
|
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
|
}
|
|
@@ -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) {
|
|
@@ -28,7 +29,7 @@ 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
|
|
|
@@ -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,
|
|
@@ -13,13 +13,13 @@
|
|
|
13
13
|
setpoint: { value: 23, required: true, align: "left" },
|
|
14
14
|
},
|
|
15
15
|
inputs: 1,
|
|
16
|
-
outputs:
|
|
16
|
+
outputs: 4,
|
|
17
17
|
color: "#FFCC66",
|
|
18
18
|
icon: "font-awesome/fa-bar-chart",
|
|
19
19
|
label: function () {
|
|
20
20
|
return this.name || "Heat capacitor";
|
|
21
21
|
},
|
|
22
|
-
outputLabels: ["T", "dT", "schedule"],
|
|
22
|
+
outputLabels: ["T", "dT", "output", "schedule"],
|
|
23
23
|
});
|
|
24
24
|
</script>
|
|
25
25
|
|