UNPKG

node-red-contrib-power-saver

Version:

A module for Node-RED that you can use to turn on and off a switch based on power prices

281 lines (254 loc) 10.2 kB
"use strict"; const { DateTime } = require("luxon"); const { roundPrice, getDiffToNextOn } = require("./utils"); function calculateOpportunities(prices, pattern, amount) { //creating a price vector with minute granularity const tempPrice = Array(prices.length * 60).fill(0); for (let i = 0; i < prices.length; i++) { tempPrice.fill(prices[i], i * 60, (i + 1) * 60); //debugger; } //Calculate weighted pattern const weight = amount / pattern.reduce((a, b) => a + b, 0); //last calculates the sum of all numbers in the pattern const weightedPattern = pattern.map((x) => x * weight); //Calculating procurement opportunities. Sliding the pattern over the price vector to find the price for procuring //at time t const dot = (a, b) => a.map((x, i) => a[i] * b[i]).reduce((m, n) => m + n); const procurementOpportunities = Array(prices.length * 60 - pattern.length + 1); for (let i = 0; i < procurementOpportunities.length; i++) { procurementOpportunities[i] = dot(weightedPattern, tempPrice.slice(i, i + pattern.length)); } return procurementOpportunities; } // This function finds the buy sell // schedule for maximum profit // two vectors containing the buy and sell indexes are returned in an array function findBestBuySellPattern(priceBuy, buyLength, priceSell, sellLength) { // Traverse through given price array const buyIndexes = []; const sellIndexes = []; let i = 0; while (i < priceBuy.length - 1) { // Find Local Minima // Note that the limit is (n-2) as we are // comparing present element to the next element while (i < priceBuy.length - 1 && priceBuy[i + 1] < priceBuy[i]) i++; // If we reached the end, break // as no further solution is possible if (i == priceBuy.length - 1) break; // Store the index of minima buyIndexes.push(i); // Move the next allowed maxima away from the minima - required due to the asymmetric buy/sell prices i = i + Math.round(buyLength / 2); // Find Local Maxima // Note that the limit is (n-1) as we are // comparing to previous element while (i < priceSell.length && priceSell[i] >= priceSell[i - 1]) i++; // Store the index of maxima sellIndexes.push(i - 1); i = i + Math.round(sellLength / 2); } return [buyIndexes, sellIndexes]; } function calculateValueDictList(buySell, buyPrices, sellPrices, startDate) { const buySellValueDictList = []; for (let i = 0; i < buySell[0].length; i++) { const buyDateTime = startDate.plus({ minutes: buySell[0][i] }); const sellDateTime = startDate.plus({ minutes: buySell[1][i] }); if (i != 0) { const prevSellDateTime = startDate.plus({ minutes: buySell[1][i - 1] }); buySellValueDictList.push({ type: "sell - buy", tradeValue: roundPrice(sellPrices[buySell[1][i - 1]] - buyPrices[buySell[0][i]]), buyIndex: buySell[0][i], buyDate: buyDateTime, buyPrice: roundPrice(buyPrices[buySell[0][i]]), sellIndex: buySell[1][i - 1], sellDate: prevSellDateTime, sellPrice: roundPrice(sellPrices[buySell[1][i - 1]]), }); } buySellValueDictList.push({ type: "buy - sell", tradeValue: roundPrice(sellPrices[buySell[1][i]] - buyPrices[buySell[0][i]]), buyIndex: buySell[0][i], buyDate: buyDateTime, buyPrice: roundPrice(buyPrices[buySell[0][i]]), sellIndex: buySell[1][i], sellDate: sellDateTime, sellPrice: roundPrice(sellPrices[buySell[1][i]]), }); } return buySellValueDictList; } function removeLowBuySellPairs(buySellPattern, buyPrices, sellPrices, minSavings, startDate) { let lowestSaving = -1; const buySellClone = Array.from(buySellPattern); while (minSavings >= lowestSaving) { const dictList = calculateValueDictList(buySellClone, buyPrices, sellPrices, startDate); if (dictList.length === 0) { return buySellClone; } let sellIndex = 0; let buyIndex = 0; for (let i = 0; i < dictList.length; i++) { if (i == 0 || dictList[i].tradeValue < lowestSaving) { lowestSaving = dictList[i].tradeValue; sellIndex = dictList[i].sellIndex; buyIndex = dictList[i].buyIndex; } } if (lowestSaving <= minSavings) { buySellClone[0] = buySellClone[0].filter((x) => x != buyIndex); buySellClone[1] = buySellClone[1].filter((x) => x != sellIndex); } } return buySellClone; } function calculateSchedule( startDate, buySellStackedArray, buyPrices, sellPrices, setpoint, maxTempAdjustment, boostTempHeat, boostTempCool, buyDuration, sellDuration ) { const arrayLength = buyPrices.length; const schedule = { startAt: startDate, temperatures: Array(arrayLength), maxTempAdjustment: maxTempAdjustment, durationInMinutes: arrayLength, boostTempHeat: boostTempHeat, boostTempCool: boostTempCool, heatingDuration: buyDuration, coolingDuration: sellDuration, minimalSchedule: [], //array of dicts with date as key and temperature as value }; function pushTempChange(startDate, minutes, tempAdj, sp) { if ( schedule.minimalSchedule.length > 0 && schedule.minimalSchedule[schedule.minimalSchedule.length - 1].adjustment == tempAdj ) return; schedule.minimalSchedule.push({ startAt: startDate.plus({ minutes: minutes }).toISO(), setpoint: sp + tempAdj, adjustment: tempAdj, }); } if (buySellStackedArray[0].length === 0) { //No procurements or sales scheduled schedule.minimalSchedule.push({ startDate: -maxTempAdjustment }); schedule.temperatures.fill(-maxTempAdjustment, 0, arrayLength); } else { let lastBuyIndex = 0; let boostHeat; let boostCool; for (let i = 0; i < buySellStackedArray[0].length; i++) { const buyIndex = buySellStackedArray[1][i]; const sellIndex = buySellStackedArray[0][i]; //If this is the start of the time-series, do not boost the temperatures sellIndex == 0 ? (boostHeat = 0) : (boostHeat = boostTempHeat); lastBuyIndex == 0 ? (boostCool = 0) : (boostCool = boostTempCool); //Cooling period. Adding boosted cooling temperature for the period of divestment pushTempChange(startDate, lastBuyIndex, -maxTempAdjustment - boostCool, setpoint); if (sellIndex - lastBuyIndex <= sellDuration) { schedule.temperatures.fill(-maxTempAdjustment - boostCool, lastBuyIndex, sellIndex); } else { pushTempChange(startDate, lastBuyIndex + sellDuration, -maxTempAdjustment, setpoint); schedule.temperatures.fill(-maxTempAdjustment - boostCool, lastBuyIndex, lastBuyIndex + sellDuration); schedule.temperatures.fill(-maxTempAdjustment, lastBuyIndex + sellDuration, sellIndex); } //Heating period. Adding boosted heating temperature for the period of procurement pushTempChange(startDate, sellIndex, maxTempAdjustment + boostHeat, setpoint); if (buyIndex - sellIndex <= buyDuration) { schedule.temperatures.fill(maxTempAdjustment + boostHeat, sellIndex, buyIndex); } else { pushTempChange(startDate, sellIndex + buyDuration, maxTempAdjustment, setpoint); schedule.temperatures.fill(maxTempAdjustment + boostHeat, sellIndex, sellIndex + buyDuration); schedule.temperatures.fill(maxTempAdjustment, sellIndex + buyDuration, buyIndex); } lastBuyIndex = buyIndex; } //final fill pushTempChange(startDate, lastBuyIndex, -maxTempAdjustment - boostCool, setpoint); if (arrayLength - lastBuyIndex <= sellDuration) { schedule.temperatures.fill(-maxTempAdjustment - boostCool, lastBuyIndex, arrayLength); } else { pushTempChange(startDate, lastBuyIndex + sellDuration, -maxTempAdjustment, setpoint); schedule.temperatures.fill(-maxTempAdjustment - boostCool, lastBuyIndex, lastBuyIndex + sellDuration); schedule.temperatures.fill(-maxTempAdjustment, lastBuyIndex + sellDuration, arrayLength); } } schedule.trades = calculateValueDictList(buySellStackedArray, buyPrices, sellPrices, startDate); return schedule; } function findTemp(date, schedule) { let closestDate = null; let temp = null; schedule.minimalSchedule.forEach((e) => { const testDate = DateTime.fromISO(e.startAt); if (date < testDate) return; if (closestDate !== null) { if (closestDate > testDate) return; // } closestDate = testDate; temp = e.adjustment; }); if (temp == null) temp = 0; return temp; } function runBuySellAlgorithm( priceData, timeHeat1C, timeCool1C, setpoint, boostTempHeat, boostTempCool, maxTempAdjustment, minSavings ) { const prices = [...priceData.map((pd) => pd.value)]; const startDate = DateTime.fromISO(priceData[0].start); //pattern for how much power is procured/sold when. //This has, for now, just a flat acquisition/divestment profile const buyDuration = Math.round(timeHeat1C * maxTempAdjustment * 2); const sellDuration = Math.round(timeCool1C * maxTempAdjustment * 2); const buyPattern = Array(buyDuration).fill(1); const sellPattern = Array(sellDuration).fill(1); //Calculate what it will cost to procure/sell 1 kWh as a function of time const buyPrices = calculateOpportunities(prices, buyPattern, 1); const sellPrices = calculateOpportunities(prices, sellPattern, 1); //Find dates for when to procure/sell const buySell = findBestBuySellPattern(buyPrices, buyPattern.length, sellPrices, sellPattern.length); //Remove small/disputable gains (least profitable buy/sell pairs) const buySellCleaned = removeLowBuySellPairs(buySell, buyPrices, sellPrices, minSavings, startDate); //Calculate temperature adjustment as a function of time const schedule = calculateSchedule( startDate, buySellCleaned, buyPrices, sellPrices, setpoint, maxTempAdjustment, boostTempHeat, boostTempCool, buyDuration, sellDuration ); return schedule; } module.exports = { runBuySellAlgorithm, findTemp, calculateOpportunities, findBestBuySellPattern, calculateValueDictList, removeLowBuySellPairs, calculateSchedule, };