diff --git a/src/configs/pumpingStation.json b/src/configs/pumpingStation.json index 42bf044..44a05ba 100644 --- a/src/configs/pumpingStation.json +++ b/src/configs/pumpingStation.json @@ -348,13 +348,13 @@ } }, "control": { - "controlStrategy": { - "default": "levelBased", + "mode": { + "default": "levelbased", "rules": { - "type": "enum", + "type": "string", "values": [ { - "value": "levelBased", + "value": "levelbased", "description": "Lead and lag pumps are controlled by basin level thresholds." }, { @@ -362,9 +362,21 @@ "description": "Pumps target a discharge pressure setpoint." }, { - "value": "flowTracking", + "value": "flowBased", "description": "Pumps modulate to match measured inflow or downstream demand." }, + { + "value": "percentageBased", + "description": "Pumps operate to maintain basin volume at a target percentage." + }, + { + "value":"powerBased", + "description": "Pumps are controlled based on power consumption.For example, to limit peak power usage or operate within netcongestion limits." + }, + { + "value": "hybrid", + "description": "Combines multiple control strategies for optimized operation." + }, { "value": "manual", "description": "Pumps are operated manually or by an external controller." @@ -373,94 +385,112 @@ "description": "Primary control philosophy for pump actuation." } }, - "levelSetpoints": { - "default": { - "startLeadPump": 1.2, - "stopLeadPump": 0.8, - "startLagPump": 1.8, - "stopLagPump": 1.4, - "alarmHigh": 2.3, - "alarmLow": 0.3 - }, + "allowedModes": { + "default": [ + "levelbased", + "pressurebased", + "flowbased", + "percentagebased", + "powerbased", + "manual" + ], "rules": { - "type": "object", - "description": "Level thresholds that govern pump staging and alarms (m).", - "schema": { - "startLeadPump": { - "default": 1.2, - "rules": { - "type": "number", - "description": "Level that starts the lead pump." - } - }, - "stopLeadPump": { - "default": 0.8, - "rules": { - "type": "number", - "description": "Level that stops the lead pump." - } - }, - "startLagPump": { - "default": 1.8, - "rules": { - "type": "number", - "description": "Level that starts the lag pump." - } - }, - "stopLagPump": { - "default": 1.4, - "rules": { - "type": "number", - "description": "Level that stops the lag pump." - } - }, - "alarmHigh": { - "default": 2.3, - "rules": { - "type": "number", - "description": "High level alarm threshold." - } - }, - "alarmLow": { - "default": 0.3, - "rules": { - "type": "number", - "description": "Low level alarm threshold." - } - } + "type": "set", + "itemType": "string", + "description": "List of control modes that the station is permitted to operate in." + } + }, + "levelbased": { + "thresholds": { + "default": [30,40,50,60,70,80,90], + "rules": { + "type": "array", + "description": "Each time a threshold is overwritten a new pump can start or kick into higher gear. Volume thresholds (%) in ascending order used for level-based control." + } + }, + "timeThresholdSeconds": { + "default": 120, + "rules": { + "type": "number", + "min": 0, + "description": "Duration the volume condition must persist before triggering pump actions (seconds)." } } }, - "pressureSetpoint": { - "default": 250, - "rules": { - "type": "number", - "min": 0, - "description": "Target discharge pressure when operating in pressure control (kPa)." + "pressureBased": { + "pressureSetpoint": { + "default": 1000, + "rules": { + "type": "number", + "min": 0, + "max": 5000, + "description": "Target discharge pressure when operating in pressure control (kPa)." + } } }, - "alarmDebounceSeconds": { - "default": 10, - "rules": { - "type": "number", - "min": 0, - "description": "Time a condition must persist before raising an alarm (seconds)." + "flowBased": { + "equalizationTargetPercent": { + "default": 60, + "rules": { + "type": "number", + "min": 0, + "max": 100, + "description": "Target fill percentage of the basin when operating in equalization mode." + } + }, + "flowBalanceTolerance": { + "default": 5, + "rules": { + "type": "number", + "min": 0, + "description": "Allowable error between inflow and outflow before adjustments are triggered (m3/h)." + } } }, - "equalizationTargetPercent": { - "default": 60, - "rules": { - "type": "number", - "min": 0, - "max": 100, - "description": "Target fill percentage of the basin when operating in equalization mode." + "percentageBased": { + "targetVolumePercent": { + "default": 50, + "rules": { + "type": "number", + "min": 0, + "max": 100, + "description": "Target basin volume percentage to maintain during percentage-based control." + } + }, + "tolerancePercent": { + "default": 5, + "rules": { + "type": "number", + "min": 0, + "description": "Acceptable deviation from the target volume percentage before corrective action is taken." + } } }, - "autoRestartAfterPowerLoss": { - "default": true, - "rules": { - "type": "boolean", - "description": "If true, pumps resume based on last known state after power restoration." + "powerBased": { + "maxPowerKW": { + "default": 50, + "rules": { + "type": "number", + "min": 0, + "description": "Maximum allowable power consumption for the pumping station (kW)." + } + }, + "powerControlMode": { + "default": "limit", + "rules": { + "type": "enum", + "values": [ + { + "value": "limit", + "description": "Limit pump operation to stay below the max power threshold." + }, + { + "value": "optimize", + "description": "Optimize pump scheduling to minimize power usage while meeting flow demands." + } + ], + "description": "Defines how power constraints are managed during operation." + } } }, "manualOverrideTimeoutMinutes": { @@ -470,37 +500,63 @@ "min": 0, "description": "Duration after which a manual override expires automatically (minutes)." } + } + }, + "safety": { + "enableDryRunProtection": { + "default": true, + "rules": { + "type": "boolean", + "description": "If true, pumps will be prevented from running if basin volume is too low." + } }, - "flowBalanceTolerance": { - "default": 5, + "dryRunThresholdPercent": { + "default": 2, "rules": { "type": "number", "min": 0, - "description": "Allowable error between inflow and outflow before adjustments are triggered (m3/h)." + "max": 100, + "description": "Volume percentage below which dry run protection activates." } }, - "thresholdLowVolume": { - "default": 10, + "dryRunDebounceSeconds": { + "default": 30, "rules": { "type": "number", "min": 0, - "description": "Volume threshold (%) below which the station will shut down pumps to prevent dry running." + "description": "Time the low-volume condition must persist before dry-run protection engages (seconds)." } }, - "thresholdHighVolume": { - "default": 90, + "enableOverfillProtection": { + "default": true, + "rules": { + "type": "boolean", + "description": "If true, high level alarms and shutdowns will be enforced to prevent overfilling." + } + }, + "overfillThresholdPercent": { + "default": 98, "rules": { "type": "number", "min": 0, - "description": "Volume threshold (%) above which the station will trigger high level alarms." + "max": 100, + "description": "Volume percentage above which overfill protection activates." } }, - "timeThreshholdSeconds": { + "overfillDebounceSeconds": { + "default": 30, + "rules": { + "type": "number", + "min": 0, + "description": "Time the high-volume condition must persist before overfill protection engages (seconds)." + } + }, + "timeleftToFullOrEmptyThresholdSeconds": { "default": 120, "rules": { "type": "number", "min": 0, - "description": "Time threshold (seconds) used in volume-based safety checks." + "description": "Time threshold (seconds) used to predict imminent full or empty conditions." } } }, diff --git a/src/pid/PIDController.js b/src/pid/PIDController.js new file mode 100644 index 0000000..1f07ac4 --- /dev/null +++ b/src/pid/PIDController.js @@ -0,0 +1,279 @@ +'use strict'; + +/** + * Discrete PID controller with optional derivative filtering and integral limits. + * Sample times are expressed in milliseconds to align with Node.js timestamps. + */ +class PIDController { + constructor(options = {}) { + const { + kp = 1, + ki = 0, + kd = 0, + sampleTime = 1000, + derivativeFilter = 0.15, + outputMin = Number.NEGATIVE_INFINITY, + outputMax = Number.POSITIVE_INFINITY, + integralMin = null, + integralMax = null, + derivativeOnMeasurement = true, + autoMode = true + } = options; + + this.kp = 0; + this.ki = 0; + this.kd = 0; + + this.setTunings({ kp, ki, kd }); + this.setSampleTime(sampleTime); + this.setOutputLimits(outputMin, outputMax); + this.setIntegralLimits(integralMin, integralMax); + this.setDerivativeFilter(derivativeFilter); + + this.derivativeOnMeasurement = Boolean(derivativeOnMeasurement); + this.autoMode = Boolean(autoMode); + + this.reset(); + } + + /** + * Update controller gains at runtime. + * Accepts partial objects, e.g. setTunings({ kp: 2.0 }). + */ + setTunings({ kp = this.kp, ki = this.ki, kd = this.kd } = {}) { + [kp, ki, kd].forEach((gain, index) => { + if (!Number.isFinite(gain)) { + const label = ['kp', 'ki', 'kd'][index]; + throw new TypeError(`${label} must be a finite number`); + } + }); + + this.kp = kp; + this.ki = ki; + this.kd = kd; + return this; + } + + /** + * Set the controller execution interval in milliseconds. + */ + setSampleTime(sampleTimeMs = this.sampleTime) { + if (!Number.isFinite(sampleTimeMs) || sampleTimeMs <= 0) { + throw new RangeError('sampleTime must be a positive number of milliseconds'); + } + + this.sampleTime = sampleTimeMs; + return this; + } + + /** + * Constrain controller output. + */ + setOutputLimits(min = this.outputMin, max = this.outputMax) { + if (!Number.isFinite(min) && min !== Number.NEGATIVE_INFINITY) { + throw new TypeError('outputMin must be finite or -Infinity'); + } + if (!Number.isFinite(max) && max !== Number.POSITIVE_INFINITY) { + throw new TypeError('outputMax must be finite or Infinity'); + } + if (min >= max) { + throw new RangeError('outputMin must be smaller than outputMax'); + } + + this.outputMin = min; + this.outputMax = max; + this.lastOutput = this._clamp(this.lastOutput ?? 0, this.outputMin, this.outputMax); + return this; + } + + /** + * Constrain the accumulated integral term. + */ + setIntegralLimits(min = this.integralMin ?? null, max = this.integralMax ?? null) { + if (min !== null && !Number.isFinite(min)) { + throw new TypeError('integralMin must be null or a finite number'); + } + if (max !== null && !Number.isFinite(max)) { + throw new TypeError('integralMax must be null or a finite number'); + } + if (min !== null && max !== null && min > max) { + throw new RangeError('integralMin must be smaller than integralMax'); + } + + this.integralMin = min; + this.integralMax = max; + this.integral = this._applyIntegralLimits(this.integral ?? 0); + return this; + } + + /** + * Configure exponential filter applied to the derivative term. + * Value 0 disables filtering, 1 keeps the previous derivative entirely. + */ + setDerivativeFilter(value = this.derivativeFilter ?? 0) { + if (!Number.isFinite(value) || value < 0 || value > 1) { + throw new RangeError('derivativeFilter must be between 0 and 1'); + } + + this.derivativeFilter = value; + return this; + } + + /** + * Switch between automatic (closed-loop) and manual mode. + */ + setMode(mode) { + if (mode !== 'automatic' && mode !== 'manual') { + throw new Error('mode must be either "automatic" or "manual"'); + } + + this.autoMode = mode === 'automatic'; + return this; + } + + /** + * Force a manual output (typically when in manual mode). + */ + setManualOutput(value) { + this._assertNumeric('manual output', value); + this.lastOutput = this._clamp(value, this.outputMin, this.outputMax); + return this.lastOutput; + } + + /** + * Reset dynamic state (integral, derivative memory, timestamps). + */ + reset(state = {}) { + const { + integral = 0, + lastOutput = 0, + timestamp = null + } = state; + + this.integral = this._applyIntegralLimits(Number.isFinite(integral) ? integral : 0); + this.prevError = null; + this.prevMeasurement = null; + this.lastOutput = this._clamp( + Number.isFinite(lastOutput) ? lastOutput : 0, + this.outputMin ?? Number.NEGATIVE_INFINITY, + this.outputMax ?? Number.POSITIVE_INFINITY + ); + this.lastTimestamp = Number.isFinite(timestamp) ? timestamp : null; + this.derivativeState = 0; + + return this; + } + + /** + * Execute one control loop iteration. + */ + update(setpoint, measurement, timestamp = Date.now()) { + this._assertNumeric('setpoint', setpoint); + this._assertNumeric('measurement', measurement); + this._assertNumeric('timestamp', timestamp); + + if (!this.autoMode) { + this.prevError = setpoint - measurement; + this.prevMeasurement = measurement; + this.lastTimestamp = timestamp; + return this.lastOutput; + } + + if (this.lastTimestamp !== null && (timestamp - this.lastTimestamp) < this.sampleTime) { + return this.lastOutput; + } + + const elapsedMs = this.lastTimestamp === null ? this.sampleTime : (timestamp - this.lastTimestamp); + const dtSeconds = Math.max(elapsedMs / 1000, Number.EPSILON); + + const error = setpoint - measurement; + this.integral = this._applyIntegralLimits(this.integral + error * dtSeconds); + + const derivative = this._computeDerivative({ error, measurement, dtSeconds }); + this.derivativeState = this.derivativeFilter === 0 + ? derivative + : this.derivativeState + (derivative - this.derivativeState) * (1 - this.derivativeFilter); + + const output = (this.kp * error) + (this.ki * this.integral) + (this.kd * this.derivativeState); + this.lastOutput = this._clamp(output, this.outputMin, this.outputMax); + + this.prevError = error; + this.prevMeasurement = measurement; + this.lastTimestamp = timestamp; + + return this.lastOutput; + } + + /** + * Inspect controller state for diagnostics or persistence. + */ + getState() { + return { + kp: this.kp, + ki: this.ki, + kd: this.kd, + sampleTime: this.sampleTime, + outputLimits: { min: this.outputMin, max: this.outputMax }, + integralLimits: { min: this.integralMin, max: this.integralMax }, + derivativeFilter: this.derivativeFilter, + derivativeOnMeasurement: this.derivativeOnMeasurement, + autoMode: this.autoMode, + integral: this.integral, + lastOutput: this.lastOutput, + lastTimestamp: this.lastTimestamp + }; + } + + getLastOutput() { + return this.lastOutput; + } + + _computeDerivative({ error, measurement, dtSeconds }) { + if (!(dtSeconds > 0) || !Number.isFinite(dtSeconds)) { + return 0; + } + + if (this.derivativeOnMeasurement && this.prevMeasurement !== null) { + return -(measurement - this.prevMeasurement) / dtSeconds; + } + + if (this.prevError === null) { + return 0; + } + + return (error - this.prevError) / dtSeconds; + } + + _applyIntegralLimits(value) { + if (!Number.isFinite(value)) { + return 0; + } + + let result = value; + if (this.integralMin !== null && result < this.integralMin) { + result = this.integralMin; + } + if (this.integralMax !== null && result > this.integralMax) { + result = this.integralMax; + } + return result; + } + + _assertNumeric(label, value) { + if (!Number.isFinite(value)) { + throw new TypeError(`${label} must be a finite number`); + } + } + + _clamp(value, min, max) { + if (value < min) { + return min; + } + if (value > max) { + return max; + } + return value; + } +} + +module.exports = PIDController; diff --git a/src/pid/examples.js b/src/pid/examples.js new file mode 100644 index 0000000..ea9cf63 --- /dev/null +++ b/src/pid/examples.js @@ -0,0 +1,87 @@ +const { PIDController } = require('./index'); + +console.log('=== PID CONTROLLER EXAMPLES ===\n'); +console.log('This guide shows how to instantiate, tune, and operate the PID helper.\n'); + +// ==================================== +// EXAMPLE 1: FLOW CONTROL LOOP +// ==================================== +console.log('--- Example 1: Pump speed control ---'); + +const pumpController = new PIDController({ + kp: 1.1, + ki: 0.35, + kd: 0.08, + sampleTime: 250, // ms + outputMin: 0, + outputMax: 100, + derivativeFilter: 0.2 +}); + +const pumpSetpoint = 75; // desired flow percentage +let pumpFlow = 20; +const pumpStart = Date.now(); + +for (let i = 0; i < 10; i += 1) { + const timestamp = pumpStart + (i + 1) * pumpController.sampleTime; + const controlSignal = pumpController.update(pumpSetpoint, pumpFlow, timestamp); + + // Simple first-order plant approximation + pumpFlow += (controlSignal - pumpFlow) * 0.12; + pumpFlow -= (pumpFlow - pumpSetpoint) * 0.05; // disturbance rejection + + console.log( + `Cycle ${i + 1}: output=${controlSignal.toFixed(2)}% | flow=${pumpFlow.toFixed(2)}%` + ); +} + +console.log('Pump loop state:', pumpController.getState(), '\n'); + +// ==================================== +// EXAMPLE 2: TANK LEVEL WITH MANUAL/AUTO +// ==================================== +console.log('--- Example 2: Tank level handover ---'); + +const tankController = new PIDController({ + kp: 2.0, + ki: 0.5, + kd: 0.25, + sampleTime: 400, + derivativeFilter: 0.25, + outputMin: 0, + outputMax: 1 +}).setIntegralLimits(-0.3, 0.3); + +tankController.setMode('manual'); +tankController.setManualOutput(0.4); +console.log(`Manual output locked at ${tankController.getLastOutput().toFixed(2)}\n`); + +tankController.setMode('automatic'); + +let level = 0.2; +const levelSetpoint = 0.8; +const tankStart = Date.now(); + +for (let step = 0; step < 8; step += 1) { + const timestamp = tankStart + (step + 1) * tankController.sampleTime; + const output = tankController.update(levelSetpoint, level, timestamp); + + // Integrating process with slight disturbance + level += (output - 0.5) * 0.18; + level += 0.02; // inflow bump + level = Math.max(0, Math.min(1, level)); + + console.log( + `Cycle ${step + 1}: output=${output.toFixed(3)} | level=${level.toFixed(3)}` + ); +} + +console.log('\nBest practice tips:'); +console.log(' - Call update() on a fixed interval (sampleTime).'); +console.log(' - Clamp output and integral to avoid windup.'); +console.log(' - Use setMode("manual") during maintenance or bump-less transfer.'); + +module.exports = { + pumpController, + tankController +}; diff --git a/src/pid/index.js b/src/pid/index.js new file mode 100644 index 0000000..7f2c82b --- /dev/null +++ b/src/pid/index.js @@ -0,0 +1,11 @@ +const PIDController = require('./PIDController'); + +/** + * Convenience factory for one-line instantiation. + */ +const createPidController = (options) => new PIDController(options); + +module.exports = { + PIDController, + createPidController +};