UNPKG

iobroker.apg-info

Version:
1,074 lines (1,000 loc) 69.2 kB
'use strict'; const utils = require('@iobroker/adapter-core'); const axios = require('axios'); const jsonExplorer = require('iobroker-jsonexplorer'); const stateAttr = require(`./lib/stateAttr.js`); // Load attribute library const isOnline = require('@esm2cjs/is-online').default; const { version } = require('./package.json'); const { getDataExaa1015, getDataExaa, getDataAwattar, getDataPeakHours, getDataEntsoe, getDataEnergyCharts } = require('./lib/getData.js'); const { addDays, cleanDate, calcDate, pad, compareSecondColumn } = require('./lib/helpers.js'); // Constants const MAX_DELAY = 25000; //25000 const API_TIMEOUT = 20000; //10000 const API_TIMEOUT_ENTSOE = 35000; //35000 class ApgInfo extends utils.Adapter { /** * @param {Partial<utils.AdapterOptions>} [options] Settings for the adapter instance */ constructor(options) { super({ ...options, name: 'apg-info', }); this.on('ready', this.onReady.bind(this)); this.axiosInstance = axios.create({ timeout: API_TIMEOUT }); this.jsonExplorer = jsonExplorer; //this.on('stateChange', this.onStateChange.bind(this)); //this.on('message', this.onMessage.bind(this)); this.on('unload', this.onUnload.bind(this)); jsonExplorer.init(this, stateAttr); this.config_calculate = false; this.feeAbsolute = 0; this.feeRelative = 0; this.vat = 0; this.charges = 0; this.gridCosts = 0; this.token = ''; this.threshold = 10; this.config_costs = []; } /** * Is called when databases are connected and adapter received configuration. */ async onReady() { let country = ''; // Initialize adapter jsonExplorer.sendVersionInfo(version); this.log.info(`Started with JSON-Explorer version ${jsonExplorer.version}`); if (this.config.threshold != undefined) { this.threshold = this.config.threshold; } else { this.log.info('Market price threshold not found and set to 10'); } this.config_forecast = this.config.forecast ?? false; this.config_calculate = this.config.calculate ?? false; this.config_peakHours = this.config.peakHours ?? false; this.config_marketPrices = this.config.marketPrices ?? false; this.config_quarterHourly = this.config.quarterHourly ?? false; this.config_hourly = this.config.hourly ?? false; this.config_details = this.config.details ?? false; this.config_costs = this.config.costs ?? []; if (this.config_calculate == true) { this.feeAbsolute = this.config.feeAbsolute ?? 0; this.feeRelative = (this.config.feeRelative ?? 0) / 100; this.vat = (this.config.vat ?? 0) / 100; this.charges = (this.config.charges ?? 0) / 100; this.gridCosts = this.config.gridCosts ?? 0; } if (this.config.country) { country = this.config.country; } else { this.log.error('Country for market not found. Please confifure in Config'); this.terminate('Missing country configuration', 11); } if (this.config.tokenEncrypted) { this.token = this.config.tokenEncrypted; } else { const instanceId = `system.adapter.${this.name}.${this.instance}`; const objInstance = await this.getForeignObjectAsync(instanceId); if (objInstance?.native) { let tokenUnEncrypted = objInstance.native.token; if (tokenUnEncrypted) { this.log.info(`Let's onetime encrypt the token...`); objInstance.native.tokenEncrypted = this.encrypt(tokenUnEncrypted); delete objInstance.native.token; await this.setForeignObject(instanceId, objInstance); this.token = tokenUnEncrypted; this.log.info(`Token encrypted and saved in instance ${instanceId}`); } } } if (!this.token && country != 'at' && country != 'de') { this.log.error('No token defined. Please check readme how to request!'); this.terminate('Missing API token', 11); } if ((await isOnline()) == false) { this.log.error('No internet connection detected'); this.terminate('No internet connection', 0); return; } this.log.debug('Internet connection detected. Everything fine!'); const now = new Date(); if (now.getMinutes() == 0 && now.getSeconds() < 15) { const callApiDelay = Math.floor(Math.random() * MAX_DELAY); this.log.info(`Delay execution by ${callApiDelay}ms to better spread API calls`); await jsonExplorer.sleep(callApiDelay); } await jsonExplorer.setLastStartTime(); const [resultPeakHours, resultMarketPrice] = await Promise.all([ this.executeRequestPeakHours(), this.executeMarketPrice(country, this.config_forecast), ]); if (resultPeakHours == 'error' || resultMarketPrice == 'error') { this.terminate('Peak hours resultet in error', utils.EXIT_CODES.UNCAUGHT_EXCEPTION); } else { this.terminate(0); } } /** * Is called when adapter shuts down - callback has to be called under any circumstances! * * @param {() => void} callback it is the callback that has to be called after all */ onUnload(callback) { try { this.log.info('cleaned everything up...'); this.unloaded = true; callback(); } catch { callback(); } } /** * Handles json-object and creates states for market prices * * @param {string} country country of the market * @param {boolean} forecast also checks 10.15 auction for next day */ async executeMarketPrice(country, forecast) { if (this.config_marketPrices == false) { await this.delObjectAsync('marketprice', { recursive: true }); await this.delObjectAsync('marketprice_quarter_hourly', { recursive: true }); return null; } this.log.debug('Execute market price retrieval'); let source1 = null, source1q = null; const configTraversJsonFalse = { replaceName: true, replaceID: true, level: 3, validateAttribute: false }; try { const day0 = cleanDate(new Date()); const day1 = addDays(day0, 1); let prices0 = [], prices0q = [], prices1 = [], prices1q = []; if (country == 'ch') { const [pprices0, pprices1] = await Promise.all([ this._getAndProcessEntsoeData(false, country, false), this._getAndProcessEntsoeData(true, country, false), ]); prices0 = pprices0?.prices ?? []; prices1 = pprices1?.prices ?? []; } else { ({ prices0, prices1, source1, prices0q, prices1q, source1q } = await this._getAndProcessMarketData(country, forecast)); } if (this.config_hourly) { jsonExplorer.stateSetCreate('marketprice.today.date', 'date', day0.getTime()); jsonExplorer.stateSetCreate('marketprice.tomorrow.date', 'date', day1.getTime()); if (this.config_details) { await jsonExplorer.traverseJson(prices0, 'marketprice.details.today', configTraversJsonFalse); await jsonExplorer.traverseJson(prices1, 'marketprice.details.tomorrow', configTraversJsonFalse); } else { await this.delObjectAsync('marketprice.details', { recursive: true }); } } if (this.config_quarterHourly) { jsonExplorer.stateSetCreate('marketprice_quarter_hourly.today.date', 'date', day0.getTime()); jsonExplorer.stateSetCreate('marketprice_quarter_hourly.tomorrow.date', 'date', day1.getTime()); if (this.config_details) { await jsonExplorer.traverseJson(prices0q, 'marketprice_quarter_hourly.details.today', configTraversJsonFalse); await jsonExplorer.traverseJson(prices1q, 'marketprice_quarter_hourly.details.tomorrow', configTraversJsonFalse); } else { await this.delObjectAsync('marketprice_quarter_hourly.details', { recursive: true }); } } const todayProcessed = this._processAndCategorizePrices(prices0, 'today', false); const tomorrowProcessed = this._processAndCategorizePrices(prices1, 'tomorrow', false); const todayProcessedq = this._processAndCategorizePrices(prices0q, 'today', true); const tomorrowProcessedq = this._processAndCategorizePrices(prices1q, 'tomorrow', true); if (!todayProcessed) { return 'error'; } const { jDay: jDay0, jDayBelowThreshold: jDay0BelowThreshold, jDayAboveThreshold: jDay0AboveThreshold, daysBelow: days0Below, daysAbove: days0Above, } = todayProcessed; if (!tomorrowProcessed) { return 'error'; } const { jDay: jDay1, jDayBelowThreshold: jDay1BelowThreshold, jDayAboveThreshold: jDay1AboveThreshold, daysBelow: days1Below, daysAbove: days1Above, } = tomorrowProcessed; if (!todayProcessedq) { return 'error'; } const { jDay: jDay0q, jDayBelowThreshold: jDay0BelowThresholdq, jDayAboveThreshold: jDay0AboveThresholdq, daysBelow: days0Belowq, daysAbove: days0Aboveq, } = todayProcessedq; if (!tomorrowProcessedq) { return 'error'; } const { jDay: jDay1q, jDayBelowThreshold: jDay1BelowThresholdq, jDayAboveThreshold: jDay1AboveThresholdq, daysBelow: days1Belowq, daysAbove: days1Aboveq, } = tomorrowProcessedq; //put data into an array let arrBelow0 = Object.keys(jDay0BelowThreshold).map(key => [key, jDay0BelowThreshold[key]]); let arrBelow1 = Object.keys(jDay1BelowThreshold).map(key => [key, jDay1BelowThreshold[key]]); let arrAll0 = Object.keys(jDay0).map(key => [key, jDay0[key]]); let arrAll1 = Object.keys(jDay1).map(key => [key, jDay1[key]]); let arrBelow0q = Object.keys(jDay0BelowThresholdq).map(key => [key, jDay0BelowThresholdq[key]]); let arrBelow1q = Object.keys(jDay1BelowThresholdq).map(key => [key, jDay1BelowThresholdq[key]]); let arrAll0q = Object.keys(jDay0q).map(key => [key, jDay0q[key]]); let arrAll1q = Object.keys(jDay1q).map(key => [key, jDay1q[key]]); jDay0BelowThreshold.numberOfHours = days0Below; jDay0AboveThreshold.numberOfHours = days0Above; jDay1BelowThreshold.numberOfHours = days1Below; jDay1AboveThreshold.numberOfHours = days1Above; jDay0BelowThresholdq.numberOfSlots = days0Belowq; jDay0AboveThresholdq.numberOfSlots = days0Aboveq; jDay1BelowThresholdq.numberOfSlots = days1Belowq; jDay1AboveThresholdq.numberOfSlots = days1Aboveq; if (this.config_hourly) { await jsonExplorer.traverseJson(jDay0, 'marketprice.today', configTraversJsonFalse); await jsonExplorer.traverseJson(jDay0BelowThreshold, 'marketprice.belowThreshold.today', configTraversJsonFalse); await jsonExplorer.traverseJson(jDay0AboveThreshold, 'marketprice.aboveThreshold.today', configTraversJsonFalse); await jsonExplorer.traverseJson(jDay1, 'marketprice.tomorrow', configTraversJsonFalse); await jsonExplorer.traverseJson(jDay1BelowThreshold, 'marketprice.belowThreshold.tomorrow', configTraversJsonFalse); await jsonExplorer.traverseJson(jDay1AboveThreshold, 'marketprice.aboveThreshold.tomorrow', configTraversJsonFalse); } if (this.config_quarterHourly) { await jsonExplorer.traverseJson(jDay0q, 'marketprice_quarter_hourly.today', configTraversJsonFalse); await jsonExplorer.traverseJson(jDay1q, 'marketprice_quarter_hourly.tomorrow', configTraversJsonFalse); await jsonExplorer.traverseJson(jDay0BelowThresholdq, 'marketprice_quarter_hourly.belowThreshold.today', configTraversJsonFalse); await jsonExplorer.traverseJson(jDay0AboveThresholdq, 'marketprice_quarter_hourly.aboveThreshold.today', configTraversJsonFalse); await jsonExplorer.traverseJson(jDay1BelowThresholdq, 'marketprice_quarter_hourly.belowThreshold.tomorrow', configTraversJsonFalse); await jsonExplorer.traverseJson(jDay1AboveThresholdq, 'marketprice_quarter_hourly.aboveThreshold.tomorrow', configTraversJsonFalse); } //copy objets to use this for charts later const arrAll0Copy = structuredClone(arrAll0); const arrAll1Copy = structuredClone(arrAll1); const arrAll0qCopy = structuredClone(arrAll0q); const arrAll1qCopy = structuredClone(arrAll1q); //now it is time to sort by prcie arrBelow0.sort(compareSecondColumn); arrBelow1.sort(compareSecondColumn); arrAll0.sort(compareSecondColumn); arrAll1.sort(compareSecondColumn); arrBelow0q.sort(compareSecondColumn); arrBelow1q.sort(compareSecondColumn); arrAll0q.sort(compareSecondColumn); arrAll1q.sort(compareSecondColumn); //prepare sorted arrays to create states let sortedHours0 = [], sortedHours1 = [], sortedHoursAll0 = [], sortedHoursAll0q = [], sortedHoursAll1 = [], sortedHoursAll1q = [], sortedHours0q = [], sortedHours1q = []; let sortedHours0Short = [], sortedHours0Shortq = [], sortedHours1Short = [], sortedHours1Shortq = [], sortedHours0ShortAll = [], sortedHours0ShortAllq = [], sortedHours1ShortAll = [], sortedHours1ShortAllq = []; let priceSum0 = 0, priceSum0q = 0, priceSum1 = 0, priceSum1q = 0; for (const idS in arrBelow0) { sortedHours0[idS] = [arrBelow0[idS][0], arrBelow0[idS][1]]; sortedHours0Short[idS] = Number(arrBelow0[idS][0].substring(0, 2)); } for (const idS in arrBelow0q) { sortedHours0q[idS] = [arrBelow0q[idS][0], arrBelow0q[idS][1]]; sortedHours0Shortq[idS] = arrBelow0q[idS][0].substring(0, 5); } for (const idS in arrBelow1) { sortedHours1[idS] = [arrBelow1[idS][0], arrBelow1[idS][1]]; sortedHours1Short[idS] = Number(arrBelow1[idS][0].substring(0, 2)); } for (const idS in arrBelow1q) { sortedHours1q[idS] = [arrBelow1q[idS][0], arrBelow1q[idS][1]]; sortedHours1Shortq[idS] = arrBelow1q[idS][0].substring(0, 5); } for (const idS in arrAll0) { sortedHoursAll0[idS] = [arrAll0[idS][0], arrAll0[idS][1]]; sortedHours0ShortAll[idS] = Number(arrAll0[idS][0].substring(0, 2)); priceSum0 = priceSum0 + arrAll0[idS][1]; } for (const idS in arrAll0q) { sortedHoursAll0q[idS] = [arrAll0q[idS][0], arrAll0q[idS][1]]; sortedHours0ShortAllq[idS] = arrAll0q[idS][0].substring(0, 5); priceSum0q = priceSum0q + arrAll0q[idS][1]; } for (const idS in arrAll1) { sortedHoursAll1[idS] = [arrAll1[idS][0], arrAll1[idS][1]]; sortedHours1ShortAll[idS] = Number(arrAll1[idS][0].substring(0, 2)); priceSum1 = priceSum1 + arrAll1[idS][1]; } for (const idS in arrAll1q) { sortedHoursAll1q[idS] = [arrAll1q[idS][0], arrAll1q[idS][1]]; sortedHours1ShortAllq[idS] = arrAll1q[idS][0].substring(0, 5); priceSum1q = priceSum1q + arrAll1q[idS][1]; } let price0Avg, price1Avg, price0Avgq, price1Avgq; if (priceSum0 == 0) { price0Avg = null; } else { price0Avg = Math.round((priceSum0 / 24) * 1000) / 1000; } if (priceSum1 == 0) { price1Avg = null; } else { price1Avg = Math.round((priceSum1 / 24) * 1000) / 1000; } if (priceSum0q == 0) { price0Avgq = null; } else { price0Avgq = Math.round((priceSum0q / (24 * 4)) * 1000) / 1000; } if (priceSum1q == 0) { price1Avgq = null; } else { price1Avgq = Math.round((priceSum1q / (24 * 4)) * 1000) / 1000; } if (this.config_hourly) { await jsonExplorer.traverseJson(sortedHours0, 'marketprice.belowThreshold.today_sorted', configTraversJsonFalse); await jsonExplorer.traverseJson(sortedHours1, 'marketprice.belowThreshold.tomorrow_sorted', configTraversJsonFalse); await jsonExplorer.traverseJson(sortedHoursAll0, 'marketprice.today_sorted', configTraversJsonFalse); await jsonExplorer.traverseJson(sortedHoursAll1, 'marketprice.tomorrow_sorted', configTraversJsonFalse); await jsonExplorer.stateSetCreate( 'marketprice.belowThreshold.today_sorted.short', 'today sorted short', JSON.stringify(sortedHours0Short), false, ); await jsonExplorer.stateSetCreate( 'marketprice.belowThreshold.tomorrow_sorted.short', 'tomorrow sorted short', JSON.stringify(sortedHours1Short), false, ); await jsonExplorer.stateSetCreate( 'marketprice.today_sorted.short', 'today sorted short', JSON.stringify(sortedHours0ShortAll), false, ); await jsonExplorer.stateSetCreate( 'marketprice.tomorrow_sorted.short', 'tomorrow sorted short', JSON.stringify(sortedHours1ShortAll), false, ); await jsonExplorer.stateSetCreate('marketprice.today.average', 'average', price0Avg, false); await jsonExplorer.stateSetCreate('marketprice.tomorrow.average', 'average', price1Avg, false); } if (this.config_quarterHourly) { await jsonExplorer.traverseJson( sortedHours0q, 'marketprice_quarter_hourly.belowThreshold.today_sorted', configTraversJsonFalse, false, ); await jsonExplorer.traverseJson( sortedHours1q, 'marketprice_quarter_hourly.belowThreshold.tomorrow_sorted', configTraversJsonFalse, false, ); await jsonExplorer.traverseJson(sortedHoursAll0q, 'marketprice_quarter_hourly.today_sorted', configTraversJsonFalse, false); await jsonExplorer.traverseJson(sortedHoursAll1q, 'marketprice_quarter_hourly.tomorrow_sorted', configTraversJsonFalse, false); await jsonExplorer.stateSetCreate( 'marketprice_quarter_hourly.today_sorted.short', 'today sorted short', JSON.stringify(sortedHours0ShortAllq), false, ); await jsonExplorer.stateSetCreate( 'marketprice_quarter_hourly.tomorrow_sorted.short', 'tomoorrow sorted short', JSON.stringify(sortedHours1ShortAllq), false, ); await jsonExplorer.stateSetCreate( 'marketprice_quarter_hourly.belowThreshold.today_sorted.short', 'today sorted short', JSON.stringify(sortedHours0Shortq), false, ); await jsonExplorer.stateSetCreate( 'marketprice_quarter_hourly.belowThreshold.tomorrow_sorted.short', 'tomorrow sorted short', JSON.stringify(sortedHours1Shortq), false, ); await jsonExplorer.stateSetCreate('marketprice_quarter_hourly.today.average', 'average', price0Avgq, false); await jsonExplorer.stateSetCreate('marketprice_quarter_hourly.tomorrow.average', 'average', price1Avgq, false); } if (this.config_hourly) { await this.createCharts(arrAll0Copy, arrAll1Copy, source1, false); await jsonExplorer.checkExpire('marketprice.*'); await jsonExplorer.deleteObjectsWithNull('marketprice.*Threshold.*'); await jsonExplorer.deleteObjectsWithNull('marketprice.details.*'); } if (this.config_quarterHourly) { await this.createCharts(arrAll0qCopy, arrAll1qCopy, source1q, true); await jsonExplorer.checkExpire('marketprice_quarter_hourly.*'); await jsonExplorer.deleteObjectsWithNull('marketprice_quarter_hourly.*Threshold.*'); await jsonExplorer.deleteObjectsWithNull('marketprice_quarter_hourly.details.*'); } if (!this.config_quarterHourly) { await this.delObjectAsync('marketprice_quarter_hourly', { recursive: true }); } if (!this.config_hourly) { await this.delObjectAsync('marketprice', { recursive: true }); } } catch (error) { let eMsg = `Error in executeMarketPrice(): ${error}`; this.log.error(eMsg); console.error(eMsg); this.sendSentry(error); } } /** * Processes and categorizes market prices for a given day. * * @param {any[] | null} prices - The array of price objects. * @param {string} dayString - A string identifier for the day (e.g., 'today', 'tomorrow'). * @param {boolean} quaterly - Indicates if the prices are in quarterly format. * @returns {{jDay: object, jDayBelowThreshold: object, jDayAboveThreshold: object, daysBelow: number, daysAbove: number} | null} return */ _processAndCategorizePrices(prices, dayString, quaterly = false) { const jDay = {}; const jDayBelowThreshold = {}; const jDayAboveThreshold = {}; let daysBelow = 0; let daysAbove = 0; for (const idS in prices) { if (prices[idS].Price == undefined) { this.log.error(`No marketprice found in marketprice-result for ${dayString}!`); return null; } const product = prices[idS].Product; let range; const productTime = dayString == 'today' ? new Date() : addDays(new Date(), 1); if (quaterly) { const productText = prices[idS].ProductText; const regexZeit = /(\d{2}:\d{2}\s*-\s*\d{2}:\d{2})/; const matchZeit = productText.match(regexZeit); if (matchZeit && matchZeit.length > 1) { range = matchZeit[1].replace(/ /g, ''); const hours = range.substring(0, 2); const minutes = range.substring(3, 5); productTime.setHours(Number(hours), Number(minutes), 0, 0); } } else { const sEndHour = product.substring(1, 3); const iEndHour = Number(sEndHour); const iBeginHour = iEndHour - 1; const sBeginHour = pad(iBeginHour, 2); range = `${sBeginHour}_to_${sEndHour}`; productTime.setHours(Number(iBeginHour), 0, 0, 0); } const marketprice = this.calcPrice(prices[idS].Price / 10, productTime); this.log.debug(`Marketprice for product ${product} is ${marketprice}`); jDay[range] = marketprice; if (marketprice < this.threshold) { jDayBelowThreshold[range] = marketprice; daysBelow++; } else { jDayAboveThreshold[range] = marketprice; daysAbove++; } } this.log.debug(`Day prices for ${dayString} look like ${JSON.stringify(jDay)}`); return { jDay, jDayBelowThreshold, jDayAboveThreshold, daysBelow, daysAbove }; } /** * Fetches and processes market data from Awattar/Exaa for today and tomorrow. * * @param {string} country The country code for the API request. * @param {boolean} forecast also checks 10.15 auction for next day * @returns {Promise<{prices0: any[], prices1: any[], source1: string |null, prices0q: any, prices1q: any, source1q: string |null}>} An object containing the processed prices for today and tomorrow and the source for tomorrow. */ async _getAndProcessMarketData(country, forecast) { //nst todayDate = cleanDate(new Date()); //const tomorrowDate = addDays(todayDate, 1); let prices0Entsoe = null, prices1Entsoe = null; let prices0Awattar, prices1Awattar, prices0Exaa, prices1Exaa, prices1Exaa1015, prices0EnergyCharts, prices1EnergyCharts, eXaaToday, eXaaTomorrow; let todayResult, tomorrowResult, todayResultq, tomorrowResultq; const useEntsoe = this.token == null || this.token.length < 10 ? false : true; //check provider for quarter-hourly market data if (this.config_quarterHourly) { this.log.info(`Let's check for quarter-hourly market data`); [prices0EnergyCharts, prices1EnergyCharts] = await Promise.all([ getDataEnergyCharts(this, false, country), getDataEnergyCharts(this, true, country), ]); //TODAY let nextActionToday = 'energChart'; if (prices0EnergyCharts?.price == null || typeof prices0EnergyCharts.price !== 'object') { this.log.info(`No quarter-hourly market data from EnergyChart for today, let's try Entsoe`); nextActionToday = 'entsoe'; } else { nextActionToday = 'done'; } if (nextActionToday == 'entsoe') { if (useEntsoe) { prices0Entsoe = await this._getAndProcessEntsoeData(false, country, false); if (prices0Entsoe?.prices == null) { this.log.info(`No quarter-hourly market data from Entsoe for today, let's try Exaa`); nextActionToday = 'exaa'; } else { nextActionToday = 'done'; } } else { this.log.info(`No token defined for Entsoe, skipped! Let's try Exaa`); nextActionToday = 'exaa'; } } if (nextActionToday == 'exaa') { eXaaToday = await getDataExaa(this, false, country); prices0Exaa = eXaaToday?.q ?? null; if (prices0Exaa == null) { this.log.info(`No quarter-hourly market data from Exaa for today!`); nextActionToday = 'noData'; } else { nextActionToday = 'done'; } } //TOMORROW let nextActionTomorrow = 'energChart'; if (prices1EnergyCharts?.price == null || typeof prices1EnergyCharts.price !== 'object') { this.log.info(`No quarter-hourly market data from EnergyChart for tomorrow, let's try Entsoe`); nextActionTomorrow = 'entsoe'; } else { nextActionTomorrow = 'done'; } if (nextActionTomorrow == 'entsoe') { if (useEntsoe) { prices1Entsoe = await this._getAndProcessEntsoeData(true, country, forecast); if (prices1Entsoe?.prices == null) { this.log.info(`No quarter-hourly market data from Entsoe for tomorrow, let's try Exaa`); nextActionTomorrow = 'exaa'; } else { if (prices1Entsoe?.source == 'entsoe1015') { this.log.info(`10:15 auction found for quarter-hourly market data for tommorrow from Entsoe, let's try Exaa`); nextActionTomorrow = 'exaa'; } else { nextActionTomorrow = 'done'; } } } else { this.log.info(`No token defined for Entsoe, skipped! Let's try Exaa`); nextActionTomorrow = 'exaa'; } } if (nextActionTomorrow == 'exaa') { eXaaTomorrow = await getDataExaa(this, true, country); prices1Exaa = eXaaTomorrow?.q ?? null; if (prices1Exaa == null) { if (prices1Entsoe?.source == 'entsoe1015') { this.log.info(`No quarter-hourly market data from Exaa for tomorrow, let's use 10:15 auction from Entsoe`); nextActionTomorrow = 'done'; } else { this.log.info(`No quarter-hourly market data from Exaa for tomorrow!`); nextActionTomorrow = 'noData'; } } else { nextActionTomorrow = 'done'; prices1Entsoe = null; } } //Check results if (nextActionToday == 'noData') { this.log.error('No quarter-hourly market data for today!'); } if (prices0Entsoe?.prices != null) { this.log.info('Found Entsoe quarter-hourly market data for today!'); } if (prices0Exaa != null) { this.log.info('Found Exaa quarter-hourly market data for today!'); } if (prices0EnergyCharts?.price && typeof prices0EnergyCharts.price === 'object') { this.log.info('Found EnergyCharts quarter-hourly market data for today!'); } if (nextActionTomorrow == 'noData') { this.log.info('No quarter-hourly market data for tomorrow!'); } if (prices1Entsoe?.prices != null) { if (prices1Entsoe?.source == 'entsoe1015') { this.log.info('Found Entsoe 10:15 auction quarter-hourly market data for tomorrow!'); } else { this.log.info('Found Entsoe quarter-hourly market data for tomorrow!'); } } if (prices1Exaa != null) { this.log.info('Found Exaa quarter-hourly market data for tomorrow!'); } if (prices1EnergyCharts?.price && typeof prices1EnergyCharts.price === 'object') { this.log.info('Found EnergyCharts quarter-hourly market data for tomorrow!'); } todayResultq = prices0Entsoe != null ? prices0Entsoe : this._processMarketPrices('today', prices0Awattar, prices0Exaa, null, prices0EnergyCharts, true); tomorrowResultq = prices1Entsoe != null ? prices1Entsoe : this._processMarketPrices('tomorrow', prices1Awattar, prices1Exaa, prices1Exaa1015, prices1EnergyCharts, true); } //check provider for quarter-hourly market data if (this.config_hourly) { this.log.info(`Let's check for hourly market data`); const [prices0Awattar, prices1Awattar] = await Promise.all([getDataAwattar(this, false, country), getDataAwattar(this, true, country)]); //TODAY let statusToday = 'awattar'; if (prices0Awattar?.data?.[0]) { this.log.info('Todays hourly market data from Awattar available'); this.log.debug(`Todays hourly market data result from Awattar is: ${JSON.stringify(prices0Awattar)}`); statusToday = 'done'; } else { this.log.info(`No hourly market data from Awattar for today, let's try Exaa`); statusToday = 'exaa'; } if (statusToday == 'exaa') { const eXaaToday = await getDataExaa(this, false, country); prices0Exaa = eXaaToday?.h ?? null; if (prices0Exaa == null) { this.log.error(`No data from Exaa and therefore no hourly market data for today!`); statusToday = 'noData'; } else { this.log.debug(`Todays hourly market data result from Exaa is: ${JSON.stringify(prices0Exaa)}`); this.log.info('Todays hourly market data from Exaa available'); statusToday = 'done'; } } //TOMORROW let statusTomorrow = 'awattar'; if (prices1Awattar?.data?.[0]) { this.log.info('Tomorrows hourly market data from Awattar available'); this.log.debug(`Tomorrows hourly market data result from Awattar is: ${JSON.stringify(prices1Awattar)}`); statusTomorrow = 'done'; } else { this.log.info(`No hourly market data from Awattar for tomorrow, let's try Exaa`); statusTomorrow = 'exaa'; } if (statusTomorrow == 'exaa') { const eXaaTomorrow = await getDataExaa(this, true, country); prices1Exaa = eXaaTomorrow?.h ?? null; if (prices1Exaa == null) { this.log.info(`No hourly market data from Exaa for tomorrow, last chance 10:15 auction!`); statusTomorrow = 'exaa1015'; } else { this.log.debug(`Tomorrows hourly market data result from Exaa is: ${JSON.stringify(prices1Exaa)}`); this.log.info('Tomorrows hourly market data from Exaa available'); statusTomorrow = 'done'; } } if (statusTomorrow == 'exaa1015') { if (!forecast) { this.log.info('No forecast requested and therefore no hourly market data for tomorrow.'); statusTomorrow = 'noData'; } else { const eXaa1015 = await getDataExaa1015(this, country); prices1Exaa1015 = eXaa1015; if (prices1Exaa1015) { this.log.info('Market hourly data from Exaa 10:15 auction available'); statusTomorrow = 'done'; } else { this.log.info('Bad luck for Exaa 10:15 auction and therefore no market data for tomorrow!'); statusTomorrow = 'noData'; } } } todayResult = this._processMarketPrices('today', prices0Awattar, prices0Exaa, null, prices0EnergyCharts, false); tomorrowResult = this._processMarketPrices('tomorrow', prices1Awattar, prices1Exaa, prices1Exaa1015, prices1EnergyCharts, false); } return { prices0: todayResult?.prices ?? [], prices1: tomorrowResult?.prices ?? [], source1: tomorrowResult?.source ?? null, prices0q: todayResultq?.prices ?? [], prices1q: tomorrowResultq?.prices ?? [], source1q: tomorrowResultq?.source ?? null, }; } /** * Fetches and processes market data from Entsoe for today and tomorrow. * Includes a retry mechanism for network-related errors. * * @param {boolean} tomorrow if true, calculation is for tomorrow * @param {string} country The country code for the API request. * @param {boolean} forecast if true 1015 forecast is checked * @returns {Promise<{prices: any[] | null, source: string}>} prices An object containing the processed prices and the source. */ async _getAndProcessEntsoeData(tomorrow, country, forecast) { const day = tomorrow ? 'tomorrow' : 'today'; let pricesEntsoe, prices; let source = ''; if (!tomorrow) { pricesEntsoe = await getDataEntsoe(this, false, country); this.log.debug(`pricesEntsoe for ${day}: ${JSON.stringify(pricesEntsoe)}`); prices = this._processEntsoeData(pricesEntsoe, 'today', false) || []; this.log.debug(`Prices w/o forecast for ${day}: ${JSON.stringify(prices)}`); source = 'entsoe'; if (prices.length > 50) { jsonExplorer.stateSetCreate('marketprice_quarter_hourly.today.source', 'Source', source); } else if (prices.length > 0) { jsonExplorer.stateSetCreate('marketprice.today.source', 'Source', source); } else { source = ''; } } else { pricesEntsoe = await getDataEntsoe(this, true, country); this.log.debug(`pricesEntsoe for ${day}: ${JSON.stringify(pricesEntsoe)}`); prices = this._processEntsoeData(pricesEntsoe, 'tomorrow', false) || []; this.log.debug(`Prices w/o forecast for ${day}: ${JSON.stringify(prices)}`); source = 'entsoe'; if (prices.length == 0 && forecast) { prices = this._processEntsoeData(pricesEntsoe, 'tomorrow', true) || []; this.log.debug(`Prices with forecast for ${day}: ${JSON.stringify(prices)}`); source = 'entsoe1015'; if (prices.length > 0) { this.log.info('Data from Entsoe for 10:15 auction available'); } } if (prices.length > 50) { jsonExplorer.stateSetCreate('marketprice_quarter_hourly.tomorrow.source', 'Source', source); } else if (prices.length > 0) { jsonExplorer.stateSetCreate('marketprice.tomorrow.source', 'Source', source); } else { source = ''; } } prices = prices.length == 0 ? null : prices; return { prices, source }; } /** * Processes market prices from different sources for a given day. * It selects the best available data source and converts it to a unified format. * * @param {'today' | 'tomorrow'} day - The day to process ('today' or 'tomorrow'). * @param {any} awattarData - Data from Awattar API. * @param {any} exaaData - Data from EXAA Market Coupling API. * @param {any} exaa1015Data - Optional data from EXAA 10:15 auction API (for tomorrow). * @param {any} energyChartsData - Data from Epex Market * @param {boolean} quarter - quater hourly data yes/no * @returns {{prices: any[], source: string}} The processed prices and the source name. */ _processMarketPrices(day, awattarData, exaaData, exaa1015Data, energyChartsData, quarter) { let prices = []; let source = ''; if (exaaData) { prices = this._convertExaaData(exaaData); source = 'exaaMC'; } else if (day === 'tomorrow' && exaa1015Data) { prices = this._convertExaa1015Data(exaa1015Data); source = 'exaa1015'; } else if (awattarData?.data?.[0]) { prices = this._convertAwattarData(awattarData); source = 'awattar'; } else if (energyChartsData) { prices = this._convertEnergyChartsData(energyChartsData); source = 'energyCharts'; } if (source) { if (quarter) { jsonExplorer.stateSetCreate(`marketprice_quarter_hourly.${day}.source`, 'Source', source); } else { jsonExplorer.stateSetCreate(`marketprice.${day}.source`, 'Source', source); } } return { prices, source }; } /** * Converts data from the Awattar API to the internal price format. * * @param {any} awattarData - The raw data from Awattar. * @returns {any[]} The converted price data. */ _convertAwattarData(awattarData) { const prices = []; for (const idS in awattarData.data) { prices[idS] = {}; prices[idS].Price = awattarData.data[idS].marketprice; const start = new Date(awattarData.data[idS].start_timestamp); const iHour = start.getHours() + 1; const sHour = pad(iHour, 2); prices[idS].Product = `H${sHour}`; } return prices; } /** * Converts data from the EXAA 10:15 auction API to the internal price format. * * @param {any} exaa1015Data - The raw data from EXAA 10:15 auction. * @returns {any[]} The converted price data. */ _convertExaa1015Data(exaa1015Data) { const prices = []; for (const idS in exaa1015Data) { prices[idS] = {}; prices[idS].Price = exaa1015Data[idS].y; const iHour = exaa1015Data[idS].x; const sHour = pad(iHour, 2); prices[idS].Product = `H${sHour}`; } this.log.debug(`prices1Exaa1015 converted to: ${JSON.stringify(prices)}`); return prices; } /** * Converts data from energyCharts API to the internal price format. * * @param {any} energyChartsData - The raw data from energyCharts * @returns {any[]} The converted price data. */ _convertEnergyChartsData(energyChartsData) { const timestamps = energyChartsData?.['unix_seconds'] ?? null; const values = energyChartsData?.price ?? null; // Return null immediately if lengths do not match or one is NULL if (timestamps == null || values == null || timestamps.length !== values.length) { return []; } // If lengths match, safely combine the arrays const combined = timestamps.map((xValue, index) => { return [xValue, values[index]]; }); const prices = []; for (const idS in combined) { prices[idS] = {}; const date = new Date(combined[idS][0] * 1000); //Unix-Seconds to JS-Timestamp const hours = date.getHours().toString().padStart(2, '0'); const minutes = date.getMinutes().toString().padStart(2, '0'); const dateEnd = new Date(date.setMinutes(date.getMinutes() + 15)); let hoursEnd = dateEnd.getHours().toString().padStart(2, '0'); if (hours == '23' && hoursEnd == '00') { hoursEnd = '24'; } const minutesEnd = dateEnd.getMinutes().toString().padStart(2, '0'); const timeId = `${hours}:${minutes}-${hoursEnd}:${minutesEnd}`; const index = Number(idS) + 1; const firstPart = Math.ceil(index / 4); const secondPart = index % 4 === 0 ? 4 : index % 4; const prod = `Q${pad(firstPart, 2)}_${secondPart}`; prices[idS].Price = combined[idS][1]; prices[idS].Product = prod; prices[idS].ProductText = `${prod.replace('Q', 'qEnCh')} (${timeId})`; prices[idS].id = timeId; } this.log.debug(`Prices energyCharts converted to: ${JSON.stringify(prices)}`); return prices; } /** * Converts data from the EXAA API to the internal price format. * * @param {any} exaaData - The raw data from EXAA auction. * @returns {any[]} The converted price data. */ _convertExaaData(exaaData) { //only for quarter-hourly data we have to extract the id from ProductText if (exaaData?.[0] != null && exaaData[0].ProductText?.substring(0, 1) == 'q') { for (const item of exaaData) { const productText = item.ProductText; const regexZeit = /(\d{2}:\d{2}\s*-\s*\d{2}:\d{2})/; const matchZeit = productText.match(regexZeit); if (matchZeit && matchZeit.length > 1) { item.id = matchZeit[1].replace(/ /g, ''); } } exaaData = exaaData.filter(item => item.id != null); } if (exaaData?.[0] != null) { for (const item of exaaData) { delete item.SellVolume; delete item.TotalVol; delete item.BuyVolume; delete item.AuctionDay; } } this.log.debug(`convertExaaData result is: ${JSON.stringify(exaaData)}`); return exaaData; } /** * Finds all TimeSeries objects where the * 'classificationSequence_AttributeInstanceComponent.position' is "1". * * @param {object} data - The fully parsed JSON data object containing the TimeSeries array. * @param {number} filter - 1 for MC and 2 for 10:15 * @returns {Array<object>} An array of matching TimeSeries objects. */ filterTimeSeriesByPosition(data, filter) { // 1. Get the TimeSeries array safely. let allTimeSeries = data?.TimeSeries ?? null; if (allTimeSeries == null) { return []; } //if there is no array convert into array if (!Array.isArray(allTimeSeries)) { const domain = String(allTimeSeries?.['in_Domain.mRID']?._text ?? ''); this.log.debug(`Domain is ${domain}`); if (domain.includes('CH-SWISS')) { return [allTimeSeries]; } allTimeSeries = [allTimeSeries]; } // 2. Filter the array based on the position criteria. const matchingSeries = allTimeSeries.filter(ts => { try { // Access the nested property using bracket notation because of the dots in the key. const position = ts['classificationSequence_AttributeInstanceComponent.position']?._text; // Check if the extracted text value is exactly '1'. return position === String(filter); } catch (error) { // Log an error if a specific TimeSeries entry is malformed and skip it. // @ts-expect-error error ok console.warn(`Skipping TimeSeries entry due to error: ${error.message}`); return false; } }); return matchingSeries; } /** * Processes the raw data from the Entsoe API. * * @param {any} entsoeData The raw data object from the Entsoe API. * @param {string} dayString A string like 'today' or 'tomorrow' for logging purposes. * @param {boolean} earlyAuction if true, 10:15 auction will be used * @returns {Array<any> | null} An array with the processed price data or null if processing fails. */ _processEntsoeData(entsoeData, dayString, earlyAuction = false) { const filter = earlyAuction ? 2 : 1; if (!entsoeData) { this.log.debug(`No Entsoe data provided for ${dayString}.`); return null; } this.log.debug(`Entsoe data for ${dayString}: ${JSON.stringify(entsoeData)}`); console.log(`Entsoe data for ${dayString}: ${JSON.stringify(entsoeData)}`); if (entsoeData.TimeSeries[0] == null && entsoeData.TimeSeries == null) { this.log.error(`No data available for ${dayString}!`); return null; } let timeSeries = this.filterTimeSeriesByPosition(entsoeData, filter)[0]; let point = timeSeries?.Period[0]?.Point ?? null; if (point == null) { point = timeSeries?.Period?.Point; } point = point == null ? null : this.fillMissingPositions(point); let prices = []; const length = point ? point.length : 0; for (let i = 0; i < length; i++) { const ii = String(i); prices[ii] = {}; const price = parseFloat(point[i].price_amount._text); const sPosition = pad(point[i].position._text, 2); //quater-hourly if (length > 50) { const slot = parseInt(point[i].position._text) - 1; const nextSlot = slot + 1; const hour = pad(Math.floor(slot / 4), 2); const minute = pad((slot % 4) * 15, 2); const nextHour = pad(Math.floor(nextS