133 lines
4.8 KiB
JavaScript
133 lines
4.8 KiB
JavaScript
|
|
'use strict';
|
||
|
|
|
||
|
|
const { HealthStatus } = require('generalFunctions');
|
||
|
|
|
||
|
|
/**
|
||
|
|
* PredictionHealth — composes per-metric drift snapshots + pressure
|
||
|
|
* initialization status into a single HealthStatus plus a numeric
|
||
|
|
* confidence figure.
|
||
|
|
*
|
||
|
|
* Per OPEN_QUESTIONS.md 2026-05-10: HealthStatus carries the standard
|
||
|
|
* five fields; `confidence` is returned as a sibling on the result.
|
||
|
|
*/
|
||
|
|
|
||
|
|
class PredictionHealth {
|
||
|
|
/**
|
||
|
|
* @param {object} ctx
|
||
|
|
* - getPressureInitializationStatus() -> { initialized, hasDifferential, source, ... }
|
||
|
|
* - isOperational() -> boolean
|
||
|
|
* - applyDriftPenalty(drift, confidence, flags, prefix) -> confidence (from DriftAssessor)
|
||
|
|
* - resolveSetpointBounds?() -> { min, max }
|
||
|
|
* - getCurrentPosition?() -> number
|
||
|
|
*/
|
||
|
|
constructor(ctx = {}) {
|
||
|
|
this.getPressureInitializationStatus = ctx.getPressureInitializationStatus;
|
||
|
|
this.isOperational = ctx.isOperational || (() => true);
|
||
|
|
this.applyDriftPenalty = ctx.applyDriftPenalty || ((_d, c) => c);
|
||
|
|
this.resolveSetpointBounds = ctx.resolveSetpointBounds;
|
||
|
|
this.getCurrentPosition = ctx.getCurrentPosition;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @param {object} driftSnapshots — { flow, power, pressure }
|
||
|
|
* pressure: { level, flags, source } (already-assessed pressure-drift status)
|
||
|
|
* @returns {{ health: object, confidence: number }}
|
||
|
|
* health is a frozen HealthStatus shape; confidence ∈ [0,1].
|
||
|
|
*/
|
||
|
|
evaluate(driftSnapshots = {}) {
|
||
|
|
const pressureDrift = driftSnapshots.pressure || { level: 0, flags: [], source: null };
|
||
|
|
const status = this._safePressureStatus();
|
||
|
|
const flags = Array.isArray(pressureDrift.flags) ? [...pressureDrift.flags] : [];
|
||
|
|
|
||
|
|
let confidence = this._baseConfidenceFromSource(status.source);
|
||
|
|
if (!this.isOperational()) {
|
||
|
|
confidence = 0;
|
||
|
|
flags.push('not_operational');
|
||
|
|
}
|
||
|
|
|
||
|
|
confidence = this._penaltyForPressureDriftLevel(pressureDrift.level, confidence);
|
||
|
|
confidence = this._penaltyForCurveEdge(confidence, flags);
|
||
|
|
|
||
|
|
confidence = this.applyDriftPenalty(driftSnapshots.flow, confidence, flags, 'flow');
|
||
|
|
confidence = this.applyDriftPenalty(driftSnapshots.power, confidence, flags, 'power');
|
||
|
|
|
||
|
|
confidence = Math.max(0, Math.min(1, confidence));
|
||
|
|
|
||
|
|
const dedupedFlags = flags.length ? Array.from(new Set(flags)) : ['nominal'];
|
||
|
|
const worstLevel = this._worstLevelFromSnapshots(pressureDrift, driftSnapshots, dedupedFlags);
|
||
|
|
const hasNonNominal = dedupedFlags.some((f) => f !== 'nominal');
|
||
|
|
const effectiveLevel = hasNonNominal ? Math.max(1, worstLevel) : worstLevel;
|
||
|
|
const sourceTag = pressureDrift.source ?? status.source ?? null;
|
||
|
|
|
||
|
|
const health = effectiveLevel === 0
|
||
|
|
? HealthStatus.ok(this._qualityLabel(confidence), sourceTag)
|
||
|
|
: HealthStatus.degraded(
|
||
|
|
effectiveLevel,
|
||
|
|
dedupedFlags,
|
||
|
|
this._qualityLabel(confidence),
|
||
|
|
sourceTag,
|
||
|
|
);
|
||
|
|
|
||
|
|
return { health, confidence };
|
||
|
|
}
|
||
|
|
|
||
|
|
_safePressureStatus() {
|
||
|
|
if (typeof this.getPressureInitializationStatus !== 'function') {
|
||
|
|
return { initialized: false, hasDifferential: false, source: null };
|
||
|
|
}
|
||
|
|
return this.getPressureInitializationStatus() || { source: null };
|
||
|
|
}
|
||
|
|
|
||
|
|
_baseConfidenceFromSource(source) {
|
||
|
|
if (source === 'differential') return 0.9;
|
||
|
|
if (source === 'upstream' || source === 'downstream') return 0.55;
|
||
|
|
return 0.2;
|
||
|
|
}
|
||
|
|
|
||
|
|
_penaltyForPressureDriftLevel(level, confidence) {
|
||
|
|
if (level >= 3) return confidence - 0.35;
|
||
|
|
if (level === 2) return confidence - 0.2;
|
||
|
|
if (level === 1) return confidence - 0.1;
|
||
|
|
return confidence;
|
||
|
|
}
|
||
|
|
|
||
|
|
_penaltyForCurveEdge(confidence, flags) {
|
||
|
|
if (typeof this.getCurrentPosition !== 'function' || typeof this.resolveSetpointBounds !== 'function') {
|
||
|
|
return confidence;
|
||
|
|
}
|
||
|
|
const cur = Number(this.getCurrentPosition());
|
||
|
|
const bounds = this.resolveSetpointBounds() || {};
|
||
|
|
const { min, max } = bounds;
|
||
|
|
if (Number.isFinite(cur) && Number.isFinite(min) && Number.isFinite(max) && max > min) {
|
||
|
|
const span = max - min;
|
||
|
|
const edgeDist = Math.min(Math.abs(cur - min), Math.abs(max - cur));
|
||
|
|
if (edgeDist < span * 0.05) {
|
||
|
|
flags.push('near_curve_edge');
|
||
|
|
return confidence - 0.1;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return confidence;
|
||
|
|
}
|
||
|
|
|
||
|
|
_worstLevelFromSnapshots(pressureDrift, snaps, flags) {
|
||
|
|
let worst = Number.isFinite(pressureDrift.level) ? pressureDrift.level : 0;
|
||
|
|
for (const id of ['flow', 'power']) {
|
||
|
|
const d = snaps[id];
|
||
|
|
if (!d || !d.valid) continue;
|
||
|
|
const lvl = Math.max(d.immediateLevel || 0, d.longTermLevel || 0);
|
||
|
|
if (lvl > worst) worst = lvl;
|
||
|
|
}
|
||
|
|
if (flags.includes('not_operational') && worst < 2) worst = 2;
|
||
|
|
return Math.max(0, Math.min(3, worst));
|
||
|
|
}
|
||
|
|
|
||
|
|
_qualityLabel(confidence) {
|
||
|
|
if (confidence >= 0.8) return 'high';
|
||
|
|
if (confidence >= 0.55) return 'medium';
|
||
|
|
if (confidence >= 0.3) return 'low';
|
||
|
|
return 'invalid';
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
module.exports = PredictionHealth;
|