UNPKG

node-red-contrib-magra-sn008

Version:

SN008 – Output vs Load Validator (Magra Core). Validates load response against output steps.

170 lines (155 loc) 6.93 kB
module.exports = function (RED) { function MagraSN008Node(config) { RED.nodes.createNode(this, config); const node = this; node.validationWindow = parseInt(config.validation_window_min ?? 15, 10); node.minExpectedDelta = Number(config.min_expected_delta ?? 2.0); node.inputsLocation = config.inputs_location || "payload"; node.debugEnabled = !!config.debug_output; const STEP_MIN = 5; // % output change to treat as a step const NO_RESP_EPS = 0.1; // absolute delta considered 'no change' function srcObj(msg){ return node.inputsLocation === "msg" ? msg : (msg.payload || {}); } function clampPct(v){ const n = Number(v); if (!Number.isFinite(n)) return NaN; return Math.max(0, Math.min(100, n)); } function num(v){ const n = Number(v); return Number.isFinite(n) ? n : NaN; } function debug(o){ if (node.debugEnabled){ try{ node.log(`[SN008] ${JSON.stringify(o)}`);}catch(e){} } } function resolveThresholds(msg, src){ const dyn = (msg.dynamic_thresholds || src.dynamic_thresholds || {}) || {}; const validationWindow = Number.isFinite(dyn.validation_window_min) ? parseInt(dyn.validation_window_min,10) : node.validationWindow; const minExpectedDelta = Number.isFinite(dyn.min_expected_delta) ? Number(dyn.min_expected_delta) : node.minExpectedDelta; return { validationWindow, minExpectedDelta }; } function expectedSignFor(assetType){ const t = String(assetType || "").toLowerCase(); if (["chiller","fcu","vrf","cooling"].includes(t)) return -1; // output up -> load should fall return 1; // default: output up -> load should rise } node.on("input", function(msg, send, done){ send = send || node.send.bind(node); done = done || function(){}; const src = srcObj(msg); const assetId = src.asset_id || msg.asset_id || null; const assetType = src.asset_type || msg.asset_type || null; const outPct = clampPct(src.output_signal_pct ?? msg.output_signal_pct); const loadVal = num(src.load_response_value ?? msg.load_response_value); const timers = src.timers || msg.timers || {}; const sinceChange = Number.isFinite(Number(timers.since_last_change_min)) ? Number(timers.since_last_change_min) : null; const { validationWindow, minExpectedDelta } = resolveThresholds(msg, src); let reason = "NO_CHANGE"; let faultType = "NONE"; let validationFlag = false; let outputChange = NaN; let loadDelta = NaN; let evalNow = false; // Retrieve / init context const ctx = node.context(); let state = ctx.get("state") || null; if (!Number.isFinite(outPct) || !Number.isFinite(loadVal)){ // INPUT_INVALID - emit NO_CHANGE but include diagnostics const out = { topic: msg.topic || "magra-core/sn008", doc_id: "MAGRA-SN008-v1.1", rule_id: "SN008", asset_id: assetId, validation_flag: false, fault_type: "NONE", reason_code: "NO_CHANGE", reason_text: "Inputs invalid || missing (no evaluation)", thresholds: { validation_window_min: validationWindow, min_expected_delta: minExpectedDelta }, diagnostics: { output_signal_change: null, load_delta: null, window_duration: validationWindow }, payload: src, eval_hash: "auto", content_anchor: "#sn008-logic" }; debug(out); send(out); return done(); } // Determine if a new step has started const lastOutput = ctx.get("lastOutput"); if (lastOutput === undefined || !Number.isFinite(lastOutput)){ ctx.set("lastOutput", outPct); } const last = Number.isFinite(lastOutput) ? lastOutput : outPct; outputChange = outPct - last; if (Math.abs(outputChange) >= STEP_MIN && (sinceChange === null || sinceChange <= 1)){ // Start window state = { open: true, startOutput: last, startLoad: loadVal, expectedSign: expectedSignFor(assetType), stepDirection: outputChange > 0 ? 1 : -1 }; ctx.set("state", state); } // Evaluate window if open and sinceChange >= validationWindow if (state && state.open && sinceChange !== null && sinceChange >= validationWindow){ evalNow = true; loadDelta = loadVal - state.startLoad; const direction = state.stepDirection; if (direction > 0){ // Only evaluate rising steps per spec const signed = state.expectedSign * loadDelta; if (signed < 0){ reason = "INVERSE_RESPONSE"; faultType = "INVERSE_RESPONSE"; validationFlag = true; } else if (Math.abs(loadDelta) < NO_RESP_EPS){ reason = "NO_RESPONSE"; faultType = "NO_RESPONSE"; validationFlag = true; } else if (Math.abs(loadDelta) < minExpectedDelta){ reason = "WEAK_RESPONSE"; faultType = "WEAK_RESPONSE"; validationFlag = true; } else { reason = "VALIDATION_PASS"; faultType = "NONE"; validationFlag = true; } } else { // Falling steps not validated -> no change reason = "NO_CHANGE"; faultType = "NONE"; validationFlag = false; } // Close window and update baseline ctx.set("state", null); ctx.set("lastOutput", outPct); } else { // No evaluation yet; update lastOutput gradually ctx.set("lastOutput", outPct); } const reasonMap = { VALIDATION_PASS: "Output step produced expected load response", NO_RESPONSE: "Output rose but no corresponding load response", INVERSE_RESPONSE: "Output rose but load moved in opposite direction", WEAK_RESPONSE: "Output rose; load response below minimum expected delta", NO_CHANGE: "No validation performed" }; const out = { topic: msg.topic || "magra-core/sn008", doc_id: "MAGRA-SN008-v1.1", rule_id: "SN008", asset_id: assetId, validation_flag: validationFlag, fault_type: faultType, reason_code: reason, reason_text: reasonMap[reason] || "Evaluated", thresholds: { validation_window_min: validationWindow, min_expected_delta: minExpectedDelta }, diagnostics: { output_signal_change: Number.isFinite(outputChange) ? Number(outputChange.toFixed(2)) : null, load_delta: Number.isFinite(loadDelta) ? Number(loadDelta.toFixed(2)) : null, window_duration: validationWindow }, payload: src, eval_hash: "auto", content_anchor: "#sn008-logic" }; debug(out); send(out); done(); }); } RED.nodes.registerType("magra-sn008", MagraSN008Node); };