P5 wave 1: extract rotatingMachine concerns into focused modules
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) <noreply@anthropic.com>
This commit is contained in:
94
CONTRACT.md
Normal file
94
CONTRACT.md
Normal file
@@ -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<Type>(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: <result> }` via `ctx.send`. |
|
||||||
|
| `query.cog` | `CoG` | none | Calls `source.showCoG()` and replies on **Port 0** with `{ topic: 'showCoG', payload: <result> }`. |
|
||||||
|
| `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: <result> }` 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: <node.id>, positionVsParent }` to
|
||||||
|
the upstream parent (typically a `machineGroupControl` or
|
||||||
|
`pumpingStation`). `positionVsParent` defaults to `'atEquipment'`.
|
||||||
|
|
||||||
|
## Events emitted by `source.measurements.emitter`
|
||||||
|
|
||||||
|
The `MeasurementContainer` fires `<type>.<variant>.<position>` 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.<position>`, `power.measured.atequipment`,
|
||||||
|
`temperature.measured.<position>` — 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 `<asset.type>.measured.<positionVsParent>`
|
||||||
|
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).
|
||||||
150
src/commands/handlers.js
Normal file
150
src/commands/handlers.js
Normal file
@@ -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);
|
||||||
|
};
|
||||||
85
src/commands/index.js
Normal file
85
src/commands/index.js
Normal file
@@ -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,
|
||||||
|
},
|
||||||
|
];
|
||||||
19
src/curves/curveLoader.js
Normal file
19
src/curves/curveLoader.js
Normal file
@@ -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 };
|
||||||
117
src/curves/curveNormalizer.js
Normal file
117
src/curves/curveNormalizer.js
Normal file
@@ -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 };
|
||||||
17
src/curves/reverseCurve.js
Normal file
17
src/curves/reverseCurve.js
Normal file
@@ -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 };
|
||||||
61
src/display/workingCurves.js
Normal file
61
src/display/workingCurves.js
Normal file
@@ -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 };
|
||||||
135
src/drift/driftAssessor.js
Normal file
135
src/drift/driftAssessor.js
Normal file
@@ -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;
|
||||||
132
src/drift/predictionHealth.js
Normal file
132
src/drift/predictionHealth.js
Normal file
@@ -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;
|
||||||
85
src/flow/flowController.js
Normal file
85
src/flow/flowController.js
Normal file
@@ -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;
|
||||||
134
src/measurement/measurementHandlers.js
Normal file
134
src/measurement/measurementHandlers.js
Normal file
@@ -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;
|
||||||
23
src/prediction/groupPredictors.js
Normal file
23
src/prediction/groupPredictors.js
Normal file
@@ -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 };
|
||||||
82
src/prediction/operatingPoint.js
Normal file
82
src/prediction/operatingPoint.js
Normal file
@@ -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;
|
||||||
25
src/prediction/predictors.js
Normal file
25
src/prediction/predictors.js
Normal file
@@ -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 };
|
||||||
100
src/pressure/pressureInitialization.js
Normal file
100
src/pressure/pressureInitialization.js
Normal file
@@ -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<string>, downstream: Set<string> }
|
||||||
|
* - 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;
|
||||||
80
src/pressure/pressureRouter.js
Normal file
80
src/pressure/pressureRouter.js
Normal file
@@ -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;
|
||||||
92
src/pressure/virtualChildren.js
Normal file
92
src/pressure/virtualChildren.js
Normal file
@@ -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;
|
||||||
58
src/state/stateBindings.js
Normal file
58
src/state/stateBindings.js
Normal file
@@ -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,
|
||||||
|
};
|
||||||
275
test/basic/commands.basic.test.js
Normal file
275
test/basic/commands.basic.test.js
Normal file
@@ -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)}`
|
||||||
|
);
|
||||||
|
});
|
||||||
30
test/basic/curveLoader.basic.test.js
Normal file
30
test/basic/curveLoader.basic.test.js
Normal file
@@ -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/);
|
||||||
|
});
|
||||||
88
test/basic/curveNormalizer.basic.test.js
Normal file
88
test/basic/curveNormalizer.basic.test.js
Normal file
@@ -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/);
|
||||||
|
});
|
||||||
130
test/basic/driftAssessor.basic.test.js
Normal file
130
test/basic/driftAssessor.basic.test.js
Normal file
@@ -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);
|
||||||
|
});
|
||||||
132
test/basic/flowController.basic.test.js
Normal file
132
test/basic/flowController.basic.test.js
Normal file
@@ -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/);
|
||||||
|
});
|
||||||
51
test/basic/groupPredictors.basic.test.js
Normal file
51
test/basic/groupPredictors.basic.test.js
Normal file
@@ -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);
|
||||||
|
});
|
||||||
149
test/basic/measurementHandlers.basic.test.js
Normal file
149
test/basic/measurementHandlers.basic.test.js
Normal file
@@ -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/);
|
||||||
|
});
|
||||||
73
test/basic/operatingPoint.basic.test.js
Normal file
73
test/basic/operatingPoint.basic.test.js
Normal file
@@ -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));
|
||||||
|
});
|
||||||
93
test/basic/predictionHealth.basic.test.js
Normal file
93
test/basic/predictionHealth.basic.test.js
Normal file
@@ -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));
|
||||||
|
});
|
||||||
49
test/basic/predictors.basic.test.js
Normal file
49
test/basic/predictors.basic.test.js
Normal file
@@ -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/);
|
||||||
|
});
|
||||||
103
test/basic/pressureInitialization.basic.test.js
Normal file
103
test/basic/pressureInitialization.basic.test.js
Normal file
@@ -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'));
|
||||||
|
});
|
||||||
101
test/basic/pressureRouter.basic.test.js
Normal file
101
test/basic/pressureRouter.basic.test.js
Normal file
@@ -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);
|
||||||
|
});
|
||||||
29
test/basic/reverseCurve.basic.test.js
Normal file
29
test/basic/reverseCurve.basic.test.js
Normal file
@@ -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), {});
|
||||||
|
});
|
||||||
91
test/basic/stateBindings.basic.test.js
Normal file
91
test/basic/stateBindings.basic.test.js
Normal file
@@ -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'],
|
||||||
|
);
|
||||||
|
});
|
||||||
70
test/basic/virtualChildren.basic.test.js
Normal file
70
test/basic/virtualChildren.basic.test.js
Normal file
@@ -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');
|
||||||
|
});
|
||||||
83
test/basic/workingCurves.basic.test.js
Normal file
83
test/basic/workingCurves.basic.test.js
Normal file
@@ -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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user