'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;