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
JavaScript
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);
};