@@ -71,6 +71,37 @@ class Machine {
this . child = { } ; // object to hold child information so we know on what to subscribe
this . childRegistrationUtils = new childRegistrationUtils ( this ) ; // Child registration utility
// --- KPI tracking ---
this . kpi = {
failures : 0 ,
totalRuntimeHours : 0 ,
totalDowntimeHours : 0 ,
lastFailureTime : null ,
lastRepairTime : null ,
MTBF : 0 ,
MTTR : 0 ,
availability : 0
} ;
this . assetHealth = {
index : 0 // 0 = optimal, 5 = failure
} ;
this . state . emitter . on ( 'stateChange' , ( payload ) => {
const stateStr = typeof payload === 'string'
? payload
: ( payload ? . state ? ? payload ? . newState ? ? payload ) ;
if ( typeof stateStr !== 'string' ) {
this . logger . warn ( ` stateChange event without parsable state: ${ JSON . stringify ( payload ) } ` ) ;
return ;
}
this . _handleStateChangeForKPI ( stateStr ) ;
} ) ;
}
/*------------------- Register child events -------------------*/
@@ -528,6 +559,136 @@ _callMeasurementHandler(measurementType, value, position, context) {
}
}
/////////////////////////////
/**
* Compute a single drift score in [0..1] using predicted vs measured series.
* Uses min/max of the *predicted* window as normalization range.
* If no usable data -> returns 0 (neutral).
*/
_computeDriftScore ( ) {
try {
const metrics = [
{ key : "pressure" , pos : "downstream" } ,
{ key : "flow" , pos : "downstream" } ,
{ key : "power" , pos : "atEquipment" }
] ;
const values = [ ] ;
for ( const m of metrics ) {
const pred = this . measurements . type ( m . key ) . variant ( "predicted" ) . position ( m . pos ) . getAllValues ( ) ? . values ;
const meas = this . measurements . type ( m . key ) . variant ( "measured" ) . position ( m . pos ) . getAllValues ( ) ? . values ;
if ( ! Array . isArray ( pred ) || ! Array . isArray ( meas ) || pred . length < 2 || meas . length < 2 ) continue ;
const expectedMin = Math . min ( ... pred ) ;
const expectedMax = Math . max ( ... pred ) ;
if ( ! Number . isFinite ( expectedMin ) || ! Number . isFinite ( expectedMax ) || expectedMax === expectedMin ) continue ;
const drift = this . errorMetrics . assessDrift ( pred , meas , expectedMin , expectedMax ) ;
if ( Number . isFinite ( drift ) ) {
// assessDrift is already normalized; keep it in [0..1]
values . push ( Math . max ( 0 , Math . min ( 1 , Math . abs ( drift ) ) ) ) ;
}
}
if ( values . length === 0 ) return 0 ; // neutral if no data
const avg = values . reduce ( ( s , v ) => s + v , 0 ) / values . length ;
return Math . max ( 0 , Math . min ( 1 , avg ) ) ;
} catch ( e ) {
this . logger ? . warn ? . ( ` Drift score error: ${ e . message } ` ) ;
return 0 ;
}
}
_calculateAssetHealthIndex ( ) {
try {
// 1) Hard fail -> worst health
// if (this.state?.getCurrentState && this.state.getCurrentState() === "failed")
if ( [ "off" ] . includes ( this . state ? . getCurrentState ? . ( ) ) ) {
this . assetHealth . index = 5 ;
return 5 ;
}
// 2) Inputs (clamped to 0..1)
const availability = typeof this . kpi ? . availability === 'number' ? this . kpi . availability : 1 ;
const unavailability = 1 - Math . max ( 0 , Math . min ( 1 , availability ) ) ;
const effPenalty = Math . max ( 0 , Math . min ( 1 , typeof this . relDistFromPeak === 'number' ? this . relDistFromPeak : 0 ) ) ;
const driftScore = this . _computeDriftScore ( ) ; // 0..1
// 3) Blend (weights sum to 1.0)
// Tweak these if you like: e.g. make drift more/less important.
const wAvail = 0.4 ; // unavailability weight
const wDrift = 0.4 ; // drift weight
const wEff = 0.2 ; // efficiency distance weight
const score01 = ( wAvail * unavailability ) + ( wDrift * driftScore ) + ( wEff * effPenalty ) ;
// 4) Scale to 0..5 integer, clamp
const index = Math . max ( 0 , Math . min ( 5 , Math . round ( score01 * 5 ) ) ) ;
this . assetHealth . index = index ;
return index ;
} catch ( err ) {
this . logger ? . error ? . ( ` AHI calc error: ${ err . message } ` ) ;
this . assetHealth . index = 0 ;
return 0 ;
}
}
_handleStateChangeForKPI ( newState ) {
const now = Date . now ( ) ;
const runtime = this . state . getRunTimeHours ( ) ;
const lastState = this . state . getPreviousState ? . ( ) || "unknown" ;
// --- Treat OFF as failure and start of downtime ---
if ( newState === "off" ) {
this . kpi . failures ++ ; // always count a new failure when OFF
this . kpi . lastFailureTime = now ; // mark the start of downtime
this . logger . warn ( ` Machine OFF (counted as failure). Total failures: ${ this . kpi . failures } ` ) ;
}
// --- When we leave OFF and become OPERATIONAL, book downtime ---
if ( newState === "operational" ) {
// Only calculate downtime if we had an OFF period before
if ( this . kpi . lastFailureTime != null ) {
const downtimeHours = ( now - this . kpi . lastFailureTime ) / 3600000 ;
this . kpi . totalDowntimeHours += downtimeHours ;
this . kpi . lastRepairTime = now ; // moment of "repaired"
this . kpi . lastFailureTime = null ; // close downtime window
this . logger . info ( ` OFF → OPERATIONAL. Added ${ downtimeHours . toFixed ( 2 ) } h downtime. ` ) ;
}
}
// --- Compute KPI Metrics ---
const failures = this . kpi . failures ;
const downtime = this . kpi . totalDowntimeHours ;
// If no failures yet: MTBF = total runtime; MTTR = 0
this . kpi . MTBF = failures > 0 ? runtime / failures : runtime ;
this . kpi . MTTR = failures > 0 ? downtime / failures : 0 ;
// --- Compute Availability ---
const mtbf = this . kpi . MTBF ? ? 0 ;
const mttr = this . kpi . MTTR ? ? 0 ;
if ( mtbf <= 0 && mttr <= 0 ) {
this . kpi . availability = 1 ; // Default: 100% if no data
} else {
const availability = mtbf / ( mtbf + mttr ) ;
this . kpi . availability = Math . min ( 1 , Math . max ( 0 , availability ) ) ; // clamp 0– 1
}
this . logger . debug (
` KPI updated — MTBF: ${ this . kpi . MTBF . toFixed ( 2 ) } h, MTTR: ${ this . kpi . MTTR . toFixed ( 2 ) } h, ` +
` Availability: ${ ( this . kpi . availability * 100 ) . toFixed ( 2 ) } % `
) ;
}
//////////////////////////////////////////////
calcDistanceFromPeak ( currentEfficiency , peakEfficiency ) {
return Math . abs ( currentEfficiency - peakEfficiency ) ;
@@ -702,6 +863,13 @@ _callMeasurementHandler(measurementType, value, position, context) {
output [ "cog" ] = this . cog ; // flow / power efficiency
output [ "NCog" ] = this . NCog ; // normalized cog
output [ "NCogPercent" ] = Math . round ( this . NCog * 100 * 100 ) / 100 ;
output [ "kpi_MTBF" ] = this . kpi . MTBF ;
output [ "kpi_MTTR" ] = this . kpi . MTTR ;
output [ "kpi_assetAvailability" ] = Math . round ( this . kpi . availability * 100 * 100 ) / 100 ;
output [ "kpi_totalFailuresCount" ] = this . kpi . failures ;
output [ "asset_tag_number" ] = 'L001' ;
// output["asset_tag_number"] = this.assetTagNumber;
if ( this . flowDrift != null ) {
const flowDrift = this . flowDrift ;
@@ -716,6 +884,21 @@ _callMeasurementHandler(measurementType, value, position, context) {
output [ "effRelDistFromPeak" ] = this . relDistFromPeak ;
//this.logger.debug(`Output: ${JSON.stringify(output)}`);
/////////////////////////////////
// this._calculateAssetHealthIndex();
// output["assetHealthIndex"] = this.assetHealth.index;
this . _calculateAssetHealthIndex ( ) ;
output [ "assetHealthIndex" ] = this . assetHealth . index ;
// 0 = darkgreen, 1 = green, 2 = yellow, 3 = orange, 4 = red, 5 = darkred
// const healthColors = ["darkgreen", "green", "yellow", "orange", "red", "darkred"];
const healthColors = [ "#006400" , "#008000" , "#FFFF00" , "#FFA500" , "#FF0000" , "#8B0000" ] ;
output [ "assetHealthColor" ] = healthColors [ this . assetHealth . index ] || "unknown" ;
//////////////////////////
return output ;
}
@@ -725,9 +908,9 @@ _callMeasurementHandler(measurementType, value, position, context) {
module . exports = Machine ;
/*------------------- Testing -------------------*/
/*
curve = require('C:/Users/zn375/.node-red/public/fallbackData.json');
/*
//curve = require('C:/Users/zn375/.node-red/public/fallbackData.json');
//import a child
const Child = require('../../measurement/src/specificClass');
@@ -743,7 +926,6 @@ const PT1 = new Child(config={
},
functionality:{
softwareType:"measurement",
positionVsParent:"upstream",
},
asset:{
supplier:"Vega",
@@ -765,7 +947,6 @@ const PT2 = new Child(config={
},
functionality:{
softwareType:"measurement",
positionVsParent:"upstream",
},
asset:{
supplier:"Vega",
@@ -781,18 +962,17 @@ console.log(`Creating machine...`);
const machineConfig = {
general: {
name: "Hy drostal",
name: "Hi drostal",
logging: {
enabled: true,
logLevel: "debug",
}
},
asset: {
supplier: "Hy drostal",
supplier: "Hi drostal",
type: "pump",
category: "centrifugal",
model: "H05K-S03R+HGM1X-X280KO ", // Ensure this field is present.
machineCurve: curve["machineCurves"]["Hydrostal"]["H05K-S03R+HGM1X-X280KO"],
model: "hidrostal-H05K-S03R ", // Ensure this field is present.
}
}
@@ -821,18 +1001,17 @@ const machine = new Machine(machineConfig, stateConfig);
machine.logger.info(`Registering child...`);
machine.childRegistrationUtils.registerChild(PT1, "upstream");
machine.childRegistrationUtils.registerChild(PT2, "downstream");
//feed curve to the machine class
//machine.updateCurve(curve["machineCurves"]["Hydrostal"]["H05K-S03R+HGM1X-X280KO"]);
/*
PT1.logger.info(`Enable sim...`);
PT1.toggleSimulation();
PT2.logger.info(`Enable sim...`);
PT2.toggleSimulation();
machine.getOutput();
*/
//manual test
//machine.handleInput("parent", "execSequence", "startup");
/*
machine.measurements.type("pressure").variant("measured").position('upstream').value(-200);
machine.measurements.type("pressure").variant("measured").position('downstream').value(1000);
@@ -842,8 +1021,8 @@ const tickLoop = setInterval(changeInput,1000);
function changeInput(){
PT1.logger.info(`tick...`);
PT1.tick();
PT2.tick();
// PT1.tick();
// PT2.tick();
}
async function testingSequences(){