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

309 lines (274 loc) 11.1 kB
"use strict"; const { DateTime } = require("luxon"); const { roundPrice, getDiffToNextOn } = require("./utils"); function buildMinutePriceVector(priceData) { if (!priceData || priceData.length === 0) { return { minutePrices: [], startDate: null }; } const sorted = [...priceData].sort( (a, b) => DateTime.fromISO(a.start).toMillis() - DateTime.fromISO(b.start).toMillis() ); const minutePrices = []; let previousIntervalMinutes = 60; for (let i = 0; i < sorted.length; i++) { const currentStart = DateTime.fromISO(sorted[i].start); let intervalMinutes = previousIntervalMinutes; if (sorted[i + 1]) { intervalMinutes = DateTime.fromISO(sorted[i + 1].start).diff(currentStart, "minutes").minutes; } else if (sorted[i].end) { intervalMinutes = DateTime.fromISO(sorted[i].end).diff(currentStart, "minutes").minutes; } intervalMinutes = Math.max(1, Math.round(intervalMinutes)); previousIntervalMinutes = intervalMinutes; for (let m = 0; m < intervalMinutes; m++) { minutePrices.push(sorted[i].value); } } return { minutePrices, startDate: DateTime.fromISO(sorted[0].start) }; } function calculateOpportunities(pricesPerMinute, pattern, amount) { const tempPrice = pricesPerMinute; //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, 0); const procurementLength = Math.max(tempPrice.length - pattern.length + 1, 0); const procurementOpportunities = Array(procurementLength); 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 { minutePrices, startDate } = buildMinutePriceVector(priceData); //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(minutePrices, buyPattern, 1); const sellPrices = calculateOpportunities(minutePrices, 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, };