diff --git a/CONTRACT.md b/CONTRACT.md new file mode 100644 index 0000000..2bcb8ef --- /dev/null +++ b/CONTRACT.md @@ -0,0 +1,71 @@ +# machineGroupControl — Contract + +Hand-maintained for Phase 4; the `## Inputs` table is generated from +`src/commands/index.js` (see Phase 9 generator). Keep ≤ 80 lines. + +## Inputs (msg.topic on Port 0) + +| Canonical | Aliases (deprecated) | Payload | Effect | +|---|---|---|---| +| `set.mode` | `setMode` | `string` — one of `prioritycontrol`, `optimalcontrol`, `dynamiccontrol`, … | Switches the control strategy via `source.setMode(payload)`. | +| `set.scaling` | `setScaling` | `string` — one of `absolute`, `normalized` | Sets the demand-scaling convention via `source.setScaling(payload)`. | +| `child.register` | `registerChild` | `string` — the child node's Node-RED id | Resolves the child via `RED.nodes.getNode` and registers it through `childRegistrationUtils.registerChild(childObj.source, msg.positionVsParent)`. | +| `set.demand` | `Qd` | numeric (number or numeric string) | Calls `source.handleInput('parent', parseFloat(payload))`. On success, replies on Port 0 with `topic = source.config.general.name`, `payload = 'done'`. Non-numeric payloads log `error` and are skipped. | + +Aliases log a one-time deprecation warning the first time they fire. + +## 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 a successful `set.demand` dispatch the + node additionally emits `{ topic: , payload: 'done' }` as an + acknowledgement. +- **Port 1 (InfluxDB telemetry):** same shape as Port 0, formatted with the + `'influxdb'` formatter. +- **Port 2 (registration):** at startup the node sends one + `{ topic: 'registerChild', payload: , positionVsParent }` + to the upstream parent. + +## Events emitted by `source.measurements.emitter` + +The `MeasurementContainer` fires `..` whenever +the corresponding series receives a new value. Parents subscribe via the +generic `child.measurements.emitter.on(eventName, ...)` handshake. +machineGroupControl publishes: + +- `flow.predicted.atequipment` — aggregated predicted group flow (sum of + member-machine predicted flows at the group operating point). +- `flow.predicted.downstream` — mirror of the live group flow seen at + the discharge header (written by `handlePressureChange` for downstream + consumers such as pumpingStation). +- `power.predicted.atequipment` — aggregated predicted group power. +- `efficiency.predicted.atequipment` — group efficiency = flow/power at + the selected operating point. +- `Ncog.predicted.atequipment` — group normalised cost-of-goods score. +- `pressure.measured.upstream`, `pressure.measured.downstream`, + `pressure.measured.differential` — mirrored from header-side + measurement children (`asset.type='pressure'`), when registered. + +The exact set is data-driven by which children register and what they +publish; downstream consumers should subscribe by event name, not assume +a fixed catalogue. + +## Children registered by this node + +machineGroupControl accepts two `softwareType`s through the +`childRegistrationUtils` handshake: + +- `machine` — a rotatingMachine. Stored in `source.machines[id]`. + The group subscribes to its child's + `pressure.measured.differential`, `pressure.measured.downstream`, and + `flow.predicted.downstream` events to trigger `handlePressureChange`. +- `measurement` — a header-side sensor (typically a pressure transmitter + at the discharge or suction manifold). The group subscribes to the + matching `.measured.` event and mirrors + the value into its own MeasurementContainer; pressure events also + trigger `handlePressureChange` so optimalControl can use ONE header + operating point for all pumps. + +Position labels accepted from children are `upstream`, `downstream`, +`atequipment` (and case variants — normalised internally). diff --git a/src/combinatorics/pumpCombinations.js b/src/combinatorics/pumpCombinations.js new file mode 100644 index 0000000..f2b05e6 --- /dev/null +++ b/src/combinatorics/pumpCombinations.js @@ -0,0 +1,96 @@ +// Pure subset/combination generators used by the optimizer. +// All callable through `ctx` so this file stays free of class state. +// `ctx` must provide: +// - groupCurves: { groupFlow, groupPower } (from ../groupOps/groupCurves) +// - logger (warn/debug) +// - readChildMeasurement(machine, type, variant, position, canonicalUnit) +// - POSITIONS, unitPolicy.canonical.flow + +const EXCLUDED_STATES = new Set(['off', 'coolingdown', 'stopping', 'emergencystop']); + +// Reduce demand by the flow that manually-driven operational machines +// are already delivering. Returns the adjusted Qd (may be < 0). +function checkSpecialCases(machines, Qd, ctx) { + const { logger, readChildMeasurement, POSITIONS, unitPolicy } = ctx; + const canonicalFlow = unitPolicy?.canonical?.flow; + + Object.values(machines).forEach(machine => { + const state = machine.state?.getCurrentState?.(); + const mode = machine.currentMode; + + if (state !== 'operational') return; + if (mode !== 'virtualControl' && mode !== 'fysicalControl') return; + + const measuredFlow = readChildMeasurement + ? readChildMeasurement(machine, 'flow', 'measured', POSITIONS.DOWNSTREAM, canonicalFlow) + : undefined; + const predictedFlow = readChildMeasurement + ? readChildMeasurement(machine, 'flow', 'predicted', POSITIONS.DOWNSTREAM, canonicalFlow) + : undefined; + + let flow = 0; + if (Number.isFinite(measuredFlow) && measuredFlow !== 0) { + flow = measuredFlow; + } else if (Number.isFinite(predictedFlow) && predictedFlow !== 0) { + flow = predictedFlow; + } else { + // Unrecoverable: a machine is producing flow we can't quantify. + // Caller decides whether to abort the dispatch tick. + logger?.error?.( + "Dont perform calculation at all seeing that there is a machine working but we dont know the flow its producing" + ); + return; + } + + Qd = Qd - flow; + }); + return Qd; +} + +// Generate all non-empty machine subsets that can deliver Qd within powerCap. +// Inputs that can't possibly contribute (off / coolingdown / mode-locked) are +// excluded before the power set is built, so 2^N stays small in practice. +function validPumpCombinations(machines, Qd, ctx, powerCap = Infinity) { + const { groupCurves } = ctx; + const groupFlow = groupCurves?.groupFlow; + const groupPower = groupCurves?.groupPower; + + Qd = checkSpecialCases(machines, Qd, ctx); + + let subsets = [[]]; + Object.keys(machines).forEach(machineId => { + const machine = machines[machineId]; + const state = machine.state?.getCurrentState?.(); + const validActionForMode = + typeof machine.isValidActionForMode === 'function' + ? machine.isValidActionForMode('execsequence', 'auto') + : true; + + if (EXCLUDED_STATES.has(state) || !validActionForMode) return; + + const newSubsets = subsets.map(set => [...set, machineId]); + subsets = subsets.concat(newSubsets); + }); + + return subsets.filter(subset => { + if (subset.length === 0) return false; + + const { maxFlow, minFlow, maxPower } = subset.reduce( + (acc, machineId) => { + const machine = machines[machineId]; + const f = groupFlow(machine); + const p = groupPower(machine); + return { + maxFlow: acc.maxFlow + f.currentFxyYMax, + minFlow: acc.minFlow + f.currentFxyYMin, + maxPower: acc.maxPower + p.currentFxyYMax, + }; + }, + { maxFlow: 0, minFlow: 0, maxPower: 0 }, + ); + + return maxFlow >= Qd && minFlow <= Qd && maxPower <= powerCap; + }); +} + +module.exports = { validPumpCombinations, checkSpecialCases, EXCLUDED_STATES }; diff --git a/src/commands/handlers.js b/src/commands/handlers.js new file mode 100644 index 0000000..eed7105 --- /dev/null +++ b/src/commands/handlers.js @@ -0,0 +1,58 @@ +'use strict'; + +// Handler functions for machineGroupControl commands. Each handler receives: +// source: the domain (specificClass) instance — exposes setMode, setScaling, +// handleInput, childRegistrationUtils.registerChild, logger, +// config.general.name. +// msg: the Node-RED input message. +// ctx: { node, RED, send, logger } — provided by BaseNodeAdapter. +// +// Pure functions: no module-level state. The registry already enforces the +// typeof-check ladder; per-topic semantic validation lives here. + +function _logger(source, ctx) { + return ctx?.logger || source?.logger || null; +} + +exports.setMode = (source, msg) => { + source.setMode(msg.payload); +}; + +exports.setScaling = (source, msg) => { + source.setScaling(msg.payload); +}; + +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); +}; + +exports.setDemand = async (source, msg, ctx) => { + const log = _logger(source, ctx); + const demand = parseFloat(msg.payload); + if (Number.isNaN(demand)) { + log?.error?.(`set.demand: invalid Qd value '${msg.payload}'`); + return; + } + try { + await source.handleInput('parent', demand); + } catch (err) { + log?.error?.(`set.demand: failed to process Qd: ${err && err.message}`); + return; + } + // Reply on Port 0 with the configured node name as topic — preserves the + // legacy "done" handshake some downstream flows rely on. + if (typeof ctx?.send === 'function') { + const reply = Object.assign({}, msg, { + topic: source?.config?.general?.name, + payload: 'done', + }); + ctx.send(reply); + } +}; diff --git a/src/commands/index.js b/src/commands/index.js new file mode 100644 index 0000000..0ca901e --- /dev/null +++ b/src/commands/index.js @@ -0,0 +1,37 @@ +'use strict'; + +// machineGroupControl 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. + +const handlers = require('./handlers'); + +module.exports = [ + { + topic: 'set.mode', + aliases: ['setMode'], + payloadSchema: { type: 'string' }, + handler: handlers.setMode, + }, + { + topic: 'set.scaling', + aliases: ['setScaling'], + payloadSchema: { type: 'string' }, + handler: handlers.setScaling, + }, + { + topic: 'child.register', + aliases: ['registerChild'], + // payload is the Node-RED id (string) of the child node. + payloadSchema: { type: 'string' }, + handler: handlers.registerChild, + }, + { + topic: 'set.demand', + aliases: ['Qd'], + // any: number or numeric string — handler runs parseFloat. + payloadSchema: { type: 'any' }, + handler: handlers.setDemand, + }, +]; diff --git a/src/dispatch/demandDispatcher.js b/src/dispatch/demandDispatcher.js new file mode 100644 index 0000000..9cc7da3 --- /dev/null +++ b/src/dispatch/demandDispatcher.js @@ -0,0 +1,38 @@ +'use strict'; + +const { LatestWinsGate } = require('generalFunctions'); + +// Thin wrapper around LatestWinsGate for the MGC demand path. Replaces +// the original `_dispatchInFlight` + `_delayedCall` pair in +// specificClass.handleInput: a new demand arriving while a dispatch is +// in flight overwrites any pending one, so the latest value always wins +// and intermediates are dropped silently. + +class DemandDispatcher { + constructor(ctx = {}, runFn) { + if (typeof runFn !== 'function') { + throw new TypeError('DemandDispatcher requires a runFn'); + } + this.ctx = ctx; + this.logger = ctx.logger || null; + this._runFn = runFn; + this._gate = new LatestWinsGate( + async (demand) => this._runFn(demand, this.ctx), + { logger: this.logger }, + ); + } + + fire(demand) { + this._gate.fire(demand); + } + + drain() { + return this._gate.drain(); + } + + get inFlight() { + return this._gate.size > 0; + } +} + +module.exports = DemandDispatcher; diff --git a/src/efficiency/groupEfficiency.js b/src/efficiency/groupEfficiency.js new file mode 100644 index 0000000..061640f --- /dev/null +++ b/src/efficiency/groupEfficiency.js @@ -0,0 +1,90 @@ +'use strict'; + +// Aggregates per-machine efficiency (cog) into group-level metrics and +// computes distance-from-peak. Extracted verbatim from specificClass.js +// (calcGroupEfficiency / calcDistanceFromPeak / calcRelativeDistanceFromPeak / +// calcDistanceBEP) so the orchestrator can delegate without inheriting +// the arithmetic. + +class GroupEfficiency { + constructor(ctx = {}) { + this.ctx = ctx; + this.logger = ctx.logger || null; + this.interpolation = ctx.interpolation || null; + this.measurements = ctx.measurements || null; + this.machines = ctx.machines || null; + } + + // Average of per-machine cog plus the worst-performing machine's cog. + // `maxEfficiency` is misleadingly named — it is in fact the MEAN cog + // across all machines, treated as the group-level "peak" target. + // Kept that way for behavioural parity with the original. + calcGroupEfficiency(machines) { + const target = machines || this.machines; + let cumEfficiency = 0; + let machineCount = 0; + let lowestEfficiency = Infinity; + + Object.entries(target || {}).forEach(([_id, machine]) => { + cumEfficiency += machine.cog; + if (machine.cog < lowestEfficiency) { + lowestEfficiency = machine.cog; + } + machineCount++; + }); + + const maxEfficiency = cumEfficiency / machineCount; + const currentEfficiency = this._readCurrentEfficiency(); + + return { maxEfficiency, lowestEfficiency, currentEfficiency }; + } + + calcDistanceFromPeak(currentEfficiency, peakEfficiency) { + return Math.abs(currentEfficiency - peakEfficiency); + } + + // Maps current efficiency onto [0..1] across [maxEfficiency..minEfficiency]. + // Degenerate case (max === min) collapses the band to a point — return 1. + calcRelativeDistanceFromPeak(currentEfficiency, maxEfficiency, minEfficiency) { + let distance = 1; + if (currentEfficiency != null && maxEfficiency !== minEfficiency && this.interpolation) { + distance = this.interpolation.interpolate_lin_single_point( + currentEfficiency, + maxEfficiency, + minEfficiency, + 0, + 1, + ); + } + return distance; + } + + // Returns both abs + rel; orchestrator decides whether to mirror onto + // its own this.absDistFromPeak / this.relDistFromPeak fields. + calcDistanceBEP(currentEfficiency, maxEfficiency, minEfficiency) { + const absDistFromPeak = this.calcDistanceFromPeak(currentEfficiency, maxEfficiency); + const relDistFromPeak = this.calcRelativeDistanceFromPeak( + currentEfficiency, + maxEfficiency, + minEfficiency, + ); + return { absDistFromPeak, relDistFromPeak }; + } + + // Pull the latest measured efficiency from the container if one was + // provided. Optional convenience — orchestrator may read it directly. + _readCurrentEfficiency() { + if (!this.measurements) return null; + try { + return this.measurements + .type('efficiency') + .variant('predicted') + .position('atequipment') + .getCurrentValue(); + } catch (_err) { + return null; + } + } +} + +module.exports = GroupEfficiency; diff --git a/src/groupOps/groupCurves.js b/src/groupOps/groupCurves.js new file mode 100644 index 0000000..b8938fd --- /dev/null +++ b/src/groupOps/groupCurves.js @@ -0,0 +1,27 @@ +// Group-scope read helpers for pump curves. +// +// Optimizers and totals evaluate each pump at the GROUP operating point +// (set by GroupOperatingPoint.equalize), not the pump's individual sensor- +// driven point. Each pump exposes a parallel "group*" predict object — +// these helpers fall back to the individual predicts when the pump hasn't +// been initialised for group operation yet (first tick after register). + +function groupFlow(machine /*, ctx */) { + return machine.groupPredictFlow ?? machine.predictFlow; +} + +function groupPower(machine /*, ctx */) { + return machine.groupPredictPower ?? machine.predictPower; +} + +function groupNCog(machine /*, ctx */) { + return machine.groupPredictFlow ? (machine.groupNCog ?? 0) : (machine.NCog ?? 0); +} + +function groupCalcPower(machine, flow /*, ctx */) { + return typeof machine.groupCalcPower === 'function' + ? machine.groupCalcPower(flow) + : machine.inputFlowCalcPower(flow); +} + +module.exports = { groupFlow, groupPower, groupNCog, groupCalcPower }; diff --git a/src/groupOps/groupOperatingPoint.js b/src/groupOps/groupOperatingPoint.js new file mode 100644 index 0000000..7a4a4be --- /dev/null +++ b/src/groupOps/groupOperatingPoint.js @@ -0,0 +1,93 @@ +const { POSITIONS } = require('generalFunctions'); + +// Group-scope measurement read/write + header equalization. +// +// Pulled out of specificClass during the P4 refactor: the equalization +// logic is the source of truth for the "one consistent header operating +// point" that the optimizer and totals modules both depend on. Keeping it +// in one place makes the order-of-operations explicit (read header, write +// onto every machine's group-scope predicts). +class GroupOperatingPoint { + constructor(ctx = {}) { + // ctx: { measurements, machines, unitPolicy, logger } + // Late-binding via getters in the orchestrator works too — but + // passing the live references avoids re-plumbing setters. + this.ctx = ctx; + } + + get measurements() { return this.ctx.measurements; } + get machines() { return this.ctx.machines; } + get unitPolicy() { return this.ctx.unitPolicy; } + get logger() { return this.ctx.logger; } + + readChild(machine, type, variant, position, unit = null) { + return machine?.measurements + ?.type(type) + ?.variant(variant) + ?.position(position) + ?.getCurrentValue(unit || undefined); + } + + writeOwn(type, variant, position, value, unit = null, timestamp = Date.now()) { + if (!Number.isFinite(value)) return; + this.measurements + .type(type) + .variant(variant) + .position(position) + .value(value, timestamp, unit || undefined); + } + + // Force every machine's predict-curve interpolators to use the same + // (header) differential pressure for MGC's optimization. See the + // original _equalizeOperatingPoint commentary in specificClass for + // the full rationale (header source order, fDimension fallback). + equalize() { + const machines = this.machines || {}; + if (Object.keys(machines).length === 0) return; + + const pressureUnit = this.unitPolicy.canonical.pressure; + const groupHeaderDown = this.measurements + .type('pressure').variant('measured').position(POSITIONS.DOWNSTREAM) + .getCurrentValue(pressureUnit); + const groupHeaderUp = this.measurements + .type('pressure').variant('measured').position(POSITIONS.UPSTREAM) + .getCurrentValue(pressureUnit); + + const childDown = []; + const childUp = []; + Object.values(machines).forEach(machine => { + const d = this.readChild(machine, 'pressure', 'measured', POSITIONS.DOWNSTREAM, pressureUnit); + const u = this.readChild(machine, 'pressure', 'measured', POSITIONS.UPSTREAM, pressureUnit); + if (Number.isFinite(d) && d > 0) childDown.push(d); + if (Number.isFinite(u) && u > 0) childUp.push(u); + }); + + const downIsHeader = Number.isFinite(groupHeaderDown) && groupHeaderDown > 0; + const upIsHeader = Number.isFinite(groupHeaderUp) && groupHeaderUp > 0; + const headerDownstream = downIsHeader ? groupHeaderDown : (childDown.length ? Math.max(...childDown) : 0); + const headerUpstream = upIsHeader ? groupHeaderUp : (childUp.length ? Math.min(...childUp) : 0); + + const headerDiff = headerDownstream - headerUpstream; + if (!Number.isFinite(headerDiff) || headerDiff <= 0) { + this.logger?.debug?.(`Skipping equalization: invalid header diff ${headerDiff} (down=${headerDownstream}, up=${headerUpstream})`); + return; + } + + this.logger?.debug?.(`Equalizing operating point: down=${headerDownstream}, up=${headerUpstream}, diff=${headerDiff}`); + + Object.values(machines).forEach(machine => { + if (typeof machine.setGroupOperatingPoint === 'function') { + machine.setGroupOperatingPoint(headerDownstream, headerUpstream); + } else { + // Older rotatingMachine without the group API — direct + // fDimension write keeps demos working while submodules + // are rolled forward. + if (machine.predictFlow) machine.predictFlow.fDimension = headerDiff; + if (machine.predictPower) machine.predictPower.fDimension = headerDiff; + if (machine.predictCtrl) machine.predictCtrl.fDimension = headerDiff; + } + }); + } +} + +module.exports = GroupOperatingPoint; diff --git a/src/optimizer/bepGravitation.js b/src/optimizer/bepGravitation.js new file mode 100644 index 0000000..a454bc3 --- /dev/null +++ b/src/optimizer/bepGravitation.js @@ -0,0 +1,188 @@ +// BEP-gravitation optimizer: bias flow allocation toward each pump's BEP, +// then refine via marginal-cost swaps. `ctx` shape matches bestCombination.js. + +const MC_ITER_CAP = 50; // marginal-cost refinement iterations +const MC_RELATIVE_EXIT = 0.001; // exit when the mc gap is < 0.1% of expensive.mc + +// Estimate dP/dQ slopes around the BEP on the group operating point. +// Returns finite numbers for everything; falls back to zero slopes if the +// curve is flat or the machine has not been initialised. +function estimateSlopesAtBEP(machine, Q_BEP, ctx, delta = 1.0) { + const { groupCurves } = ctx; + const { groupFlow, groupNCog, groupCalcPower } = groupCurves; + + const minFlow = groupFlow(machine).currentFxyYMin; + const maxFlow = groupFlow(machine).currentFxyYMax; + const span = Math.max(0, maxFlow - minFlow); + const normalizedCog = Math.max(0, Math.min(1, groupNCog(machine) || 0)); + const targetBEP = Q_BEP ?? (minFlow + span * normalizedCog); + + const clampFlow = (flow) => Math.min(maxFlow, Math.max(minFlow, flow)); + const center = clampFlow(targetBEP); + const deltaSafe = Math.max(delta, 0.01); + const leftFlow = clampFlow(center - deltaSafe); + const rightFlow = clampFlow(center + deltaSafe); + + const powerAt = (flow) => groupCalcPower(machine, flow); + const P_center = powerAt(center); + const P_left = powerAt(leftFlow); + const P_right = powerAt(rightFlow); + const slopeLeft = (P_center - P_left) / Math.max(1e-6, center - leftFlow); + const slopeRight = (P_right - P_center) / Math.max(1e-6, rightFlow - center); + const alpha = Math.max(1e-6, (Math.abs(slopeLeft) + Math.abs(slopeRight)) / 2); + + return { slopeLeft, slopeRight, alpha, Q_BEP: center, P_BEP: P_center }; +} + +// Redistribute `delta` across pumps using slope-derived weights; flatter +// curves attract more flow. Bounded: exits on zero progress or no capacity. +function redistributeFlowBySlope(pumpInfos, flowDistribution, delta, directional = true) { + const tolerance = 1e-3; + let remaining = delta; + const entryMap = new Map(flowDistribution.map(entry => [entry.machineId, entry])); + + while (Math.abs(remaining) > tolerance) { + const increasing = remaining > 0; + const candidates = pumpInfos.map(info => { + const entry = entryMap.get(info.id); + if (!entry) return null; + const capacity = increasing ? info.maxFlow - entry.flow : entry.flow - info.minFlow; + if (capacity <= tolerance) return null; + const slope = increasing + ? (directional ? info.slopes.slopeRight : info.slopes.alpha) + : (directional ? info.slopes.slopeLeft : info.slopes.alpha); + const weight = 1 / Math.max(1e-6, Math.abs(slope) || info.slopes.alpha || 1); + return { entry, capacity, weight }; + }).filter(Boolean); + + if (!candidates.length) break; + const weightSum = candidates.reduce((sum, c) => sum + c.weight * c.capacity, 0); + if (weightSum <= 0) break; + + let progress = 0; + candidates.forEach(candidate => { + let share = (candidate.weight * candidate.capacity / weightSum) * Math.abs(remaining); + share = Math.min(share, candidate.capacity); + if (share <= 0) return; + if (increasing) candidate.entry.flow += share; + else candidate.entry.flow -= share; + progress += share; + }); + + if (progress <= tolerance) break; + remaining += increasing ? -progress : progress; + } +} + +function _marginalCostRefine(flowDistribution, pumpInfos, Qd, ctx) { + const { groupCalcPower } = ctx.groupCurves; + const mcDelta = Math.max(1e-6, (Qd / pumpInfos.length) * 0.005); + + for (let iter = 0; iter < MC_ITER_CAP; iter++) { + const mcEntries = flowDistribution.map(entry => { + const info = pumpInfos.find(i => i.id === entry.machineId); + const pNow = groupCalcPower(info.machine, entry.flow); + const pUp = groupCalcPower(info.machine, Math.min(info.maxFlow, entry.flow + mcDelta)); + return { entry, info, mc: (pUp - pNow) / mcDelta }; + }); + + let expensive = null; + let cheap = null; + for (const e of mcEntries) { + if (e.entry.flow > e.info.minFlow + mcDelta && (!expensive || e.mc > expensive.mc)) expensive = e; + if (e.entry.flow < e.info.maxFlow - mcDelta && (!cheap || e.mc < cheap.mc)) cheap = e; + } + if (!expensive || !cheap || expensive === cheap) break; + if (expensive.mc - cheap.mc < expensive.mc * MC_RELATIVE_EXIT) break; + + const before = groupCalcPower(expensive.info.machine, expensive.entry.flow) + + groupCalcPower(cheap.info.machine, cheap.entry.flow); + const after = groupCalcPower(expensive.info.machine, expensive.entry.flow - mcDelta) + + groupCalcPower(cheap.info.machine, cheap.entry.flow + mcDelta); + if (after < before) { + expensive.entry.flow -= mcDelta; + cheap.entry.flow += mcDelta; + } else { + break; + } + } +} + +function calcBestCombinationBEPGravitation(combinations, Qd, ctx, method = 'BEP-Gravitation-Directional') { + const { machines, groupCurves } = ctx; + const { groupFlow, groupNCog, groupCalcPower } = groupCurves; + const directional = method === 'BEP-Gravitation-Directional'; + + let bestCombination = null; + let bestPower = Infinity; + let bestFlow = 0; + let bestCog = 0; + let bestDeviation = Infinity; + + combinations.forEach(combination => { + const pumpInfos = combination.map(machineId => { + const machine = machines[machineId]; + const minFlow = groupFlow(machine).currentFxyYMin; + const maxFlow = groupFlow(machine).currentFxyYMax; + const span = Math.max(0, maxFlow - minFlow); + const NCog = Math.max(0, Math.min(1, groupNCog(machine) || 0)); + const estimatedBEP = minFlow + span * NCog; + const slopes = estimateSlopesAtBEP(machine, estimatedBEP, ctx); + return { id: machineId, machine, minFlow, maxFlow, NCog, Q_BEP: slopes.Q_BEP, slopes }; + }); + + if (pumpInfos.length === 0) return; + + const flowDistribution = pumpInfos.map(info => ({ + machineId: info.id, + flow: Math.min(info.maxFlow, Math.max(info.minFlow, info.Q_BEP)), + })); + + let totalFlow = flowDistribution.reduce((s, e) => s + e.flow, 0); + const delta = Qd - totalFlow; + if (Math.abs(delta) > 1e-6) { + redistributeFlowBySlope(pumpInfos, flowDistribution, delta, directional); + } + + flowDistribution.forEach(entry => { + const info = pumpInfos.find(i => i.id === entry.machineId); + entry.flow = Math.min(info.maxFlow, Math.max(info.minFlow, entry.flow)); + }); + + _marginalCostRefine(flowDistribution, pumpInfos, Qd, ctx); + + let totalPower = 0; + totalFlow = 0; + flowDistribution.forEach(entry => { + totalFlow += entry.flow; + const info = pumpInfos.find(i => i.id === entry.machineId); + totalPower += groupCalcPower(info.machine, entry.flow); + }); + + const totalCog = pumpInfos.reduce((s, info) => s + info.NCog, 0); + const deviation = pumpInfos.reduce((sum, info) => { + const entry = flowDistribution.find(item => item.machineId === info.id); + const deltaFlow = entry ? (entry.flow - info.Q_BEP) : 0; + return sum + (deltaFlow * deltaFlow) * (info.slopes.alpha || 1); + }, 0); + + const shouldUpdate = totalPower < bestPower + || (totalPower === bestPower && deviation < bestDeviation); + + if (shouldUpdate) { + bestCombination = flowDistribution.map(e => ({ ...e })); + bestPower = totalPower; + bestFlow = totalFlow; + bestCog = totalCog; + bestDeviation = deviation; + } + }); + + return { bestCombination, bestPower, bestFlow, bestCog, bestDeviation, method }; +} + +module.exports = { + calcBestCombinationBEPGravitation, + estimateSlopesAtBEP, + redistributeFlowBySlope, +}; diff --git a/src/optimizer/bestCombination.js b/src/optimizer/bestCombination.js new file mode 100644 index 0000000..66cc794 --- /dev/null +++ b/src/optimizer/bestCombination.js @@ -0,0 +1,88 @@ +// CoG-based combination optimizer. +// Pure function: picks the combination whose CoG-weighted flow allocation +// yields the lowest total power, clamped to each machine's curve envelope. +// +// `ctx` must provide: +// - machines: machineId -> machine +// - groupCurves: { groupFlow, groupNCog, groupCalcPower } +// - logger (optional, for debug traces) + +const ROUND_2 = 100; + +function calcBestCombination(combinations, Qd, ctx) { + const { machines, groupCurves, logger } = ctx; + const { groupFlow, groupNCog, groupCalcPower } = groupCurves; + + let bestCombination = null; + let bestPower = Infinity; + let bestFlow = 0; + let bestCog = 0; + + combinations.forEach(combination => { + const totalCoG = combination.reduce((sum, id) => { + return sum + Math.round((groupNCog(machines[id]) || 0) * ROUND_2) / ROUND_2; + }, 0); + + // CoG-weighted initial distribution; if all CoGs are 0, split evenly. + let flowDistribution = combination.map(machineId => { + const machine = machines[machineId]; + let flow; + if (totalCoG === 0) { + flow = Qd / combination.length; + } else { + flow = ((groupNCog(machine) || 0) / totalCoG) * Qd; + logger?.debug?.(`Machine Normalized CoG-based distribution ${machineId} flow: ${flow}`); + } + return { machineId, flow }; + }); + + const clamped = flowDistribution.map(entry => { + const machine = machines[entry.machineId]; + const min = groupFlow(machine).currentFxyYMin; + const max = groupFlow(machine).currentFxyYMax; + const clampedFlow = Math.min(max, Math.max(min, entry.flow)); + return { ...entry, flow: clampedFlow, min, max, desired: entry.flow }; + }); + + // Spill the unmet remainder once: distribute proportionally to each + // machine's *desired* share, weighted toward those with headroom. + let remainder = Qd - clamped.reduce((sum, entry) => sum + entry.flow, 0); + if (Math.abs(remainder) > 1e-6) { + const adjustable = clamped.filter(entry => + remainder > 0 ? entry.flow < entry.max : entry.flow > entry.min, + ); + const weightSum = adjustable.reduce((s, e) => s + e.desired, 0) || adjustable.length; + + adjustable.forEach(entry => { + const weight = entry.desired / weightSum || 1 / adjustable.length; + const delta = remainder * weight; + const next = remainder > 0 + ? Math.min(entry.max, entry.flow + delta) + : Math.max(entry.min, entry.flow + delta); + remainder -= (next - entry.flow); + entry.flow = next; + }); + } + + flowDistribution = clamped; + + let totalFlow = 0; + let totalPower = 0; + flowDistribution.forEach(({ machineId, flow }) => { + totalFlow += flow; + totalPower += groupCalcPower(machines[machineId], flow); + }); + + if (totalPower < bestPower) { + logger?.debug?.(`New best combination found: ${totalPower} < ${bestPower}`); + bestPower = totalPower; + bestFlow = totalFlow; + bestCog = totalCoG; + bestCombination = flowDistribution; + } + }); + + return { bestCombination, bestPower, bestFlow, bestCog }; +} + +module.exports = { calcBestCombination }; diff --git a/src/optimizer/index.js b/src/optimizer/index.js new file mode 100644 index 0000000..3a495f8 --- /dev/null +++ b/src/optimizer/index.js @@ -0,0 +1,17 @@ +const cog = require('./bestCombination'); +const bep = require('./bepGravitation'); + +// Pick the optimizer module by config string. +// Anything other than the two BEP variants falls back to CoG. +function pickOptimizer(method) { + if (method === 'BEP-Gravitation' || method === 'BEP-Gravitation-Directional') return bep; + return cog; +} + +module.exports = { + pickOptimizer, + calcBestCombination: cog.calcBestCombination, + calcBestCombinationBEPGravitation: bep.calcBestCombinationBEPGravitation, + estimateSlopesAtBEP: bep.estimateSlopesAtBEP, + redistributeFlowBySlope: bep.redistributeFlowBySlope, +}; diff --git a/src/totals/totalsCalculator.js b/src/totals/totalsCalculator.js new file mode 100644 index 0000000..517b321 --- /dev/null +++ b/src/totals/totalsCalculator.js @@ -0,0 +1,117 @@ +const { POSITIONS } = require('generalFunctions'); +const { groupFlow, groupPower, groupNCog } = require('../groupOps/groupCurves'); + +// Aggregations across every machine in the group. +// +// calcAbsoluteTotals scans the full input-curve envelope (worst/best case +// over the pump's entire pressure range). calcDynamicTotals reads the +// current group operating point (after equalize). activeTotals only sums +// machines that are operationally active right now. +class TotalsCalculator { + constructor(ctx = {}) { + // ctx: { machines, unitPolicy, logger, operatingPoint, isMachineActive } + // operatingPoint is a GroupOperatingPoint instance (for readChild). + // isMachineActive is delegated back to the orchestrator so the + // state-machine vocabulary lives in one place. + this.ctx = ctx; + this.dynamicTotals = { flow: { min: Infinity, max: 0 }, power: { min: Infinity, max: 0 }, NCog: 0 }; + this.absoluteTotals = { flow: { min: Infinity, max: 0 }, power: { min: Infinity, max: 0 } }; + } + + get machines() { return this.ctx.machines || {}; } + get unitPolicy() { return this.ctx.unitPolicy; } + get logger() { return this.ctx.logger; } + get operatingPoint() { return this.ctx.operatingPoint; } + + isMachineActive(id) { + if (typeof this.ctx.isMachineActive === 'function') return this.ctx.isMachineActive(id); + const s = this.machines[id]?.state?.getCurrentState?.(); + return s === 'operational' || s === 'accelerating' || s === 'decelerating'; + } + + calcAbsoluteTotals() { + const out = { flow: { min: Infinity, max: 0 }, power: { min: Infinity, max: 0 } }; + + Object.values(this.machines).forEach(machine => { + const totals = { flow: { min: Infinity, max: 0 }, power: { min: Infinity, max: 0 } }; + Object.entries(machine.predictFlow.inputCurve).forEach(([pressure, xyCurve]) => { + const minFlow = Math.min(...xyCurve.y); + const maxFlow = Math.max(...xyCurve.y); + const minPower = Math.min(...machine.predictPower.inputCurve[pressure].y); + const maxPower = Math.max(...machine.predictPower.inputCurve[pressure].y); + if (minFlow < totals.flow.min) totals.flow.min = minFlow; + if (minPower < totals.power.min) totals.power.min = minPower; + if (maxFlow > totals.flow.max) totals.flow.max = maxFlow; + if (maxPower > totals.power.max) totals.power.max = maxPower; + }); + if (totals.flow.min < out.flow.min) out.flow.min = totals.flow.min; + if (totals.power.min < out.power.min) out.power.min = totals.power.min; + out.flow.max += totals.flow.max; + out.power.max += totals.power.max; + }); + + // Empty-group + sentinel reset: Infinity / -Infinity are math + // artefacts of the reducer's initial values; downstream code + // expects clean zeros. + if (out.flow.min === Infinity) { this.logger?.warn?.('Flow min Infinity — zeroing.'); out.flow.min = 0; } + if (out.power.min === Infinity) { this.logger?.warn?.('Power min Infinity — zeroing.'); out.power.min = 0; } + if (out.flow.max === -Infinity) { this.logger?.warn?.('Flow max -Infinity — zeroing.'); out.flow.max = 0; } + if (out.power.max === -Infinity) { this.logger?.warn?.('Power max -Infinity — zeroing.'); out.power.max = 0; } + + this.absoluteTotals = out; + return out; + } + + calcDynamicTotals() { + const out = { flow: { min: Infinity, max: 0, act: 0 }, power: { min: Infinity, max: 0, act: 0 }, NCog: 0 }; + const fUnit = this.unitPolicy.canonical.flow; + const pUnit = this.unitPolicy.canonical.power; + + Object.values(this.machines).forEach(machine => { + if (!machine.hasCurve) { + this.logger?.error?.(`Machine ${machine.config?.general?.id} has no valid curve — skipping.`); + return; + } + const gpf = groupFlow(machine); + const gpp = groupPower(machine); + + const minFlow = gpf.currentFxyYMin; + const maxFlow = gpf.currentFxyYMax; + const minPower = gpp.currentFxyYMin; + const maxPower = gpp.currentFxyYMax; + + const actFlow = this.operatingPoint?.readChild(machine, 'flow', 'predicted', POSITIONS.DOWNSTREAM, fUnit) || 0; + const actPower = this.operatingPoint?.readChild(machine, 'power', 'predicted', POSITIONS.AT_EQUIPMENT, pUnit) || 0; + + if (minFlow < out.flow.min) out.flow.min = minFlow; + if (minPower < out.power.min) out.power.min = minPower; + out.flow.max += maxFlow; + out.power.max += maxPower; + out.flow.act += actFlow; + out.power.act += actPower; + out.NCog += groupNCog(machine); + }); + + this.dynamicTotals = out; + return out; + } + + activeTotals() { + const out = { flow: { min: 0, max: 0 }, power: { min: 0, max: 0 }, countActiveMachines: 0 }; + + Object.entries(this.machines).forEach(([id, machine]) => { + if (!this.isMachineActive(id)) return; + const gpf = groupFlow(machine); + const gpp = groupPower(machine); + out.flow.min += gpf.currentFxyYMin; + out.flow.max += gpf.currentFxyYMax; + out.power.min += gpp.currentFxyYMin; + out.power.max += gpp.currentFxyYMax; + out.countActiveMachines += 1; + }); + + return out; + } +} + +module.exports = TotalsCalculator; diff --git a/test/basic/bepGravitation.basic.test.js b/test/basic/bepGravitation.basic.test.js new file mode 100644 index 0000000..ae1fb7b --- /dev/null +++ b/test/basic/bepGravitation.basic.test.js @@ -0,0 +1,110 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + calcBestCombinationBEPGravitation, + estimateSlopesAtBEP, + redistributeFlowBySlope, +} = require('../../src/optimizer/bepGravitation'); +const optimizerIndex = require('../../src/optimizer'); + +function makeMachine({ id, fMin = 0, fMax = 100, NCog = 0.5, costFn } = {}) { + return { + config: { general: { id } }, + NCog, + predictFlow: { currentFxyYMin: fMin, currentFxyYMax: fMax }, + predictPower: { currentFxyYMin: 0, currentFxyYMax: fMax * 2 }, + // Default: convex cost so marginal-cost refinement has a clear winner. + inputFlowCalcPower: costFn ?? ((f) => 0.001 * f * f + f), + }; +} + +function mkCtx(machines) { + return { + machines, + groupCurves: { + groupFlow: (m) => m.predictFlow, + groupPower: (m) => m.predictPower, + groupNCog: (m) => m.NCog ?? 0, + groupCalcPower: (m, f) => m.inputFlowCalcPower(f), + }, + logger: { debug: () => {} }, + }; +} + +test('estimateSlopesAtBEP: returns finite slopes/alpha/Q_BEP/P_BEP for a typical machine', () => { + const machine = makeMachine({ id: 'a', fMin: 10, fMax: 90, NCog: 0.5 }); + const ctx = mkCtx({ a: machine }); + const slopes = estimateSlopesAtBEP(machine, 50, ctx); + assert.ok(Number.isFinite(slopes.slopeLeft)); + assert.ok(Number.isFinite(slopes.slopeRight)); + assert.ok(Number.isFinite(slopes.alpha)); + assert.ok(slopes.alpha > 0); + assert.ok(Number.isFinite(slopes.Q_BEP)); + assert.equal(slopes.Q_BEP, 50); + assert.ok(Number.isFinite(slopes.P_BEP)); +}); + +test('redistributeFlowBySlope: redistributes within capacity, never exceeding min/max', () => { + const pumpInfos = [ + { id: 'a', minFlow: 0, maxFlow: 50, + slopes: { slopeLeft: 1, slopeRight: 1, alpha: 1 } }, + { id: 'b', minFlow: 0, maxFlow: 50, + slopes: { slopeLeft: 1, slopeRight: 1, alpha: 1 } }, + ]; + const flowDist = [{ machineId: 'a', flow: 10 }, { machineId: 'b', flow: 10 }]; + redistributeFlowBySlope(pumpInfos, flowDist, 30); // add 30 across 2 pumps + const total = flowDist.reduce((s, e) => s + e.flow, 0); + assert.ok(Math.abs(total - 50) < 1e-2, `expected total ~50, got ${total}`); + for (const e of flowDist) { + assert.ok(e.flow <= 50 + 1e-6 && e.flow >= 0 - 1e-6); + } +}); + +test('marginal-cost refinement bounded (no infinite loop on a flat-curve scenario)', () => { + // Flat cost everywhere -> marginal cost identical -> loop must exit cleanly. + const machines = { + a: makeMachine({ id: 'a', fMin: 0, fMax: 100, costFn: (f) => f }), + b: makeMachine({ id: 'b', fMin: 0, fMax: 100, costFn: (f) => f }), + }; + const ctx = mkCtx(machines); + const start = Date.now(); + const res = calcBestCombinationBEPGravitation([['a', 'b']], 30, ctx); + const elapsed = Date.now() - start; + assert.ok(elapsed < 1000, `refinement should be fast, took ${elapsed}ms`); + assert.ok(res.bestCombination); + const total = res.bestCombination.reduce((s, e) => s + e.flow, 0); + assert.ok(Math.abs(total - 30) < 1e-2, `total should be ~Qd, got ${total}`); +}); + +test('method selection: directional uses slopeRight/slopeLeft; non-directional uses alpha', () => { + // Asymmetric slopes so the two methods produce different allocations. + const pumpInfos = [ + { id: 'a', minFlow: 0, maxFlow: 100, + slopes: { slopeLeft: 10, slopeRight: 0.1, alpha: 5 } }, + { id: 'b', minFlow: 0, maxFlow: 100, + slopes: { slopeLeft: 0.1, slopeRight: 10, alpha: 5 } }, + ]; + const distDir = [{ machineId: 'a', flow: 10 }, { machineId: 'b', flow: 10 }]; + const distAlpha = [{ machineId: 'a', flow: 10 }, { machineId: 'b', flow: 10 }]; + + // Increase by 30 -> directional should prefer 'a' (shallow right slope). + redistributeFlowBySlope(pumpInfos, distDir, 30, true); + // Alpha mode: same slope-weight per pump -> roughly equal split. + redistributeFlowBySlope(pumpInfos, distAlpha, 30, false); + + const aDir = distDir.find(e => e.machineId === 'a').flow; + const bDir = distDir.find(e => e.machineId === 'b').flow; + const aAlpha = distAlpha.find(e => e.machineId === 'a').flow; + const bAlpha = distAlpha.find(e => e.machineId === 'b').flow; + + assert.ok(aDir > bDir, `directional should send more to a (got a=${aDir}, b=${bDir})`); + assert.ok(Math.abs(aAlpha - bAlpha) < 1e-2, `alpha mode should split evenly (got a=${aAlpha}, b=${bAlpha})`); + + // pickOptimizer wires the right module. + assert.equal(optimizerIndex.pickOptimizer('BEP-Gravitation-Directional').calcBestCombinationBEPGravitation, + calcBestCombinationBEPGravitation); + assert.equal(optimizerIndex.pickOptimizer('BEP-Gravitation').calcBestCombinationBEPGravitation, + calcBestCombinationBEPGravitation); + assert.ok(optimizerIndex.pickOptimizer('CoG').calcBestCombination); +}); diff --git a/test/basic/bestCombination.basic.test.js b/test/basic/bestCombination.basic.test.js new file mode 100644 index 0000000..a96f288 --- /dev/null +++ b/test/basic/bestCombination.basic.test.js @@ -0,0 +1,67 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { calcBestCombination } = require('../../src/optimizer/bestCombination'); + +function makeMachine({ id, fMin = 0, fMax = 100, NCog = 0.5, costFn } = {}) { + return { + config: { general: { id } }, + NCog, + predictFlow: { currentFxyYMin: fMin, currentFxyYMax: fMax }, + predictPower: { currentFxyYMin: 0, currentFxyYMax: fMax * 2 }, + // Power model: caller picks the cost function so we can shape who wins. + inputFlowCalcPower: costFn ?? ((flow) => flow * 1.0), + }; +} + +function mkCtx(machines) { + return { + machines, + groupCurves: { + groupFlow: (m) => m.predictFlow, + groupPower: (m) => m.predictPower, + groupNCog: (m) => m.NCog ?? 0, + groupCalcPower: (m, f) => m.inputFlowCalcPower(f), + }, + logger: { debug: () => {} }, + }; +} + +test('calcBestCombination: 1 machine in combination receives Qd clamped to its range', () => { + const machines = { a: makeMachine({ id: 'a', fMin: 5, fMax: 60 }) }; + const ctx = mkCtx(machines); + + const res = calcBestCombination([['a']], 40, ctx); + assert.ok(res.bestCombination); + assert.equal(res.bestCombination.length, 1); + assert.equal(res.bestCombination[0].flow, 40); + + // Above max — clamps to max. + const high = calcBestCombination([['a']], 200, ctx); + assert.equal(high.bestCombination[0].flow, 60); +}); + +test('calcBestCombination: 2 machines with equal NCog split flow evenly', () => { + const machines = { + a: makeMachine({ id: 'a', NCog: 0.5, fMin: 0, fMax: 100 }), + b: makeMachine({ id: 'b', NCog: 0.5, fMin: 0, fMax: 100 }), + }; + const ctx = mkCtx(machines); + const res = calcBestCombination([['a', 'b']], 40, ctx); + const aFlow = res.bestCombination.find(e => e.machineId === 'a').flow; + const bFlow = res.bestCombination.find(e => e.machineId === 'b').flow; + assert.ok(Math.abs(aFlow - bFlow) < 1e-6, `expected even split, got a=${aFlow} b=${bFlow}`); + assert.ok(Math.abs(aFlow + bFlow - 40) < 1e-6); +}); + +test('calcBestCombination: returns combination with the lowest total power', () => { + // Two combinations: [a] (expensive) vs [b] (cheap). Both can deliver Qd=20. + const machines = { + a: makeMachine({ id: 'a', fMin: 0, fMax: 100, costFn: (f) => f * 10 }), + b: makeMachine({ id: 'b', fMin: 0, fMax: 100, costFn: (f) => f * 1 }), + }; + const ctx = mkCtx(machines); + const res = calcBestCombination([['a'], ['b']], 20, ctx); + assert.equal(res.bestCombination[0].machineId, 'b'); + assert.equal(res.bestPower, 20); +}); diff --git a/test/basic/commands.basic.test.js b/test/basic/commands.basic.test.js new file mode 100644 index 0000000..9345822 --- /dev/null +++ b/test/basic/commands.basic.test.js @@ -0,0 +1,172 @@ +// Basic tests for the machineGroupControl 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 = 'mgc-1', handleInputResult = undefined } = {}) { + const calls = { + setMode: [], + setScaling: [], + handleInput: [], + registerChild: [], + }; + const source = { + logger: makeLogger(), + config: { general: { name } }, + setMode: (m) => calls.setMode.push(m), + setScaling: (s) => calls.setScaling.push(s), + handleInput: async (src, demand) => { + calls.handleInput.push({ src, demand }); + if (handleInputResult instanceof Error) throw handleInputResult; + return handleInputResult; + }, + 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: 'prioritycontrol' }, source, makeCtx()); + assert.deepEqual(calls.setMode, ['prioritycontrol']); + + await reg.dispatch({ topic: 'set.scaling', payload: 'normalized' }, source, makeCtx()); + assert.deepEqual(calls.setScaling, ['normalized']); + + await reg.dispatch({ topic: 'set.demand', payload: '12.5' }, source, makeCtx()); + assert.equal(calls.handleInput.length, 1); + assert.deepEqual(calls.handleInput[0], { src: 'parent', demand: 12.5 }); +}); + +test('child.register canonical resolves child via RED.nodes.getNode', async () => { + const { source, calls } = makeSource(); + const child = { id: 'child-1', source: { tag: 'child-domain' } }; + const reg = makeRegistry(makeLogger()); + + await reg.dispatch( + { topic: 'child.register', payload: 'child-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('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: 'prioritycontrol' }, source, makeCtx({ logger: ctxLogger })); + await reg.dispatch({ topic: 'setMode', payload: 'optimalcontrol' }, source, makeCtx({ logger: ctxLogger })); + assert.deepEqual(calls.setMode, ['prioritycontrol', 'optimalcontrol']); + let warns = ctxLogger.calls.warn.filter((m) => m.includes("'setMode' is deprecated")); + assert.equal(warns.length, 1, 'setMode deprecation warning should log exactly once'); + + await reg.dispatch({ topic: 'setScaling', payload: 'absolute' }, source, makeCtx({ logger: ctxLogger })); + warns = ctxLogger.calls.warn.filter((m) => m.includes("'setScaling' is deprecated")); + assert.equal(warns.length, 1); + assert.deepEqual(calls.setScaling, ['absolute']); + + await reg.dispatch({ topic: 'Qd', payload: 5 }, source, makeCtx({ logger: ctxLogger })); + warns = ctxLogger.calls.warn.filter((m) => m.includes("'Qd' is deprecated")); + assert.equal(warns.length, 1); + assert.equal(calls.handleInput.length, 1); + + const child = { id: 'child-x', source: { tag: 'child-domain' } }; + await reg.dispatch( + { topic: 'registerChild', payload: 'child-x', positionVsParent: 'atEquipment' }, + source, + makeCtx({ child, logger: ctxLogger }) + ); + warns = ctxLogger.calls.warn.filter((m) => m.includes("'registerChild' is deprecated")); + assert.equal(warns.length, 1); + assert.equal(calls.registerChild.length, 1); +}); + +test('set.demand with non-numeric payload logs error and does not call handleInput', async () => { + const { source, calls } = makeSource(); + const ctxLogger = makeLogger(); + const reg = makeRegistry(makeLogger()); + + await reg.dispatch({ topic: 'set.demand', payload: 'oops' }, source, makeCtx({ logger: ctxLogger })); + assert.equal(calls.handleInput.length, 0); + assert.ok( + ctxLogger.calls.error.some((m) => m.includes('set.demand') && m.includes('oops')), + `expected error about invalid Qd, got: ${JSON.stringify(ctxLogger.calls.error)}` + ); +}); + +test('set.demand on success calls ctx.send with reply { topic: config.general.name, payload: "done" }', async () => { + const { source, calls } = makeSource({ name: 'mgc-A' }); + const sent = []; + const ctx = makeCtx({ sendSpy: (m) => sent.push(m) }); + const reg = makeRegistry(makeLogger()); + + await reg.dispatch({ topic: 'set.demand', payload: 7.5 }, source, ctx); + + assert.equal(calls.handleInput.length, 1); + assert.deepEqual(calls.handleInput[0], { src: 'parent', demand: 7.5 }); + assert.equal(sent.length, 1); + assert.equal(sent[0].topic, 'mgc-A'); + assert.equal(sent[0].payload, 'done'); +}); + +test('child.register with unknown child id logs warn and does not throw', async () => { + const { source, calls } = makeSource(); + const ctxLogger = makeLogger(); + const reg = makeRegistry(makeLogger()); + + await assert.doesNotReject(() => + reg.dispatch( + { topic: 'child.register', payload: 'missing-id', positionVsParent: 'atEquipment' }, + source, + makeCtx({ logger: ctxLogger }) + ) + ); + assert.equal(calls.registerChild.length, 0); + assert.ok( + ctxLogger.calls.warn.some((m) => m.includes('registerChild') && m.includes('missing-id')), + `expected warn about missing child, got: ${JSON.stringify(ctxLogger.calls.warn)}` + ); +}); diff --git a/test/basic/demandDispatcher.basic.test.js b/test/basic/demandDispatcher.basic.test.js new file mode 100644 index 0000000..0c1b8af --- /dev/null +++ b/test/basic/demandDispatcher.basic.test.js @@ -0,0 +1,140 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const DemandDispatcher = require('../../src/dispatch/demandDispatcher.js'); + +const silentLogger = { warn() {}, error() {}, debug() {}, info() {} }; + +// Helper: a manually-resolvable promise so we can pin a dispatch in flight. +function deferred() { + let resolve; + let reject; + const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + return { promise, resolve, reject }; +} + +test('fire(50) triggers runFn with 50', async () => { + const calls = []; + const dispatcher = new DemandDispatcher( + { logger: silentLogger }, + async (demand) => { calls.push(demand); }, + ); + dispatcher.fire(50); + await dispatcher.drain(); + assert.deepEqual(calls, [50]); +}); + +test('two fires back-to-back during in-flight — only the second runs after first settles', async () => { + const calls = []; + const gates = [deferred()]; + const dispatcher = new DemandDispatcher( + { logger: silentLogger }, + async (demand) => { + calls.push(demand); + await gates[0].promise; + }, + ); + + dispatcher.fire(10); + // first invocation is now in flight (after a microtask) + await Promise.resolve(); + await Promise.resolve(); + dispatcher.fire(20); + // 20 should be pending, not yet run. + assert.deepEqual(calls, [10]); + gates[0].resolve(); + await dispatcher.drain(); + assert.deepEqual(calls, [10, 20]); +}); + +test('three rapid fires — only first + last run; middle dropped', async () => { + const calls = []; + const gate = deferred(); + const dispatcher = new DemandDispatcher( + { logger: silentLogger }, + async (demand) => { + calls.push(demand); + if (calls.length === 1) await gate.promise; + }, + ); + + dispatcher.fire(1); + await Promise.resolve(); + await Promise.resolve(); + dispatcher.fire(2); + dispatcher.fire(3); // overwrites the pending 2 + + assert.deepEqual(calls, [1]); + gate.resolve(); + await dispatcher.drain(); + assert.deepEqual(calls, [1, 3]); +}); + +test('drain() resolves only when idle', async () => { + const gate = deferred(); + let runs = 0; + const dispatcher = new DemandDispatcher( + { logger: silentLogger }, + async () => { runs++; await gate.promise; }, + ); + + // drain() on an idle gate resolves immediately. + await dispatcher.drain(); + + dispatcher.fire('a'); + let drained = false; + const drainPromise = dispatcher.drain().then(() => { drained = true; }); + // Let a few microtasks run — drain must NOT be resolved while in flight. + for (let i = 0; i < 5; i++) await Promise.resolve(); + assert.equal(drained, false); + assert.equal(runs, 1); + gate.resolve(); + await drainPromise; + assert.equal(drained, true); +}); + +test('error in runFn does not deadlock; subsequent fire still works', async () => { + const calls = []; + const dispatcher = new DemandDispatcher( + { logger: silentLogger }, + async (demand) => { + calls.push(demand); + if (demand === 'boom') throw new Error('boom'); + }, + ); + dispatcher.fire('boom'); + await dispatcher.drain(); + dispatcher.fire('ok'); + await dispatcher.drain(); + assert.deepEqual(calls, ['boom', 'ok']); +}); + +test('inFlight getter reports correctly', async () => { + const gate = deferred(); + const dispatcher = new DemandDispatcher( + { logger: silentLogger }, + async () => { await gate.promise; }, + ); + assert.equal(dispatcher.inFlight, false); + dispatcher.fire(1); + // Microtask scheduling — gate flips to inFlight after one tick. + await Promise.resolve(); + assert.equal(dispatcher.inFlight, true); + gate.resolve(); + await dispatcher.drain(); + assert.equal(dispatcher.inFlight, false); +}); + +test('runFn receives the ctx supplied at construction', async () => { + const seen = []; + const ctx = { logger: silentLogger, marker: 'mgc-A' }; + const dispatcher = new DemandDispatcher( + ctx, + async (demand, runCtx) => { seen.push({ demand, marker: runCtx.marker }); }, + ); + dispatcher.fire(42); + await dispatcher.drain(); + assert.deepEqual(seen, [{ demand: 42, marker: 'mgc-A' }]); +}); diff --git a/test/basic/groupCurves.basic.test.js b/test/basic/groupCurves.basic.test.js new file mode 100644 index 0000000..7c79f94 --- /dev/null +++ b/test/basic/groupCurves.basic.test.js @@ -0,0 +1,66 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { groupFlow, groupPower, groupNCog, groupCalcPower } = require('../../src/groupOps/groupCurves'); + +function predictView(min, max, current = (min + max) / 2) { + return { + currentF: current, + currentFxyYMin: min, + currentFxyYMax: max, + }; +} + +test('groupFlow returns the same shape as the original _groupFlow (groupPredictFlow preferred)', () => { + const machine = { + predictFlow: predictView(0, 1, 0.5), + groupPredictFlow: predictView(0.1, 0.9, 0.4), + }; + const v = groupFlow(machine); + assert.equal(v, machine.groupPredictFlow); + assert.equal(v.currentFxyYMin, 0.1); + assert.equal(v.currentFxyYMax, 0.9); + assert.equal(v.currentF, 0.4); +}); + +test('groupFlow falls back to predictFlow when groupPredictFlow is absent', () => { + const machine = { predictFlow: predictView(0, 1) }; + assert.equal(groupFlow(machine), machine.predictFlow); +}); + +test('groupPower returns groupPredictPower when present, else predictPower', () => { + const m1 = { predictPower: predictView(0, 100), groupPredictPower: predictView(10, 90) }; + assert.equal(groupPower(m1), m1.groupPredictPower); + + const m2 = { predictPower: predictView(0, 100) }; + assert.equal(groupPower(m2), m2.predictPower); +}); + +test('groupNCog returns the group value when groupPredictFlow is present', () => { + const m = { groupPredictFlow: predictView(0, 1), groupNCog: 0.42, NCog: 0.99, predictFlow: predictView(0, 1) }; + assert.equal(groupNCog(m), 0.42); +}); + +test('groupNCog falls back to NCog when no groupPredictFlow', () => { + const m = { predictFlow: predictView(0, 1), NCog: 0.7 }; + assert.equal(groupNCog(m), 0.7); +}); + +test('groupNCog defaults to 0 when neither is defined', () => { + const m = { predictFlow: predictView(0, 1) }; + assert.equal(groupNCog(m), 0); +}); + +test('groupCalcPower prefers machine.groupCalcPower', () => { + let lastFlow = null; + const m = { + groupCalcPower(flow) { lastFlow = flow; return flow * 2; }, + inputFlowCalcPower(flow) { return flow * 999; }, + }; + assert.equal(groupCalcPower(m, 0.3), 0.6); + assert.equal(lastFlow, 0.3); +}); + +test('groupCalcPower falls back to inputFlowCalcPower when groupCalcPower missing', () => { + const m = { inputFlowCalcPower(flow) { return flow + 1; } }; + assert.equal(groupCalcPower(m, 5), 6); +}); diff --git a/test/basic/groupEfficiency.basic.test.js b/test/basic/groupEfficiency.basic.test.js new file mode 100644 index 0000000..a12b6ec --- /dev/null +++ b/test/basic/groupEfficiency.basic.test.js @@ -0,0 +1,71 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { interpolation } = require('generalFunctions'); +const GroupEfficiency = require('../../src/efficiency/groupEfficiency.js'); + +function makeMachines(cogs) { + const out = {}; + cogs.forEach((cog, i) => { out[`m${i}`] = { cog }; }); + return out; +} + +function makeGE(extra = {}) { + return new GroupEfficiency({ + interpolation: new interpolation(), + logger: { warn() {}, error() {}, debug() {}, info() {} }, + ...extra, + }); +} + +test('calcGroupEfficiency aggregates across 3 machines', () => { + const ge = makeGE(); + const machines = makeMachines([0.9, 0.8, 0.7]); + const { maxEfficiency, lowestEfficiency } = ge.calcGroupEfficiency(machines); + assert.equal(lowestEfficiency, 0.7); + // maxEfficiency in the original code is actually the MEAN cog. + assert.ok(Math.abs(maxEfficiency - 0.8) < 1e-12); +}); + +test('calcDistanceFromPeak returns |a - b|', () => { + const ge = makeGE(); + assert.ok(Math.abs(ge.calcDistanceFromPeak(0.85, 0.92) - 0.07) < 1e-12); + assert.ok(Math.abs(ge.calcDistanceFromPeak(0.92, 0.85) - 0.07) < 1e-12); +}); + +test('calcRelativeDistanceFromPeak maps current onto [0..1]', () => { + const ge = makeGE(); + // current=0.85, max=0.92, min=0.7 → maps 0.85 in [0.92..0.7] onto [0..1]. + // interpolate_lin_single_point treats first range as input domain: + // 0.85 → ((0.85 - 0.92) / (0.7 - 0.92)) * (1 - 0) + 0 = 0.07/0.22 ≈ 0.3181818... + const v = ge.calcRelativeDistanceFromPeak(0.85, 0.92, 0.7); + const expected = (0.85 - 0.92) / (0.7 - 0.92); + assert.ok(Math.abs(v - expected) < 1e-9, `got ${v} expected ${expected}`); +}); + +test('calcDistanceBEP returns both abs + rel', () => { + const ge = makeGE(); + const { absDistFromPeak, relDistFromPeak } = ge.calcDistanceBEP(0.85, 0.92, 0.7); + assert.ok(Math.abs(absDistFromPeak - 0.07) < 1e-12); + const expectedRel = (0.85 - 0.92) / (0.7 - 0.92); + assert.ok(Math.abs(relDistFromPeak - expectedRel) < 1e-9); +}); + +test('calcRelativeDistanceFromPeak returns 1 when max === min (degenerate)', () => { + const ge = makeGE(); + assert.equal(ge.calcRelativeDistanceFromPeak(0.85, 0.8, 0.8), 1); +}); + +test('calcRelativeDistanceFromPeak returns 1 when current is null', () => { + const ge = makeGE(); + assert.equal(ge.calcRelativeDistanceFromPeak(null, 0.92, 0.7), 1); +}); + +test('calcGroupEfficiency handles a single machine', () => { + const ge = makeGE(); + const { maxEfficiency, lowestEfficiency } = ge.calcGroupEfficiency(makeMachines([0.77])); + assert.equal(maxEfficiency, 0.77); + assert.equal(lowestEfficiency, 0.77); +}); diff --git a/test/basic/groupOperatingPoint.basic.test.js b/test/basic/groupOperatingPoint.basic.test.js new file mode 100644 index 0000000..e07063a --- /dev/null +++ b/test/basic/groupOperatingPoint.basic.test.js @@ -0,0 +1,131 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { MeasurementContainer, POSITIONS } = require('generalFunctions'); +const GroupOperatingPoint = require('../../src/groupOps/groupOperatingPoint'); + +const unitPolicy = { + canonical: { pressure: 'Pa', flow: 'm3/s', power: 'W', temperature: 'K' }, + output: { pressure: 'Pa', flow: 'm3/s', power: 'W', temperature: 'K' }, +}; + +const silentLogger = { debug() {}, info() {}, warn() {}, error() {} }; + +function makeContainer() { + return new MeasurementContainer({ + defaultUnits: unitPolicy.output, + preferredUnits: unitPolicy.output, + canonicalUnits: unitPolicy.canonical, + storeCanonical: true, + autoConvert: true, + }); +} + +function makeMachine(id, pressures = {}) { + // pressures: { down?: Pa, up?: Pa } — written into a real container + const m = { + config: { general: { id } }, + measurements: makeContainer(), + setGroupOperatingPointCalls: [], + setGroupOperatingPoint(down, up) { + this.setGroupOperatingPointCalls.push({ down, up }); + }, + }; + const now = Date.now(); + if (pressures.down != null) { + m.measurements.type('pressure').variant('measured').position(POSITIONS.DOWNSTREAM).value(pressures.down, now, 'Pa'); + } + if (pressures.up != null) { + m.measurements.type('pressure').variant('measured').position(POSITIONS.UPSTREAM).value(pressures.up, now, 'Pa'); + } + return m; +} + +test('readChild returns value in requested unit when present', () => { + const machines = {}; + const m = makeMachine('m1', { down: 150000 }); + machines[m.config.general.id] = m; + const gop = new GroupOperatingPoint({ measurements: makeContainer(), machines, unitPolicy, logger: silentLogger }); + + const v = gop.readChild(m, 'pressure', 'measured', POSITIONS.DOWNSTREAM, 'Pa'); + assert.equal(v, 150000); +}); + +test('readChild returns null when measurement missing', () => { + const m = makeMachine('m1'); + const gop = new GroupOperatingPoint({ measurements: makeContainer(), machines: { m1: m }, unitPolicy, logger: silentLogger }); + + const v = gop.readChild(m, 'pressure', 'measured', POSITIONS.UPSTREAM, 'Pa'); + assert.equal(v, null); +}); + +test("writeOwn writes to the group's measurements container", () => { + const ownC = makeContainer(); + const gop = new GroupOperatingPoint({ measurements: ownC, machines: {}, unitPolicy, logger: silentLogger }); + + gop.writeOwn('flow', 'predicted', POSITIONS.AT_EQUIPMENT, 0.1, 'm3/s'); + const v = ownC.type('flow').variant('predicted').position(POSITIONS.AT_EQUIPMENT).getCurrentValue('m3/s'); + assert.equal(v, 0.1); +}); + +test('writeOwn skips non-finite values', () => { + const ownC = makeContainer(); + const gop = new GroupOperatingPoint({ measurements: ownC, machines: {}, unitPolicy, logger: silentLogger }); + + gop.writeOwn('flow', 'predicted', POSITIONS.AT_EQUIPMENT, NaN, 'm3/s'); + const v = ownC.type('flow').variant('predicted').position(POSITIONS.AT_EQUIPMENT).getCurrentValue('m3/s'); + assert.equal(v, null); +}); + +test('equalize() pushes the worst-case header onto each machine when 3 pressures differ', () => { + // No group header → max child downstream, min positive child upstream. + // max(120k, 140k, 100k) = 140000, min(80k, 90k, 70k) = 70000. + const machines = { + a: makeMachine('a', { down: 120000, up: 80000 }), + b: makeMachine('b', { down: 140000, up: 90000 }), + c: makeMachine('c', { down: 100000, up: 70000 }), + }; + const gop = new GroupOperatingPoint({ measurements: makeContainer(), machines, unitPolicy, logger: silentLogger }); + + gop.equalize(); + + for (const id of ['a', 'b', 'c']) { + const last = machines[id].setGroupOperatingPointCalls.at(-1); + assert.ok(last, `machine ${id} should have been called`); + assert.equal(last.down, 140000); + assert.equal(last.up, 70000); + } +}); + +test('equalize() is a no-op when there is no pressure data', () => { + const machines = { a: makeMachine('a'), b: makeMachine('b') }; + const gop = new GroupOperatingPoint({ measurements: makeContainer(), machines, unitPolicy, logger: silentLogger }); + + gop.equalize(); + + assert.equal(machines.a.setGroupOperatingPointCalls.length, 0); + assert.equal(machines.b.setGroupOperatingPointCalls.length, 0); +}); + +test('equalize() is a no-op when machines map is empty', () => { + const gop = new GroupOperatingPoint({ measurements: makeContainer(), machines: {}, unitPolicy, logger: silentLogger }); + assert.doesNotThrow(() => gop.equalize()); +}); + +test('equalize() falls back to direct fDimension when setGroupOperatingPoint is missing', () => { + const m = { + config: { general: { id: 'old' } }, + measurements: makeContainer(), + predictFlow: { fDimension: 0 }, + predictPower: { fDimension: 0 }, + predictCtrl: { fDimension: 0 }, + }; + m.measurements.type('pressure').variant('measured').position(POSITIONS.DOWNSTREAM).value(200000, Date.now(), 'Pa'); + m.measurements.type('pressure').variant('measured').position(POSITIONS.UPSTREAM).value(100000, Date.now(), 'Pa'); + + const gop = new GroupOperatingPoint({ measurements: makeContainer(), machines: { old: m }, unitPolicy, logger: silentLogger }); + gop.equalize(); + + assert.equal(m.predictFlow.fDimension, 100000); + assert.equal(m.predictPower.fDimension, 100000); + assert.equal(m.predictCtrl.fDimension, 100000); +}); diff --git a/test/basic/pumpCombinations.basic.test.js b/test/basic/pumpCombinations.basic.test.js new file mode 100644 index 0000000..211bfea --- /dev/null +++ b/test/basic/pumpCombinations.basic.test.js @@ -0,0 +1,90 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +// Local stub for groupCurves — replace once ../groupOps/groupCurves lands. +const groupCurves = { + groupFlow: (m) => m.predictFlow, + groupPower: (m) => m.predictPower, + groupNCog: (m) => m.NCog ?? 0, + groupCalcPower: (m, f) => m.inputFlowCalcPower(f), +}; + +const { validPumpCombinations, checkSpecialCases } = + require('../../src/combinatorics/pumpCombinations'); + +function makeMachine({ id, state = 'off', mode = 'auto', + fMin = 0, fMax = 100, pMax = 100, + NCog = 0.5, validAction = true } = {}) { + return { + config: { general: { id } }, + state: { getCurrentState: () => state }, + currentMode: mode, + NCog, + predictFlow: { currentFxyYMin: fMin, currentFxyYMax: fMax }, + predictPower: { currentFxyYMin: 0, currentFxyYMax: pMax }, + inputFlowCalcPower: (flow) => flow * 0.5, + isValidActionForMode: () => validAction, + }; +} + +const POSITIONS = { DOWNSTREAM: 'downstream' }; +const baseCtx = (extra = {}) => ({ + groupCurves, + logger: { warn: () => {}, debug: () => {}, error: () => {} }, + readChildMeasurement: () => undefined, + POSITIONS, + unitPolicy: { canonical: { flow: 'm3/s' } }, + ...extra, +}); + +test('validPumpCombinations: 3 idle machines + Qd in range returns subsets that can deliver', () => { + const machines = { + a: makeMachine({ id: 'a', state: 'idle', fMin: 10, fMax: 50 }), + b: makeMachine({ id: 'b', state: 'idle', fMin: 10, fMax: 50 }), + c: makeMachine({ id: 'c', state: 'idle', fMin: 10, fMax: 50 }), + }; + const combos = validPumpCombinations(machines, 40, baseCtx()); + assert.ok(combos.length > 0, 'expected at least one combination'); + // every combination must be able to deliver Qd + for (const subset of combos) { + const maxF = subset.reduce((s, id) => s + machines[id].predictFlow.currentFxyYMax, 0); + const minF = subset.reduce((s, id) => s + machines[id].predictFlow.currentFxyYMin, 0); + assert.ok(maxF >= 40); + assert.ok(minF <= 40); + } +}); + +test('validPumpCombinations: excludes machines in off/coolingdown/stopping/emergencystop', () => { + const machines = { + a: makeMachine({ id: 'a', state: 'off', fMin: 10, fMax: 50 }), + b: makeMachine({ id: 'b', state: 'coolingdown', fMin: 10, fMax: 50 }), + c: makeMachine({ id: 'c', state: 'stopping', fMin: 10, fMax: 50 }), + d: makeMachine({ id: 'd', state: 'emergencystop', fMin: 10, fMax: 50 }), + e: makeMachine({ id: 'e', state: 'idle', fMin: 10, fMax: 50 }), + }; + const combos = validPumpCombinations(machines, 30, baseCtx()); + // Only "e" can be in a combination + for (const subset of combos) { + for (const id of subset) assert.equal(id, 'e'); + } +}); + +test('checkSpecialCases: reduces Qd by flow of manually controlled operational machines', () => { + const machines = { + a: makeMachine({ id: 'a', state: 'operational', mode: 'virtualControl' }), + b: makeMachine({ id: 'b', state: 'idle' }), + }; + const ctx = baseCtx({ + readChildMeasurement: (m, type, variant) => { + if (m.config.general.id === 'a' && variant === 'measured') return 12; + return undefined; + }, + }); + const adjusted = checkSpecialCases(machines, 50, ctx); + assert.equal(adjusted, 38); +}); + +test('validPumpCombinations: no machines returns empty array', () => { + const combos = validPumpCombinations({}, 10, baseCtx()); + assert.deepEqual(combos, []); +}); diff --git a/test/basic/totalsCalculator.basic.test.js b/test/basic/totalsCalculator.basic.test.js new file mode 100644 index 0000000..d0581ff --- /dev/null +++ b/test/basic/totalsCalculator.basic.test.js @@ -0,0 +1,128 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const TotalsCalculator = require('../../src/totals/totalsCalculator'); + +const unitPolicy = { + canonical: { pressure: 'Pa', flow: 'm3/s', power: 'W', temperature: 'K' }, + output: { pressure: 'Pa', flow: 'm3/s', power: 'W', temperature: 'K' }, +}; +const silent = { debug() {}, info() {}, warn() {}, error() {} }; + +function predictView(min, max) { + return { currentF: (min + max) / 2, currentFxyYMin: min, currentFxyYMax: max }; +} + +function makeMachine(id, opts = {}) { + const { + flowMin = 0.0, flowMax = 1.0, + powerMin = 100, powerMax = 1000, + state = 'operational', + hasCurve = true, + NCog = 0.5, + // Input-curve envelope (for calcAbsoluteTotals): { [pressureKey]: { y: [...] } } + inputCurve = null, + actFlow = 0, + actPower = 0, + } = opts; + + const fakeInput = inputCurve || { + '50000': { y: [flowMin, (flowMin + flowMax) / 2, flowMax] }, + }; + const fakePower = inputCurve + ? Object.fromEntries(Object.keys(inputCurve).map(k => [k, { y: [powerMin, (powerMin + powerMax) / 2, powerMax] }])) + : { '50000': { y: [powerMin, (powerMin + powerMax) / 2, powerMax] } }; + + return { + config: { general: { id } }, + hasCurve, + state: { getCurrentState: () => state }, + NCog, + predictFlow: { inputCurve: fakeInput, ...predictView(flowMin, flowMax) }, + predictPower: { inputCurve: fakePower, ...predictView(powerMin, powerMax) }, + _actFlow: actFlow, + _actPower: actPower, + }; +} + +function fakeOperatingPoint(/* machines */) { + return { + readChild(machine, type, _variant, _position /*, _unit */) { + if (type === 'flow') return machine._actFlow; + if (type === 'power') return machine._actPower; + return null; + }, + }; +} + +test('calcAbsoluteTotals returns zeros when no machines', () => { + const tc = new TotalsCalculator({ machines: {}, unitPolicy, logger: silent }); + const t = tc.calcAbsoluteTotals(); + assert.deepEqual(t, { flow: { min: 0, max: 0 }, power: { min: 0, max: 0 } }); +}); + +test('calcAbsoluteTotals scans curve envelope (sum of maxes, min of mins)', () => { + const machines = { + a: makeMachine('a', { flowMin: 0.1, flowMax: 0.5, powerMin: 100, powerMax: 500 }), + b: makeMachine('b', { flowMin: 0.2, flowMax: 0.8, powerMin: 200, powerMax: 700 }), + }; + const tc = new TotalsCalculator({ machines, unitPolicy, logger: silent }); + const t = tc.calcAbsoluteTotals(); + assert.equal(t.flow.min, 0.1); + assert.equal(t.power.min, 100); + // max is summed across all machines + assert.equal(t.flow.max, 0.5 + 0.8); + assert.equal(t.power.max, 500 + 700); +}); + +test('calcDynamicTotals sums across machines and skips machines with no valid curve', () => { + const machines = { + a: makeMachine('a', { flowMin: 0.1, flowMax: 0.5, powerMin: 100, powerMax: 500, actFlow: 0.3, actPower: 300 }), + b: makeMachine('b', { flowMin: 0.2, flowMax: 0.7, powerMin: 200, powerMax: 600, actFlow: 0.4, actPower: 400 }), + skip: makeMachine('skip', { hasCurve: false }), + }; + const tc = new TotalsCalculator({ + machines, unitPolicy, logger: silent, + operatingPoint: fakeOperatingPoint(machines), + }); + + const t = tc.calcDynamicTotals(); + + assert.equal(t.flow.min, 0.1); + assert.equal(t.flow.max, 0.5 + 0.7); + assert.equal(t.flow.act, 0.3 + 0.4); + assert.equal(t.power.min, 100); + assert.equal(t.power.max, 500 + 600); + assert.equal(t.power.act, 300 + 400); + assert.equal(t.NCog, machines.a.NCog + machines.b.NCog); +}); + +test('activeTotals skips machines whose state is off or maintenance', () => { + const machines = { + a: makeMachine('a', { flowMin: 0.1, flowMax: 0.5, powerMin: 100, powerMax: 500, state: 'operational' }), + b: makeMachine('b', { flowMin: 0.2, flowMax: 0.7, powerMin: 200, powerMax: 600, state: 'off' }), + c: makeMachine('c', { flowMin: 0.3, flowMax: 0.9, powerMin: 300, powerMax: 900, state: 'maintenance' }), + d: makeMachine('d', { flowMin: 0.05, flowMax: 0.4, powerMin: 50, powerMax: 400, state: 'accelerating' }), + }; + const tc = new TotalsCalculator({ machines, unitPolicy, logger: silent }); + + const t = tc.activeTotals(); + assert.equal(t.countActiveMachines, 2); // a + d + assert.equal(t.flow.min, 0.1 + 0.05); + assert.equal(t.flow.max, 0.5 + 0.4); + assert.equal(t.power.min, 100 + 50); + assert.equal(t.power.max, 500 + 400); +}); + +test('activeTotals honours the injected isMachineActive override', () => { + const machines = { + a: makeMachine('a', { flowMin: 0.1, flowMax: 0.5, powerMin: 100, powerMax: 500, state: 'operational' }), + b: makeMachine('b', { flowMin: 0.2, flowMax: 0.7, powerMin: 200, powerMax: 600, state: 'operational' }), + }; + const tc = new TotalsCalculator({ + machines, unitPolicy, logger: silent, + isMachineActive: (id) => id === 'b', + }); + const t = tc.activeTotals(); + assert.equal(t.countActiveMachines, 1); + assert.equal(t.flow.max, 0.7); +});