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.
Files changed (37) hide show
  1. package/CHANGELOG.md +1 -1
  2. package/docs/.vuepress/client.js +7 -0
  3. package/docs/.vuepress/clientAppEnhance.js +1 -1
  4. package/docs/.vuepress/components/AdsenseAdd.vue +1 -1
  5. package/docs/.vuepress/config.js +31 -30
  6. package/docs/README.md +6 -0
  7. package/docs/changelog/README.md +4 -0
  8. package/docs/examples/example-grid-tariff-capacity-part.md +16 -5
  9. package/docs/privacy.md +17 -0
  10. package/examples/example-grid-tariff-capacity-flow.json +2 -2
  11. package/package.json +19 -18
  12. package/src/schedule-merger-functions.js +2 -1
  13. package/src/strategy-heat-capacitor-functions.js +38 -4
  14. package/src/strategy-heat-capacitor.html +2 -2
  15. package/src/strategy-heat-capacitor.js +94 -63
  16. package/test/commands-input-best-save.test.js +15 -15
  17. package/test/commands-input-lowest-price.test.js +15 -15
  18. package/test/commands-input-schedule-merger.test.js +11 -11
  19. package/test/data/heat-capacitor-prices-NaN-test.json +197 -0
  20. package/test/elvia.test.js +2 -2
  21. package/test/general-add-tariff-functions.test.js +6 -6
  22. package/test/general-add-tariff.test.js +7 -7
  23. package/test/mostSavedStrategy.test.js +17 -17
  24. package/test/receive-price-functions.test.js +9 -9
  25. package/test/receive-price.test.js +11 -11
  26. package/test/schedule-merger-functions.test.js +18 -28
  27. package/test/schedule-merger.test.js +17 -17
  28. package/test/send-config-input.test.js +13 -13
  29. package/test/strategy-best-save-overlap.test.js +2 -2
  30. package/test/strategy-best-save.test.js +28 -28
  31. package/test/strategy-fixed-schedule.test.js +5 -5
  32. package/test/strategy-heat-capacitor-node.test.js +115 -16
  33. package/test/strategy-heat-capacitor.test.js +8 -6
  34. package/test/strategy-lowest-price-3days.test.js +4 -4
  35. package/test/strategy-lowest-price-functions.test.js +13 -13
  36. package/test/strategy-lowest-price.test.js +34 -34
  37. package/test/utils.test.js +46 -45
package/CHANGELOG.md CHANGED
@@ -1 +1 @@
1
- [The changelog has moved here](http://power-saver.smoky.no/changelog/)
1
+ [The changelog has moved here](https://powersaver.no/changelog/#change-log)
@@ -0,0 +1,7 @@
1
+ import { defineClientConfig } from "@vuepress/client";
2
+ import FloatingVue from "floating-vue";
3
+ import "floating-vue/dist/style.css";
4
+
5
+ export default defineClientConfig({
6
+ rootComponents: [FloatingVue],
7
+ });
@@ -1,5 +1,5 @@
1
1
  import FloatingVue from "floating-vue";
2
2
  import "floating-vue/dist/style.css";
3
- export default ({ app, router, siteData }) => {
3
+ export default ({ app }) => {
4
4
  app.use(FloatingVue);
5
5
  };
@@ -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"
@@ -1,12 +1,34 @@
1
- const { path } = require("@vuepress/utils");
2
- const navbar = require("./navbar");
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
- module.exports = {
5
- lang: "en-US",
6
- title: "Power Saver",
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
- themeConfig: {
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
- head: [
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/>
@@ -7,6 +7,10 @@ sidebarDepth: 1
7
7
 
8
8
  List the most significant changes.
9
9
 
10
+ ## 4.1.4
11
+
12
+ - Update dependencies.
13
+
10
14
  ## 4.1.3
11
15
 
12
16
  - Fix bug that saved some data in wrong context storage.
@@ -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
- return; // First item in buffer
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) / timeLeftSec;
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) / timeLeftSec;
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) / timeLeftSec;
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";
@@ -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 return // First item in buffer\n}\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;",
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 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 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// Calculate reduction\nconst reductionRequired = alarmLevel < ALARM ? 0 :\n Math.max((currentMonthlyEstimate - currentStep) * highestCounting.length, 0)\n * 3600 / timeLeftSec;\nconst reductionRecommended = alarmLevel < 3 ? 0 :\n Math.max(hourEstimate + SAFE_ZONE - currentStep, 0)\n * 3600 / timeLeftSec;\n\n// Calculate increase possible\nconst increasePossible = alarmLevel >= 3 ? 0 :\n Math.max(currentStep - hourEstimate - SAFE_ZONE, 0)\n * 3600 / timeLeftSec;\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;",
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",
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": "^2.0.0-beta.36",
50
- "@vuepress/plugin-google-analytics": "^2.0.0-beta.36",
51
- "@vuepress/plugin-register-components": "^2.0.0-beta.36",
52
- "@vuepress/plugin-search": "^2.0.0-beta.38",
53
- "@vuepress/utils": "^2.0.0-beta.35",
54
- "eslint": "^8.25.0",
55
- "expect": "^27.5.1",
56
- "mocha": "^9.2.0",
57
- "node-red": "^3.0.2",
58
- "node-red-node-test-helper": "^0.2.7",
59
- "sass-loader": "^12.6.0",
60
- "vuepress": "^2.0.0-beta.36"
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": "^2.0.0-beta.20",
64
- "lodash.clonedeep": "^4.5.0",
65
- "luxon": "^3.1.0",
66
- "nano-time": "^1.0.0",
67
- "node-fetch": "^2.6.7"
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 diff = Math.round(date.diff(schedule.startAt).as("minutes"));
190
- return schedule.temperatures[diff];
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: 3,
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