UNPKG

node-red-contrib-nordpool-chargecheap

Version:

Nordpool price analyzer with smart night/day, rolling 24h and HA override integration for Node-RED

542 lines (478 loc) 24.3 kB
module.exports = function (RED) { function NordpoolChargeCheapNode(config) { RED.nodes.createNode(this, config); const node = this; // --- Configuration --- const cfg = { manualStart: toInt(config.start), manualStop: toInt(config.stop), manualCount: toInt(config.count), payloadOn: config.payload_on || "on", payloadOff: config.payload_off || "off", forceValue: Number(config.force_value) || -600, haEntity: config.ha_entity || "", invertSelection: !!config.invert_selection, contiguousMode: !!config.contiguous_mode, debug: !!config.debug }; // --- Utility functions --- function toInt(v) { if (v === undefined || v === null || v === "") return null; const n = Number(v); return isNaN(n) ? null : Math.floor(n); } function normalizeInput(value, max = 95) { if (value === undefined || value === null) return null; let num = Number(value); if (isNaN(num)) { const match = String(value).match(/\d+/); if (match) num = Number(match[0]); } if (isNaN(num)) return null; num = Math.floor(num); if (num < 0) num = 0; if (num > max) num = max; return num; } function detectUnitConversion(data) { const um = (data.unit_of_measurement || "").toLowerCase(); const priceInCents = data.price_in_cents === true; if (priceInCents || um.includes("öre") || um.includes("ore")) { return (v) => Number(v); } if (um.includes("eur")) { return (v) => Number(v) * 100; } if (um.includes("sek")) { return (v) => Number(v) * 100; } return (v) => Number(v); } function toLocalLabel(dateObj) { return dateObj .toLocaleString("sv-SE", { timeZone: "Europe/Stockholm", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }) .replace(",", ""); } function mergeRawArray(arr, convertFn) { if (!Array.isArray(arr)) return []; return arr .map(obj => { const start = obj.start || obj.start_time || obj.startTime || obj.date; const rawVal = obj.value !== undefined ? obj.value : (obj.price !== undefined ? obj.price : NaN); const value = convertFn(rawVal); return { start, value }; }) .filter(x => x.start && !isNaN(x.value)); } function dedupeByStart(data) { const seen = new Set(); return data.filter(d => { let key; try { key = new Date(d.start).toISOString(); } catch (e) { return false; } if (seen.has(key)) return false; seen.add(key); return true; }); } function buildPeriod(baseDate, fromHour, toHour) { const start = new Date(baseDate); start.setHours(fromHour, 0, 0, 0); const end = new Date(baseDate); if (fromHour > toHour) { end.setDate(end.getDate() + 1); end.setHours(toHour, 0, 0, 0); } else { end.setHours(toHour, 0, 0, 0); } return { start, end }; } function detectIntervalMinutes(series) { if (series.length < 2) return 60; const times = series .map(s => new Date(s.start).getTime()) .filter(t => !isNaN(t)) .sort((a, b) => a - b); let minDiff = Infinity; for (let i = 1; i < times.length; i++) { const diffMin = (times[i] - times[i - 1]) / 60000; if (diffMin > 0 && diffMin < minDiff) { minDiff = diffMin; } } if (minDiff === Infinity) return 60; const rounded = Math.round(minDiff); if (![15, 30, 60].includes(rounded)) return rounded; return rounded; } function selectContiguous(inPeriod, count, invertSelection) { let bestAvg = invertSelection ? -Infinity : Infinity; let bestStartIdx = 0; // Prefix sums for O(1) block average calculation const prefix = new Array(inPeriod.length + 1).fill(0); for (let i = 0; i < inPeriod.length; i++) { prefix[i + 1] = prefix[i] + inPeriod[i].value; } for (let i = 0; i <= inPeriod.length - count; i++) { const sum = prefix[i + count] - prefix[i]; const avg = sum / count; const better = invertSelection ? (avg > bestAvg) : (avg < bestAvg); if (better) { bestAvg = avg; bestStartIdx = i; } } const selected = inPeriod.slice(bestStartIdx, bestStartIdx + count) .sort((a, b) => new Date(a.start) - new Date(b.start)); return { selected, meta: { contiguous: true, blockAverage: bestAvg, blockStart: selected[0].start, blockStop: selected[selected.length - 1].start } }; } function selectCheapOrExpensive(inPeriod, count, invertSelection, contiguousMode) { if (inPeriod.length === 0) return { selected: [], meta: {} }; if (count <= 0) return { selected: [], meta: {} }; if (count > inPeriod.length) count = inPeriod.length; if (contiguousMode) { return selectContiguous(inPeriod, count, invertSelection); } else { const sorted = [...inPeriod].sort((a, b) => invertSelection ? (b.value - a.value) : (a.value - b.value)); const selected = sorted.slice(0, count) .sort((a, b) => new Date(a.start) - new Date(b.start)); return { selected, meta: { contiguous: false } }; } } function buildAttributes(selected, periodLabel, invertSelection, sourceLabel, contiguousMeta, intervalMinutes, rolling24h) { const attr = {}; selected.forEach((v, i) => { const dt = new Date(v.start); attr[`time_${String(i + 1).padStart(2, "0")}`] = `${toLocalLabel(dt)} :: ${v.value.toFixed(2)}Öre`; }); if (selected.length > 0) { let max = selected[0], min = selected[0]; selected.forEach(v => { if (v.value > max.value) max = v; if (v.value < min.value) min = v; }); attr.max_time = `${toLocalLabel(new Date(max.start))} :: ${max.value.toFixed(2)}Öre`; attr.min_time = `${toLocalLabel(new Date(min.start))} :: ${min.value.toFixed(2)}Öre`; } const values = selected.map(v => v.value); const refCheap = values.length ? Math.max(...values) : null; const refExpensive = values.length ? Math.min(...values) : null; // Reference price semantics const reference = invertSelection ? refExpensive : refCheap; attr.reference_price = reference !== null ? `${reference.toFixed(2)}Öre` : null; attr.reference_price_mode = invertSelection ? "expensive_selection_min" : "cheap_selection_max"; attr.reference_price_role = invertSelection ? "lower_bound_for_discharging" : "upper_bound_for_charging"; attr.selection_mode = invertSelection ? "expensive" : "cheap"; attr.count = selected.length; attr.search_period = periodLabel; attr.data_source = sourceLabel; attr.interval_minutes = intervalMinutes; attr.contiguous_mode = contiguousMeta.contiguous ? "on" : "off"; attr.selection_strategy = contiguousMeta.contiguous ? "contiguous_block" : "discrete_slots"; attr.rolling_24h = rolling24h ? "on" : "off"; if (contiguousMeta.contiguous && selected.length > 0) { const blockStart = new Date(contiguousMeta.blockStart); const lastStart = new Date(contiguousMeta.blockStop); const blockStop = new Date(lastStart.getTime() + intervalMinutes * 60000); attr.block_mode_start = toLocalLabel(blockStart); attr.block_mode_stop = toLocalLabel(blockStop); attr.block_mode_average = `${contiguousMeta.blockAverage.toFixed(2)}Öre`; } if (selected.length === 1) { attr.single_selection = true; } // Slot alignment attribute if (selected.length > 0) { attr.slot_alignment = `First slot: ${toLocalLabel(new Date(selected[0].start))}, Last slot: ${toLocalLabel(new Date(selected[selected.length - 1].start))}`; } return { attributes: attr, reference }; } function isActiveNow(selected, intervalMinutes) { const now = new Date(); return selected.some(v => { const start = new Date(v.start); const end = new Date(start.getTime() + intervalMinutes * 60000); return now >= start && now < end; }); } node.on("input", function (msg) { const context = node.context(); if (msg.ha_enable !== undefined) { const haEnabled = String(msg.ha_enable).toLowerCase() === "on"; context.set("ha_enabled", haEnabled); if (cfg.debug) node.debug(`HA enable changed: ${haEnabled}`); } const haEnabled = context.get("ha_enabled"); if (haEnabled === false) { node.status({ fill: "yellow", shape: "ring", text: "HA disabled (manual override)" }); } if (msg.reset !== undefined) { [ "today_data", "yesterday_data", "tomorrow_data", "selected_for_period", "start_time", "stop_time", "count_hour" ].forEach(k => context.set(k, null)); context.set("ha_enabled", null); node.status({ fill: "blue", shape: "dot", text: "Full context reset" }); node.send([null, null, { payload: "context fully reset" }, null]); if (cfg.debug) node.debug("Context reset executed."); return; } const startIn = normalizeInput(msg.start); const stopIn = normalizeInput(msg.stop); const countIn = normalizeInput(msg.count, 95); if (startIn !== null) context.set("start_time", startIn); if (stopIn !== null) context.set("stop_time", stopIn); if (countIn !== null) context.set("count_hour", countIn); let flowStart = context.get("start_time") ?? cfg.manualStart; let flowStop = context.get("stop_time") ?? cfg.manualStop; let flowCount = context.get("count_hour") ?? cfg.manualCount; if ([flowStart, flowStop, flowCount].some(v => v === null || isNaN(v))) { node.status({ fill: "red", shape: "dot", text: "Missing start/stop/count" }); node.send([null, null, { payload: { error: "start/stop/count missing" } }, null]); if (cfg.debug) node.debug("Missing essential parameters start/stop/count."); return; } try { const dataRoot = (msg.data?.attributes || msg.data?.new_state?.attributes) || {}; const rolling24h = (flowStart === flowStop); const isNight = !rolling24h && (flowStart > flowStop); const convertFn = detectUnitConversion(dataRoot); let dataDate = null; if (Array.isArray(dataRoot.raw_today) && dataRoot.raw_today.length > 0) { try { dataDate = new Date(dataRoot.raw_today[0].start).toISOString().slice(0, 10); } catch (e) { } } const existingToday = context.get("today_data"); if (existingToday && existingToday.date && dataDate && existingToday.date !== dataDate) { const prev = existingToday.date; if (new Date(prev) < new Date(dataDate)) { context.set("yesterday_data", existingToday); context.set("today_data", null); if (cfg.debug) node.debug(`Rotated today_data to yesterday_data (${prev} -> ${dataDate}).`); } } if (dataDate && Array.isArray(dataRoot.raw_today) && dataRoot.raw_today.length > 0) { context.set("today_data", { date: dataDate, data: dataRoot.raw_today }); } const todayStore = context.get("today_data") || {}; const yesterdayStore = context.get("yesterday_data") || {}; let rawTomorrow = []; const storedTomorrow = context.get("tomorrow_data"); if (Array.isArray(dataRoot.raw_tomorrow) && dataRoot.raw_tomorrow.length > 0) { rawTomorrow = dataRoot.raw_tomorrow; context.set("tomorrow_data", rawTomorrow); } else if (Array.isArray(storedTomorrow) && storedTomorrow.length > 0) { rawTomorrow = storedTomorrow; } let all = []; let sourceLabel = ""; if (rolling24h) { all = mergeRawArray(yesterdayStore.data, convertFn) .concat(mergeRawArray(todayStore.data, convertFn)) .concat(mergeRawArray(rawTomorrow, convertFn)); sourceLabel = "yesterday + today + tomorrow"; } else { const nowHour = new Date().getHours(); if (isNight && nowHour < flowStop) { all = mergeRawArray(yesterdayStore.data, convertFn).concat(mergeRawArray(todayStore.data, convertFn)); sourceLabel = "yesterday + today"; } else if (isNight && (!Array.isArray(rawTomorrow) || rawTomorrow.length === 0)) { all = mergeRawArray(yesterdayStore.data, convertFn).concat(mergeRawArray(todayStore.data, convertFn)); sourceLabel = "yesterday + today"; } else { all = mergeRawArray(todayStore.data, convertFn).concat(mergeRawArray(rawTomorrow, convertFn)); sourceLabel = "today + tomorrow"; } } all = dedupeByStart(all); let startDate, endDate; if (rolling24h) { const now = new Date(); startDate = new Date(now); startDate.setHours(flowStart, 0, 0, 0); if (now < startDate) { startDate.setDate(startDate.getDate() - 1); } endDate = new Date(startDate.getTime() + 24 * 60 * 60 * 1000 - 1); } else { let baseDate = new Date(); const nowHourFixed = baseDate.getHours(); if (isNight && nowHourFixed < flowStop) { baseDate.setDate(baseDate.getDate() - 1); } else if (!isNight && nowHourFixed >= flowStop) { baseDate.setDate(baseDate.getDate() + 1); } const tmp = buildPeriod(baseDate, flowStart, flowStop); startDate = tmp.start; endDate = tmp.end; } const periodLabel = `${toLocalLabel(startDate)} → ${toLocalLabel(endDate)}`; // --- Improved slot filtering --- // Only slots starting >= startDate and < endDate are included const inPeriod = all.filter(entry => { const entryDate = new Date(entry.start); return entryDate >= startDate && entryDate < endDate; }); if (inPeriod.length === 0) { node.status({ fill: "yellow", shape: "ring", text: "Waiting for Nordpool data" }); const infoMsg = { payload: { state: null, attributes: { info: "No valid times, waiting for data", rolling_24h: rolling24h ? "on" : "off", calculated_at: new Date().toISOString() } } }; const haMsg = (cfg.haEntity && cfg.haEntity.trim() !== "") ? { payload: { action: "input_number.set_value", data: { entity_id: cfg.haEntity, value: cfg.forceValue } } } : null; node.send([null, { payload: cfg.payloadOff }, infoMsg, haMsg]); if (cfg.debug) node.debug("No data in period; sent waiting status."); return; } const intervalMinutes = detectIntervalMinutes(inPeriod); if (intervalMinutes >= 55 && flowCount > 23) { flowCount = 23; } const { selected, meta } = selectCheapOrExpensive(inPeriod, flowCount, cfg.invertSelection, cfg.contiguousMode); const expectedPoints = Math.round((endDate - startDate) / (intervalMinutes * 60000)); const missingPoints = Math.max(0, expectedPoints - inPeriod.length); const { attributes: attr, reference } = buildAttributes( selected, periodLabel, cfg.invertSelection, sourceLabel, meta, intervalMinutes, rolling24h ); // Extra diagnostics and semantic attributes const totalHours = (endDate - startDate) / 3600000; attr.total_hours_span = Number(totalHours.toFixed(2)); attr.expected_points = expectedPoints; attr.actual_points = inPeriod.length; if (missingPoints > 0) { attr.missing_points = missingPoints; attr.partial_period = true; } attr.reference_price_numeric = reference ?? null; attr.calculated_at = new Date().toISOString(); // Override/normal semantics if (haEnabled === false) { attr.ha_override = "on"; attr.control_mode = "override"; attr.ha_sent_value = cfg.forceValue; attr.next_reference_when_enabled = reference ?? null; attr.reference_price_effective = null; // Not used now } else { attr.ha_override = "off"; attr.control_mode = "normal"; attr.ha_sent_value = (reference ?? cfg.forceValue); attr.next_reference_when_enabled = null; attr.reference_price_effective = attr.reference_price; } const newMsg = { payload: { state: reference, attributes: attr } }; const active = isActiveNow(selected, intervalMinutes); let outsidePeriod = !(new Date() >= startDate && new Date() < endDate); let haMsgInside, haMsgOutside; if (haEnabled === false) { haMsgInside = haMsgOutside = (cfg.haEntity && cfg.haEntity.trim() !== "") ? { payload: { action: "input_number.set_value", data: { entity_id: cfg.haEntity, value: cfg.forceValue } } } : null; } else { haMsgInside = (cfg.haEntity && cfg.haEntity.trim() !== "") ? { payload: { action: "input_number.set_value", data: { entity_id: cfg.haEntity, value: reference ?? cfg.forceValue } } } : null; haMsgOutside = (cfg.haEntity && cfg.haEntity.trim() !== "") ? { payload: { action: "input_number.set_value", data: { entity_id: cfg.haEntity, value: cfg.forceValue } } } : null; } if (haEnabled !== false) { node.status({ fill: active ? "green" : "grey", shape: "dot", text: `${String(flowStart).padStart(2, "0")}→${String(flowStop).padStart(2, "0")} (${flowCount}x ${cfg.invertSelection ? "expensive" : "cheap"})${rolling24h ? " 24h" : ""}` }); } if (outsidePeriod || reference === null || isNaN(reference)) { node.send([null, { payload: cfg.payloadOff }, newMsg, haMsgOutside]); if (cfg.debug) node.debug("Outside period or invalid reference -> OFF."); } else if (active) { node.send([{ payload: cfg.payloadOn }, null, newMsg, haMsgInside]); if (cfg.debug) node.debug("Active slot -> ON."); } else { node.send([null, { payload: cfg.payloadOff }, newMsg, haMsgInside]); if (cfg.debug) node.debug("Inside period but not active slot -> OFF (standby)."); } } catch (err) { node.error(`Error in Nordpool analysis: ${err.message}`); node.status({ fill: "red", shape: "ring", text: "Error" }); node.send([null, null, { payload: { error: err.message } }, null]); if (cfg.debug) node.debug(`Exception caught: ${err.stack}`); } }); } RED.nodes.registerType("nordpool-chargecheap", NordpoolChargeCheapNode, { outputs: 4, defaults: { name: { value: "" }, start: { value: "", required: false }, stop: { value: "", required: false }, count: { value: "", required: false }, payload_on: { value: "on", required: false }, payload_off: { value: "off", required: false }, force_value: { value: -600, required: false }, ha_entity: { value: "", required: false }, invert_selection: { value: false, required: false }, contiguous_mode: { value: false, required: false }, debug: { value: false, required: false } }, label: function () { return this.name || "Nordpool ChargeCheap"; } }); };