diff --git a/README.md b/README.md index 9c39350..a8a6d79 100644 --- a/README.md +++ b/README.md @@ -4,20 +4,64 @@ Mean Time Between Failures: MTBF is a key indicator of asset reliability. It rep - Formula: MTBF = runtime / failures Mean Time to Repair: MTTR focuses on the speed of failure recovery. It indicates how long it takes on average to repair a failure and restore the system to operational condition. The lower the value, the better. -- Formula: MTTR = downtime / failures +- Formula: MTTR = maintenance_time / failures Asset Availability: Asset availability indicates how often machines are available for use. Higher availability means that the equipment experiences fewer breakdowns and remains operational for a greater amount of time. - Formula: availability = MTBF / (MTBF + MTTR) * 100% Asset Health Index: A score ranging from 0 (optimal condition) to 5 (worst case) that reflects the overall health status of an asset. + How Asset Health Index is being calculated: + 1. Hard rule first: If the machine state is "maintenance", the health index is set straight to 5 (worst). + 2. Otherwise it uses 3 inputs: + Unavailability = 1 - availability + If the machine is often down (low availability), this gets higher → worse health. + + Drift score = how much measured vs. predicted pressure/flow/power differ over time. + More mismatch → higher drift score → worse health. + + Efficiency penalty = how far the current efficiency is from the best efficiency point. + Further from the peak efficiency → higher penalty → worse health. + + 3. These are combined into one score: + score01 = 0.4 * unavailability + + 0.4 * driftScore + + 0.2 * effPenalty; + + So: availability (40%) + drift (40%) + efficiency loss (20%). + + 4. Convert to index 0–5: + index = round(score01 * 5) + Clamp between 0 and 5 and store in this.assetHealth.index. + + Summary: Health index = mix of downtime, sensor/prediction mismatch, and efficiency loss, scaled to a 0–5 scale (0 good, 5 bad). + +Remaining Useful Life: the estimated amount of time an asset has until it becomes unusable or requires replacement. + How RUL is being calculated: combination of MTBF and Asset Health Index ---KPI message--- The message consists of the following components: -- asset tagnumber: +- asset tagnumber: unique identifier of an asset +- Maintenance mode: If asset in maintenance, this returns true, else this return false +- Maintenance Time: Total time the asset has been in maintenance - asset availability - mean time between failures - mean time to repair - asset health index - asset health color: Gives a color based on the asset health index (0 = Darkgreen, 1 = Green, 2 = Yellow, 3 = Orange, 4 = Red, 5 = Darkred.) -- total failures: the total number of failures that have occured for a particular asset \ No newline at end of file +- total failures: the total number of failures that have occured for a particular asset +- Remaining Useful Life: the length from the current time to the end of the useful life + +Example message: +{ + asset_tag_number: "L001" + maintenance_mode: false + maintenance_time: 0.23494861111111115 + kpi_asset_availability: 95.83 + kpi_mtbf: 1.3505209027777778 + kpi_mttr: 0.05873715277777779 + kpi_asset_health_index: 2 + kpi_asset_health_color: "#FFFF00" + kpi_total_failures: 4 + remaining_useful_life: 1.0804167222222223 +} \ No newline at end of file diff --git a/src/specificClass.js b/src/specificClass.js index b0379b6..707b66c 100644 --- a/src/specificClass.js +++ b/src/specificClass.js @@ -89,15 +89,19 @@ class Machine { // --- KPI tracking --- this.kpi = { - failures: 0, - totalRuntimeHours: 0, - totalDowntimeHours: 0, - lastFailureTime: null, - lastRepairTime: null, - MTBF: 0, - MTTR: 0, - availability: 0 - }; + failures: 0, + totalRuntimeHours: 0, + totalDowntimeHours: 0, + lastFailureTime: null, + lastRepairTime: null, + MTBF: 0, + MTTR: 0, + availability: 1, + maintenanceMode: false, + lastFailureRuntime: 0, + lastRUL: null, // remember previous RUL value + lastFailureCause: null + }; this.assetHealth = { index: 0 // 0 = optimal, 5 = failure @@ -116,48 +120,7 @@ class Machine { this._handleStateChangeForKPI(stateStr); }); - - - // --- 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); - }); - - - } - - _updateState(){ - const isOperational = this._isOperationalState(); - if(!isOperational){ - //overrule the last prediction this should be 0 now - this.measurements.type("flow").variant("predicted").position("downstream").value(0); - } - } + } _updateState(){ const isOperational = this._isOperationalState(); @@ -298,11 +261,9 @@ _callMeasurementHandler(measurementType, value, position, context) { case "execmovement": return await this.setpoint(parameter); - case "entermaintenance": - + case "entermaintenance": return await this.executeSequence(parameter); - case "exitmaintenance": return await this.executeSequence(parameter); @@ -649,12 +610,7 @@ _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). - */ +///////////////////////////// Calculate Asset Health Index ///////////////////////////// _computeDriftScore() { try { const metrics = [ @@ -693,32 +649,23 @@ _callMeasurementHandler(measurementType, value, position, context) { _calculateAssetHealthIndex() { try { - // 1) Hard fail -> worst health - // if (this.state?.getCurrentState && this.state.getCurrentState() === "failed") - if (["off"].includes(this.state?.getCurrentState?.())){ + if (["maintenance"].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 driftScore = this._computeDriftScore(); + const wAvail = 0.2; // unavailability weight const wDrift = 0.4; // drift weight - const wEff = 0.2; // efficiency distance weight + const wEff = 0.4; // 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) { @@ -728,56 +675,68 @@ _callMeasurementHandler(measurementType, value, position, context) { } } -_handleStateChangeForKPI(newState) { - const now = Date.now(); - const runtime = this.state.getRunTimeHours(); - const lastState = this.state.getPreviousState?.() || "unknown"; + _registerFailure(cause, runtimeOverride) { + const runtime = runtimeOverride ?? this.state.getRunTimeHours(); + const maintenance_time = this.state.getMaintenanceTimeHours(); - // --- 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}`); - } + this.kpi.failures += 1; + this.kpi.rulFailureTriggered = false; + this.kpi.lastFailureTime = Date.now(); + this.kpi.lastFailureRuntime = runtime; + this.kpi.lastFailureCause = cause; - // --- 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.`); + const failures = this.kpi.failures; + + // If no failures yet: MTBF = total runtime & MTTR = 0 + this.kpi.MTBF = failures > 0 ? runtime / failures : runtime; + this.kpi.MTTR = failures > 0 ? maintenance_time / failures : 0; + + const mtbf = this.kpi.MTBF ?? 0; + const mttr = this.kpi.MTTR ?? 0; + + if (mtbf <= 0 && mttr <= 0) { + this.kpi.availability = 1; + } else { + const availability = mtbf / (mtbf + mttr); + this.kpi.availability = Math.min(1, Math.max(0, availability)); } + + this.logger.warn( + `Failure registered (cause=${cause}). Total failures=${this.kpi.failures}, runtime=${runtime.toFixed(2)}h` + ); } - // --- Compute KPI Metrics --- + _handleStateChangeForKPI(newState) { + const runtime = this.state.getRunTimeHours(); + + if (newState === "maintenance") { + if (this.kpi.rulFailureTriggered) { + this.kpi.rulFailureTriggered = false; + this._registerFailure("MAINTENANCE", runtime); + } else { + this._registerFailure("MAINTENANCE", runtime); + } + + this.kpi.maintenanceMode = true; + } else { + this.kpi.maintenanceMode = false; + } + + // Recalculate KPIs after possibly registering a failure: const failures = this.kpi.failures; - const downtime = this.kpi.totalDowntimeHours; - - // If no failures yet: MTBF = total runtime; MTTR = 0 + const maintenance_time = this.state.getMaintenanceTimeHours(); this.kpi.MTBF = failures > 0 ? runtime / failures : runtime; - this.kpi.MTTR = failures > 0 ? downtime / failures : 0; - - // --- Compute Availability --- + this.kpi.MTTR = failures > 0 ? maintenance_time / failures : 0; 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 + this.kpi.availability = 1; } else { const availability = mtbf / (mtbf + mttr); - this.kpi.availability = Math.min(1, Math.max(0, availability)); // clamp 0–1 + this.kpi.availability = Math.min(1, Math.max(0, availability)); } - - 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); @@ -871,6 +830,7 @@ _handleStateChangeForKPI(newState) { if (power != 0 && flow != 0) { // Calculate efficiency after measurements update this.measurements.type("efficiency").variant(variant).position('atEquipment').value((flow / power)); + } else { this.measurements.type("efficiency").variant(variant).position('atEquipment').value(null); } @@ -879,6 +839,65 @@ _handleStateChangeForKPI(newState) { } +calculateSimpleRUL() { + const mtbf = this.kpi?.MTBF ?? 0; + const healthIndex = typeof this.assetHealth?.index === "number" + ? this.assetHealth.index + : 0; + + if (this.kpi?.rulFailureTriggered) { + this.kpi.lastRUL = 0; + return 0; + } + + if (mtbf <= 0) { + this.logger.debug("No MTBF available, RUL cannot be estimated."); + this.kpi.lastRUL = 0; + return 0; + } + + const clampedHI = Math.min(5, Math.max(0, healthIndex)); + const runtime = this.state?.getRunTimeHours?.() ?? 0; + const lastFailureRuntime = this.kpi?.lastFailureRuntime ?? 0; + const ageSinceLastFailure = Math.max(0, runtime - lastFailureRuntime); + + const fractionByHealth = (5 - clampedHI) / 5; + const fractionByAge = Math.max(0, 1 - ageSinceLastFailure / mtbf); + const fractionLeft = Math.min(fractionByHealth, fractionByAge); + + let rul = mtbf * fractionLeft; + if (rul < 0) rul = 0; + + const prevRUL = this.kpi.lastRUL ?? null; + const EPS = 1e-3; + + const state = this.state?.getCurrentState?.(); + const isOperational = ["operational", "accelerating", "decelerating"].includes(state); + + if (isOperational) { + const hadPrev = prevRUL !== null; + const wasPositive = hadPrev && prevRUL > EPS; + const isNowZeroish = rul <= EPS; + const isDecreasing = hadPrev && rul <= prevRUL + EPS; + + if (wasPositive && isNowZeroish && isDecreasing) { + this.kpi.rulFailureTriggered = true; + + this.kpi.lastFailureTime = Date.now(); + this.kpi.lastFailureRuntime = runtime; + this.kpi.lastFailureCause = "RUL"; + + rul = 0; + } + } + + this.kpi.lastRUL = rul; + + return rul; +} + + + updateCurve(newCurve) { this.logger.info(`Updating machine curve`); const newConfig = { asset: { machineCurve: newCurve } }; @@ -940,14 +959,20 @@ _handleStateChangeForKPI(newState) { output["cog"] = this.cog; // flow / power efficiency output["NCog"] = this.NCog; // normalized cog output["NCogPercent"] = Math.round(this.NCog * 100 * 100) / 100 ; + + // KPIs 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; + output["asset_tag_number"] = 'L001'; // hardcoded for now + output["maintenanceMode"] = this.kpi.maintenanceMode; output["maintenanceTime"] = this.state.getMaintenanceTimeHours(); + output["rul_hours"] = this.calculateSimpleRUL(); + this._calculateAssetHealthIndex(); + output["assetHealthIndex"] = this.assetHealth.index; + const healthColors = ["#006400", "#008000", "#FFFF00", "#FFA500", "#FF0000", "#8B0000"]; // 0 = darkgreen, 1 = green, 2 = yellow, 3 = orange, 4 = red, 5 = darkred + output["assetHealthColor"] = healthColors[this.assetHealth.index] || "unknown"; if(this.flowDrift != null){ const flowDrift = this.flowDrift; @@ -962,21 +987,6 @@ _handleStateChangeForKPI(newState) { 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; }