Capacity part of grid tariff

Introduction

I Norway, there has been introduced a monthly fee for grid capacity. The purpose is to get people to avoid peaks of high capacity usage.

The fee is calculated based on the average consumption per hour (kWh). For each day, the worst hour is used, that is the hour with highest consumption that day. Then the 3 days where this number is highest (the 3 highest) is used. The average of these 3 days is calculated, and the result decides what capacity step you will pay for.

The steps are for example like this:

  • 0-2 kW
  • 2-5 kW
  • 5-10 kW
  • 10-15 kW
  • 15-20 kW

This may be different for different grid providers.

If, for example, the average of the 3 worst days is 6 kW, you pay for step 5-10 kW.

Here is an example how this can be controlled. The example contains several features, and you may not want to use them all, so it is a good idea to read through it all before you decide how to use it.

Capacity Flow

The first part of nodes (upper part) is used to read consumption from Tibber, and to perform all calculations. The upper right two nodes are used to update a set of sensors in HA. You can use those sensor for many purposes related to the grid capacity. The bottom part is used to take actions in order to reduce power consumption, or to reset actions when the consumption is lowered.

You may use the whole example, or only part of it, so you should read through the whole documentation before you start, so you can decide what to use and how to use it.

The complex parts are solved by scripts in the function nodes, and you will have to change parts of them to use it all.

No guarantee

There is no guarantee that this works, so use at your own risk.

Requirements

You need the following to be able to use this example:

If you have the same information from other sources, you may be able to adapt the example.

Features

  • 15-20 sensors in HA showing information related to the capacity and the calculation, including:
    • Status Ok, Warning or Alarm related to current consumption compared to the next step.
    • Alarm level 0-9 based on how serious the current consumption is.
    • Current step based on usage the whole month.
    • The highest consumption any hour today.
    • Reduction required to avoid breaking the limit.
    • Reduction recommended to reduce risk of breaking he limit.
    • Estimates for the current hour.
    • Current consumption based on average the last minute (can be configured).
  • Automatically take actions to reduce consumption if it is recommended or required.
  • Automatically reset actions if the consumption is sufficiently reduced.
  • Log actions taken to a file
  • Actions may also be done by directly and automatically overriding strategy nodes.

Algorithm

The consumption is measured by Tibber Pulse continuously (every 2 seconds), and is used to calculate average consumption the last minute. (Can be configured to another number of minutes.) It is assumed that you will have this consumption the rest of the current hour. Based on the real consumption until now this hour, and the assumed consumption the rest of the hour, the total for the current hour is estimated. This is the hourEstimate.

The highest hour previously this day is found as highestToday.

The highest hour for each day this month is found, and the 3 highest is used in the calculation as the highestCounting.

Then the hourEstimate is compared to those other numbers to calculate an alarm level.

If the current hour is todays worst, it is ranked together with the other days this month. If today is ranked as 4 or better, where 1 is the worst this month, then status is Ok. It will not affect the fee.

The currentStep is decided based on the 3 worst days, including today, but not considering the current hour.

If today is ranked as 1, 2 or 3, it may affect the fee. Then the average of the three worst days, including this hour, is calculated. This is currentMonthlyEstimate.

Other sensor values are calculated based on values available. See description for each of them for details.

Calculated sensor values

The Calculate values node creates a payload with values that are used to create sensors in Home Assistant. The Update sensors node maps these values to entity ids, and the Set entity node uses the HA API to update sensors.

Here is a description of each of those sensor values.

Status

The status of the current hour as text: Ok, Warning or Alarm.

Alarm means that if you do not reduce the consumption, you will break the limit of the next step.

Warning means that there is an increased risk for breaking the limit.

Ok means you are pretty safe.

Ok

A boolean value, true if the status is Ok, false if not.

Warning

A boolean value, true if the status is Warning, false if not.

Alarm

A boolean value, true if the status is Alarm, false if not.

You can configure the constant ALARM to the lowest level that will cause status Alarm. Default is 8, meaning you have a small buffer (the BUFFER).

Alarm Level

A number from 0 to 9, meaning as follows:

LevelDescription
0You're good, either because consumption is low or because there was a worse hour earlier today.
9The current consumption will lead to breaking the limit for the next step, if continued.
8Same as 9but with a buffer you can configure to reduce risk. The buffer is a constant named BUFFER, and the default value is 0.5 kWh
7The hourEstimate (estimate for the hour) is higher than the limit for the next step, but since previous consumption has been low, the average of the 3 worst is below the limit, so you will not break the limit yet (!).
6Same as 7 but with a the same buffer as for alarm level 8
5The hourEstimate is the worst this month, and it is only a safe zone away from breaking the limit. The safe zone can be configured using the constant called SAFE_ZONE, default 2 kWh.
4The hourEstimate is the second to worst this month, and it is only a safe zone away from breaking the limit.
3The hourEstimate is the third to worst this month, and it is only a safe zone away from breaking the limit.
2The hourEstimate is the worst this month. Consumption may still be low.
1The hourEstimate is the second to worst this month. Consumption may still be low.

Current Step

Shows the step you are currently on (under), that is the limit you do not want to break. The steps are configured using the constant STEPS in the script. The default is

const STEPS = [2, 5, 10, 15, 20]

Configure steps

You must edit the STEPSconstant in the script. Set it to the relevant steps for your grid supplier. You should omit steps that you do not plan to stay under, so you avoid alarms and actions for that step in the beginning of the month.

Hour Estimate

The estimated consumption for the current hour (hourEstimate). This is calculated as real consumption earlier this hour plus estimated consumption for the rest of the hour.

Current Hour Ranking

A number (0-4) ranking the current hour with the 3 highest hours this month.

The current hour ranking has the following meaning:

ValueDescription
0Not counting, since there has been another hour earlier today that is higher.
1This is estimated to be the worst hour in the month.
2This is estimated to be the second worst hour in the month.
3This is estimated to be the third worst hour in the month.
4This hour is estimated to be the worst today, but not one of the top 3 this month.

Max ranking value

The highest value here, 4, is based on the value of MAX_COUNTING in the Find highest per day node. If you change MAX_COUNTING, the values here will also change.

Monthly Estimate

The average of the 3 worst hours so far this month, in kWh.

Highest Today

The consumption in kWh of the worst hour today, not including the current hour.

Highest Today Hour

The start time of the worst hour today (Highest Today).

Reduction Required

The number of kW you must reduce the rest of the hour in order to not get alarm (break the limit), based on hourEstimate.

This follows the Alarm state, and thus also the ALARM constant, so if alarm level 8 gives status Alarm, reduction required is also set when the alarm level is 8. However, if you reconfigure ALARM to be only on level 9, then reduction required is only set when the alarm level is 9.

Please note that this takes the time in consideration, so if the hourEstimate is 1 kWh over the limit, and the time left of the hour is 15 minutes, the reduction required will be 4 kW. You must reduce the consumption with 4kW in order to save 1 kWh during 15 minutes.

The number of kW you must reduce the rest of the hour in order to stay under alarm level 3, based on hourEstimate. Calculated the same way as reduction required.

Increase Possible

The number of kW you can increase consumption and still stay within the configured safe zone. If alarm level is > 2, this value will be 0. You will never have increase possible at the same time as reduction recommended (nor reduction required).

Estimate Rest Of Hour

The estimated consumption in kWh for the rest of the hour.

Consumption Accumulated Hour

The actual consumption in kWh until now this hour.

Time Left

The time left of the current hour in seconds.

Consumption Now

The current consumption, measured as average the last minute. Can be set to another number of minutes using the ESTIMATION_TIME_MINUTES constant in the Collect estimate for hour node.

Node description

Here is a description of each node in the example.

Get live data

This is a tibber-feed node. It sets up a subscription of live Tibber data from Tibber Pulse, and uses this data to run the automation. Tick the following check boxes:

  • Timestamp
  • Power
  • Accumulated consumption
  • Accumulated consumption last hour

Uncheck all the others.

If you do not use Tibber, but can get this information from another source, convert it to the following format and use it the same way as data from this node is used:

{
  "timestamp": "2022-06-12T14:42:00.000+02:00",
  "power": 9503,
  "accumulatedConsumption": 33.459665,
  "accumulatedConsumptionLastHour": 7.046665
}

Build query for consumption

This is a function node that is used to build a Tibber query. It runs for all the live data, takes the time and calculates how many hours there has been since the beginning of the month. It uses this number to build a Tibber query to get consumption per hour since the beginning of the month. It sends output only when first started and when the hour changes, so it initiates a Tibber query once per hour.

Tibber Home Id

This node needs the tibber home id, so you must find it in the Tibber Developer Pagesopen in new window and set the vale of TIBBER_HOME_ID in the beginning of the code.

Code
context.set("previousHour", undefined);
/*
   Calculate number of hours to receive consumption for,
   that is number of hours in the month until now.
   Constructs a tibber query to get consumption per hour.
*/

const TIBBER_HOME_ID = "put your tibber ome id here";

const timestamp = msg.payload.timestamp;

// Stop if hour has not changed
const time = new Date(timestamp);
const hour = time.getHours();
const previousHour = context.get("previousHour");
if (previousHour !== undefined && hour === previousHour) {
  return;
}
context.set("previousHour", hour);

// Calculate number of hours to query
const date = time.getDate() - 1;
const hour2 = time.getHours();
const count = date * 24 + hour2;

// Build query
const query = `
{
  viewer {
    home (id: "${TIBBER_HOME_ID}") {
      consumption(resolution: HOURLY, last: ${count}) {
        nodes {
          from
          consumption
        }
      }
    }
  }
}
`;

msg.payload = query;
return msg;

Get consumption

This is a tibber-query node used to get consumption per hour for passed hours. It takes a Tibber query as input, and sends the result as output. The query is built by the previous node.

If you do not use Tibber, but can get this information from another source, convert it to the following format and use it the same way as data from this node is used:

{
  "viewer": {
    "home": {
      "consumption": {
        "nodes": [
          {
            "from": "2022-06-01T00:00:00.000+02:00",
            "consumption": 4.307
          },
          {
            "from": "2022-06-01T01:00:00.000+02:00",
            "consumption": 3.648
          },
          {
            "from": "2022-06-01T02:00:00.000+02:00",
            "consumption": 2.406
          },
          // ...
          {
            "from": "2022-06-12T12:00:00.000+02:00",
            "consumption": 0.969
          },
          {
            "from": "2022-06-12T13:00:00.000+02:00",
            "consumption": 7.612
          }
        ]
      }
    }
  }
}

It must contain data for every hour from the beginning of the month until the last complete hour.

Collect estimate for hour

This is a function node that receives all the live data and estimates the consumption for the rest of the current hour. It sums up the actual consumption from the beginning of the hour until the current time, and adds the estimate for the rest of the hour, giving a total estimate for the hour.

In the beginning of the code, there is a constant ESTIMATION_TIME_MINUTES that you can use to decide how many minutes that is used to calculate assumed average consumption.

The function keeps a buffer with all readings for the last period of length given with ESTIMATION_TIME_MINUTES. It uses this buffer to calculate the average consumption for the period. It uses the result and estimates the consumption for the rest of the hour, assuming that the consumption will be the same.

As outputs it sends the following:

NameDescription
accumulatedConsumptionAccumulated consumption the current day
accumulatedConsumptionLastHourAccumulated consumption the current hour.
periodMsPeriod the average is calculated for, in milliseconds. It will increase in the beginning, until it reaches ESTIMATION_TIME_MINUTES * 60 * 1000
consumptionInPeriodConsumption in the last periodMs milliseconds. Used as estimate for the remaining of the hour.
averageConsumptionNowConsumption the last minute (or ESTIMATION_TIME_MINUTES minutes).
timeLeftMsNumber of milliseconds left in the current hour.
consumptionLeftEstimated consumption the remaining of the hour.
hourEstimateThe estimated consumption for the total hour.
currentHourThe time of the current hour.
Code
context.set("buffer", []);
// Number of minutes used to calculate assumed consumption:
const ESTIMATION_TIME_MINUTES = 1;
// Allows records to deviate from maxAgeMs
const DELAY_TIME_MS_ALLOWED = 3 * 1000;

const buffer = context.get("buffer") || [];

// Add new record to buffer
const time = new Date(msg.payload.timestamp);
const timeMs = time.getTime();
const accumulatedConsumption = msg.payload.accumulatedConsumption;
const accumulatedConsumptionLastHour = msg.payload.accumulatedConsumptionLastHour;
buffer.push({ timeMs, accumulatedConsumption });

const currentHour = new Date(msg.payload.timestamp);
currentHour.setMinutes(0);
currentHour.setSeconds(0);

// Remove too old records from buffer
const maxAgeMs = (ESTIMATION_TIME_MINUTES * 60 * 1000) + DELAY_TIME_MS_ALLOWED;
let oldest = buffer[0];
while (timeMs - oldest.timeMs > maxAgeMs) {
  buffer.splice(0, 1);
  oldest = buffer[0];
}
context.set("buffer", buffer);

// Calculate buffer
const periodMs = buffer[buffer.length - 1].timeMs - buffer[0].timeMs;
let consumptionInPeriod = buffer[buffer.length - 1].accumulatedConsumption - buffer[0].accumulatedConsumption;
if (consumptionInPeriod < 0) {
  consumptionInPeriod = 0;
}
if (periodMs === 0) {
  //Should only occur during startup
  node.status({ fill: "red", shape: "dot", text: "First item in buffer" });
  return; // Stopping rest of the flow for this message
}
node.status({ fill: "green", shape: "dot", text: "Working" });

// Estimate remaining of current hour
const timeLeftMs = 60 * 60 * 1000 - (time.getMinutes() * 60000 + time.getSeconds() * 1000 + time.getMilliseconds());
const consumptionLeft = (consumptionInPeriod / periodMs) * timeLeftMs;
const averageConsumptionNow = (consumptionInPeriod / periodMs) * 60 * 60 * 1000;

// Estimate total hour
const hourEstimate = accumulatedConsumptionLastHour + consumptionLeft + 0; // Change for testing

msg.payload = {
  accumulatedConsumption,
  accumulatedConsumptionLastHour,
  periodMs,
  consumptionInPeriod,
  averageConsumptionNow,
  timeLeftMs,
  consumptionLeft,
  hourEstimate,
  currentHour,
};

return msg;

Find highest per day

Based on the result from the tibber query, gives the following output:

NameDescription
highestPerDayThe highest hour for each day until now this month, including current day.
highestCountingThe 3 highest days current month. Can be other than 3 by changing the MAX_COUNTING constant in the beginning of the script.
highestTodayThe highest hour that has ended until now this day.
currentMonthlyEstimateThe average of the 3 highest days until now this month. That is the capacity that will be used unless it is increased.

Output is sent when the query is run, that is on startup and when the hour changes.

Code
const MAX_COUNTING = 3;
const hours = msg.payload.viewer.home.consumption.nodes;
const days = new Map();
hours.forEach((h) => {
  const date = new Date(h.from).getDate();
  if (!days.has(date) || h.consumption > days.get(date).consumption) {
    days.set(date, { from: h.from, consumption: h.consumption });
  }
});
const highestToday = days.get(new Date().getDate()) ?? 0;
const highestPerDay = [...days.values()].sort((a, b) => b.consumption - a.consumption);
const highestCounting = highestPerDay.slice(0, MAX_COUNTING);
const currentMonthlyMaxAverage =
  highestCounting.length === 0
    ? 0
    : highestCounting.reduce((prev, val) => prev + val.consumption, 0) / highestCounting.length;
msg.payload = {
  highestPerDay,
  highestCounting,
  highestToday,
  currentMonthlyMaxAverage,
};
return msg;

Calculate values

This is where calculation is done to produce all the output sensor values.

In the beginning of the script there are some constants you can configure:

const HA_NAME = "homeAssistant"; // Your HA name
const STEPS = [2, 5, 10, 15, 20]; // Grid tariff steps in kWh
const MAX_COUNTING = 3; // Number of days to calculate month average of
const BUFFER = 0.5; // kWh - Closer to limit increases alarm level
const SAFE_SONE = 2; // kWh - Further from limit reduces level
const ALARM = 8; // Min level that causes status to be alarm
const MIN_TIMELEFT = 30; //Min level for time left (30 seconds)

The HA_NAME must be set to the name you have given your Home Assistant. One place to find this is in Node-RED, in the Context Data window (next to the Debug window), under Global, click the refresh button and see the homeassistant object. Find the name used to the right. In this example the value you are looking for is homeAssistant:

Global context window

You must configure the STEPS array to contain steps relevant for you. You should omit steps you do not plan to go under, to avoid non-necessary actions and warnings.

See Calculated sensor values for description of the output.

Code
const HA_NAME = "homeAssistant"; // Your HA name
const STEPS = [10, 15, 20];
const MAX_COUNTING = 3; // Number of days to calculate month
const BUFFER = 0.5; // Closer to limit increases level
const SAFE_ZONE = 2; // Further from limit reduces level
const ALARM = 8; // Min level that causes status to be alarm
const MIN_TIMELEFT = 3 * 60; //Min level for time left

const ha = global.get("homeassistant")[HA_NAME];
if (!ha.isConnected) {
  node.status({ fill: "red", shape: "dot", text: "Ha not connected" });
  return;
}

function isNull(value) {
  return value === null || value === undefined;
}

function calculateLevel(hourEstimate, currentHourRanking, highestCountingAverageWithCurrent, nextStep) {
  if (currentHourRanking === 0) {
    return 0;
  }
  if (highestCountingAverageWithCurrent > nextStep) {
    return 9;
  }
  if (highestCountingAverageWithCurrent > nextStep - BUFFER) {
    return 8;
  }
  if (hourEstimate > nextStep) {
    return 7;
  }
  if (hourEstimate > nextStep - BUFFER) {
    return 6;
  }
  if (currentHourRanking === 1 && nextStep - hourEstimate < SAFE_ZONE) {
    return 5;
  }
  if (currentHourRanking === 2 && nextStep - hourEstimate < SAFE_ZONE) {
    return 4;
  }
  if (currentHourRanking === 3 && nextStep - hourEstimate < SAFE_ZONE) {
    return 3;
  }
  if (currentHourRanking === 1) {
    return 2;
  }
  if (currentHourRanking === 2) {
    return 1;
  }
  return 0;
}

if (msg.payload.highestPerDay) {
  context.set("highestPerDay", msg.payload.highestPerDay);
  context.set("highestCounting", msg.payload.highestCounting);
  context.set("highestToday", msg.payload.highestToday);
  context.set("currentMonthlyMaxAverage", msg.payload.currentMonthlyMaxAverage);
  node.status({ fill: "green", shape: "ring", text: "Got ranking" });
  return;
}

const highestPerDay = context.get("highestPerDay");
const highestCounting = context.get("highestCounting");
const highestToday = context.get("highestToday");
const currentMonthlyMaxAverage = context.get("currentMonthlyMaxAverage");
const hourEstimate = msg.payload.hourEstimate;
const timeLeftMs = msg.payload.timeLeftMs;
const timeLeftSec = timeLeftMs / 1000;
const periodMs = msg.payload.periodMs;
const accumulatedConsumption = msg.payload.accumulatedConsumption;
const accumulatedConsumptionLastHour = msg.payload.accumulatedConsumptionLastHour;
const consumptionLeft = msg.payload.consumptionLeft;
const averageConsumptionNow = msg.payload.averageConsumptionNow;
const currentHour = msg.payload.currentHour;

if (timeLeftSec === 0) {
  node.status({ fill: "red", shape: "dot", text: "Time Left 0" });
  return null;
}

if (isNull(highestPerDay)) {
  node.status({ fill: "red", shape: "dot", text: "No highest per day" });
  return;
}
if (isNull(highestToday)) {
  node.status({ fill: "red", shape: "dot", text: "No highest today" });
  return;
}
if (isNull(hourEstimate)) {
  node.status({ fill: "red", shape: "dot", text: "No estimate" });
  return;
}

const currentStep = STEPS.reduceRight(
  (prev, val) => (val > currentMonthlyMaxAverage ? val : prev),
  STEPS[STEPS.length - 1]
);

// Set currentHourRanking
let currentHourRanking = MAX_COUNTING + 1;
for (let i = highestCounting.length - 1; i >= 0; i--) {
  if (hourEstimate > highestCounting[i].consumption) {
    currentHourRanking = i + 1;
  }
}
if (hourEstimate < highestToday.consumption) {
  currentHourRanking = 0;
}

const current = { from: currentHour, consumption: hourEstimate };
const highestCountingWithCurrent = [...highestCounting, current]
  .sort((a, b) => b.consumption - a.consumption)
  .slice(0, highestCounting.length);
const currentMonthlyEstimate =
  highestCountingWithCurrent.length === 0
    ? 0
    : highestCountingWithCurrent.reduce((prev, val) => prev + val.consumption, 0) / highestCountingWithCurrent.length;

// Set alarm level
const alarmLevel = calculateLevel(hourEstimate, currentHourRanking, currentMonthlyEstimate, currentStep);

// Evaluate status
const status = alarmLevel >= ALARM ? "Alarm" : alarmLevel > 0 ? "Warning" : "Ok";

// Avoid calculations to increase too much when timeLeftSec is approaching zero
const minTimeLeftSec = Math.max(timeLeftSec, MIN_TIMELEFT);
// Calculate reduction
const reductionRequired =
  alarmLevel < ALARM
    ? 0
    : (Math.max((currentMonthlyEstimate - currentStep) * highestCounting.length, 0) * 3600) / minTimeLeftSec;
const reductionRecommended =
  alarmLevel < 3 ? 0 : (Math.max(hourEstimate + SAFE_ZONE - currentStep, 0) * 3600) / minTimeLeftSec;

// Calculate increase possible
const increasePossible =
  alarmLevel >= 3 ? 0 : (Math.max(currentStep - hourEstimate - SAFE_ZONE, 0) * 3600) / minTimeLeftSec;

// Create output
const fill = status === "Ok" ? "green" : status === "Alarm" ? "red" : "yellow";
node.status({ fill, shape: "dot", text: "Working" });

const RESOLUTION = 1000;

const payload = {
  status, // Ok, Warning, Alarm
  statusOk: status === "Ok",
  statusWarning: status === "Warning",
  statusAlarm: status === "Alarm",
  alarmLevel,
  highestPerDay,
  highestCounting,
  highestCountingWithCurrent,
  highestToday,
  highestTodayConsumption: highestToday.consumption,
  highestTodayFrom: highestToday.from,
  currentMonthlyEstimate: Math.round(currentMonthlyEstimate * RESOLUTION) / RESOLUTION,
  accumulatedConsumptionLastHour: Math.round(accumulatedConsumptionLastHour * RESOLUTION) / RESOLUTION,
  consumptionLeft: Math.round(consumptionLeft * RESOLUTION) / RESOLUTION,
  hourEstimate: Math.round(hourEstimate * RESOLUTION) / RESOLUTION,
  averageConsumptionNow: Math.round(averageConsumptionNow * RESOLUTION) / RESOLUTION,
  reductionRequired: Math.round(reductionRequired * RESOLUTION) / RESOLUTION,
  reductionRecommended: Math.round(reductionRecommended * RESOLUTION) / RESOLUTION,
  increasePossible: Math.round(increasePossible * RESOLUTION) / RESOLUTION,
  currentStep,
  currentHourRanking,
  timeLeftSec,
  periodMs,
  accumulatedConsumption,
};

msg.payload = payload;

return msg;

Update sensors

This function node maps the values from the previous node to entity_ids that shall be updated in HA. Then, for each sensor, it sends output that uses the API node to perform the actual update.

You can remove sensors that you do not want.

Code
const sensors = [
  { id: "sensor.ps_cap_status", value: "status", uom: null },
  { id: "binary_sensor.ps_cap_ok", value: "statusOk", uom: null },
  { id: "binary_sensor.ps_cap_warning", value: "statusWarning", uom: null },
  { id: "binary_sensor.ps_cap_alarm", value: "statusAlarm", uom: null },
  { id: "sensor.ps_cap_alarm_level", value: "alarmLevel", uom: null },
  { id: "sensor.ps_cap_current_step", value: "currentStep", uom: "kW" },
  { id: "sensor.ps_cap_hour_estimate", value: "hourEstimate", uom: "kW" },
  { id: "sensor.ps_cap_current_hour_ranking", value: "currentHourRanking", uom: null },
  { id: "sensor.ps_cap_monthly_estimate", value: "currentMonthlyEstimate", uom: "kW" },
  { id: "sensor.ps_cap_highest_today", value: "highestTodayConsumption", uom: "kW" },
  { id: "sensor.ps_cap_highest_today_time", value: "highestTodayFrom", uom: null },
  { id: "sensor.ps_cap_reduction_required", value: "reductionRequired", uom: "kW" },
  { id: "sensor.ps_cap_reduction_recommended", value: "reductionRecommended", uom: "kW" },
  { id: "sensor.ps_cap_increase_possible", value: "increasePossible", uom: "kW" },
  { id: "sensor.ps_cap_estimate_rest_of_hour", value: "consumptionLeft", uom: "kW" },
  { id: "sensor.ps_cap_consumption_accumulated_hour", value: "accumulatedConsumptionLastHour", uom: "kW" },
  { id: "sensor.ps_cap_time_left", value: "timeLeftSec", uom: "s" },
  { id: "sensor.ps_cap_consumption_now", value: "averageConsumptionNow", uom: "kW" },
];

sensors.forEach((sensor) => {
  const payload = {
    protocol: "http",
    method: "post",
    path: "/api/states/" + sensor.id,
    data: {
      state: msg.payload[sensor.value],
      attributes: { unit_of_measurement: sensor.uom },
    },
  };
  node.send({ payload });
});

Set entity

Reduction Actions

This is where you set up actions to be taken in case reduction is required or recommended.

Configure actions

You must set up your own actions in this script, if you are going to use actions.

In the On Start tab of this node, you set up the actions by writing a Javascript array, the actions array. The example shows some actions, but you may set up any number of actions.

There are two types of actions that can be sent:

Call service actions

These actions are taken by sending a payload to a HA call service node (the Perform action node). The items in the actions array contains the payload you need to send to the call service node in order to take action, and the payload you need to send to the same call service node in order to reset the action.

Override Power Saver actions

These actions are taken by sending an override message to one or more strategy nodes. Reduction is done by sending override off, and reset is done by sending override auto. To use this type of action, specify the name of the strategy node that shall be overridden in the nameOfStrategyToOverride attribute of the action.

Then send output 2 from the Reduction Actions node to the input of the strategy node.

You may have multiple actions controlling multiple strategy nodes. They are separated using the name of the strategy node, so make sure they all have different names. Output 2 must be sent to all strategy nodes that shall be controlled.

If you send the output to two strategy nodes with the same name, they will both be controlled.

You can use this to override the following nodes:

  • Best Save
  • Lowest Price
  • Schedule Merger
  • Fixed Schedule

If you are using the schedule merger node, you do not have to override the strategy nodes preceding the schedule merger, only override the schedule merger.

Entity consumption

An action may be to turn on or off a switch, to perform a climate control or what ever else you can do to control your entities.

In order to know how much power that is saved by turning off an action, you should specify this for each action, using the consumption attribute. This can be used in 3 different ways:

  • The entity_id of a sensor that gives the consumption in kW (recommended)
  • A number with the consumption in kW
  • A function returning the consumption.

Consumption must be kW

If your sensor gives consumption in W, not the required kW, you should find a way to divide it by 1000.

Actions configuration

Each item in the actions array contains the following data:

Variable nameDescription
consumptionThe consumption that will be reduced by taking the action, given as either a) (Recommended) The entity_id of a sensor that gives the consumption, or b) A number with the consumption in kWh, or c) a function returning the consumption.
nameThe name of the actions. Can be any thing.
idA unique id of the action.
minAlarmLevelThe minimum alarm level that must be present to take this action.
reduceWhenRecommendedIf true the action will be taken when Reduction Recommended > 0. If false the action will be taken only when Reduction Required > 0
minTimeOffSecThe action will not be reset until minimum this number of seconds has passed since the action was taken.
payloadToTakeActionThe payload that shall be sent to the call service node to take the action (for example turn off a switch).
payloadToResetActionThe payload that shall be sent to the call service node to reset the action (for example turn a switch back on again).
nameOfStrategyToOverrideThe name of the strategy node that shall be overridden by this action.

Action type

Use either nameOfStrategyToOverride or payloadToTakeAction and payloadToResetAction. If you use them both at the same time, both actions are performed.

Actions order

Actions to reduce consumption are taken in the order they appear in the actions array until enough reduction has been done, so put first the actions you want to take first, and last those actions that you want to take only when really necessary.

Here is an example of an actions array with two items (a water heater and a heating cable):

On Start code

Please note that there is a small piece of code after the actions array in the On Start tab. Make sure you do not delete that code.

Sensors without actions

If you don't want the actions, or you want to control actions another way, you can omit the action-related nodes and only use the nodes creating the sensors.

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.

Code
// You MUST edit the actions array with your own actions.

const actions = [
  {
    consumption: "sensor.varmtvannsbereder_electric_consumption_w",
    name: "Varmtvannsbereder",
    id: "vvb",
    minAlarmLevel: 3,
    reduceWhenRecommended: true,
    minTimeOffSec: 300,
    nameOfStrategyToOverride: "Lowest Price VVB",
  },
  {
    consumption: "sensor.varme_gulv_bad_electric_consumption_w_2",
    name: "Varme gulv bad 1. etg.",
    id: "gulvbad",
    minAlarmLevel: 3,
    reduceWhenRecommended: true,
    minTimeOffSec: 300,
    nameOfStrategyToOverride: "Lowest Price Varmekabel",
  },
  {
    consumption: "sensor.varme_gulv_gang_electric_consumption_w",
    name: "Varme gulv gang 1. etg.",
    id: "gulvgang",
    minAlarmLevel: 3,
    reduceWhenRecommended: true,
    minTimeOffSec: 300,
    payloadToTakeAction: {
      domain: "climate",
      service: "turn_off",
      target: {
        entity_id: ["climate.varme_gulv_gang"],
      },
    },
    payloadToResetAction: {
      domain: "climate",
      service: "turn_on",
      target: {
        entity_id: ["climate.varme_gulv_gang"],
      },
    },
  },
  {
    consumption: "sensor.varme_gulv_kjellerstue_electric_consumption_w",
    name: "Varme gulv kjellerstue",
    id: "gulvkjeller",
    minAlarmLevel: 3,
    reduceWhenRecommended: true,
    minTimeOffSec: 300,
    payloadToTakeAction: {
      domain: "climate",
      service: "turn_off",
      target: {
        entity_id: ["climate.varme_gulv_kjellerstue"],
      },
    },
    payloadToResetAction: {
      domain: "climate",
      service: "turn_on",
      target: {
        entity_id: ["climate.varme_gulv_kjellerstue"],
      },
    },
  },
  {
    consumption: 0.1,
    name: "Test",
    id: "test",
    minAlarmLevel: 3,
    reduceWhenRecommended: true,
    minTimeOffSec: 30,
    payloadToTakeAction: {
      domain: "switch",
      service: "turn_off",
      target: {
        entity_id: ["switch.lys_kjokkenskap_switch"],
      },
    },
    payloadToResetAction: {
      domain: "switch",
      service: "turn_on",
      target: {
        entity_id: ["switch.lys_kjokkenskap_switch"],
      },
    },
  },
];
// End of actions array

// DO NOT DELETE THE CODE BELOW

// Set default values for all actions
actions.forEach((a) => {
  a.actionTaken = false;
  a.savedConsumption = 0;
});

flow.set("actions", actions);
const MIN_CONSUMPTION_TO_CARE = 0.05; // Do not reduce unless at least 50W
const MIN_MINUTES_INTO_HOUR_TO_TAKE_ACTION = 5;

const actions = flow.get("actions");
const ha = global.get("homeassistant").homeAssistant;

let reductionRequired = msg.payload.reductionRequired;
let reductionRecommended = msg.payload.reductionRecommended;

node.status({});

if (reductionRecommended <= 0) {
  return null;
}

if (3600 - msg.payload.timeLeftSec < MIN_MINUTES_INTO_HOUR_TO_TAKE_ACTION * 60) {
  node.status({
    fill: "yellow",
    shape: "ring",
    text: "No action during first " + MIN_MINUTES_INTO_HOUR_TO_TAKE_ACTION + " minutes",
  });
  return;
}

function takeAction(action, consumption) {
  const info = {
    time: new Date().toISOString(),
    name: "Reduction action",
    data: msg.payload,
    action,
  };

  // output1 is for actions
  const output1 = action.payloadToTakeAction ? { payload: action.payloadToTakeAction } : null;
  // output 2 is for overriding PS strategies
  const output2 = action.nameOfStrategyToOverride
    ? { payload: { config: { override: "off" }, name: action.nameOfStrategyToOverride } }
    : null;
  // output 3 is for logging
  const output3 = { payload: info };

  node.send([output1, output2, output3]);
  reductionRequired = Math.max(0, reductionRequired - consumption);
  reductionRecommended = Math.max(0, reductionRecommended - consumption);
  action.actionTaken = true;
  action.actionTime = Date.now();
  action.savedConsumption = consumption;
  flow.set("actions", actions);
}

function getConsumption(consumption) {
  if (typeof consumption === "string") {
    const sensor = ha.states[consumption];
    return sensor.state / 1000;
  } else if (typeof consumption === "number") {
    return consumption;
  } else if (typeof consumption === "function") {
    return consumption();
  } else {
    node.warn("Config error: consumption has illegal type: " + typeof consumption);
    return 0;
  }
}

actions
  .filter((a) => msg.payload.alarmLevel >= a.minAlarmLevel && !a.actionTaken)
  .forEach((a) => {
    const consumption = getConsumption(a.consumption);
    if (consumption < MIN_CONSUMPTION_TO_CARE) {
      return;
    }
    if (reductionRequired > 0 || (reductionRecommended > 0 && a.reduceWhenRecommended)) {
      takeAction(a, consumption);
    }
  });

Reset Actions

This node will reset actions when there is enough capacity available, that is for example turning switches back on again.

In the script, there is a BUFFER_TO_RESET constant used to set a buffer (in kW) so actions are not reset until there is some spare capacity. By default is it set to 1 kW.

Code
const actions = flow.get("actions");
const ha = global.get("homeassistant").homeAssistant;

const BUFFER_TO_RESET = 1; // Must have 1kW extra to perform reset

let increasePossible = msg.payload.increasePossible;

if (increasePossible <= 0) {
  return null;
}

function resetAction(action) {
  const info = {
    time: new Date().toISOString(),
    name: "Reset action",
    data: msg.payload,
    action,
  };
  const output1 = action.payloadToResetAction ? { payload: action.payloadToResetAction } : null;
  const output2 = action.nameOfStrategyToOverride
    ? { payload: { config: { override: "auto" }, name: action.nameOfStrategyToOverride } }
    : null;
  const output3 = { payload: info };

  node.send([output1, output2, output3]);
  increasePossible -= action.savedConsumption;
  action.actionTaken = false;
  action.savedConsumption = 0;
  flow.set("actions", actions);
}

actions
  .filter(
    (a) =>
      a.actionTaken &&
      a.savedConsumption + BUFFER_TO_RESET <= increasePossible &&
      Date.now() - a.actionTime > a.minTimeOffSec * 1000
  )
  .forEach((a) => resetAction(a));

Perform action

This is a call service node used to perform the call service actions (both taking actions and resetting actions). There is no setup here except selecting the HA server.

Save actions to file

Saves some information to a file, when actions are taken or reset. This is just so you can watch what has been done. Please make sure the file name configured works for you (for example that the folder exists in your HA instance).

Catch action errors

This is supposed to catch any errors in the action-related nodes, and log them to the file.

The code

Below is the code for the Node-RED flow. Copy the code and paste it to Node-RED using Import in the NR menu.

Flow code
[
  {
    "id": "2a073d402b1b6573",
    "type": "function",
    "z": "d938c47f.3398f8",
    "name": "Build query for consumption",
    "func": "/*\n   Calculate number of hours to receive consumption for,\n   that is number of hours in the month until now.\n   Constructs a tibber query to get consumption per hour.\n*/\n\nconst TIBBER_HOME_ID = \"142c4839-64cf-4df4-ba6d-942527a757c4\"\n\nconst timestamp = msg.payload.timestamp\n\n// Stop if hour has not changed\nconst time = new Date(timestamp)\nconst hour = time.getHours()\nconst previousHour = context.get(\"previousHour\")\nif(previousHour !== undefined && hour === previousHour) {\n    return\n}\ncontext.set(\"previousHour\", hour)\n\n// Calculate number of hours to query\nconst date = time.getDate() - 1\nconst hour2 = time.getHours()\nconst count = date * 24 + hour2\n\n// Build query\nconst query = `\n{\n  viewer {\n    home (id: \"${TIBBER_HOME_ID}\") {\n      consumption(resolution: HOURLY, last: ${count}) {\n        nodes {\n          from\n          consumption\n        }\n      }\n    }\n  }\n}\n`\n\nmsg.payload = query\nreturn msg;",
    "outputs": 1,
    "noerr": 0,
    "initialize": "// Code added here will be run once\n// whenever the node is started.\ncontext.set(\"previousHour\", undefined)",
    "finalize": "",
    "libs": [],
    "x": 340,
    "y": 1100,
    "wires": [["cf42844ec5bdfd21"]]
  },
  {
    "id": "cf42844ec5bdfd21",
    "type": "tibber-query",
    "z": "d938c47f.3398f8",
    "name": "Get consumption",
    "active": true,
    "apiEndpointRef": "b70ec5d0.6f8f08",
    "x": 150,
    "y": 1180,
    "wires": [["172bdb20196bc56a"]]
  },
  {
    "id": "b5b84faebe49979e",
    "type": "tibber-feed",
    "z": "d938c47f.3398f8",
    "name": "Get live data",
    "active": true,
    "apiEndpointRef": "b70ec5d0.6f8f08",
    "homeId": "your-home-id-from-tibber",
    "timestamp": "1",
    "power": "1",
    "lastMeterConsumption": false,
    "accumulatedConsumption": true,
    "accumulatedProduction": false,
    "accumulatedConsumptionLastHour": "1",
    "accumulatedProductionLastHour": false,
    "accumulatedCost": false,
    "accumulatedReward": false,
    "currency": false,
    "minPower": false,
    "averagePower": false,
    "maxPower": false,
    "powerProduction": false,
    "minPowerProduction": false,
    "maxPowerProduction": false,
    "lastMeterProduction": false,
    "powerFactor": false,
    "voltagePhase1": false,
    "voltagePhase2": false,
    "voltagePhase3": false,
    "currentL1": false,
    "currentL2": false,
    "currentL3": false,
    "signalStrength": false,
    "x": 110,
    "y": 1080,
    "wires": [["90412687d7504168", "2a073d402b1b6573"]]
  },
  {
    "id": "172bdb20196bc56a",
    "type": "function",
    "z": "d938c47f.3398f8",
    "name": "Find highest per day",
    "func": "const MAX_COUNTING = 3\nconst hours = msg.payload.viewer.home.consumption.nodes\nconst days = new Map()\nhours.forEach (h => {\n    const date = (new Date(h.from)).getDate()\n    if (!days.has(date) || h.consumption > days.get(date).consumption) {\n        days.set(date, {from: h.from, consumption: h.consumption})\n    }\n})\nconst highestToday = days.get((new Date()).getDate()) ?? {\n    consumption: 0,\n    from: null\n}\nconst highestPerDay = [...days.values()].sort((a, b) => b.consumption - a.consumption)\nconst highestCounting = highestPerDay.slice(0, MAX_COUNTING)\nconst currentMonthlyMaxAverage = highestCounting.length === 0 \n? 0 \n: highestCounting.reduce((prev, val) => \n  prev + val.consumption, 0) / highestCounting.length\nmsg.payload = {\n    highestPerDay,\n    highestCounting,\n    highestToday,\n    currentMonthlyMaxAverage\n}\nreturn msg;",
    "outputs": 1,
    "noerr": 0,
    "initialize": "",
    "finalize": "",
    "libs": [],
    "x": 380,
    "y": 1160,
    "wires": [["deee9c5a2e504afd"]]
  },
  {
    "id": "90412687d7504168",
    "type": "function",
    "z": "d938c47f.3398f8",
    "name": "Collect estimate for hour",
    "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;",
    "outputs": 1,
    "noerr": 0,
    "initialize": "// Code added here will be run once\n// whenever the node is started.\ncontext.set(\"buffer\", [])",
    "finalize": "",
    "libs": [],
    "x": 330,
    "y": 1060,
    "wires": [["deee9c5a2e504afd"]]
  },
  {
    "id": "deee9c5a2e504afd",
    "type": "function",
    "z": "d938c47f.3398f8",
    "name": "Calculate values",
    "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;",
    "outputs": 1,
    "noerr": 0,
    "initialize": "",
    "finalize": "",
    "libs": [],
    "x": 590,
    "y": 1060,
    "wires": [["ac0b86c136f40790", "3cdb68064ac5a5bc", "db2946b0d86cd4cd"]]
  },
  {
    "id": "3cdb68064ac5a5bc",
    "type": "function",
    "z": "d938c47f.3398f8",
    "name": "Reduction Actions",
    "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",
    "outputs": 3,
    "noerr": 0,
    "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",
    "finalize": "const actions = flow.get(\"actions\")\n\nactions\n    .filter(a => a.actionTaken)\n    .forEach(a => \n        node.send({ payload: a.payloadToResetAction })\n    )",
    "libs": [],
    "x": 460,
    "y": 1270,
    "wires": [["28a20e58f1058b6d"], ["c0f07cbad0e324dd", "2c50865d59881701"], ["1d738e15969dd163"]]
  },
  {
    "id": "ac0b86c136f40790",
    "type": "function",
    "z": "d938c47f.3398f8",
    "name": "Reset Actions",
    "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",
    "outputs": 3,
    "noerr": 0,
    "initialize": "",
    "finalize": "",
    "libs": [],
    "x": 450,
    "y": 1330,
    "wires": [["28a20e58f1058b6d"], ["c0f07cbad0e324dd", "2c50865d59881701"], ["1d738e15969dd163"]]
  },
  {
    "id": "28a20e58f1058b6d",
    "type": "api-call-service",
    "z": "d938c47f.3398f8",
    "name": "Perform action",
    "server": "ec4a12a1.b2be9",
    "version": 5,
    "debugenabled": false,
    "domain": "",
    "service": "",
    "areaId": [],
    "deviceId": [],
    "entityId": [],
    "data": "",
    "dataType": "jsonata",
    "mergeContext": "",
    "mustacheAltTags": false,
    "outputProperties": [
      {
        "property": "payload",
        "propertyType": "msg",
        "value": "payload",
        "valueType": "msg"
      }
    ],
    "queue": "none",
    "x": 770,
    "y": 1360,
    "wires": [[]]
  },
  {
    "id": "1d738e15969dd163",
    "type": "file",
    "z": "d938c47f.3398f8",
    "name": "Save actions to file",
    "filename": "/share/capacity-actions.txt",
    "filenameType": "str",
    "appendNewline": true,
    "createDir": false,
    "overwriteFile": "false",
    "encoding": "none",
    "x": 780,
    "y": 1420,
    "wires": [[]]
  },
  {
    "id": "0656818b7253b0aa",
    "type": "catch",
    "z": "d938c47f.3398f8",
    "name": "Catch action errors",
    "scope": ["3cdb68064ac5a5bc", "ac0b86c136f40790"],
    "uncaught": false,
    "x": 460,
    "y": 1400,
    "wires": [["1d738e15969dd163"]]
  },
  {
    "id": "7f34cef20e2c0841",
    "type": "ha-api",
    "z": "d938c47f.3398f8",
    "name": "Set entity",
    "server": "ec4a12a1.b2be9",
    "version": 1,
    "debugenabled": false,
    "protocol": "http",
    "method": "post",
    "path": "",
    "data": "",
    "dataType": "json",
    "responseType": "json",
    "outputProperties": [
      {
        "property": "payload",
        "propertyType": "msg",
        "value": "",
        "valueType": "results"
      }
    ],
    "x": 990,
    "y": 1060,
    "wires": [[]]
  },
  {
    "id": "db2946b0d86cd4cd",
    "type": "function",
    "z": "d938c47f.3398f8",
    "name": "Update sensors",
    "func": "const sensors = [\n    { id: \"sensor.ps_cap_status\", value: \"status\", uom: null },\n    { id: \"binary_sensor.ps_cap_ok\", value: \"statusOk\", uom: null },\n    { id: \"binary_sensor.ps_cap_warning\", value: \"statusWarning\", uom: null },\n    { id: \"binary_sensor.ps_cap_alarm\", value: \"statusAlarm\", uom: null },\n    { id: \"sensor.ps_cap_alarm_level\", value: \"alarmLevel\", uom: null },\n    { id: \"sensor.ps_cap_current_step\", value: \"currentStep\", uom: \"kW\" },\n    { id: \"sensor.ps_cap_hour_estimate\", value: \"hourEstimate\", uom: \"kW\" },\n    { id: \"sensor.ps_cap_current_hour_ranking\", value: \"currentHourRanking\", uom: null },\n    { id: \"sensor.ps_cap_monthly_estimate\", value: \"currentMonthlyEstimate\", uom: \"kW\" },\n    { id: \"sensor.ps_cap_highest_today\", value: \"highestTodayConsumption\", uom: \"kW\" },\n    { id: \"sensor.ps_cap_highest_today_time\", value: \"highestTodayFrom\", uom: null },\n    { id: \"sensor.ps_cap_reduction_required\", value: \"reductionRequired\", uom: \"kW\" },\n    { id: \"sensor.ps_cap_reduction_recommended\", value: \"reductionRecommended\", uom: \"kW\" },\n    { id: \"sensor.ps_cap_increase_possible\", value: \"increasePossible\", uom: \"kW\" },\n    { id: \"sensor.ps_cap_estimate_rest_of_hour\", value: \"consumptionLeft\", uom: \"kW\" },\n    { id: \"sensor.ps_cap_consumption_accumulated_hour\", value: \"accumulatedConsumptionLastHour\", uom: \"kW\" },\n    { id: \"sensor.ps_cap_time_left\", value: \"timeLeftSec\", uom: \"s\" },\n    { id: \"sensor.ps_cap_consumption_now\", value: \"averageConsumptionNow\", uom: \"kW\" },\n]\n\nsensors.forEach((sensor) => {\n    const payload = {\n        protocol: \"http\",\n        method: \"post\",\n        path: \"/api/states/\" + sensor.id,\n        data: {\n            state: msg.payload[sensor.value],\n            attributes: { unit_of_measurement: sensor.uom }\n        }\n    }\n    node.send({payload})\n})\n",
    "outputs": 1,
    "noerr": 0,
    "initialize": "",
    "finalize": "",
    "libs": [],
    "x": 820,
    "y": 1060,
    "wires": [["7f34cef20e2c0841"]]
  },
  {
    "id": "b70ec5d0.6f8f08",
    "type": "tibber-api-endpoint",
    "feedUrl": "wss://api.tibber.com/v1-beta/gql/subscriptions",
    "queryUrl": "https://api.tibber.com/v1-beta/gql",
    "name": "Tibber API"
  }
]
Last Updated: