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