UNPKG

node-red-contrib-magra-sn014

Version:

SN014 – Signal Response Check (Magra Core). Validates measured response after control change within a time window.

222 lines (201 loc) 8.9 kB
module.exports = function (RED) { function MagraSN014(config) { RED.nodes.createNode(this, config); const node = this; // Config defaults const defWin = parseFloat(config.response_window_sec); const defDelta = parseFloat(config.expected_delta); node.inputsLocation = config.inputs_location || "payload"; node.debugEnabled = !!config.debug_output; function srcObj(msg){ return node.inputsLocation === "msg" ? msg : (msg.payload || {}); } function debug(o){ if (node.debugEnabled){ try{ node.log("[SN014] " + JSON.stringify(o)); }catch(e){} } } node.defaults = { response_window_sec: isNaN(defWin) ? 120 : defWin, expected_delta: isNaN(defDelta) ? 2.0 : defDelta, control_property: (config.control_property || "").toString() }; // Context state const ctx = node.context(); if (!ctx.get("lastControl")) ctx.set("lastControl", null); if (!ctx.get("pending")) ctx.set("pending", false); if (!ctx.get("startTs")) ctx.set("startTs", null); if (!ctx.get("baseline")) ctx.set("baseline", null); function now() { return Date.now(); } function toNumberOrBool(v) { if (typeof v === "boolean") return v ? 1 : 0; if (typeof v === "number") return v; return null; } node.on("input", function(msg, send, done) { try { const ft = Number(msg.flow_temp), rt = Number(msg.return_temp); if (!isNaN(ft) && !isNaN(rt) && msg.mode === "heating" && ft < rt) { msg.fault = msg.fault || {}; msg.fault.sensorSanity = "flow<return in heating"; node.status({fill:"yellow", shape:"ring", text:"sensor sanity"}); } } catch(e) { node.warn("sanity check failed: "+e.message); } try { const p = srcObj(msg); const dyn = (msg && msg.dynamic_thresholds) || (p && p.dynamic_thresholds) || {}; const policy = p.policy || {}; const timers = p.timers || {}; // thresholds: dyn > policy > timers/payload > node defaults const thresholds = { response_window_sec: (typeof dyn.response_window_sec === "number") ? dyn.response_window_sec : (typeof policy.max_time === "number") ? policy.max_time : (typeof p.response_window_sec === "number") ? p.response_window_sec : node.defaults.response_window_sec, expected_delta: (typeof dyn.expected_delta === "number") ? dyn.expected_delta : (typeof policy.min_response === "number") ? policy.min_response : (typeof p.expected_delta === "number") ? p.expected_delta : node.defaults.expected_delta }; const assetId = p.asset_id || msg.asset_id || ""; const control = toNumberOrBool(p.control_signal ?? msg.control_signal); const measured = (typeof p.measured_response === "number") ? p.measured_response : (typeof msg.measured_response === "number") ? msg.measured_response : null; // load context let lastControl = ctx.get("lastControl"); let pending = ctx.get("pending"); let startTs = ctx.get("startTs"); let baseline = ctx.get("baseline"); // Detect control change let triggered = false; if (control !== null) { if (lastControl === null || control !== lastControl) { triggered = true; pending = true; startTs = now(); baseline = (typeof measured === "number") ? measured : null; lastControl = control; node.status({ fill: "blue", shape: "dot", text: "triggered" }); } } // If no control change and no pending test if (!pending && !triggered) { const outDiag = { topic: "magra-core/sn014", doc_id: "MAGRA-SN014-v1.1", rule_id: "SN014", asset_id: assetId, action_or_flag: "DIAGNOSTIC", reason_code: "NO_TRIGGER", reason_text: "No control change detected; no test started.", thresholds, diagnostics: { delta_achieved: null, response_time: 0 }, payload: p, eval_hash: "auto", content_anchor: "#sn014-logic" }; send(outDiag); if (done) done(); return; } // Pending evaluation if (pending) { const elapsed = Math.round((now() - startTs) / 1000); let delta = null; if (typeof measured === "number" && typeof baseline === "number") { delta = Math.abs(measured - baseline); } // Sensor fault while pending if (delta === null) { node.status({ fill: "yellow", shape: "ring", text: "SENSOR_FAULT" }); const outFault = { topic: "magra-core/sn014", doc_id: "MAGRA-SN014-v1.1", rule_id: "SN014", asset_id: assetId, action_or_flag: "DIAGNOSTIC", reason_code: "SENSOR_FAULT", reason_text: "Measured response unavailable during test.", thresholds, diagnostics: { delta_achieved: null, response_time: elapsed }, payload: p }; send(outFault); if (done) done(); return; } // Early PASS if (delta >= thresholds.expected_delta) { node.status({ fill: "green", shape: "dot", text: "PASS" }); const outPass = { topic: "magra-core/sn014", doc_id: "MAGRA-SN014-v1.1", rule_id: "SN014", asset_id: assetId, action_or_flag: "PASS", reason_code: "PASS", reason_text: `Response exceeded ${thresholds.expected_delta} within ${elapsed}s`, thresholds, diagnostics: { delta_achieved: delta, response_time: elapsed }, payload: p }; // clear pending pending = false; startTs = null; baseline = null; ctx.set("pending", pending); ctx.set("startTs", startTs); ctx.set("baseline", baseline); ctx.set("lastControl", lastControl); send(outPass); if (done) done(); return; } // Window expiry if (elapsed >= thresholds.response_window_sec) { let reason = "NO_RESPONSE"; if (delta > 0 && delta < thresholds.expected_delta) reason = "PARTIAL_RESPONSE"; const outFail = { topic: "magra-core/sn014", doc_id: "MAGRA-SN014-v1.1", rule_id: "SN014", asset_id: assetId, action_or_flag: "FAIL", reason_code: reason, reason_text: reason === "PARTIAL_RESPONSE" ? `Partial response (${delta}) < ${thresholds.expected_delta} within ${thresholds.response_window_sec}s` : `Control signal issued but no measurable response within ${thresholds.response_window_sec}s`, thresholds, diagnostics: { delta_achieved: delta, response_time: thresholds.response_window_sec }, payload: p }; node.status({ fill: "red", shape: "dot", text: reason }); // clear pending pending = false; startTs = null; baseline = null; ctx.set("pending", pending); ctx.set("startTs", startTs); ctx.set("baseline", baseline); ctx.set("lastControl", lastControl); send(outFail); if (done) done(); return; } // Still waiting - send diagnostic heartbeat node.status({ fill: "blue", shape: "ring", text: `waiting ${elapsed}s` }); const outDiag = { topic: "magra-core/sn014", doc_id: "MAGRA-SN014-v1.1", rule_id: "SN014", asset_id: assetId, action_or_flag: "DIAGNOSTIC", reason_code: "WAITING", reason_text: "Awaiting response within window.", thresholds, diagnostics: { delta_achieved: delta, response_time: elapsed }, payload: p, eval_hash: "auto", content_anchor: "#sn014-logic" }; send(outDiag); if (done) done(); return; } // persist context ctx.set("lastControl", lastControl); ctx.set("pending", pending); ctx.set("startTs", startTs); ctx.set("baseline", baseline); if (done) done(); } catch (err) { node.status({ fill: "red", shape: "ring", text: "error" }); if (done) done(err); else node.error(err); } }); } RED.nodes.registerType("magra-sn014", MagraSN014); };