Compare commits

..

7 Commits

Author SHA1 Message Date
znetsixe
6be3bf92ef first creation of PID controller + adjustments to pumpingstation 2025-11-10 13:41:41 +01:00
znetsixe
efe4a5f97d update flow arrow 2025-11-07 15:30:24 +01:00
znetsixe
e5c98b7d30 removed some old comments, added thresholds for safeguard 2025-11-07 15:09:35 +01:00
znetsixe
4a489acd89 some formatting 2025-11-06 16:47:17 +01:00
znetsixe
98cd44d3ae updated output utils bug fixes for formatting 2025-11-06 11:18:54 +01:00
znetsixe
44adfdece6 removed caps sensitivity 2025-11-05 17:15:32 +01:00
znetsixe
9ada6e2acd Added support for maintenance tracking in hours. "getMaintenanceTimeHours" default in output of machine now 2025-11-05 15:47:05 +01:00
19 changed files with 2263 additions and 192 deletions

View File

@@ -66,33 +66,6 @@
"units": ["g/m³", "mol/m³"]
}
]
},
{
"name": "Quantity (Ammonium)",
"models": [
{
"name": "VegaAmmoniaSense 10",
"units": ["g/m³", "mol/m³"]
}
]
},
{
"name": "Quantity (NOx)",
"models": [
{
"name": "VegaNOxSense 10",
"units": ["g/m³", "mol/m³"]
}
]
},
{
"name": "Quantity (TSS)",
"models": [
{
"name": "VegaSolidsProbe",
"units": ["g/m³"]
}
]
}
]
}
@@ -110,6 +83,7 @@
{
"id": "hidrostal-pump-001",
"name": "hidrostal-H05K-S03R",
"units": ["l/s"]
},
{

View File

@@ -1 +0,0 @@
Database connection failed: SQLSTATE[28000] [1045] Access denied for user 'pimmoe1q_rdlab'@'localhost' (using password: YES)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,229 @@
{
"success": true,
"message": "Product modellen succesvol opgehaald.",
"data": [
{
"id": "1",
"name": "Macbook Air 12",
"product_model_subtype_id": "1",
"product_model_description": null,
"vendor_id": "1",
"product_model_status": null,
"vendor_name": "Apple",
"product_subtype_name": "Laptop",
"product_model_meta": []
},
{
"id": "2",
"name": "Macbook Air 13",
"product_model_subtype_id": "1",
"product_model_description": null,
"vendor_id": "1",
"product_model_status": null,
"vendor_name": "Apple",
"product_subtype_name": "Laptop",
"product_model_meta": []
},
{
"id": "3",
"name": "AirMac 1 128 GB White",
"product_model_subtype_id": "2",
"product_model_description": null,
"vendor_id": "1",
"product_model_status": null,
"vendor_name": "Apple",
"product_subtype_name": "Desktop",
"product_model_meta": []
},
{
"id": "4",
"name": "AirMac 2 256 GB Black",
"product_model_subtype_id": "2",
"product_model_description": null,
"vendor_id": "1",
"product_model_status": null,
"vendor_name": "Apple",
"product_subtype_name": "Desktop",
"product_model_meta": []
},
{
"id": "5",
"name": "AirMac 2 256 GB White",
"product_model_subtype_id": "2",
"product_model_description": null,
"vendor_id": "1",
"product_model_status": null,
"vendor_name": "Apple",
"product_subtype_name": "Desktop",
"product_model_meta": []
},
{
"id": "6",
"name": "Vegabar 14",
"product_model_subtype_id": "3",
"product_model_description": "vegabar 14",
"vendor_id": "4",
"product_model_status": "Actief",
"vendor_name": "vega",
"product_subtype_name": "pressure",
"product_model_meta": {
"machineCurve": {
"np": {
"700": {
"x": [
0,
24.59,
49.18,
73.77,
100
],
"y": [
12.962460720759278,
20.65443723573673,
31.029351002816465,
44.58926412111886,
62.87460150792057
]
},
"800": {
"x": [
0,
24.59,
49.18,
73.77,
100
],
"y": [
13.035157335397209,
20.74906989186132,
31.029351002816465,
44.58926412111886,
62.87460150792057
]
},
"900": {
"x": [
0,
24.59,
49.18,
73.77,
100
],
"y": [
13.064663380158798,
20.927197054134297,
31.107126521989933,
44.58926412111886,
62.87460150792057
]
},
"1000": {
"x": [
0,
24.59,
49.18,
73.77,
100
],
"y": [
13.039271391128953,
21.08680188366637,
31.30899920405947,
44.58926412111886,
62.87460150792057
]
},
"1100": {
"x": [
0,
24.59,
49.18,
73.77,
100
],
"y": [
12.940075520572446,
21.220547481589954,
31.51468295656385,
44.621326083982,
62.87460150792057
]
}
},
"nq": {
"700": {
"x": [
0,
24.59,
49.18,
73.77,
100
],
"y": [
119.13938764447377,
150.12178608265387,
178.82698019104356,
202.3699313222398,
227.06382297856618
]
},
"800": {
"x": [
0,
24.59,
49.18,
73.77,
100
],
"y": [
112.59072109293984,
148.15847460389205,
178.82698019104356,
202.3699313222398,
227.06382297856618
]
},
"900": {
"x": [
0,
24.59,
49.18,
73.77,
100
],
"y": [
105.6217241180404,
144.00502117747064,
177.15212647335034,
202.3699313222398,
227.06382297856618
]
}
}
}
}
},
{
"id": "7",
"name": "Vegabar 10",
"product_model_subtype_id": "3",
"product_model_description": null,
"vendor_id": "4",
"product_model_status": "Actief",
"vendor_name": "vega",
"product_subtype_name": "pressure",
"product_model_meta": []
},
{
"id": "8",
"name": "VegaFlow 10",
"product_model_subtype_id": "4",
"product_model_description": null,
"vendor_id": "4",
"product_model_status": "Actief",
"vendor_name": "vega",
"product_subtype_name": "flow",
"product_model_meta": []
}
]
}

View File

@@ -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,80 +385,50 @@
"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."
"type": "set",
"itemType": "string",
"description": "List of control modes that the station is permitted to operate in."
}
},
"stopLeadPump": {
"default": 0.8,
"levelbased": {
"thresholds": {
"default": [30,40,50,60,70,80,90],
"rules": {
"type": "number",
"description": "Level that stops the lead pump."
"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."
}
},
"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."
}
}
}
}
},
"pressureSetpoint": {
"default": 250,
"timeThresholdSeconds": {
"default": 120,
"rules": {
"type": "number",
"min": 0,
"description": "Duration the volume condition must persist before triggering pump actions (seconds)."
}
}
},
"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": {
@@ -456,11 +438,59 @@
"description": "Target fill percentage of the basin when operating in equalization mode."
}
},
"autoRestartAfterPowerLoss": {
"default": true,
"flowBalanceTolerance": {
"default": 5,
"rules": {
"type": "boolean",
"description": "If true, pumps resume based on last known state after power restoration."
"type": "number",
"min": 0,
"description": "Allowable error between inflow and outflow before adjustments are triggered (m3/h)."
}
}
},
"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."
}
}
},
"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,13 +500,63 @@
"min": 0,
"description": "Duration after which a manual override expires automatically (minutes)."
}
}
},
"flowBalanceTolerance": {
"default": 5,
"safety": {
"enableDryRunProtection": {
"default": true,
"rules": {
"type": "boolean",
"description": "If true, pumps will be prevented from running if basin volume is too low."
}
},
"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."
}
},
"dryRunDebounceSeconds": {
"default": 30,
"rules": {
"type": "number",
"min": 0,
"description": "Time the low-volume condition must persist before dry-run protection engages (seconds)."
}
},
"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,
"max": 100,
"description": "Volume percentage above which overfill protection activates."
}
},
"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 to predict imminent full or empty conditions."
}
}
},

View File

@@ -245,10 +245,6 @@
{
"value": "fysicalControl",
"description": "Controlled via physical buttons or switches; ignores external automated commands."
},
{
"value": "maintenance",
"description": "No active control from auto, virtual, or fysical sources."
}
],
"description": "The operational mode of the machine."
@@ -260,7 +256,13 @@
"type": "object",
"schema":{
"auto": {
"default": ["statusCheck", "execMovement", "execSequence", "emergencyStop"],
"default": [
"statuscheck",
"execmovement",
"execsequence",
"emergencystop",
"entermaintenance"
],
"rules": {
"type": "set",
"itemType": "string",
@@ -268,7 +270,13 @@
}
},
"virtualControl": {
"default": ["statusCheck", "execMovement", "execSequence", "emergencyStop"],
"default": [
"statuscheck",
"execmovement",
"execsequence",
"emergencystop",
"exitmaintenance"
],
"rules": {
"type": "set",
"itemType": "string",
@@ -276,24 +284,21 @@
}
},
"fysicalControl": {
"default": ["statusCheck", "emergencyStop"],
"default": [
"statuscheck",
"emergencystop",
"entermaintenance",
"exitmaintenance"
],
"rules": {
"type": "set",
"itemType": "string",
"description": "Actions allowed in fysicalControl mode."
}
},
"maintenance": {
"default": ["statusCheck"],
"rules": {
"type": "set",
"itemType": "string",
"description": "Actions allowed in maintenance mode."
}
}
},
"description": "Information about valid command sources recognized by the machine."
}
},
"allowedSources":{
"default": {},
@@ -386,6 +391,22 @@
"itemType": "string",
"description": "Sequence of states for booting up the machine."
}
},
"entermaintenance":{
"default": ["stopping","coolingdown","idle","maintenance"],
"rules": {
"type": "set",
"itemType": "string",
"description": "Sequence of states if the machine is running to put it in maintenance state"
}
},
"exitmaintenance":{
"default": ["off","idle"],
"rules": {
"type": "set",
"itemType": "string",
"description": "Sequence of states if the machine is running to put it in maintenance state"
}
}
}
},
@@ -412,14 +433,6 @@
],
"description": "The frequency at which calculations are performed."
}
},
"flowNumber": {
"default": 1,
"rules": {
"type": "number",
"nullable": false,
"description": "Defines which effluent flow of the parent node to handle."
}
}
}

View File

@@ -11,12 +11,8 @@ class ChildRegistrationUtils {
this.logger.debug(`Registering child: ${name} (${id}) as ${softwareType} at ${positionVsParent}`);
// Enhanced child setup - multiple parents
if (Array.isArray(child.parent)) {
child.parent.push(this.mainClass);
} else {
child.parent = [this.mainClass];
}
// Enhanced child setup
child.parent = this.mainClass;
child.positionVsParent = positionVsParent;
// Enhanced measurement container with rich context

View File

@@ -180,7 +180,6 @@ async apiCall(node) {
// Only add tagCode to URL if it exists
if (tagCode) {
apiUrl += `&asset_tag_number=${tagCode}`;
console.log('hello there');
}
assetregisterAPI += apiUrl;
@@ -461,10 +460,6 @@ populateModels(
// Store only the metadata for the selected model
node["modelMetadata"] = modelData.find((model) => model.name === selectedModel);
});
/*
console.log('hello here I am:');
console.log(node["modelMetadata"]);
*/
});
})

View File

@@ -180,7 +180,6 @@ async apiCall(node) {
// Only add tagCode to URL if it exists
if (tagCode) {
apiUrl += `&asset_tag_number=${tagCode}`;
console.log('hello there');
}
assetregisterAPI += apiUrl;
@@ -461,10 +460,7 @@ populateModels(
// Store only the metadata for the selected model
node["modelMetadata"] = modelData.find((model) => model.name === selectedModel);
});
/*
console.log('hello here I am:');
console.log(node["modelMetadata"]);
*/
});
})

View File

@@ -64,7 +64,7 @@ class OutputUtils {
influxDBFormat(changedFields, config , flatTags) {
// Create the measurement and topic using softwareType and name config.functionality.softwareType + .
const measurement = config.general.name;
const measurement = `${config.functionality?.softwareType}_${config.general?.id}`;
const payload = {
measurement: measurement,
fields: changedFields,
@@ -104,24 +104,23 @@ class OutputUtils {
return {
// general properties
id: config.general?.id,
name: config.general?.name,
unit: config.general?.unit,
// functionality properties
softwareType: config.functionality?.softwareType,
role: config.functionality?.role,
// asset properties (exclude machineCurve)
uuid: config.asset?.uuid,
tagcode: config.asset?.tagcode,
geoLocation: config.asset?.geoLocation,
supplier: config.asset?.supplier,
category: config.asset?.category,
type: config.asset?.type,
subType: config.asset?.subType,
model: config.asset?.model,
unit: config.general?.unit,
};
}
processFormat(changedFields,config) {
// Create the measurement and topic using softwareType and name config.functionality.softwareType + .
const measurement = config.general.name;
const measurement = `${config.functionality?.softwareType}_${config.general?.id}`;
const payload = changedFields;
const topic = measurement;
const msg = { topic: topic, payload: payload };

View File

@@ -113,7 +113,7 @@ class Measurement {
// Create a new measurement that is the difference between two positions
static createDifference(upstreamMeasurement, downstreamMeasurement) {
console.log('hello:');
if (upstreamMeasurement.type !== downstreamMeasurement.type ||
upstreamMeasurement.variant !== downstreamMeasurement.variant) {
throw new Error('Cannot calculate difference between different measurement types or variants');

View File

@@ -6,9 +6,9 @@ class PhysicalPositionMenu {
return {
positionGroups: [
{ group: 'Positional', options: [
{ value: 'upstream', label: ' Upstream', icon: '←'},
{ value: 'upstream', label: ' Upstream', icon: '←'}, //flow is then typically left to right
{ value: 'atEquipment', label: '⊥ in place' , icon: '⊥' },
{ value: 'downstream', label: ' Downstream' , icon: '→' }
{ value: 'downstream', label: ' Downstream' , icon: '→' }
]
}
],

279
src/pid/PIDController.js Normal file
View File

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

87
src/pid/examples.js Normal file
View File

@@ -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
};

11
src/pid/index.js Normal file
View File

@@ -0,0 +1,11 @@
const PIDController = require('./PIDController');
/**
* Convenience factory for one-line instantiation.
*/
const createPidController = (options) => new PIDController(options);
module.exports = {
PIDController,
createPidController
};

View File

@@ -52,6 +52,10 @@ class state{
return this.stateManager.getRunTimeHours();
}
getMaintenanceTimeHours(){
return this.stateManager.getMaintenanceTimeHours();
}
async moveTo(targetPosition) {

View File

@@ -205,6 +205,10 @@
{
"value": "off",
"description": "Machine is off."
},
{
"value": "maintenance",
"description": "Machine locked for inspection or repair; automatic control disabled."
}
],
"description": "Current state of the machine."
@@ -216,7 +220,7 @@
"type": "object",
"schema": {
"idle": {
"default": ["starting", "off","emergencystop"],
"default": ["starting", "off","emergencystop","maintenance"],
"rules":{
"type": "set",
"itemType": "string",
@@ -280,7 +284,7 @@
}
},
"off": {
"default": ["idle","emergencystop"],
"default": ["idle","emergencystop","maintenance"],
"rules":{
"type": "set",
"itemType": "string",
@@ -288,12 +292,20 @@
}
},
"emergencystop": {
"default": ["idle","off"],
"default": ["idle","off","maintenance"],
"rules":{
"type": "set",
"itemType": "string",
"description": "Allowed transitions from emergency stop state."
}
},
"maintenance": {
"default": ["maintenance","idle","off"],
"rules":{
"type": "set",
"itemType": "string",
"description": "Allowed transitions for maintenance mode"
}
}
},
"description": "Allowed transitions between states."

View File

@@ -48,10 +48,14 @@ class stateManager {
// Define valid transitions (can be extended dynamically if needed)
this.validTransitions = config.state.allowedTransitions;
// NEW: Initialize runtime tracking
//runtime tracking
this.runTimeHours = 0; // cumulative runtime in hours
this.runTimeStart = null; // timestamp when active state began
//maintenance tracking
this.maintenanceTimeStart = null; //timestamp when active state began
this.maintenanceTimeHours = 0; //cumulative
// Define active states (runtime counts only in these states)
this.activeStates = config.state.activeStates;
}
@@ -73,8 +77,9 @@ class stateManager {
); //go back early and reject promise
}
// NEW: Handle runtime tracking based on active states
//Time tracking based on active states
this.handleRuntimeTracking(newState);
this.handleMaintenancetimeTracking(newState);
const transitionDuration = this.transitionTimes[this.currentState] || 0; // Default to 0 if no transition time
this.logger.debug(
@@ -100,7 +105,7 @@ class stateManager {
}
handleRuntimeTracking(newState) {
// NEW: Handle runtime tracking based on active states
//Handle runtime tracking based on active states
const wasActive = this.activeStates.has(this.currentState);
const willBeActive = this.activeStates.has(newState);
if (wasActive && !willBeActive && this.runTimeStart) {
@@ -120,6 +125,28 @@ class stateManager {
}
}
handleMaintenancetimeTracking(newState) {
//is this maintenance time ?
const wasActive = (this.currentState == "maintenance"? true:false);
const willBeActive = ( newState == "maintenance" ? true:false );
if (wasActive && this.maintenanceTimeStart) {
// stop runtime timer and accumulate elapsed time
const elapsed = (Date.now() - this.maintenanceTimeStart) / 3600000; // hours
this.maintenanceTimeHours += elapsed;
this.maintenanceTimeStart = null;
this.logger.debug(
`Maintenance timer stopped; elapsed=${elapsed.toFixed(
3
)}h, total=${this.maintenanceTimeHours.toFixed(3)}h.`
);
} else if (willBeActive && !this.runTimeStart) {
// starting new runtime
this.maintenanceTimeStart = Date.now();
this.logger.debug("Runtime timer started.");
}
}
isValidTransition(newState) {
this.logger.debug(
`Check 1 Transition valid ? From ${
@@ -150,7 +177,6 @@ class stateManager {
return this.descriptions[state] || "No description available.";
}
// NEW: Getter to retrieve current cumulative runtime (active time) in hours.
getRunTimeHours() {
// If currently active add the ongoing duration.
let currentElapsed = 0;
@@ -159,6 +185,15 @@ class stateManager {
}
return this.runTimeHours + currentElapsed;
}
getMaintenanceTimeHours() {
// If currently active add the ongoing duration.
let currentElapsed = 0;
if (this.maintenanceTimeStart) {
currentElapsed = (Date.now() - this.maintenanceTimeStart) / 3600000;
}
return this.maintenanceTimeHours + currentElapsed;
}
}
module.exports = stateManager;