From c5bb375dd0189f9739c1d5b6e94a77d5c63e8325 Mon Sep 17 00:00:00 2001 From: znetsixe Date: Sun, 10 May 2026 21:38:45 +0200 Subject: [PATCH] P5 wave 1: extract rotatingMachine concerns into focused modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit src/curves/ loader + normalizer (with cross-pressure anomaly detection) + reverseCurve helper src/prediction/ predictors (predictFlow/Power/Ctrl) + groupPredictors (lazy group-scope views) + OperatingPoint (pressure-driven prediction setpoints) src/drift/ DriftAssessor (per-metric drift) + PredictionHealth (composes flow/power/pressure into HealthStatus + confidence sibling — see OPEN_QUESTIONS 2026-05-10) src/pressure/ VirtualPressureChildren (dashboard-sim) + PressureInitialization (real-vs-virtual tracking) + PressureRouter (dispatches by position) src/state/ stateBindings (state.emitter listener helper) + isOperationalState src/measurement/ measurementHandlers (dispatcher for flow/power/temp/pressure) src/flow/ flowController (handleInput body — execSequence, execMovement, flowMovement, emergencystop) src/display/ workingCurves (showWorkingCurves + showCoG admin) src/commands/ canonical names: set.mode, cmd.startup/shutdown/estop, set.setpoint, set.flow-setpoint, data.simulate-measurement, query.curves, query.cog, child.register. execSequence demuxes by payload.action to canonical cmd.* handlers. CONTRACT.md inputs/outputs/events/children surface 110 basic tests pass (100 new + 10 pre-existing). specificClass.js / nodeClass.js untouched — integration in P5 wave 2. Co-Authored-By: Claude Opus 4.7 (1M context) --- CONTRACT.md | 94 ++++++ src/commands/handlers.js | 150 ++++++++++ src/commands/index.js | 85 ++++++ src/curves/curveLoader.js | 19 ++ src/curves/curveNormalizer.js | 117 ++++++++ src/curves/reverseCurve.js | 17 ++ src/display/workingCurves.js | 61 ++++ src/drift/driftAssessor.js | 135 +++++++++ src/drift/predictionHealth.js | 132 +++++++++ src/flow/flowController.js | 85 ++++++ src/measurement/measurementHandlers.js | 134 +++++++++ src/prediction/groupPredictors.js | 23 ++ src/prediction/operatingPoint.js | 82 ++++++ src/prediction/predictors.js | 25 ++ src/pressure/pressureInitialization.js | 100 +++++++ src/pressure/pressureRouter.js | 80 +++++ src/pressure/virtualChildren.js | 92 ++++++ src/state/stateBindings.js | 58 ++++ test/basic/commands.basic.test.js | 275 ++++++++++++++++++ test/basic/curveLoader.basic.test.js | 30 ++ test/basic/curveNormalizer.basic.test.js | 88 ++++++ test/basic/driftAssessor.basic.test.js | 130 +++++++++ test/basic/flowController.basic.test.js | 132 +++++++++ test/basic/groupPredictors.basic.test.js | 51 ++++ test/basic/measurementHandlers.basic.test.js | 149 ++++++++++ test/basic/operatingPoint.basic.test.js | 73 +++++ test/basic/predictionHealth.basic.test.js | 93 ++++++ test/basic/predictors.basic.test.js | 49 ++++ .../pressureInitialization.basic.test.js | 103 +++++++ test/basic/pressureRouter.basic.test.js | 101 +++++++ test/basic/reverseCurve.basic.test.js | 29 ++ test/basic/stateBindings.basic.test.js | 91 ++++++ test/basic/virtualChildren.basic.test.js | 70 +++++ test/basic/workingCurves.basic.test.js | 83 ++++++ 34 files changed, 3036 insertions(+) create mode 100644 CONTRACT.md create mode 100644 src/commands/handlers.js create mode 100644 src/commands/index.js create mode 100644 src/curves/curveLoader.js create mode 100644 src/curves/curveNormalizer.js create mode 100644 src/curves/reverseCurve.js create mode 100644 src/display/workingCurves.js create mode 100644 src/drift/driftAssessor.js create mode 100644 src/drift/predictionHealth.js create mode 100644 src/flow/flowController.js create mode 100644 src/measurement/measurementHandlers.js create mode 100644 src/prediction/groupPredictors.js create mode 100644 src/prediction/operatingPoint.js create mode 100644 src/prediction/predictors.js create mode 100644 src/pressure/pressureInitialization.js create mode 100644 src/pressure/pressureRouter.js create mode 100644 src/pressure/virtualChildren.js create mode 100644 src/state/stateBindings.js create mode 100644 test/basic/commands.basic.test.js create mode 100644 test/basic/curveLoader.basic.test.js create mode 100644 test/basic/curveNormalizer.basic.test.js create mode 100644 test/basic/driftAssessor.basic.test.js create mode 100644 test/basic/flowController.basic.test.js create mode 100644 test/basic/groupPredictors.basic.test.js create mode 100644 test/basic/measurementHandlers.basic.test.js create mode 100644 test/basic/operatingPoint.basic.test.js create mode 100644 test/basic/predictionHealth.basic.test.js create mode 100644 test/basic/predictors.basic.test.js create mode 100644 test/basic/pressureInitialization.basic.test.js create mode 100644 test/basic/pressureRouter.basic.test.js create mode 100644 test/basic/reverseCurve.basic.test.js create mode 100644 test/basic/stateBindings.basic.test.js create mode 100644 test/basic/virtualChildren.basic.test.js create mode 100644 test/basic/workingCurves.basic.test.js diff --git a/CONTRACT.md b/CONTRACT.md new file mode 100644 index 0000000..58fcf3c --- /dev/null +++ b/CONTRACT.md @@ -0,0 +1,94 @@ +# rotatingMachine — Contract + +Hand-maintained for Phase 5; the `## Inputs` table is generated from +`src/commands/index.js` (see Phase 9 generator). Keep ≤ 100 lines. + +## Inputs (msg.topic on Port 0) + +| Canonical | Aliases (deprecated) | Payload | Effect | +|---|---|---|---| +| `set.mode` | `setMode` | `string` — one of the allowed mode names | Calls `source.setMode(payload)`. | +| `cmd.startup` | — | `{ source?: string }` | Calls `source.handleInput(payload.source ?? 'parent', 'execSequence', 'startup')`. | +| `cmd.shutdown` | — | `{ source?: string }` | Calls `source.handleInput(payload.source ?? 'parent', 'execSequence', 'shutdown')`. | +| `cmd.estop` | `emergencystop` | `{ source?: string, action?: string }` | Calls `source.handleInput(payload.source ?? 'parent', payload.action ?? 'emergencystop')`. | +| `execSequence` | — (legacy umbrella) | `{ source, action, parameter }` with `action ∈ {'startup','shutdown'}` | Content-based router: forwards to `cmd.startup` / `cmd.shutdown` handler based on `payload.action`. Unknown action logs `warn` and is dropped. Whole topic is legacy — prefer the canonical `cmd.*` topics. | +| `set.setpoint` | `execMovement` | `{ source, action, setpoint }` — setpoint coerced to `Number` | Calls `source.handleInput(payload.source ?? 'parent', payload.action ?? 'execMovement', Number(payload.setpoint))`. | +| `set.flow-setpoint` | `flowMovement` | `{ source, action, setpoint }` | Calls `source.handleInput(payload.source ?? 'parent', payload.action ?? 'flowMovement', Number(payload.setpoint))`. | +| `data.simulate-measurement` | `simulateMeasurement` | `{ type, position?, value, unit, timestamp? }` — `type ∈ {pressure, flow, temperature, power}`; `position` defaults to `'atEquipment'` | Validated dispatch: rejects non-finite `value`, unsupported `type`, missing `unit`, or unit that fails `isUnitValidForType`. Pressure routes via `updateSimulatedMeasurement(type, position, value, ctx)`; flow/temperature/power route via `updateMeasured(value, position, ctx)`. The injected `childId/childName = 'dashboard-sim'` marks the source. | +| `query.curves` | `showWorkingCurves` | none | Calls `source.showWorkingCurves()` and replies on **Port 0** with `{ topic: 'showWorkingCurves', payload: }` via `ctx.send`. | +| `query.cog` | `CoG` | none | Calls `source.showCoG()` and replies on **Port 0** with `{ topic: 'showCoG', payload: }`. | +| `child.register` | `registerChild` | `string` — child Node-RED id; `msg.positionVsParent` carries position | Resolves child via `RED.nodes.getNode(payload)` and registers it through `childRegistrationUtils.registerChild(child.source, msg.positionVsParent)`. Unknown ids log `warn`. | + +Aliases log a one-time deprecation warning the first time they fire. + +### `execSequence` demux + +The pre-refactor topic `execSequence` carried `{ source, action, parameter }` +where `action` selected the verb (`startup` or `shutdown`). The command +registry does not natively dispatch by payload content, so `execSequence` +keeps its own descriptor whose handler **forwards directly** to the +canonical `cmd.startup` / `cmd.shutdown` handler based on +`payload.action`. The deprecation warning fires once. Future-Phase-7 +removal of `execSequence` is a behavioural change — callers must migrate +to `cmd.startup` / `cmd.shutdown`. + +## Outputs (msg.topic on Port 0/1/2) + +- **Port 0 (process):** `msg.topic = config.general.name`. Payload built by + `outputUtils.formatMsg(..., 'process')` from `getOutput()` — delta-compressed + (only changed fields are emitted). On `query.curves` / `query.cog` the + node additionally emits `{ topic: 'showWorkingCurves' | 'showCoG', + payload: }` as a synchronous reply on Port 0. +- **Port 1 (InfluxDB telemetry):** same shape as Port 0, formatted with the + `'influxdb'` formatter. +- **Port 2 (registration):** at startup the node sends one + `{ topic: 'registerChild', payload: , positionVsParent }` to + the upstream parent (typically a `machineGroupControl` or + `pumpingStation`). `positionVsParent` defaults to `'atEquipment'`. + +## Events emitted by `source.measurements.emitter` + +The `MeasurementContainer` fires `..` whenever +the corresponding series receives a new value. Parents subscribe via the +generic `child.measurements.emitter.on(eventName, ...)` handshake. +rotatingMachine publishes: + +- `flow.predicted.atequipment`, `flow.predicted.downstream`, + `flow.predicted.max`, `flow.predicted.min` — predicted operating point. +- `power.predicted.atequipment` — predicted shaft power. +- `temperature.measured.atequipment` — ambient/process temperature. +- `atmPressure.measured.atequipment` — barometric reference. +- `pressure.measured.upstream`, `pressure.measured.downstream`, + `pressure.measured.differential` — when pressure children register or + `data.simulate-measurement type=pressure` runs. +- `flow.measured.`, `power.measured.atequipment`, + `temperature.measured.` — when sensor children register or + the `data.simulate-measurement` topic supplies values. + +Position labels are normalised to lowercase in the event name. The exact +set is data-driven by which children register and what they publish. + +## Events emitted by `source.state.emitter` + +- `positionChange` — fires when the position percentage changes (per + movement tick). Data: `{ position, state, mode, timestamp }`. +- `stateChange` — fires on transitions of the operating state machine + (`idle → starting → warmingup → operational → accelerating → + decelerating → stopping → coolingdown → idle`, plus `off`, + `maintenance`). Data: the new state string. + +## Children registered by this node + +rotatingMachine accepts `measurement` children through the +`childRegistrationUtils` handshake. Children typically have +`asset.type ∈ {pressure, flow, power, temperature}`. The machine +subscribes to the matching `.measured.` +event and mirrors the value into its own `MeasurementContainer`. + +Two **virtual** children are reserved by the `data.simulate-measurement` +topic: incoming simulated values are tagged with +`childId/childName = 'dashboard-sim'` so dashboard-driven inputs are +distinguishable from real sensor children in downstream telemetry. + +Position labels accepted from children are `upstream`, `downstream`, +`atEquipment` (and case variants — normalised internally). diff --git a/src/commands/handlers.js b/src/commands/handlers.js new file mode 100644 index 0000000..5b1079d --- /dev/null +++ b/src/commands/handlers.js @@ -0,0 +1,150 @@ +'use strict'; + +// Handler functions for rotatingMachine commands. Each handler receives: +// source: the domain (specificClass) instance — exposes setMode, handleInput, +// updateMeasured*, updateSimulatedMeasurement, isUnitValidForType, +// showWorkingCurves, showCoG, childRegistrationUtils, logger. +// msg: the Node-RED input message. +// ctx: { node, RED, send, logger } — provided by BaseNodeAdapter. +// +// Pure functions: validation that goes beyond the registry's typeof-check +// ladder lives here. Reply messages (query.*) use ctx.send when available. + +const SUPPORTED_SIM_TYPES = new Set(['pressure', 'flow', 'temperature', 'power']); + +function _logger(source, ctx) { + return ctx?.logger || source?.logger || null; +} + +function _send(ctx, ports) { + if (typeof ctx?.send === 'function') ctx.send(ports); +} + +exports.setMode = (source, msg) => { + source.setMode(msg.payload); +}; + +// Canonical execution handlers. The legacy execSequence demuxer below +// forwards to these directly so behaviour is identical. +exports.startup = async (source, msg) => { + const p = msg.payload || {}; + await source.handleInput(p.source ?? 'parent', 'execSequence', 'startup'); +}; + +exports.shutdown = async (source, msg) => { + const p = msg.payload || {}; + await source.handleInput(p.source ?? 'parent', 'execSequence', 'shutdown'); +}; + +exports.estop = async (source, msg) => { + const p = msg.payload || {}; + // Legacy emergencystop carried { source, action } — action defaults to + // 'emergencystop' when only source is supplied via the canonical topic. + await source.handleInput(p.source ?? 'parent', p.action ?? 'emergencystop'); +}; + +// Content-based alias router: legacy `execSequence` carried payload.action in +// {'startup','shutdown'}. We dispatch back into the canonical handler so the +// behaviour and logs are identical regardless of which topic was used. +exports.execSequenceAlias = async (source, msg, ctx) => { + const log = _logger(source, ctx); + const action = msg?.payload?.action; + if (action === 'startup') return exports.startup(source, msg, ctx); + if (action === 'shutdown') return exports.shutdown(source, msg, ctx); + log?.warn?.(`execSequence: unsupported action '${action}'`); +}; + +exports.setSetpoint = async (source, msg) => { + const p = msg.payload || {}; + const action = p.action ?? 'execMovement'; + await source.handleInput(p.source ?? 'parent', action, Number(p.setpoint)); +}; + +exports.setFlowSetpoint = async (source, msg) => { + const p = msg.payload || {}; + const action = p.action ?? 'flowMovement'; + await source.handleInput(p.source ?? 'parent', action, Number(p.setpoint)); +}; + +exports.simulateMeasurement = (source, msg, ctx) => { + const log = _logger(source, ctx); + const payload = msg.payload || {}; + const type = String(payload.type || '').toLowerCase(); + const position = payload.position || 'atEquipment'; + const value = Number(payload.value); + const unit = typeof payload.unit === 'string' ? payload.unit.trim() : ''; + const context = { + timestamp: payload.timestamp || Date.now(), + unit, + childName: 'dashboard-sim', + childId: 'dashboard-sim', + }; + + if (!Number.isFinite(value)) { + log?.warn?.('simulateMeasurement payload.value must be a finite number'); + return; + } + if (!SUPPORTED_SIM_TYPES.has(type)) { + log?.warn?.(`Unsupported simulateMeasurement type: ${type}`); + return; + } + if (!unit) { + log?.warn?.('simulateMeasurement payload.unit is required'); + return; + } + if (typeof source.isUnitValidForType === 'function' && + !source.isUnitValidForType(type, unit)) { + log?.warn?.(`simulateMeasurement payload.unit '${unit}' is invalid for type '${type}'`); + return; + } + + _dispatchSimulated(source, type, position, value, context); +}; + +function _dispatchSimulated(source, type, position, value, context) { + switch (type) { + case 'pressure': + if (typeof source.updateSimulatedMeasurement === 'function') { + source.updateSimulatedMeasurement(type, position, value, context); + } else { + source.updateMeasuredPressure(value, position, context); + } + return; + case 'flow': + source.updateMeasuredFlow(value, position, context); + return; + case 'temperature': + source.updateMeasuredTemperature(value, position, context); + return; + case 'power': + source.updateMeasuredPower(value, position, context); + return; + } +} + +exports.queryCurves = (source, msg, ctx) => { + const reply = Object.assign({}, msg, { + topic: 'showWorkingCurves', + payload: source.showWorkingCurves(), + }); + _send(ctx, [reply, null, null]); +}; + +exports.queryCog = (source, msg, ctx) => { + const reply = Object.assign({}, msg, { + topic: 'showCoG', + payload: source.showCoG(), + }); + _send(ctx, [reply, null, null]); +}; + +exports.registerChild = (source, msg, ctx) => { + const log = _logger(source, ctx); + const childId = msg.payload; + const childObj = ctx?.RED?.nodes?.getNode?.(childId); + if (!childObj || !childObj.source) { + log?.warn?.(`registerChild: child '${childId}' not found or has no .source`); + return; + } + source.childRegistrationUtils.registerChild(childObj.source, msg.positionVsParent); +}; diff --git a/src/commands/index.js b/src/commands/index.js new file mode 100644 index 0000000..307bd9e --- /dev/null +++ b/src/commands/index.js @@ -0,0 +1,85 @@ +'use strict'; + +// rotatingMachine command registry. Consumed by BaseNodeAdapter via +// `static commands = require('./commands')`. Each descriptor maps a +// canonical msg.topic to its handler; legacy names are listed under +// `aliases` and emit a one-time deprecation warning at runtime. +// +// `execSequence` is special: the legacy payload carried `{source, action, +// parameter}` where `action` selected the canonical verb (startup / +// shutdown). The registry does not natively dispatch by payload content, +// so we keep `execSequence` as its own descriptor whose handler routes to +// the canonical `cmd.startup` / `cmd.shutdown` handler. Behaviour matches +// the canonical topics exactly; the deprecation warning still fires once. + +const handlers = require('./handlers'); + +module.exports = [ + { + topic: 'set.mode', + aliases: ['setMode'], + payloadSchema: { type: 'string' }, + handler: handlers.setMode, + }, + { + topic: 'cmd.startup', + payloadSchema: { type: 'any' }, + handler: handlers.startup, + }, + { + topic: 'cmd.shutdown', + payloadSchema: { type: 'any' }, + handler: handlers.shutdown, + }, + { + topic: 'cmd.estop', + aliases: ['emergencystop'], + payloadSchema: { type: 'any' }, + handler: handlers.estop, + }, + { + // Legacy umbrella topic. Content-based demux inside the handler routes + // to the canonical startup / shutdown logic. Emits the registry's + // one-time deprecation warning the first time it fires. + topic: 'execSequence', + payloadSchema: { type: 'object' }, + handler: handlers.execSequenceAlias, + _legacy: true, + }, + { + topic: 'set.setpoint', + aliases: ['execMovement'], + payloadSchema: { type: 'object' }, + handler: handlers.setSetpoint, + }, + { + topic: 'set.flow-setpoint', + aliases: ['flowMovement'], + payloadSchema: { type: 'object' }, + handler: handlers.setFlowSetpoint, + }, + { + topic: 'data.simulate-measurement', + aliases: ['simulateMeasurement'], + payloadSchema: { type: 'object' }, + handler: handlers.simulateMeasurement, + }, + { + topic: 'query.curves', + aliases: ['showWorkingCurves'], + payloadSchema: { type: 'any' }, + handler: handlers.queryCurves, + }, + { + topic: 'query.cog', + aliases: ['CoG'], + payloadSchema: { type: 'any' }, + handler: handlers.queryCog, + }, + { + topic: 'child.register', + aliases: ['registerChild'], + payloadSchema: { type: 'string' }, + handler: handlers.registerChild, + }, +]; diff --git a/src/curves/curveLoader.js b/src/curves/curveLoader.js new file mode 100644 index 0000000..bb676a7 --- /dev/null +++ b/src/curves/curveLoader.js @@ -0,0 +1,19 @@ +const { loadCurve } = require('generalFunctions'); + +/** + * Resolve a raw curve dataset by model name. Pure wrapper around + * generalFunctions.loadCurve so the constructor doesn't have to encode the + * "no model"/"model not found" error states inline. + */ +function loadModelCurve(model) { + if (!model) { + return { rawCurve: null, error: 'Model not specified' }; + } + const raw = loadCurve(model); + if (!raw) { + return { rawCurve: null, error: `Curve not found for model ${model}` }; + } + return { rawCurve: raw, error: null }; +} + +module.exports = { loadModelCurve }; diff --git a/src/curves/curveNormalizer.js b/src/curves/curveNormalizer.js new file mode 100644 index 0000000..e5715a6 --- /dev/null +++ b/src/curves/curveNormalizer.js @@ -0,0 +1,117 @@ +const { convert } = require('generalFunctions'); + +/** + * Strict numeric unit conversion. Mirrors specificClass._convertUnitValue + * so the curve normalizer is testable without a Machine instance. + */ +function convertUnitValue(value, fromUnit, toUnit, contextLabel = 'unit conversion') { + const numeric = Number(value); + if (!Number.isFinite(numeric)) { + throw new Error(`${contextLabel}: value '${value}' is not finite`); + } + if (!fromUnit || !toUnit || fromUnit === toUnit) return numeric; + return convert(numeric).from(fromUnit).to(toUnit); +} + +/** + * Convert one curve section (nq or np) from supplied units to canonical + * units. Logs a warning when the per-pressure median y jumps by more than + * 3x relative to the previous pressure level — that almost always means the + * curve file is corrupt (mixed units, swapped rows) and the predict module + * would otherwise silently produce nonsense values. + */ +function normalizeCurveSection(section, fromYUnit, toYUnit, fromPressureUnit, toPressureUnit, sectionName, logger) { + const normalized = {}; + let prevMedianY = null; + + for (const [pressureKey, pair] of Object.entries(section || {})) { + const canonicalPressure = convertUnitValue( + Number(pressureKey), + fromPressureUnit, + toPressureUnit, + `${sectionName} pressure axis` + ); + const xArray = Array.isArray(pair?.x) ? pair.x.map(Number) : []; + const yArray = Array.isArray(pair?.y) + ? pair.y.map((v) => convertUnitValue(v, fromYUnit, toYUnit, `${sectionName} output`)) + : []; + if (!xArray.length || !yArray.length || xArray.length !== yArray.length) { + throw new Error(`Invalid ${sectionName} section at pressure '${pressureKey}'.`); + } + + const sortedY = [...yArray].sort((a, b) => a - b); + const medianY = sortedY[Math.floor(sortedY.length / 2)]; + if (prevMedianY != null && prevMedianY > 0) { + const ratio = medianY / prevMedianY; + if (ratio > 3 || ratio < 0.33) { + const msg = `Curve anomaly in ${sectionName} at pressure ${pressureKey}: median y=${medianY.toFixed(2)} ` + + `deviates ${ratio.toFixed(1)}x from adjacent level (${prevMedianY.toFixed(2)}). Check curve data.`; + if (logger && typeof logger.warn === 'function') { + logger.warn(msg); + } + } + } + prevMedianY = medianY; + + normalized[String(canonicalPressure)] = { x: xArray, y: yArray }; + } + return normalized; +} + +/** + * Normalize a raw machine curve ({nq, np}) into canonical SI units, using + * the unit declarations on the supplied UnitPolicy. `unitPolicy.curve` is + * the source unit map; `unitPolicy.canonical(type)` gives the target. + */ +function normalizeMachineCurve(rawCurve, unitPolicy, logger) { + if (!rawCurve || typeof rawCurve !== 'object' || !rawCurve.nq || !rawCurve.np) { + throw new Error('Machine curve is missing required nq/np sections.'); + } + const curveUnits = readCurveUnits(unitPolicy); + const canonicalFlow = readCanonical(unitPolicy, 'flow'); + const canonicalPower = readCanonical(unitPolicy, 'power'); + const canonicalPressure = readCanonical(unitPolicy, 'pressure'); + return { + nq: normalizeCurveSection( + rawCurve.nq, + curveUnits.flow, + canonicalFlow, + curveUnits.pressure, + canonicalPressure, + 'nq', + logger + ), + np: normalizeCurveSection( + rawCurve.np, + curveUnits.power, + canonicalPower, + curveUnits.pressure, + canonicalPressure, + 'np', + logger + ), + }; +} + +// UnitPolicy stores curve units as a frozen object on `_curve`, exposed via +// `curve(type)`. Accept either the live UnitPolicy or a plain {curve, canonical} +// bag so the normalizer can also be driven from raw config fixtures in tests. +function readCurveUnits(unitPolicy) { + if (!unitPolicy) return {}; + if (typeof unitPolicy.curve === 'function') { + return { + flow: unitPolicy.curve('flow'), + power: unitPolicy.curve('power'), + pressure: unitPolicy.curve('pressure'), + }; + } + return unitPolicy.curve || {}; +} + +function readCanonical(unitPolicy, type) { + if (!unitPolicy) return null; + if (typeof unitPolicy.canonical === 'function') return unitPolicy.canonical(type); + return (unitPolicy.canonical || {})[type] || null; +} + +module.exports = { normalizeMachineCurve, normalizeCurveSection, convertUnitValue }; diff --git a/src/curves/reverseCurve.js b/src/curves/reverseCurve.js new file mode 100644 index 0000000..568c3ab --- /dev/null +++ b/src/curves/reverseCurve.js @@ -0,0 +1,17 @@ +/** + * Swap x and y of every pressure-keyed section so a forward "ctrl -> flow" + * curve becomes a reverse "flow -> ctrl" curve. Used to build predictCtrl + * from the same nq data feeding predictFlow. + */ +function reverseCurve(curveSection) { + const reversed = {}; + for (const [pressure, values] of Object.entries(curveSection || {})) { + reversed[pressure] = { + x: [...values.y], + y: [...values.x], + }; + } + return reversed; +} + +module.exports = { reverseCurve }; diff --git a/src/display/workingCurves.js b/src/display/workingCurves.js new file mode 100644 index 0000000..8141146 --- /dev/null +++ b/src/display/workingCurves.js @@ -0,0 +1,61 @@ +/** + * Read-only snapshots of the active machine curves and the centre-of-gravity + * statistics. These back the rotatingMachine admin endpoints used by the + * editor (`/rotatingMachine/working-curves`, `/rotatingMachine/cog`). + * + * Both functions accept a single `predictors` argument — an object describing + * the current curve state. By taking everything via that one parameter the + * helpers stay pure and trivially testable with a plain fixture; the host + * just passes itself (or a slim adapter) in. + * + * Expected shape of `predictors`: + * { + * hasCurve: boolean, + * predictFlow, predictPower, // generalFunctions/predict instances + * getCurrentCurves(): { powerCurve, flowCurve }, + * calcCog(): { cog, cogIndex, NCog, minEfficiency }, + * cog, cogIndex, NCog, + * minEfficiency, + * currentEfficiencyCurve, + * absDistFromPeak, relDistFromPeak, + * } + */ + +const NO_CURVE_ERROR = 'No curve data available'; + +function showCoG(predictors) { + if (!predictors || !predictors.hasCurve) { + return { error: NO_CURVE_ERROR, cog: 0, NCog: 0, cogIndex: 0 }; + } + const { cog, cogIndex, NCog, minEfficiency } = predictors.calcCog(); + return { + cog, + cogIndex, + NCog, + NCogPercent: Math.round(NCog * 100 * 100) / 100, + minEfficiency, + currentEfficiencyCurve: predictors.currentEfficiencyCurve, + absDistFromPeak: predictors.absDistFromPeak, + relDistFromPeak: predictors.relDistFromPeak, + }; +} + +function showWorkingCurves(predictors) { + if (!predictors || !predictors.hasCurve) { + return { error: NO_CURVE_ERROR }; + } + const { powerCurve, flowCurve } = predictors.getCurrentCurves(); + return { + powerCurve, + flowCurve, + cog: predictors.cog, + cogIndex: predictors.cogIndex, + NCog: predictors.NCog, + minEfficiency: predictors.minEfficiency, + currentEfficiencyCurve: predictors.currentEfficiencyCurve, + absDistFromPeak: predictors.absDistFromPeak, + relDistFromPeak: predictors.relDistFromPeak, + }; +} + +module.exports = { showWorkingCurves, showCoG }; diff --git a/src/drift/driftAssessor.js b/src/drift/driftAssessor.js new file mode 100644 index 0000000..d3f845c --- /dev/null +++ b/src/drift/driftAssessor.js @@ -0,0 +1,135 @@ +'use strict'; + +/** + * DriftAssessor — extracted from rotatingMachine specificClass. + * + * Wraps the generalFunctions errorMetrics into a per-metric drift + * pipeline (flow / power). Holds the latest drift objects so + * predictionHealth can reuse them; the host node still mirrors them + * onto its own fields for output compatibility. + */ + +class DriftAssessor { + /** + * @param {object} ctx + * - errorMetrics: assessPoint(metricId, predicted, measured, opts) + assessDrift(...) + * - measurements: MeasurementContainer (for assessDrift history pulls) + * - driftProfiles: { flow, power, ... } + * - resolveProcessRange(metricId, predicted, measured) -> { processMin, processMax } + * - measurementPositionForMetric(metricId) -> string + * - logger: { warn, debug, ... } + */ + constructor(ctx = {}) { + this.errorMetrics = ctx.errorMetrics; + this.measurements = ctx.measurements; + this.driftProfiles = ctx.driftProfiles || {}; + this.resolveProcessRange = ctx.resolveProcessRange; + this.measurementPositionForMetric = ctx.measurementPositionForMetric; + this.logger = ctx.logger || { warn() {}, debug() {} }; + this.latest = { flow: null, power: null }; + } + + /** + * Compute drift for a metric given a freshly-arrived measured value. + * Returns the drift object (or null on error / non-finite inputs). + */ + updateMetricDrift(metricId, measuredValue, context = {}) { + const position = this._positionForMetric(metricId); + const predictedValue = this._getPredicted(metricId, position); + const measured = Number(measuredValue); + if (!Number.isFinite(predictedValue) || !Number.isFinite(measured)) return null; + + const { processMin, processMax } = this._processRange(metricId, predictedValue, measured); + const timestamp = Number(context.timestamp || Date.now()); + const profile = this.driftProfiles[metricId] || {}; + + try { + const drift = this.errorMetrics.assessPoint(metricId, predictedValue, measured, { + ...profile, + processMin, + processMax, + predictedTimestamp: timestamp, + measuredTimestamp: timestamp, + }); + if (drift && drift.valid) this.latest[metricId] = drift; + return drift; + } catch (err) { + this.logger.warn(`Drift update failed for metric '${metricId}': ${err.message}`); + return null; + } + } + + /** + * Pull stored predicted/measured series and run a full drift assessment. + */ + assessDrift(measurement, processMin, processMax) { + const metricId = String(measurement || '').toLowerCase(); + const position = this._positionForMetric(metricId); + const predicted = this.measurements + ?.type(metricId).variant('predicted').position(position).getAllValues(); + const measured = this.measurements + ?.type(metricId).variant('measured').position(position).getAllValues(); + if (!predicted?.values || !measured?.values) return null; + + return this.errorMetrics.assessDrift( + predicted.values, + measured.values, + processMin, + processMax, + { + metricId, + predictedTimestamps: predicted.timestamps, + measuredTimestamps: measured.timestamps, + ...(this.driftProfiles[metricId] || {}), + }, + ); + } + + /** + * Pure helper: reduce a confidence figure by drift severity and push + * matching flag strings. Returns the updated confidence. + */ + applyDriftPenalty(drift, confidence, flags, prefix) { + if (!drift || !drift.valid || !Number.isFinite(drift.nrmse)) return confidence; + if (drift.immediateLevel >= 3) { + confidence -= 0.3; + flags.push(`${prefix}_high_immediate_drift`); + } else if (drift.immediateLevel === 2) { + confidence -= 0.2; + flags.push(`${prefix}_medium_immediate_drift`); + } else if (drift.immediateLevel === 1) { + confidence -= 0.1; + flags.push(`${prefix}_low_immediate_drift`); + } + if (drift.longTermLevel >= 2) { + confidence -= 0.1; + flags.push(`${prefix}_long_term_drift`); + } + return confidence; + } + + _positionForMetric(metricId) { + if (typeof this.measurementPositionForMetric === 'function') { + return this.measurementPositionForMetric(metricId); + } + return metricId === 'flow' ? 'downstream' : 'atEquipment'; + } + + _processRange(metricId, predicted, measured) { + if (typeof this.resolveProcessRange === 'function') { + return this.resolveProcessRange(metricId, predicted, measured); + } + const lo = Math.min(predicted, measured); + const hi = Math.max(predicted, measured); + return { processMin: lo, processMax: hi > lo ? hi : lo + 1 }; + } + + _getPredicted(metricId, position) { + return Number( + this.measurements + ?.type(metricId).variant('predicted').position(position).getCurrentValue(), + ); + } +} + +module.exports = DriftAssessor; diff --git a/src/drift/predictionHealth.js b/src/drift/predictionHealth.js new file mode 100644 index 0000000..85f542a --- /dev/null +++ b/src/drift/predictionHealth.js @@ -0,0 +1,132 @@ +'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; diff --git a/src/flow/flowController.js b/src/flow/flowController.js new file mode 100644 index 0000000..48d890d --- /dev/null +++ b/src/flow/flowController.js @@ -0,0 +1,85 @@ +/** + * Dispatches inbound control actions (execSequence / execMovement / + * flowMovement / emergencyStop / enter|exitMaintenance / statusCheck) + * to the state machine and motion helpers on the host. + * + * Behaviour mirrors the original specificClass.handleInput exactly: + * - actions are lower-cased + * - mode/source gating runs first + * - flow-setpoints are unit-converted (output -> canonical) before + * calcCtrl + setpoint + * - thrown errors are caught + logged (no re-throw) so a misbehaving + * parent never crashes the FSM + */ + +class FlowController { + constructor(ctx) { + if (!ctx || !ctx.host) { + throw new Error('FlowController: ctx.host is required'); + } + this.host = ctx.host; + this.logger = ctx.logger || ctx.host.logger; + } + + async handle(source, action, parameter) { + const host = this.host; + + if (typeof action !== 'string') { + this.logger.error('Action must be string'); + return; + } + action = action.toLowerCase(); + + if (!host.isValidActionForMode(action, host.currentMode)) return; + if (!host.isValidSourceForMode(source, host.currentMode)) return; + + this.logger.info( + `Handling input from source '${source}' with action '${action}' in mode '${host.currentMode}'.`, + ); + + try { + switch (action) { + case 'execsequence': + return await host.executeSequence(parameter); + + case 'execmovement': + return await host.setpoint(parameter); + + case 'entermaintenance': + case 'exitmaintenance': + return await host.executeSequence(parameter); + + case 'flowmovement': { + const canonicalFlowSetpoint = host._convertUnitValue( + parameter, + host.unitPolicy.output.flow, + host.unitPolicy.canonical.flow, + 'flowmovement setpoint', + ); + const pos = host.calcCtrl(canonicalFlowSetpoint); + return await host.setpoint(pos); + } + + case 'emergencystop': + this.logger.warn(`Emergency stop activated by '${source}'.`); + return await host.executeSequence('emergencystop'); + + case 'statuscheck': + this.logger.info( + `Status Check: Mode = '${host.currentMode}', Source = '${source}'.`, + ); + break; + + default: + this.logger.warn(`Action '${action}' is not implemented.`); + break; + } + this.logger.debug(`Action '${action}' successfully executed`); + return { status: true, feedback: `Action '${action}' successfully executed.` }; + } catch (error) { + this.logger.error(`Error handling input: ${error}`); + } + } +} + +module.exports = FlowController; diff --git a/src/measurement/measurementHandlers.js b/src/measurement/measurementHandlers.js new file mode 100644 index 0000000..4665007 --- /dev/null +++ b/src/measurement/measurementHandlers.js @@ -0,0 +1,134 @@ +/** + * Centralised measurement update routing for rotatingMachine. + * + * Wraps the four measurement types coming from child measurement nodes + * (flow / power / temperature / pressure) and dispatches each to the + * appropriate handler. Pressure is delegated to the host's pressureRouter + * (built in P5.4); the other three are normalised + written + drift-tracked + * here. + * + * The handlers reach back into the host for `_resolveMeasurementUnit`, + * `_updateMetricDrift`, `_updatePredictionHealth`, `updatePosition` and the + * measurements container. Behaviour is preserved 1:1 from the original + * specificClass methods. + */ + +class MeasurementHandlers { + constructor(ctx) { + if (!ctx || !ctx.host) { + throw new Error('MeasurementHandlers: ctx.host is required'); + } + this.host = ctx.host; + this.logger = ctx.logger || ctx.host.logger; + } + + /** + * Single entry point used by child-measurement event listeners. + * Unknown types warn and fall back to a no-op position refresh so a + * mis-configured child can't silently break the FSM tick. + */ + dispatch(measurementType, value, position, context = {}) { + switch (measurementType) { + case 'pressure': + return this.host.updateMeasuredPressure(value, position, context); + case 'flow': + return this.updateMeasuredFlow(value, position, context); + case 'power': + return this.updateMeasuredPower(value, position, context); + case 'temperature': + return this.updateMeasuredTemperature(value, position, context); + default: + this.logger.warn(`No handler for measurement type: ${measurementType}`); + return this.host.updatePosition(); + } + } + + updateMeasuredTemperature(value, position, context = {}) { + const host = this.host; + this.logger.debug( + `Temperature update: ${value} at ${position} from ${context.childName || 'child'} (${context.childId || 'unknown-id'})`, + ); + let unit; + try { + unit = host._resolveMeasurementUnit('temperature', context.unit); + } catch (error) { + this.logger.warn(`Rejected temperature update: ${error.message}`); + return; + } + host.measurements + .type('temperature') + .variant('measured') + .position(position || 'atEquipment') + .child(context.childId) + .value(value, context.timestamp, unit); + } + + updateMeasuredFlow(value, position, context = {}) { + const host = this.host; + if (!host._isOperationalState()) { + this.logger.warn(`Machine not operational, skipping flow update from ${context.childName || 'unknown'}`); + return; + } + this.logger.debug(`Flow update: ${value} at ${position} from ${context.childName || 'child'}`); + let unit; + try { + unit = host._resolveMeasurementUnit('flow', context.unit); + } catch (error) { + this.logger.warn(`Rejected flow update: ${error.message}`); + return; + } + + host.measurements + .type('flow').variant('measured').position(position).child(context.childId) + .value(value, context.timestamp, unit); + + if (host.predictFlow) { + const canonical = host.unitPolicy.canonical.flow; + const predicted = host.predictFlow.outputY || 0; + host.measurements.type('flow').variant('predicted').position('downstream') + .value(predicted, Date.now(), canonical); + host.measurements.type('flow').variant('predicted').position('atEquipment') + .value(predicted, Date.now(), canonical); + } + + const measuredCanonical = host.measurements + .type('flow').variant('measured').position(position) + .getCurrentValue(host.unitPolicy.canonical.flow); + + host._updateMetricDrift('flow', measuredCanonical, context); + host._updatePredictionHealth(); + } + + updateMeasuredPower(value, position, context = {}) { + const host = this.host; + if (!host._isOperationalState()) { + this.logger.warn(`Machine not operational, skipping power update from ${context.childName || 'unknown'}`); + return; + } + this.logger.debug(`Power update: ${value} at ${position} from ${context.childName || 'child'}`); + let unit; + try { + unit = host._resolveMeasurementUnit('power', context.unit); + } catch (error) { + this.logger.warn(`Rejected power update: ${error.message}`); + return; + } + host.measurements + .type('power').variant('measured').position(position).child(context.childId) + .value(value, context.timestamp, unit); + + if (host.predictPower) { + host.measurements.type('power').variant('predicted').position('atEquipment') + .value(host.predictPower.outputY || 0, Date.now(), host.unitPolicy.canonical.power); + } + + const measuredCanonical = host.measurements + .type('power').variant('measured').position(position) + .getCurrentValue(host.unitPolicy.canonical.power); + + host._updateMetricDrift('power', measuredCanonical, context); + host._updatePredictionHealth(); + } +} + +module.exports = MeasurementHandlers; diff --git a/src/prediction/groupPredictors.js b/src/prediction/groupPredictors.js new file mode 100644 index 0000000..b6db881 --- /dev/null +++ b/src/prediction/groupPredictors.js @@ -0,0 +1,23 @@ +const { predict } = require('generalFunctions'); + +/** + * Build group-scope predicts that share input curves (and splines) with the + * individual ones via Predict.shareInputsFrom. They maintain independent + * operating-point state so an MGC parent can evaluate every pump curve at + * one shared manifold differential without disturbing the pump's own + * sensor-driven outputs. + * + * Returns null when the source predictors are absent (curve load failed). + */ +function buildGroupPredictors(predictors) { + if (!predictors || !predictors.predictFlow || !predictors.predictPower || !predictors.predictCtrl) { + return null; + } + return { + groupPredictFlow: new predict({ shareInputsFrom: predictors.predictFlow }), + groupPredictPower: new predict({ shareInputsFrom: predictors.predictPower }), + groupPredictCtrl: new predict({ shareInputsFrom: predictors.predictCtrl }), + }; +} + +module.exports = { buildGroupPredictors }; diff --git a/src/prediction/operatingPoint.js b/src/prediction/operatingPoint.js new file mode 100644 index 0000000..e069a3d --- /dev/null +++ b/src/prediction/operatingPoint.js @@ -0,0 +1,82 @@ +/** + * Pure operating-point helper. Centralises the "set the working pressure + * and read a derived value" pattern used by both the pump's own pressure + * stream and the MGC group-scope evaluation. Does NOT touch the parent + * Machine's measurements or pressure-routing — that stays in specificClass. + * + * `individual` is the {predictFlow, predictPower, predictCtrl} set from + * buildPredictors(). `group` is the optional set from buildGroupPredictors() + * (may be null when no MGC parent is active). + */ +class OperatingPoint { + constructor(individual, group = null) { + this._individual = individual || null; + this._group = group || null; + this._scope = 'individual'; + } + + setGroupPredictors(group) { + this._group = group || null; + } + + useIndividual() { + this._scope = 'individual'; + return this; + } + + useGroup() { + this._scope = 'group'; + return this; + } + + setIndividual(pressureDiff) { + if (!this._individual) return false; + if (!Number.isFinite(pressureDiff)) return false; + this._individual.predictFlow.fDimension = pressureDiff; + this._individual.predictPower.fDimension = pressureDiff; + this._individual.predictCtrl.fDimension = pressureDiff; + return true; + } + + setGroup(pressureDiff) { + if (!this._group) return false; + if (!Number.isFinite(pressureDiff)) return false; + this._group.groupPredictFlow.fDimension = pressureDiff; + this._group.groupPredictPower.fDimension = pressureDiff; + this._group.groupPredictCtrl.fDimension = pressureDiff; + return true; + } + + _activeFlow() { + return this._scope === 'group' ? this._group?.groupPredictFlow : this._individual?.predictFlow; + } + _activePower() { + return this._scope === 'group' ? this._group?.groupPredictPower : this._individual?.predictPower; + } + _activeCtrl() { + return this._scope === 'group' ? this._group?.groupPredictCtrl : this._individual?.predictCtrl; + } + + flowFor(ctrl) { + const p = this._activeFlow(); + if (!p) return null; + p.currentX = ctrl; + return p.y(ctrl); + } + + powerFor(ctrl) { + const p = this._activePower(); + if (!p) return null; + p.currentX = ctrl; + return p.y(ctrl); + } + + ctrlFor(flow) { + const p = this._activeCtrl(); + if (!p) return null; + p.currentX = flow; + return p.y(flow); + } +} + +module.exports = OperatingPoint; diff --git a/src/prediction/predictors.js b/src/prediction/predictors.js new file mode 100644 index 0000000..2fc61f4 --- /dev/null +++ b/src/prediction/predictors.js @@ -0,0 +1,25 @@ +const { predict } = require('generalFunctions'); +const { reverseCurve } = require('../curves/reverseCurve'); + +/** + * Build the three individual-scope predict instances that drive a single + * pump's flow/power/ctrl outputs from its own pressure measurements. + * predictFlow: ctrl -> flow (from machineCurve.nq) + * predictPower: ctrl -> power (from machineCurve.np) + * predictCtrl: flow -> ctrl (from reversed machineCurve.nq) + * + * The reverse is built here rather than in the caller so the predictors + * folder owns the full "what is needed to predict" knowledge. + */ +function buildPredictors(machineCurve) { + if (!machineCurve || !machineCurve.nq || !machineCurve.np) { + throw new Error('buildPredictors: machineCurve.nq and .np are required'); + } + return { + predictFlow: new predict({ curve: machineCurve.nq }), + predictPower: new predict({ curve: machineCurve.np }), + predictCtrl: new predict({ curve: reverseCurve(machineCurve.nq) }), + }; +} + +module.exports = { buildPredictors }; diff --git a/src/pressure/pressureInitialization.js b/src/pressure/pressureInitialization.js new file mode 100644 index 0000000..8a96f64 --- /dev/null +++ b/src/pressure/pressureInitialization.js @@ -0,0 +1,100 @@ +'use strict'; + +/** + * PressureInitialization — tracks real pressure children per position + * and reports the overall pressure-input status (initialized, has + * differential, preferred source). + * + * Extracted from rotatingMachine specificClass.getPressureInitializationStatus + * + the realPressureChildIds set tracking. + */ + +class PressureInitialization { + /** + * @param {object} ctx + * - measurements: MeasurementContainer + * - virtualPressureChildIds: { upstream, downstream } + * - realPressureChildIds?: { upstream: Set, downstream: Set } + * - logger + */ + constructor(ctx = {}) { + this.measurements = ctx.measurements; + this.virtualPressureChildIds = ctx.virtualPressureChildIds || {}; + this.realPressureChildIds = ctx.realPressureChildIds || { + upstream: new Set(), + downstream: new Set(), + }; + this.logger = ctx.logger || { warn() {}, debug() {} }; + } + + registerReal(position, childId) { + const pos = this._normPosition(position); + if (!this.realPressureChildIds[pos]) this.realPressureChildIds[pos] = new Set(); + this.realPressureChildIds[pos].add(childId); + } + + unregisterReal(position, childId) { + const pos = this._normPosition(position); + if (this.realPressureChildIds[pos]) this.realPressureChildIds[pos].delete(childId); + } + + /** + * @returns {{ hasUpstream, hasDownstream, hasDifferential, initialized, source }} + * source ∈ 'differential' | 'upstream' | 'downstream' | null. + * Matches the original getPressureInitializationStatus() shape. + */ + getStatus() { + const upstream = this._getPreferred('upstream'); + const downstream = this._getPreferred('downstream'); + const hasUpstream = upstream != null; + const hasDownstream = downstream != null; + const hasDifferential = hasUpstream && hasDownstream; + + let source = null; + if (hasDifferential) source = 'differential'; + else if (hasDownstream) source = 'downstream'; + else if (hasUpstream) source = 'upstream'; + + return { + hasUpstream, + hasDownstream, + hasDifferential, + initialized: hasUpstream || hasDownstream, + source, + }; + } + + /** + * Get the preferred pressure value at a position. Real children win + * over virtual; final fallback is the bare (position-only) container slot. + */ + getPreferredValue(position) { + return this._getPreferred(this._normPosition(position)); + } + + _getPreferred(position) { + const realIds = Array.from(this.realPressureChildIds[position] || []); + for (const id of realIds) { + const v = this._readChild(position, id); + if (v != null) return v; + } + const virtualId = this.virtualPressureChildIds[position]; + if (virtualId) { + const v = this._readChild(position, virtualId); + if (v != null) return v; + } + return this.measurements + ?.type('pressure').variant('measured').position(position).getCurrentValue(); + } + + _readChild(position, childId) { + return this.measurements + ?.type('pressure').variant('measured').position(position).child(childId).getCurrentValue(); + } + + _normPosition(position) { + return String(position || '').toLowerCase(); + } +} + +module.exports = PressureInitialization; diff --git a/src/pressure/pressureRouter.js b/src/pressure/pressureRouter.js new file mode 100644 index 0000000..cca7300 --- /dev/null +++ b/src/pressure/pressureRouter.js @@ -0,0 +1,80 @@ +'use strict'; + +/** + * PressureRouter — routes a measured pressure value into the right + * MeasurementContainer slot and triggers downstream side-effects + * (position recompute + drift/health refresh) only when the source + * is a real child (not a dashboard-sim virtual one). + * + * Extracted from rotatingMachine specificClass.updateMeasuredPressure. + */ + +class PressureRouter { + /** + * @param {object} ctx + * - measurements: MeasurementContainer + * - virtualPressureChildIds: { upstream, downstream } + * - resolveMeasurementUnit(type, unit) -> canonical unit string (throws on invalid) + * - updatePosition?(): called after a real-source write + * - refreshDrift?(): called after a real-source write (e.g. _updatePressureDriftStatus) + * - refreshHealth?(): called after a real-source write (e.g. _updatePredictionHealth) + * - getPressure?(): optional, returns the current preferred pressure (for logging) + * - logger + */ + constructor(ctx = {}) { + this.measurements = ctx.measurements; + this.virtualPressureChildIds = ctx.virtualPressureChildIds || {}; + this.resolveMeasurementUnit = ctx.resolveMeasurementUnit || ((_t, u) => u); + this.updatePosition = ctx.updatePosition; + this.refreshDrift = ctx.refreshDrift; + this.refreshHealth = ctx.refreshHealth; + this.getPressure = ctx.getPressure; + this.logger = ctx.logger || { warn() {}, debug() {} }; + } + + /** + * Route a measured pressure to the right container slot. + * @returns {boolean} true on successful write, false on rejection. + */ + route(position, value, context = {}) { + const pos = String(position || '').toLowerCase(); + const childId = context.childId; + let unit; + try { + unit = this.resolveMeasurementUnit('pressure', context.unit); + } catch (err) { + this.logger.warn(`Rejected pressure update: ${err.message}`); + return false; + } + + this.measurements + ?.type('pressure').variant('measured').position(pos).child(childId) + .value(value, context.timestamp, unit); + + const isVirtual = this._isVirtual(childId); + this.logger.debug(`Pressure routed: ${value} ${unit} at ${pos} from ${context.childName || 'child'} (${childId || 'unknown-id'}) virtual=${isVirtual}`); + + if (!isVirtual) { + if (typeof this.updatePosition === 'function') this.updatePosition(); + if (typeof this.refreshDrift === 'function') this.refreshDrift(); + if (typeof this.refreshHealth === 'function') this.refreshHealth(); + } + + if (typeof this.getPressure === 'function') { + const p = this.getPressure(); + this.logger.debug(`Using pressure: ${p} for calculations`); + } + + return true; + } + + _isVirtual(childId) { + if (childId == null) return false; + for (const id of Object.values(this.virtualPressureChildIds)) { + if (id === childId) return true; + } + return false; + } +} + +module.exports = PressureRouter; diff --git a/src/pressure/virtualChildren.js b/src/pressure/virtualChildren.js new file mode 100644 index 0000000..62a66de --- /dev/null +++ b/src/pressure/virtualChildren.js @@ -0,0 +1,92 @@ +'use strict'; + +const { MeasurementContainer } = require('generalFunctions'); + +/** + * VirtualPressureChildren — builds two dashboard-sim children backed + * by their own MeasurementContainer (upstream + downstream). Children + * are signed as belonging to a parent machine via `setParentRef`. + * + * Extracted from rotatingMachine specificClass._initVirtualPressureChildren. + */ + +const DEFAULT_IDS = { + upstream: 'dashboard-sim-upstream', + downstream: 'dashboard-sim-downstream', +}; + +class VirtualPressureChildren { + /** + * @param {object} opts + * - logger: pass-through to MeasurementContainer + * - unitPolicy: { canonical, output } + * - parentRef: object to use as parent for setParentRef (optional) + * - ids: override the default { upstream, downstream } id pair (optional) + */ + constructor({ logger, unitPolicy, parentRef = null, ids = DEFAULT_IDS } = {}) { + this.logger = logger || { warn() {}, debug() {} }; + this.unitPolicy = unitPolicy; + this.parentRef = parentRef; + this.ids = { ...DEFAULT_IDS, ...(ids || {}) }; + } + + /** + * @returns {{ upstream: VirtualChild, downstream: VirtualChild }} + * Each child = { config: { general, functionality, asset }, measurements }. + */ + build() { + return { + upstream: this._createChild('upstream'), + downstream: this._createChild('downstream'), + }; + } + + _createChild(position) { + const id = this.ids[position]; + const name = `dashboard-sim-${position}`; + const measurements = new MeasurementContainer({ + autoConvert: true, + defaultUnits: this._unitMap('output'), + preferredUnits: this._unitMap('output'), + canonicalUnits: this.unitPolicy?.canonical, + storeCanonical: true, + strictUnitValidation: true, + throwOnInvalidUnit: true, + requireUnitForTypes: ['pressure'], + }, this.logger); + + if (typeof measurements.setChildId === 'function') measurements.setChildId(id); + if (typeof measurements.setChildName === 'function') measurements.setChildName(name); + if (this.parentRef && typeof measurements.setParentRef === 'function') { + measurements.setParentRef(this.parentRef); + } + + return { + config: { + general: { id, name }, + functionality: { + softwareType: 'measurement', + positionVsParent: position, + }, + asset: { + type: 'pressure', + unit: this.unitPolicy?.output?.pressure, + }, + }, + measurements, + }; + } + + _unitMap(section) { + const src = this.unitPolicy?.[section] || {}; + return { + pressure: src.pressure, + flow: src.flow, + power: src.power, + temperature: src.temperature, + }; + } +} + +VirtualPressureChildren.DEFAULT_IDS = DEFAULT_IDS; +module.exports = VirtualPressureChildren; diff --git a/src/state/stateBindings.js b/src/state/stateBindings.js new file mode 100644 index 0000000..39653f2 --- /dev/null +++ b/src/state/stateBindings.js @@ -0,0 +1,58 @@ +/** + * Thin adapter over the generalFunctions state machine emitter. + * Holds no state of its own — exposes bind/unbind and the + * shared definition of which states count as "operational" for + * downstream measurement processing. + */ + +const OPERATIONAL_STATES = [ + 'operational', + 'accelerating', + 'decelerating', + 'warmingup', +]; + +/** + * Attaches positionChange / stateChange listeners to a state machine. + * Returns an idempotent teardown function. Both handlers are required — + * the bindings encode the lifecycle contract between the FSM and the + * specificClass orchestrator, so leaving one half wired is a bug. + */ +function bindStateEvents(ctx) { + if (!ctx || !ctx.state || !ctx.state.emitter) { + throw new Error('bindStateEvents: ctx.state.emitter is required'); + } + const { state, onPositionChange, onStateChange } = ctx; + if (typeof onPositionChange !== 'function' || typeof onStateChange !== 'function') { + throw new Error('bindStateEvents: onPositionChange and onStateChange handlers are required'); + } + + state.emitter.on('positionChange', onPositionChange); + state.emitter.on('stateChange', onStateChange); + + let removed = false; + return function teardown() { + if (removed) return; + removed = true; + state.emitter.off('positionChange', onPositionChange); + state.emitter.off('stateChange', onStateChange); + }; +} + +/** + * True when the FSM is in a state that should accept measurement + * updates and recompute predictions. Pure helper, accepts the state + * machine instance so callers can pass a fake in tests. + */ +function isOperationalState(stateInstance) { + if (!stateInstance || typeof stateInstance.getCurrentState !== 'function') { + return false; + } + return OPERATIONAL_STATES.includes(stateInstance.getCurrentState()); +} + +module.exports = { + bindStateEvents, + isOperationalState, + OPERATIONAL_STATES, +}; diff --git a/test/basic/commands.basic.test.js b/test/basic/commands.basic.test.js new file mode 100644 index 0000000..85c94fc --- /dev/null +++ b/test/basic/commands.basic.test.js @@ -0,0 +1,275 @@ +// Basic tests for the rotatingMachine commands registry. +// Run with: node --test test/basic/commands.basic.test.js + +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { createRegistry } = require('generalFunctions'); +const commands = require('../../src/commands'); + +// --- helpers --------------------------------------------------------------- + +function makeLogger() { + const calls = { warn: [], error: [], info: [], debug: [] }; + return { + calls, + warn: (m) => calls.warn.push(String(m)), + error: (m) => calls.error.push(String(m)), + info: (m) => calls.info.push(String(m)), + debug: (m) => calls.debug.push(String(m)), + }; +} + +function makeSource({ name = 'rm-1', unitValid = true } = {}) { + const calls = { + setMode: [], + handleInput: [], + registerChild: [], + sim: [], + updatePressure: [], + updateFlow: [], + updateTemp: [], + updatePower: [], + showWorkingCurves: 0, + showCoG: 0, + }; + const source = { + logger: makeLogger(), + config: { general: { name } }, + setMode: (m) => calls.setMode.push(m), + handleInput: async (src, action, parameter) => { + calls.handleInput.push({ src, action, parameter }); + }, + isUnitValidForType: () => unitValid, + updateSimulatedMeasurement: (type, position, value, ctx) => + calls.sim.push({ type, position, value, ctx }), + updateMeasuredPressure: (v, p, c) => calls.updatePressure.push({ v, p, c }), + updateMeasuredFlow: (v, p, c) => calls.updateFlow.push({ v, p, c }), + updateMeasuredTemperature: (v, p, c) => calls.updateTemp.push({ v, p, c }), + updateMeasuredPower: (v, p, c) => calls.updatePower.push({ v, p, c }), + showWorkingCurves: () => { calls.showWorkingCurves++; return { curves: 'mock' }; }, + showCoG: () => { calls.showCoG++; return { cog: 'mock' }; }, + childRegistrationUtils: { + registerChild: (childSource, position) => + calls.registerChild.push({ childSource, position }), + }, + }; + return { source, calls }; +} + +function makeCtx({ child = null, logger = makeLogger(), sendSpy = null } = {}) { + return { + logger, + RED: { nodes: { getNode: (id) => (child && child.id === id ? child : undefined) } }, + node: {}, + send: sendSpy || (() => {}), + }; +} + +function makeRegistry(logger) { + return createRegistry(commands, { logger }); +} + +// --- tests ----------------------------------------------------------------- + +test('canonical topics dispatch to their handlers', async () => { + const { source, calls } = makeSource(); + const reg = makeRegistry(makeLogger()); + + await reg.dispatch({ topic: 'set.mode', payload: 'GUI' }, source, makeCtx()); + assert.deepEqual(calls.setMode, ['GUI']); + + await reg.dispatch( + { topic: 'cmd.startup', payload: { source: 'GUI' } }, source, makeCtx()); + assert.deepEqual(calls.handleInput.at(-1), { src: 'GUI', action: 'execSequence', parameter: 'startup' }); + + await reg.dispatch( + { topic: 'cmd.shutdown', payload: { source: 'GUI' } }, source, makeCtx()); + assert.deepEqual(calls.handleInput.at(-1), { src: 'GUI', action: 'execSequence', parameter: 'shutdown' }); + + await reg.dispatch( + { topic: 'cmd.estop', payload: { source: 'GUI', action: 'emergencystop' } }, source, makeCtx()); + assert.deepEqual(calls.handleInput.at(-1), { src: 'GUI', action: 'emergencystop', parameter: undefined }); + + await reg.dispatch( + { topic: 'set.setpoint', payload: { source: 'GUI', action: 'execMovement', setpoint: '75' } }, + source, makeCtx()); + assert.deepEqual(calls.handleInput.at(-1), { src: 'GUI', action: 'execMovement', parameter: 75 }); + + await reg.dispatch( + { topic: 'set.flow-setpoint', payload: { source: 'GUI', action: 'flowMovement', setpoint: '12' } }, + source, makeCtx()); + assert.deepEqual(calls.handleInput.at(-1), { src: 'GUI', action: 'flowMovement', parameter: 12 }); +}); + +test('aliases dispatch to the same handler and log a one-time deprecation', async () => { + const { source, calls } = makeSource(); + const ctxLogger = makeLogger(); + const reg = makeRegistry(ctxLogger); + + await reg.dispatch({ topic: 'setMode', payload: 'GUI' }, source, makeCtx({ logger: ctxLogger })); + await reg.dispatch({ topic: 'setMode', payload: 'virtualControl' }, source, makeCtx({ logger: ctxLogger })); + assert.deepEqual(calls.setMode, ['GUI', 'virtualControl']); + let warns = ctxLogger.calls.warn.filter((m) => m.includes("'setMode' is deprecated")); + assert.equal(warns.length, 1); + + await reg.dispatch({ topic: 'emergencystop', payload: { source: 'GUI', action: 'emergencystop' } }, + source, makeCtx({ logger: ctxLogger })); + warns = ctxLogger.calls.warn.filter((m) => m.includes("'emergencystop' is deprecated")); + assert.equal(warns.length, 1); + + await reg.dispatch({ topic: 'execMovement', payload: { source: 'GUI', action: 'execMovement', setpoint: 50 } }, + source, makeCtx({ logger: ctxLogger })); + warns = ctxLogger.calls.warn.filter((m) => m.includes("'execMovement' is deprecated")); + assert.equal(warns.length, 1); + + await reg.dispatch({ topic: 'flowMovement', payload: { source: 'GUI', action: 'flowMovement', setpoint: 5 } }, + source, makeCtx({ logger: ctxLogger })); + warns = ctxLogger.calls.warn.filter((m) => m.includes("'flowMovement' is deprecated")); + assert.equal(warns.length, 1); +}); + +test('execSequence with payload.action=startup reaches cmd.startup handler', async () => { + const { source, calls } = makeSource(); + const ctxLogger = makeLogger(); + const reg = makeRegistry(ctxLogger); + + await reg.dispatch( + { topic: 'execSequence', payload: { source: 'GUI', action: 'startup' } }, + source, makeCtx({ logger: ctxLogger })); + + assert.equal(calls.handleInput.length, 1); + assert.deepEqual(calls.handleInput[0], { src: 'GUI', action: 'execSequence', parameter: 'startup' }); + // Registry logs the legacy-topic deprecation (no canonical alias, but + // the demux handler accepts both startup/shutdown actions). +}); + +test('execSequence with payload.action=shutdown reaches cmd.shutdown handler', async () => { + const { source, calls } = makeSource(); + const reg = makeRegistry(makeLogger()); + + await reg.dispatch( + { topic: 'execSequence', payload: { source: 'GUI', action: 'shutdown' } }, + source, makeCtx()); + + assert.equal(calls.handleInput.length, 1); + assert.deepEqual(calls.handleInput[0], { src: 'GUI', action: 'execSequence', parameter: 'shutdown' }); +}); + +test('execSequence with unknown action logs warn and does not call handleInput', async () => { + const { source, calls } = makeSource(); + const ctxLogger = makeLogger(); + const reg = makeRegistry(makeLogger()); + + await reg.dispatch( + { topic: 'execSequence', payload: { source: 'GUI', action: 'frobnicate' } }, + source, makeCtx({ logger: ctxLogger })); + assert.equal(calls.handleInput.length, 0); + assert.ok(ctxLogger.calls.warn.some((m) => m.includes('execSequence') && m.includes('frobnicate')), + `expected warn, got: ${JSON.stringify(ctxLogger.calls.warn)}`); +}); + +test('data.simulate-measurement happy path dispatches to the right updater', async () => { + const { source, calls } = makeSource(); + const reg = makeRegistry(makeLogger()); + + await reg.dispatch( + { topic: 'data.simulate-measurement', + payload: { type: 'pressure', position: 'upstream', value: 1013, unit: 'mbar' } }, + source, makeCtx()); + assert.equal(calls.sim.length, 1); + assert.equal(calls.sim[0].type, 'pressure'); + assert.equal(calls.sim[0].value, 1013); + + await reg.dispatch( + { topic: 'data.simulate-measurement', + payload: { type: 'flow', value: 30, unit: 'm3/h' } }, + source, makeCtx()); + assert.equal(calls.updateFlow.length, 1); +}); + +test('data.simulate-measurement validation: bad type / missing unit / non-finite value', async () => { + const { source, calls } = makeSource(); + const ctxLogger = makeLogger(); + const reg = makeRegistry(makeLogger()); + + // unsupported type + await reg.dispatch( + { topic: 'data.simulate-measurement', payload: { type: 'voltage', value: 1, unit: 'V' } }, + source, makeCtx({ logger: ctxLogger })); + assert.ok(ctxLogger.calls.warn.some((m) => m.includes('Unsupported simulateMeasurement type: voltage'))); + + // missing unit + await reg.dispatch( + { topic: 'data.simulate-measurement', payload: { type: 'pressure', value: 1013 } }, + source, makeCtx({ logger: ctxLogger })); + assert.ok(ctxLogger.calls.warn.some((m) => m.includes('unit is required'))); + + // non-finite value + await reg.dispatch( + { topic: 'data.simulate-measurement', payload: { type: 'pressure', value: 'abc', unit: 'mbar' } }, + source, makeCtx({ logger: ctxLogger })); + assert.ok(ctxLogger.calls.warn.some((m) => m.includes('must be a finite number'))); + + // nothing was forwarded to the source + assert.equal(calls.sim.length, 0); + assert.equal(calls.updateFlow.length, 0); + assert.equal(calls.updatePressure.length, 0); +}); + +test('query.curves and query.cog reply on Port 0 via ctx.send', async () => { + const { source, calls } = makeSource(); + const sent = []; + const ctx = makeCtx({ sendSpy: (ports) => sent.push(ports) }); + const reg = makeRegistry(makeLogger()); + + await reg.dispatch({ topic: 'query.curves' }, source, ctx); + await reg.dispatch({ topic: 'query.cog' }, source, ctx); + + assert.equal(calls.showWorkingCurves, 1); + assert.equal(calls.showCoG, 1); + assert.equal(sent.length, 2); + // First port carries the reply; Ports 1 & 2 are null. + assert.equal(sent[0][0].topic, 'showWorkingCurves'); + assert.deepEqual(sent[0][0].payload, { curves: 'mock' }); + assert.equal(sent[0][1], null); + assert.equal(sent[0][2], null); + assert.equal(sent[1][0].topic, 'showCoG'); + assert.deepEqual(sent[1][0].payload, { cog: 'mock' }); +}); + +test('child.register canonical resolves child via RED.nodes.getNode', async () => { + const { source, calls } = makeSource(); + const child = { id: 'm-1', source: { tag: 'm-domain' } }; + const reg = makeRegistry(makeLogger()); + + await reg.dispatch( + { topic: 'child.register', payload: 'm-1', positionVsParent: 'upstream' }, + source, + makeCtx({ child }) + ); + assert.equal(calls.registerChild.length, 1); + assert.equal(calls.registerChild[0].childSource, child.source); + assert.equal(calls.registerChild[0].position, 'upstream'); +}); + +test('child.register with unknown id logs warn and does not throw', async () => { + const { source, calls } = makeSource(); + const ctxLogger = makeLogger(); + const reg = makeRegistry(makeLogger()); + + await assert.doesNotReject(() => + reg.dispatch( + { topic: 'child.register', payload: 'missing-id', positionVsParent: 'atEquipment' }, + source, + makeCtx({ logger: ctxLogger }) + ) + ); + assert.equal(calls.registerChild.length, 0); + assert.ok( + ctxLogger.calls.warn.some((m) => m.includes('registerChild') && m.includes('missing-id')), + `expected warn about missing child, got: ${JSON.stringify(ctxLogger.calls.warn)}` + ); +}); diff --git a/test/basic/curveLoader.basic.test.js b/test/basic/curveLoader.basic.test.js new file mode 100644 index 0000000..eb2fe0e --- /dev/null +++ b/test/basic/curveLoader.basic.test.js @@ -0,0 +1,30 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { loadModelCurve } = require('../../src/curves/curveLoader'); + +test('curveLoader: valid model returns rawCurve and null error', () => { + const result = loadModelCurve('hidrostal-H05K-S03R'); + assert.equal(result.error, null); + assert.ok(result.rawCurve); + assert.ok(result.rawCurve.np); + assert.ok(result.rawCurve.nq); +}); + +test('curveLoader: missing model returns Model not specified', () => { + const result = loadModelCurve(''); + assert.equal(result.rawCurve, null); + assert.equal(result.error, 'Model not specified'); +}); + +test('curveLoader: undefined model returns Model not specified', () => { + const result = loadModelCurve(undefined); + assert.equal(result.rawCurve, null); + assert.equal(result.error, 'Model not specified'); +}); + +test('curveLoader: unknown model returns Curve not found error', () => { + const result = loadModelCurve('this-model-does-not-exist'); + assert.equal(result.rawCurve, null); + assert.match(result.error, /Curve not found for model/); +}); diff --git a/test/basic/curveNormalizer.basic.test.js b/test/basic/curveNormalizer.basic.test.js new file mode 100644 index 0000000..6f50d4c --- /dev/null +++ b/test/basic/curveNormalizer.basic.test.js @@ -0,0 +1,88 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { UnitPolicy } = require('generalFunctions'); +const { + normalizeMachineCurve, + normalizeCurveSection, + convertUnitValue, +} = require('../../src/curves/curveNormalizer'); + +function makePolicy() { + return UnitPolicy.declare({ + canonical: { pressure: 'Pa', flow: 'm3/s', power: 'W', temperature: 'K' }, + output: { pressure: 'mbar', flow: 'm3/h', power: 'kW', temperature: 'C' }, + curve: { pressure: 'mbar', flow: 'm3/h', power: 'kW', control: '%' }, + }); +} + +function captureLogger() { + const warns = []; + return { + warn: (m) => warns.push(m), + warns, + }; +} + +test('normalizeMachineCurve: rejects raw without nq/np', () => { + const policy = makePolicy(); + assert.throws(() => normalizeMachineCurve(null, policy), /missing required nq\/np/); + assert.throws(() => normalizeMachineCurve({ nq: { 700: { x: [0], y: [0] } } }, policy), /missing required nq\/np/); + assert.throws(() => normalizeMachineCurve({ np: { 700: { x: [0], y: [0] } } }, policy), /missing required nq\/np/); +}); + +test('normalizeMachineCurve: converts pressure mbar -> Pa and flow m3/h -> m3/s', () => { + const policy = makePolicy(); + const raw = { + nq: { + 1000: { x: [0, 100], y: [0, 3600] }, // 3600 m3/h = 1 m3/s + }, + np: { + 1000: { x: [0, 100], y: [0, 1] }, // 1 kW = 1000 W + }, + }; + const out = normalizeMachineCurve(raw, policy); + // 1000 mbar = 100000 Pa + const pressureKey = Object.keys(out.nq)[0]; + assert.equal(Number(pressureKey), 100000); + assert.ok(Math.abs(out.nq[pressureKey].y[1] - 1) < 1e-9, `expected 1 m3/s got ${out.nq[pressureKey].y[1]}`); + assert.ok(Math.abs(out.np[pressureKey].y[1] - 1000) < 1e-6, `expected 1000 W got ${out.np[pressureKey].y[1]}`); +}); + +test('normalizeCurveSection: warns on cross-pressure median > 3x jump', () => { + const logger = captureLogger(); + const section = { + 1000: { x: [0, 50, 100], y: [0, 5, 10] }, // median 5 + 1100: { x: [0, 50, 100], y: [0, 50, 100] }, // median 50 (10x jump) + }; + normalizeCurveSection(section, 'm3/h', 'm3/h', 'mbar', 'mbar', 'nq', logger); + const hit = logger.warns.find((w) => /Curve anomaly/.test(w)); + assert.ok(hit, `expected a Curve anomaly warning, got: ${JSON.stringify(logger.warns)}`); + assert.match(hit, /pressure 1100/); +}); + +test('normalizeCurveSection: does not warn on smooth progressions', () => { + const logger = captureLogger(); + const section = { + 1000: { x: [0, 50, 100], y: [0, 5, 10] }, + 1100: { x: [0, 50, 100], y: [0, 6, 11] }, + }; + normalizeCurveSection(section, 'm3/h', 'm3/h', 'mbar', 'mbar', 'nq', logger); + assert.equal(logger.warns.filter((w) => /Curve anomaly/.test(w)).length, 0); +}); + +test('normalizeCurveSection: throws when x/y length mismatch', () => { + assert.throws( + () => normalizeCurveSection({ 1000: { x: [0, 50], y: [0, 5, 10] } }, 'm3/h', 'm3/s', 'mbar', 'Pa', 'nq', null), + /Invalid nq section/ + ); +}); + +test('convertUnitValue: identity when units match or missing', () => { + assert.equal(convertUnitValue(42, 'm3/h', 'm3/h'), 42); + assert.equal(convertUnitValue(42, null, null), 42); +}); + +test('convertUnitValue: throws on non-finite input', () => { + assert.throws(() => convertUnitValue('not-a-number', 'm3/h', 'm3/s', 'test'), /not finite/); +}); diff --git a/test/basic/driftAssessor.basic.test.js b/test/basic/driftAssessor.basic.test.js new file mode 100644 index 0000000..e3d87e6 --- /dev/null +++ b/test/basic/driftAssessor.basic.test.js @@ -0,0 +1,130 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const DriftAssessor = require('../../src/drift/driftAssessor'); + +/* ---- fakes ---- */ +function fakeMeasurements(predictedValue) { + return { + type() { return this; }, + variant() { return this; }, + position() { return this; }, + getCurrentValue() { return predictedValue; }, + getAllValues() { return { values: [predictedValue], timestamps: [1] }; }, + }; +} + +function makeErrorMetrics(driftFactory) { + return { + assessPoint: (metricId, predicted, measured, opts) => driftFactory(metricId, predicted, measured, opts), + assessDrift: () => ({ nrmse: 0.1, valid: true }), + }; +} + +const SILENT = { warn() {}, debug() {} }; + +test('updateMetricDrift returns drift object when predicted+measured both finite', () => { + const drift = { valid: true, nrmse: 0.05, immediateLevel: 0, longTermLevel: 0 }; + const assessor = new DriftAssessor({ + errorMetrics: makeErrorMetrics(() => drift), + measurements: fakeMeasurements(10), + driftProfiles: { flow: {} }, + logger: SILENT, + }); + + const out = assessor.updateMetricDrift('flow', 11); + assert.deepEqual(out, drift); + assert.equal(assessor.latest.flow, drift); +}); + +test('updateMetricDrift returns null when predicted is non-finite', () => { + const assessor = new DriftAssessor({ + errorMetrics: makeErrorMetrics(() => ({ valid: true })), + measurements: fakeMeasurements(NaN), + driftProfiles: {}, + logger: SILENT, + }); + assert.equal(assessor.updateMetricDrift('flow', 5), null); +}); + +test('updateMetricDrift catches errorMetrics throw and logs', () => { + const warns = []; + const assessor = new DriftAssessor({ + errorMetrics: { assessPoint() { throw new Error('boom'); } }, + measurements: fakeMeasurements(10), + driftProfiles: {}, + logger: { warn(m) { warns.push(m); }, debug() {} }, + }); + const out = assessor.updateMetricDrift('flow', 11); + assert.equal(out, null); + assert.match(warns[0], /Drift update failed for metric 'flow'/); +}); + +test('applyDriftPenalty leaves confidence unchanged for null/invalid drift', () => { + const assessor = new DriftAssessor({ logger: SILENT }); + const flags = []; + assert.equal(assessor.applyDriftPenalty(null, 0.9, flags, 'flow'), 0.9); + assert.equal(assessor.applyDriftPenalty({ valid: false }, 0.9, flags, 'flow'), 0.9); + assert.deepEqual(flags, []); +}); + +test('applyDriftPenalty level 1 reduces confidence by 0.1 + flag', () => { + const assessor = new DriftAssessor({ logger: SILENT }); + const flags = []; + const c = assessor.applyDriftPenalty( + { valid: true, nrmse: 0.1, immediateLevel: 1, longTermLevel: 0 }, + 0.9, flags, 'flow', + ); + assert.ok(Math.abs(c - 0.8) < 1e-9); + assert.deepEqual(flags, ['flow_low_immediate_drift']); +}); + +test('applyDriftPenalty level 2 reduces confidence by 0.2 + flag', () => { + const assessor = new DriftAssessor({ logger: SILENT }); + const flags = []; + const c = assessor.applyDriftPenalty( + { valid: true, nrmse: 0.2, immediateLevel: 2, longTermLevel: 0 }, + 0.9, flags, 'power', + ); + assert.ok(Math.abs(c - 0.7) < 1e-9); + assert.deepEqual(flags, ['power_medium_immediate_drift']); +}); + +test('applyDriftPenalty level 3 reduces confidence by 0.3 + flag', () => { + const assessor = new DriftAssessor({ logger: SILENT }); + const flags = []; + const c = assessor.applyDriftPenalty( + { valid: true, nrmse: 0.5, immediateLevel: 3, longTermLevel: 0 }, + 0.9, flags, 'flow', + ); + assert.ok(Math.abs(c - 0.6) < 1e-9); + assert.deepEqual(flags, ['flow_high_immediate_drift']); +}); + +test('applyDriftPenalty stacks long-term penalty', () => { + const assessor = new DriftAssessor({ logger: SILENT }); + const flags = []; + const c = assessor.applyDriftPenalty( + { valid: true, nrmse: 0.4, immediateLevel: 2, longTermLevel: 2 }, + 0.9, flags, 'flow', + ); + assert.ok(Math.abs(c - 0.6) < 1e-9); + assert.deepEqual(flags, ['flow_medium_immediate_drift', 'flow_long_term_drift']); +}); + +test('assessDrift returns null if no stored series', () => { + const assessor = new DriftAssessor({ + errorMetrics: makeErrorMetrics(() => ({ valid: true })), + measurements: { + type() { return this; }, + variant() { return this; }, + position() { return this; }, + getAllValues() { return {}; }, + }, + driftProfiles: {}, + logger: SILENT, + }); + assert.equal(assessor.assessDrift('flow', 0, 1), null); +}); diff --git a/test/basic/flowController.basic.test.js b/test/basic/flowController.basic.test.js new file mode 100644 index 0000000..d8881f3 --- /dev/null +++ b/test/basic/flowController.basic.test.js @@ -0,0 +1,132 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const FlowController = require('../../src/flow/flowController'); + +function makeLogger() { + const calls = { debug: [], info: [], warn: [], error: [] }; + return { + calls, + debug: (m) => calls.debug.push(m), + info: (m) => calls.info.push(m), + warn: (m) => calls.warn.push(m), + error: (m) => calls.error.push(m), + }; +} + +function makeHost({ + mode = 'auto', + allowedActions = new Set(['execsequence', 'execmovement', 'flowmovement', 'emergencystop', 'statuscheck', 'entermaintenance', 'exitmaintenance']), + allowedSources = true, + setpointError, +} = {}) { + const logger = makeLogger(); + const host = { + logger, + currentMode: mode, + unitPolicy: { + canonical: { flow: 'm3/s' }, + output: { flow: 'm3/h' }, + }, + isValidActionForMode: (action) => allowedActions.has(action), + isValidSourceForMode: () => allowedSources, + calls: { executeSequence: [], setpoint: [], calcCtrl: [], convertUnit: [] }, + async executeSequence(seq) { host.calls.executeSequence.push(seq); return { ran: seq }; }, + async setpoint(sp) { + host.calls.setpoint.push(sp); + if (setpointError) throw setpointError; + return { moved: sp }; + }, + calcCtrl: (canonicalFlow) => { host.calls.calcCtrl.push(canonicalFlow); return canonicalFlow / 2; }, + _convertUnitValue: (val, from, to, label) => { + host.calls.convertUnit.push({ val, from, to, label }); + return val * 1000; // pretend m3/h -> m3/s factor + }, + }; + return host; +} + +test('handle("parent","execSequence","startup") triggers executeSequence', async () => { + const host = makeHost(); + const fc = new FlowController({ host }); + const result = await fc.handle('parent', 'execSequence', 'startup'); + assert.deepEqual(host.calls.executeSequence, ['startup']); + assert.deepEqual(result, { ran: 'startup' }); +}); + +test('handle("parent","execMovement",50) invokes setpoint(50)', async () => { + const host = makeHost(); + const fc = new FlowController({ host }); + const result = await fc.handle('parent', 'execMovement', 50); + assert.deepEqual(host.calls.setpoint, [50]); + assert.deepEqual(result, { moved: 50 }); +}); + +test('handle("parent","flowMovement",X) converts unit -> calcCtrl -> setpoint', async () => { + const host = makeHost(); + const fc = new FlowController({ host }); + await fc.handle('parent', 'flowMovement', 36); + assert.equal(host.calls.convertUnit.length, 1); + assert.equal(host.calls.convertUnit[0].from, 'm3/h'); + assert.equal(host.calls.convertUnit[0].to, 'm3/s'); + assert.deepEqual(host.calls.calcCtrl, [36 * 1000]); + assert.deepEqual(host.calls.setpoint, [(36 * 1000) / 2]); +}); + +test('handle("parent","emergencyStop") fires executeSequence("emergencystop") and logs warn', async () => { + const host = makeHost(); + const fc = new FlowController({ host }); + await fc.handle('parent', 'emergencyStop'); + assert.deepEqual(host.calls.executeSequence, ['emergencystop']); + assert.ok(host.logger.calls.warn.some((m) => /Emergency stop activated/.test(m))); +}); + +test('handle rejects non-string action', async () => { + const host = makeHost(); + const fc = new FlowController({ host }); + await fc.handle('parent', 123, 'x'); + assert.deepEqual(host.calls.executeSequence, []); + assert.deepEqual(host.calls.setpoint, []); + assert.ok(host.logger.calls.error.some((m) => /Action must be string/.test(m))); +}); + +test('handle bails out when action not allowed for mode', async () => { + const host = makeHost({ allowedActions: new Set(['statuscheck']) }); + const fc = new FlowController({ host }); + await fc.handle('parent', 'execSequence', 'startup'); + assert.deepEqual(host.calls.executeSequence, []); +}); + +test('handle bails out when source not allowed for mode', async () => { + const host = makeHost({ allowedSources: false }); + const fc = new FlowController({ host }); + await fc.handle('externalApi', 'execSequence', 'startup'); + assert.deepEqual(host.calls.executeSequence, []); +}); + +test('handle catches downstream errors and logs them (does not propagate)', async () => { + const host = makeHost({ setpointError: new Error('boom') }); + const fc = new FlowController({ host }); + const result = await fc.handle('parent', 'execMovement', 12); + assert.equal(result, undefined); + assert.ok(host.logger.calls.error.some((m) => /Error handling input/.test(m))); +}); + +test('handle returns a success envelope for statuscheck', async () => { + const host = makeHost(); + const fc = new FlowController({ host }); + const out = await fc.handle('parent', 'statusCheck'); + assert.equal(out.status, true); + assert.ok(out.feedback.includes('statuscheck')); +}); + +test('handle warns on unimplemented action', async () => { + const host = makeHost({ allowedActions: new Set(['weirdaction']) }); + const fc = new FlowController({ host }); + await fc.handle('parent', 'weirdAction'); + assert.ok(host.logger.calls.warn.some((m) => /is not implemented/.test(m))); +}); + +test('constructor validates host', () => { + assert.throws(() => new FlowController({}), /ctx\.host is required/); +}); diff --git a/test/basic/groupPredictors.basic.test.js b/test/basic/groupPredictors.basic.test.js new file mode 100644 index 0000000..a706f7e --- /dev/null +++ b/test/basic/groupPredictors.basic.test.js @@ -0,0 +1,51 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { predict } = require('generalFunctions'); +const { buildPredictors } = require('../../src/prediction/predictors'); +const { buildGroupPredictors } = require('../../src/prediction/groupPredictors'); + +function makeCanonicalCurve() { + return { + nq: { + 100000: { x: [0, 50, 100], y: [0, 0.005, 0.01] }, + 120000: { x: [0, 50, 100], y: [0, 0.006, 0.012] }, + }, + np: { + 100000: { x: [0, 50, 100], y: [0, 500, 1000] }, + 120000: { x: [0, 50, 100], y: [0, 600, 1200] }, + }, + }; +} + +test('buildGroupPredictors: returns null when source predictors absent', () => { + assert.equal(buildGroupPredictors(null), null); + assert.equal(buildGroupPredictors({ predictFlow: null, predictPower: null, predictCtrl: null }), null); +}); + +test('buildGroupPredictors: returns three group-scope Predict instances', () => { + const predictors = buildPredictors(makeCanonicalCurve()); + const group = buildGroupPredictors(predictors); + assert.ok(group); + assert.ok(group.groupPredictFlow instanceof predict); + assert.ok(group.groupPredictPower instanceof predict); + assert.ok(group.groupPredictCtrl instanceof predict); +}); + +test('buildGroupPredictors: group instances share input curves with individuals', () => { + const predictors = buildPredictors(makeCanonicalCurve()); + const group = buildGroupPredictors(predictors); + // Predict._adoptInputsFrom copies these refs from the source. + assert.equal(group.groupPredictFlow.inputCurve, predictors.predictFlow.inputCurve); + assert.equal(group.groupPredictPower.inputCurve, predictors.predictPower.inputCurve); + assert.equal(group.groupPredictCtrl.inputCurve, predictors.predictCtrl.inputCurve); +}); + +test('buildGroupPredictors: group operating-point state is independent of individual', () => { + const predictors = buildPredictors(makeCanonicalCurve()); + const group = buildGroupPredictors(predictors); + predictors.predictFlow.fDimension = 100000; + group.groupPredictFlow.fDimension = 120000; + assert.equal(predictors.predictFlow.currentF, 100000); + assert.equal(group.groupPredictFlow.currentF, 120000); +}); diff --git a/test/basic/measurementHandlers.basic.test.js b/test/basic/measurementHandlers.basic.test.js new file mode 100644 index 0000000..461df3d --- /dev/null +++ b/test/basic/measurementHandlers.basic.test.js @@ -0,0 +1,149 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const MeasurementHandlers = require('../../src/measurement/measurementHandlers'); + +function makeChainable(sink) { + const builder = { + _path: {}, + type(t) { this._path.type = t; return this; }, + variant(v) { this._path.variant = v; return this; }, + position(p){ this._path.position = p; return this; }, + child(id) { this._path.child = id; return this; }, + value(v, ts, unit) { + sink.push({ ...this._path, value: v, ts, unit }); + this._path = {}; + }, + getCurrentValue(unit) { + return sink._currentValue != null ? sink._currentValue : 0; + }, + }; + return builder; +} + +function makeLogger() { + const calls = { debug: [], info: [], warn: [], error: [] }; + return { + calls, + debug: (m) => calls.debug.push(m), + info: (m) => calls.info.push(m), + warn: (m) => calls.warn.push(m), + error: (m) => calls.error.push(m), + }; +} + +function makeHost({ operational = true } = {}) { + const writes = []; + const logger = makeLogger(); + const host = { + logger, + writes, + measurementUnits: { flow: 'm3/h', power: 'kW', temperature: 'C', pressure: 'mbar' }, + unitPolicy: { + canonical: { flow: 'm3/s', power: 'W', temperature: 'K', pressure: 'Pa' }, + output: { flow: 'm3/h', power: 'kW', temperature: 'C', pressure: 'mbar' }, + }, + predictFlow: { outputY: 7 }, + predictPower: { outputY: 1234 }, + measurements: makeChainable(writes), + _isOperationalState: () => operational, + _resolveMeasurementUnit: (type, unit) => { + if (!unit) throw new Error(`Missing unit for ${type} measurement.`); + return unit; + }, + _updateMetricDrift: (...args) => { host.driftCalls.push(args); }, + _updatePredictionHealth: () => { host.healthCalls++; }, + driftCalls: [], + healthCalls: 0, + updateMeasuredPressure: (...args) => { host.pressureCalls.push(args); }, + pressureCalls: [], + updatePosition: () => { host.positionCalls++; }, + positionCalls: 0, + }; + return host; +} + +test('dispatch("flow", …) routes to updateMeasuredFlow', () => { + const host = makeHost(); + const mh = new MeasurementHandlers({ host }); + mh.dispatch('flow', 5, 'downstream', { unit: 'm3/h', childId: 'c1', childName: 'FT-1' }); + + const flowWrite = host.writes.find((w) => w.type === 'flow' && w.variant === 'measured'); + assert.ok(flowWrite, 'expected measured flow write'); + assert.equal(flowWrite.value, 5); + assert.equal(flowWrite.position, 'downstream'); + assert.equal(flowWrite.child, 'c1'); + + const predictedWrites = host.writes.filter((w) => w.type === 'flow' && w.variant === 'predicted'); + assert.equal(predictedWrites.length, 2, 'two predicted writes (downstream+atEquipment)'); + assert.equal(host.driftCalls.length, 1); + assert.equal(host.driftCalls[0][0], 'flow'); + assert.equal(host.healthCalls, 1); +}); + +test('dispatch("temperature", …) writes to measurements (works in non-operational state too)', () => { + const host = makeHost({ operational: false }); + const mh = new MeasurementHandlers({ host }); + mh.dispatch('temperature', 22.5, 'atEquipment', { unit: 'C', childId: 'tc', childName: 'TT-1', timestamp: 111 }); + + const write = host.writes.find((w) => w.type === 'temperature'); + assert.ok(write); + assert.equal(write.value, 22.5); + assert.equal(write.unit, 'C'); + assert.equal(write.ts, 111); +}); + +test('dispatch("power", …) routes to updateMeasuredPower and respects unit', () => { + const host = makeHost(); + const mh = new MeasurementHandlers({ host }); + mh.dispatch('power', 1500, 'atEquipment', { unit: 'kW', childId: 'pwr', childName: 'P-1' }); + + const measured = host.writes.find((w) => w.type === 'power' && w.variant === 'measured'); + assert.ok(measured); + assert.equal(measured.unit, 'kW'); + const predicted = host.writes.find((w) => w.type === 'power' && w.variant === 'predicted'); + assert.ok(predicted); + assert.equal(host.driftCalls.length, 1); + assert.equal(host.driftCalls[0][0], 'power'); +}); + +test('flow/power updates are skipped when machine is not operational', () => { + const host = makeHost({ operational: false }); + const mh = new MeasurementHandlers({ host }); + mh.dispatch('flow', 5, 'downstream', { unit: 'm3/h' }); + mh.dispatch('power', 99, 'atEquipment', { unit: 'kW' }); + + assert.equal(host.writes.length, 0); + assert.equal(host.driftCalls.length, 0); + assert.ok(host.logger.calls.warn.some((m) => /Machine not operational/.test(m))); +}); + +test('dispatch("pressure", …) delegates to host.updateMeasuredPressure (pressureRouter)', () => { + const host = makeHost(); + const mh = new MeasurementHandlers({ host }); + mh.dispatch('pressure', 1013, 'upstream', { unit: 'mbar', childId: 'PT-1' }); + + assert.equal(host.pressureCalls.length, 1); + assert.deepEqual(host.pressureCalls[0][0], 1013); +}); + +test('dispatch(unknown, …) logs warn and falls back to updatePosition', () => { + const host = makeHost(); + const mh = new MeasurementHandlers({ host }); + mh.dispatch('vibration', 1, 'atEquipment', {}); + + assert.equal(host.positionCalls, 1); + assert.ok(host.logger.calls.warn.some((m) => /No handler for measurement type/.test(m))); +}); + +test('handler rejects update when unit resolution throws', () => { + const host = makeHost(); + const mh = new MeasurementHandlers({ host }); + mh.dispatch('flow', 5, 'downstream', { /* no unit */ }); + assert.equal(host.writes.length, 0); + assert.ok(host.logger.calls.warn.some((m) => /Rejected flow update/.test(m))); +}); + +test('constructor validates host', () => { + assert.throws(() => new MeasurementHandlers({}), /ctx\.host is required/); +}); diff --git a/test/basic/operatingPoint.basic.test.js b/test/basic/operatingPoint.basic.test.js new file mode 100644 index 0000000..cdfbe50 --- /dev/null +++ b/test/basic/operatingPoint.basic.test.js @@ -0,0 +1,73 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { buildPredictors } = require('../../src/prediction/predictors'); +const { buildGroupPredictors } = require('../../src/prediction/groupPredictors'); +const OperatingPoint = require('../../src/prediction/operatingPoint'); + +function makeCanonicalCurve() { + return { + nq: { + 100000: { x: [0, 50, 100], y: [0, 0.005, 0.01] }, + 120000: { x: [0, 50, 100], y: [0, 0.006, 0.012] }, + }, + np: { + 100000: { x: [0, 50, 100], y: [0, 500, 1000] }, + 120000: { x: [0, 50, 100], y: [0, 600, 1200] }, + }, + }; +} + +test('OperatingPoint.setIndividual: updates working pressure on all three predictors', () => { + const predictors = buildPredictors(makeCanonicalCurve()); + const op = new OperatingPoint(predictors); + const ok = op.setIndividual(100000); + assert.equal(ok, true); + assert.equal(predictors.predictFlow.currentF, 100000); + assert.equal(predictors.predictPower.currentF, 100000); + assert.equal(predictors.predictCtrl.currentF, 100000); +}); + +test('OperatingPoint.setIndividual: rejects non-finite pressure', () => { + const predictors = buildPredictors(makeCanonicalCurve()); + const op = new OperatingPoint(predictors); + assert.equal(op.setIndividual(NaN), false); + assert.equal(op.setIndividual('not-a-number'), false); +}); + +test('OperatingPoint.setGroup: no-op when group predictors absent', () => { + const predictors = buildPredictors(makeCanonicalCurve()); + const op = new OperatingPoint(predictors, null); + assert.equal(op.setGroup(100000), false); +}); + +test('OperatingPoint.setGroup: updates only group predictors', () => { + const predictors = buildPredictors(makeCanonicalCurve()); + const group = buildGroupPredictors(predictors); + const op = new OperatingPoint(predictors, group); + predictors.predictFlow.fDimension = 120000; + op.setGroup(100000); + assert.equal(group.groupPredictFlow.currentF, 100000); + assert.equal(predictors.predictFlow.currentF, 120000); +}); + +test('OperatingPoint.flowFor: returns a finite predicted flow', () => { + const predictors = buildPredictors(makeCanonicalCurve()); + const op = new OperatingPoint(predictors); + op.setIndividual(100000); + const flow = op.flowFor(50); + assert.ok(Number.isFinite(flow), `expected finite flow, got ${flow}`); + assert.ok(flow > 0); +}); + +test('OperatingPoint.useGroup: switches getters to group predictors', () => { + const predictors = buildPredictors(makeCanonicalCurve()); + const group = buildGroupPredictors(predictors); + const op = new OperatingPoint(predictors, group); + op.setIndividual(100000); + op.setGroup(120000); + const indivFlow = op.useIndividual().flowFor(50); + const groupFlow = op.useGroup().flowFor(50); + assert.ok(Number.isFinite(indivFlow)); + assert.ok(Number.isFinite(groupFlow)); +}); diff --git a/test/basic/predictionHealth.basic.test.js b/test/basic/predictionHealth.basic.test.js new file mode 100644 index 0000000..1c0b4b9 --- /dev/null +++ b/test/basic/predictionHealth.basic.test.js @@ -0,0 +1,93 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const PredictionHealth = require('../../src/drift/predictionHealth'); +const DriftAssessor = require('../../src/drift/driftAssessor'); + +function makeHealth(overrides = {}) { + return new PredictionHealth({ + getPressureInitializationStatus: () => ({ + initialized: true, hasDifferential: true, source: 'differential', + }), + isOperational: () => true, + applyDriftPenalty: new DriftAssessor({}).applyDriftPenalty.bind(new DriftAssessor({})), + ...overrides, + }); +} + +test('empty snapshots + differential pressure → nominal health, confidence=0.9', () => { + const ph = makeHealth(); + const { health, confidence } = ph.evaluate({ + flow: null, + power: null, + pressure: { level: 0, flags: [], source: 'differential' }, + }); + assert.equal(health.level, 0); + assert.ok(Math.abs(confidence - 0.9) < 1e-9); + assert.equal(typeof health.message, 'string'); +}); + +test('pressure not initialized + flow drift level 2 → composite level >= 2 and multiple flags', () => { + const ph = makeHealth({ + getPressureInitializationStatus: () => ({ + initialized: false, hasDifferential: false, source: null, + }), + }); + const { health, confidence } = ph.evaluate({ + flow: { valid: true, nrmse: 0.3, immediateLevel: 2, longTermLevel: 0 }, + power: null, + pressure: { level: 2, flags: ['no_pressure_input'], source: null }, + }); + assert.ok(health.level >= 2); + assert.ok(health.flags.includes('no_pressure_input')); + assert.ok(health.flags.includes('flow_medium_immediate_drift')); + assert.ok(confidence < 0.5); +}); + +test('returned object has both health and confidence', () => { + const ph = makeHealth(); + const out = ph.evaluate({ flow: null, power: null, pressure: { level: 0, flags: [], source: 'differential' } }); + assert.ok('health' in out); + assert.ok('confidence' in out); + assert.equal(typeof out.confidence, 'number'); + assert.equal(typeof out.health.level, 'number'); +}); + +test('non-operational forces confidence=0 and bumps level >=2', () => { + const ph = makeHealth({ isOperational: () => false }); + const { health, confidence } = ph.evaluate({ + flow: null, power: null, + pressure: { level: 0, flags: [], source: 'differential' }, + }); + assert.equal(confidence, 0); + assert.ok(health.flags.includes('not_operational')); + assert.ok(health.level >= 2); +}); + +test('curve-edge penalty applies when current position is near min/max', () => { + const ph = makeHealth({ + getCurrentPosition: () => 0.01, + resolveSetpointBounds: () => ({ min: 0, max: 1 }), + }); + const { health, confidence } = ph.evaluate({ + flow: null, power: null, + pressure: { level: 0, flags: [], source: 'differential' }, + }); + assert.ok(health.flags.includes('near_curve_edge')); + assert.ok(confidence < 0.9); +}); + +test('HealthStatus shape — has the standardised five fields', () => { + const ph = makeHealth(); + const { health } = ph.evaluate({ + flow: null, power: null, + pressure: { level: 0, flags: [], source: 'differential' }, + }); + assert.ok('level' in health); + assert.ok('flags' in health); + assert.ok('message' in health); + assert.ok('source' in health); + assert.ok(Array.isArray(health.flags)); +}); diff --git a/test/basic/predictors.basic.test.js b/test/basic/predictors.basic.test.js new file mode 100644 index 0000000..827a8d8 --- /dev/null +++ b/test/basic/predictors.basic.test.js @@ -0,0 +1,49 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { predict } = require('generalFunctions'); +const { buildPredictors } = require('../../src/prediction/predictors'); + +function makeCanonicalCurve() { + // Canonical units already applied: pressure Pa, flow m3/s, power W, + // x-axis is control %. Two pressure levels, monotonically rising y. + return { + nq: { + 100000: { x: [0, 50, 100], y: [0, 0.005, 0.01] }, + 120000: { x: [0, 50, 100], y: [0, 0.006, 0.012] }, + }, + np: { + 100000: { x: [0, 50, 100], y: [0, 500, 1000] }, + 120000: { x: [0, 50, 100], y: [0, 600, 1200] }, + }, + }; +} + +test('buildPredictors: returns three Predict instances', () => { + const predictors = buildPredictors(makeCanonicalCurve()); + assert.ok(predictors.predictFlow instanceof predict); + assert.ok(predictors.predictPower instanceof predict); + assert.ok(predictors.predictCtrl instanceof predict); +}); + +test('buildPredictors: predictFlow yMax/yMin reflect input range', () => { + const predictors = buildPredictors(makeCanonicalCurve()); + // After buildAllFxyCurves the fDimension is initialised to fValues.min. + // currentFxyYMin/Max are the y-range at that pressure curve. + assert.ok(Number.isFinite(predictors.predictFlow.currentFxyYMax)); + assert.ok(Number.isFinite(predictors.predictFlow.currentFxyYMin)); + assert.ok(predictors.predictFlow.currentFxyYMax > predictors.predictFlow.currentFxyYMin); +}); + +test('buildPredictors: predictCtrl is built from reversed nq (flow->ctrl mapping)', () => { + const predictors = buildPredictors(makeCanonicalCurve()); + // predictCtrl's x-axis values must come from y-values in nq. + // sanity-check via currentFxyXMax being in the flow range + assert.ok(predictors.predictCtrl.currentFxyXMax <= 0.02, // flow range upper bound + `expected predictCtrl xMax in flow-range, got ${predictors.predictCtrl.currentFxyXMax}`); +}); + +test('buildPredictors: throws when machineCurve is missing nq or np', () => { + assert.throws(() => buildPredictors(null), /machineCurve\.nq and \.np are required/); + assert.throws(() => buildPredictors({ nq: {} }), /required/); +}); diff --git a/test/basic/pressureInitialization.basic.test.js b/test/basic/pressureInitialization.basic.test.js new file mode 100644 index 0000000..dc9cb3a --- /dev/null +++ b/test/basic/pressureInitialization.basic.test.js @@ -0,0 +1,103 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const PressureInitialization = require('../../src/pressure/pressureInitialization'); + +const SILENT = { warn() {}, debug() {} }; + +/* A tiny in-memory stand-in for MeasurementContainer's chained API. */ +function makeFakeMeasurements() { + const store = new Map(); + const key = (pos, childId) => `${pos}::${childId == null ? '*' : childId}`; + return { + _write(pos, childId, value) { store.set(key(pos, childId), value); }, + type() { return this; }, + variant() { return this; }, + position(p) { this._pos = p; return this; }, + child(c) { this._child = c; return this; }, + getCurrentValue() { + const k = key(this._pos, this._child); + this._child = null; + const v = store.get(k); + if (v != null) return v; + // fallback to bare position when no child specified + return store.get(key(this._pos, null)); + }, + }; +} + +test('getStatus reports initialized:false when neither real nor virtual data present', () => { + const init = new PressureInitialization({ + measurements: makeFakeMeasurements(), + virtualPressureChildIds: { upstream: 'sim-u', downstream: 'sim-d' }, + logger: SILENT, + }); + const s = init.getStatus(); + assert.equal(s.initialized, false); + assert.equal(s.hasDifferential, false); + assert.equal(s.source, null); +}); + +test('registerReal then getStatus reports initialized:true for that position', () => { + const meas = makeFakeMeasurements(); + const init = new PressureInitialization({ + measurements: meas, + virtualPressureChildIds: { upstream: 'sim-u', downstream: 'sim-d' }, + logger: SILENT, + }); + + init.registerReal('upstream', 'pt-101'); + meas._write('upstream', 'pt-101', 5000); + + const s = init.getStatus(); + assert.equal(s.initialized, true); + assert.equal(s.hasUpstream, true); + assert.equal(s.hasDownstream, false); + assert.equal(s.hasDifferential, false); + assert.equal(s.source, 'upstream'); +}); + +test('hasDifferential true only when both upstream + downstream have data', () => { + const meas = makeFakeMeasurements(); + const init = new PressureInitialization({ + measurements: meas, + virtualPressureChildIds: { upstream: 'sim-u', downstream: 'sim-d' }, + logger: SILENT, + }); + init.registerReal('upstream', 'pt-1'); + meas._write('upstream', 'pt-1', 5000); + assert.equal(init.getStatus().hasDifferential, false); + + init.registerReal('downstream', 'pt-2'); + meas._write('downstream', 'pt-2', 7000); + const s = init.getStatus(); + assert.equal(s.hasDifferential, true); + assert.equal(s.source, 'differential'); +}); + +test('virtual fallback when no real children registered', () => { + const meas = makeFakeMeasurements(); + const init = new PressureInitialization({ + measurements: meas, + virtualPressureChildIds: { upstream: 'sim-u', downstream: 'sim-d' }, + logger: SILENT, + }); + meas._write('upstream', 'sim-u', 5000); + const s = init.getStatus(); + assert.equal(s.hasUpstream, true); + assert.equal(s.source, 'upstream'); +}); + +test('unregisterReal removes a tracked child id', () => { + const init = new PressureInitialization({ + measurements: makeFakeMeasurements(), + virtualPressureChildIds: { upstream: 'sim-u', downstream: 'sim-d' }, + logger: SILENT, + }); + init.registerReal('upstream', 'pt-1'); + assert.ok(init.realPressureChildIds.upstream.has('pt-1')); + init.unregisterReal('upstream', 'pt-1'); + assert.ok(!init.realPressureChildIds.upstream.has('pt-1')); +}); diff --git a/test/basic/pressureRouter.basic.test.js b/test/basic/pressureRouter.basic.test.js new file mode 100644 index 0000000..f76e123 --- /dev/null +++ b/test/basic/pressureRouter.basic.test.js @@ -0,0 +1,101 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const PressureRouter = require('../../src/pressure/pressureRouter'); + +const SILENT = { warn() {}, debug() {} }; + +function makeFakeMeasurements() { + const writes = []; + return { + writes, + type() { return this; }, + variant() { return this; }, + position(p) { this._pos = p; return this; }, + child(c) { this._child = c; return this; }, + value(v, t, u) { writes.push({ pos: this._pos, child: this._child, value: v, t, u }); }, + }; +} + +test('route("upstream", 1, ctx) writes to the upstream pressure slot', () => { + const meas = makeFakeMeasurements(); + const router = new PressureRouter({ + measurements: meas, + virtualPressureChildIds: { upstream: 'sim-u', downstream: 'sim-d' }, + resolveMeasurementUnit: () => 'mbar', + logger: SILENT, + }); + router.route('upstream', 1, { childId: 'real-1', unit: 'mbar', timestamp: 1234 }); + assert.equal(meas.writes.length, 1); + assert.equal(meas.writes[0].pos, 'upstream'); + assert.equal(meas.writes[0].child, 'real-1'); + assert.equal(meas.writes[0].value, 1); + assert.equal(meas.writes[0].u, 'mbar'); +}); + +test('virtual source: refresh hooks NOT called', () => { + const meas = makeFakeMeasurements(); + let posCalled = 0, driftCalled = 0, healthCalled = 0; + const router = new PressureRouter({ + measurements: meas, + virtualPressureChildIds: { upstream: 'sim-u', downstream: 'sim-d' }, + resolveMeasurementUnit: () => 'mbar', + updatePosition: () => { posCalled++; }, + refreshDrift: () => { driftCalled++; }, + refreshHealth: () => { healthCalled++; }, + logger: SILENT, + }); + router.route('upstream', 7, { childId: 'sim-u', unit: 'mbar' }); + assert.equal(posCalled, 0); + assert.equal(driftCalled, 0); + assert.equal(healthCalled, 0); +}); + +test('real source: all refresh hooks called', () => { + const meas = makeFakeMeasurements(); + let posCalled = 0, driftCalled = 0, healthCalled = 0; + const router = new PressureRouter({ + measurements: meas, + virtualPressureChildIds: { upstream: 'sim-u', downstream: 'sim-d' }, + resolveMeasurementUnit: () => 'mbar', + updatePosition: () => { posCalled++; }, + refreshDrift: () => { driftCalled++; }, + refreshHealth: () => { healthCalled++; }, + logger: SILENT, + }); + router.route('upstream', 7, { childId: 'real-pt-1', unit: 'mbar' }); + assert.equal(posCalled, 1); + assert.equal(driftCalled, 1); + assert.equal(healthCalled, 1); +}); + +test('rejected unit returns false and skips the write', () => { + const meas = makeFakeMeasurements(); + const warns = []; + const router = new PressureRouter({ + measurements: meas, + virtualPressureChildIds: {}, + resolveMeasurementUnit: () => { throw new Error('bad unit'); }, + logger: { warn(m) { warns.push(m); }, debug() {} }, + }); + const ok = router.route('upstream', 1, { childId: 'x', unit: 'wat' }); + assert.equal(ok, false); + assert.equal(meas.writes.length, 0); + assert.match(warns[0], /Rejected pressure update/); +}); + +test('childId null is treated as not-virtual', () => { + const meas = makeFakeMeasurements(); + let posCalled = 0; + const router = new PressureRouter({ + measurements: meas, + virtualPressureChildIds: { upstream: 'sim-u' }, + resolveMeasurementUnit: () => 'mbar', + updatePosition: () => { posCalled++; }, + logger: SILENT, + }); + router.route('upstream', 2, { unit: 'mbar' }); + assert.equal(posCalled, 1); +}); diff --git a/test/basic/reverseCurve.basic.test.js b/test/basic/reverseCurve.basic.test.js new file mode 100644 index 0000000..89074c3 --- /dev/null +++ b/test/basic/reverseCurve.basic.test.js @@ -0,0 +1,29 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { reverseCurve } = require('../../src/curves/reverseCurve'); + +test('reverseCurve: swaps x and y for each pressure key', () => { + const input = { + 700: { x: [0, 50, 100], y: [0, 10, 20] }, + 800: { x: [0, 50, 100], y: [0, 11, 22] }, + }; + const out = reverseCurve(input); + assert.deepEqual(out['700'].x, [0, 10, 20]); + assert.deepEqual(out['700'].y, [0, 50, 100]); + assert.deepEqual(out['800'].x, [0, 11, 22]); + assert.deepEqual(out['800'].y, [0, 50, 100]); +}); + +test('reverseCurve: returns a fresh object with cloned arrays', () => { + const input = { 700: { x: [1, 2], y: [3, 4] } }; + const out = reverseCurve(input); + out['700'].x.push(999); + assert.deepEqual(input['700'].x, [1, 2]); + assert.deepEqual(input['700'].y, [3, 4]); +}); + +test('reverseCurve: handles empty input', () => { + assert.deepEqual(reverseCurve({}), {}); + assert.deepEqual(reverseCurve(null), {}); +}); diff --git a/test/basic/stateBindings.basic.test.js b/test/basic/stateBindings.basic.test.js new file mode 100644 index 0000000..ab47c83 --- /dev/null +++ b/test/basic/stateBindings.basic.test.js @@ -0,0 +1,91 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const EventEmitter = require('events'); + +const { bindStateEvents, isOperationalState, OPERATIONAL_STATES } = + require('../../src/state/stateBindings'); + +function makeFakeState() { + const emitter = new EventEmitter(); + let current = 'idle'; + return { + emitter, + setState(s) { current = s; }, + getCurrentState() { return current; }, + }; +} + +test('bindStateEvents attaches both listeners and they fire on emit', () => { + const state = makeFakeState(); + let posCalls = 0; + let stateCalls = 0; + let lastStateArg = null; + + bindStateEvents({ + state, + onPositionChange: () => { posCalls++; }, + onStateChange: (newState) => { stateCalls++; lastStateArg = newState; }, + }); + + assert.equal(state.emitter.listenerCount('positionChange'), 1); + assert.equal(state.emitter.listenerCount('stateChange'), 1); + + state.emitter.emit('positionChange', 42); + state.emitter.emit('stateChange', 'operational'); + + assert.equal(posCalls, 1); + assert.equal(stateCalls, 1); + assert.equal(lastStateArg, 'operational'); +}); + +test('bindStateEvents teardown removes both listeners and is idempotent', () => { + const state = makeFakeState(); + const teardown = bindStateEvents({ + state, + onPositionChange: () => {}, + onStateChange: () => {}, + }); + + assert.equal(state.emitter.listenerCount('positionChange'), 1); + assert.equal(state.emitter.listenerCount('stateChange'), 1); + + teardown(); + assert.equal(state.emitter.listenerCount('positionChange'), 0); + assert.equal(state.emitter.listenerCount('stateChange'), 0); + + teardown(); + assert.equal(state.emitter.listenerCount('positionChange'), 0); +}); + +test('bindStateEvents validates context shape', () => { + assert.throws(() => bindStateEvents(null), /ctx\.state\.emitter is required/); + assert.throws( + () => bindStateEvents({ state: makeFakeState() }), + /handlers are required/, + ); +}); + +test('isOperationalState returns true for operational/accelerating/decelerating/warmingup', () => { + const state = makeFakeState(); + for (const s of ['operational', 'accelerating', 'decelerating', 'warmingup']) { + state.setState(s); + assert.equal(isOperationalState(state), true, `expected ${s} to be operational`); + } +}); + +test('isOperationalState returns false for non-operational states and bad input', () => { + const state = makeFakeState(); + for (const s of ['idle', 'starting', 'stopping', 'coolingdown', 'emergencystopped']) { + state.setState(s); + assert.equal(isOperationalState(state), false, `expected ${s} not to be operational`); + } + assert.equal(isOperationalState(null), false); + assert.equal(isOperationalState({}), false); +}); + +test('OPERATIONAL_STATES list is exported and frozen-ish (no extras beyond contract)', () => { + assert.deepEqual( + [...OPERATIONAL_STATES].sort(), + ['accelerating', 'decelerating', 'operational', 'warmingup'], + ); +}); diff --git a/test/basic/virtualChildren.basic.test.js b/test/basic/virtualChildren.basic.test.js new file mode 100644 index 0000000..0412301 --- /dev/null +++ b/test/basic/virtualChildren.basic.test.js @@ -0,0 +1,70 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const VirtualPressureChildren = require('../../src/pressure/virtualChildren'); + +const SILENT = { warn() {}, debug() {}, info() {}, error() {} }; + +const UNIT_POLICY = { + canonical: { pressure: 'Pa', flow: 'm3/s', power: 'W', temperature: 'K', atmPressure: 'Pa' }, + output: { pressure: 'mbar', flow: 'm3/h', power: 'kW', temperature: 'C' }, +}; + +test('build() returns two children with the expected config shape', () => { + const factory = new VirtualPressureChildren({ logger: SILENT, unitPolicy: UNIT_POLICY }); + const { upstream, downstream } = factory.build(); + + for (const child of [upstream, downstream]) { + assert.ok(child.config.general.id); + assert.ok(child.config.general.name); + assert.equal(child.config.functionality.softwareType, 'measurement'); + assert.ok(['upstream', 'downstream'].includes(child.config.functionality.positionVsParent)); + assert.equal(child.config.asset.type, 'pressure'); + assert.equal(child.config.asset.unit, 'mbar'); + } + + assert.equal(upstream.config.functionality.positionVsParent, 'upstream'); + assert.equal(downstream.config.functionality.positionVsParent, 'downstream'); +}); + +test('each child has its own MeasurementContainer instance', () => { + const factory = new VirtualPressureChildren({ logger: SILENT, unitPolicy: UNIT_POLICY }); + const { upstream, downstream } = factory.build(); + assert.ok(upstream.measurements); + assert.ok(downstream.measurements); + assert.notStrictEqual(upstream.measurements, downstream.measurements); +}); + +test('the MeasurementContainer accepts pressure writes (unit policy applied)', () => { + const factory = new VirtualPressureChildren({ logger: SILENT, unitPolicy: UNIT_POLICY }); + const { upstream } = factory.build(); + upstream.measurements + .type('pressure').variant('measured').position('upstream') + .value(1000, Date.now(), 'mbar'); + const v = upstream.measurements + .type('pressure').variant('measured').position('upstream').getCurrentValue(); + assert.ok(v != null); +}); + +test('setParentRef wires children to the supplied parent ref', () => { + const parent = { id: 'parent-machine' }; + const factory = new VirtualPressureChildren({ + logger: SILENT, unitPolicy: UNIT_POLICY, parentRef: parent, + }); + const { upstream, downstream } = factory.build(); + assert.equal(typeof upstream.measurements.setParentRef, 'function'); + assert.equal(typeof downstream.measurements.setParentRef, 'function'); +}); + +test('custom ids are honoured', () => { + const factory = new VirtualPressureChildren({ + logger: SILENT, + unitPolicy: UNIT_POLICY, + ids: { upstream: 'sim-u', downstream: 'sim-d' }, + }); + const { upstream, downstream } = factory.build(); + assert.equal(upstream.config.general.id, 'sim-u'); + assert.equal(downstream.config.general.id, 'sim-d'); +}); diff --git a/test/basic/workingCurves.basic.test.js b/test/basic/workingCurves.basic.test.js new file mode 100644 index 0000000..451cc31 --- /dev/null +++ b/test/basic/workingCurves.basic.test.js @@ -0,0 +1,83 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { showWorkingCurves, showCoG } = require('../../src/display/workingCurves'); + +function makePredictors(overrides = {}) { + return { + hasCurve: true, + cog: 0.65, + cogIndex: 7, + NCog: 0.5, + minEfficiency: 0.4, + currentEfficiencyCurve: { x: [0, 1], y: [0.4, 0.8] }, + absDistFromPeak: 0.15, + relDistFromPeak: 0.3, + calcCog: () => ({ cog: 0.65, cogIndex: 7, NCog: 0.5, minEfficiency: 0.4 }), + getCurrentCurves: () => ({ + powerCurve: { x: [0, 1], y: [10, 20] }, + flowCurve: { x: [0, 1], y: [0, 5] }, + }), + ...overrides, + }; +} + +test('showWorkingCurves returns the expected shape when curves exist', () => { + const p = makePredictors(); + const out = showWorkingCurves(p); + assert.deepEqual(out.powerCurve, { x: [0, 1], y: [10, 20] }); + assert.deepEqual(out.flowCurve, { x: [0, 1], y: [0, 5] }); + assert.equal(out.cog, 0.65); + assert.equal(out.cogIndex, 7); + assert.equal(out.NCog, 0.5); + assert.equal(out.minEfficiency, 0.4); + assert.deepEqual(out.currentEfficiencyCurve, { x: [0, 1], y: [0.4, 0.8] }); + assert.equal(out.absDistFromPeak, 0.15); + assert.equal(out.relDistFromPeak, 0.3); +}); + +test('showWorkingCurves returns error envelope when hasCurve is false', () => { + const out = showWorkingCurves(makePredictors({ hasCurve: false })); + assert.deepEqual(out, { error: 'No curve data available' }); +}); + +test('showWorkingCurves handles null predictors safely', () => { + const out = showWorkingCurves(null); + assert.equal(out.error, 'No curve data available'); +}); + +test('showCoG returns CoG data with rounded NCogPercent when curves exist', () => { + const p = makePredictors(); + const out = showCoG(p); + assert.equal(out.cog, 0.65); + assert.equal(out.cogIndex, 7); + assert.equal(out.NCog, 0.5); + // 0.5 * 100 = 50.0, rounded *100 /100 still 50 + assert.equal(out.NCogPercent, 50); + assert.equal(out.minEfficiency, 0.4); + assert.deepEqual(out.currentEfficiencyCurve, { x: [0, 1], y: [0.4, 0.8] }); + assert.equal(out.absDistFromPeak, 0.15); + assert.equal(out.relDistFromPeak, 0.3); +}); + +test('showCoG rounds NCogPercent to 2 decimal places', () => { + const p = makePredictors({ + calcCog: () => ({ cog: 0.1, cogIndex: 1, NCog: 0.123456, minEfficiency: 0.2 }), + }); + const out = showCoG(p); + assert.equal(out.NCogPercent, 12.35); +}); + +test('showCoG returns degraded shape when hasCurve is false', () => { + const out = showCoG(makePredictors({ hasCurve: false })); + assert.equal(out.error, 'No curve data available'); + assert.equal(out.cog, 0); + assert.equal(out.NCog, 0); + assert.equal(out.cogIndex, 0); +}); + +test('showCoG handles null predictors safely', () => { + const out = showCoG(null); + assert.equal(out.error, 'No curve data available'); + assert.equal(out.cog, 0); +});