From 27a6d3c7098ccc80e4753c8d8c31e69f65c11461 Mon Sep 17 00:00:00 2001 From: znetsixe <73483679+znetsixe@users.noreply.github.com> Date: Wed, 11 Mar 2026 11:13:05 +0100 Subject: [PATCH] updates --- datasets/assetData/curves/index.js | 29 +- datasets/assetData/measurement.json | 63 +++ index.js | 5 + package.json | 1 + src/configs/machineGroupControl.json | 8 +- src/configs/pumpingStation.json | 89 +++- src/configs/rotatingMachine.json | 51 ++- src/helper/validationUtils.js | 66 ++- src/measurements/MeasurementContainer.js | 181 ++++++-- src/nrmse/errorMetrics.js | 458 +++++++++++++++----- src/nrmse/nrmseConfig.json | 46 +- src/pid/PIDController.js | 514 ++++++++++++++++++++--- src/pid/index.js | 9 +- src/state/movementManager.js | 2 +- test/00-barrel-contract.test.js | 8 + test/curve-loader.test.js | 13 + test/measurement-container-core.test.js | 36 ++ test/nrmse.test.js | 19 + test/pid-controller.test.js | 105 +++++ test/validation-utils.test.js | 81 +++- 20 files changed, 1555 insertions(+), 229 deletions(-) create mode 100644 test/curve-loader.test.js create mode 100644 test/pid-controller.test.js diff --git a/datasets/assetData/curves/index.js b/datasets/assetData/curves/index.js index c5d3dd4..96c212e 100644 --- a/datasets/assetData/curves/index.js +++ b/datasets/assetData/curves/index.js @@ -25,7 +25,11 @@ class AssetLoader { */ loadAsset(datasetType, assetId) { //const cacheKey = `${datasetType}/${assetId}`; - const cacheKey = `${assetId}`; + const normalizedAssetId = String(assetId || '').trim(); + if (!normalizedAssetId) { + return null; + } + const cacheKey = normalizedAssetId.toLowerCase(); // Check cache first @@ -34,11 +38,11 @@ class AssetLoader { } try { - const filePath = path.join(this.baseDir, `${assetId}.json`); + const filePath = this._resolveAssetPath(normalizedAssetId); // Check if file exists - if (!fs.existsSync(filePath)) { - console.warn(`Asset not found: ${filePath}`); + if (!filePath || !fs.existsSync(filePath)) { + console.warn(`Asset not found for id '${normalizedAssetId}' in ${this.baseDir}`); return null; } @@ -56,6 +60,21 @@ class AssetLoader { } } + _resolveAssetPath(assetId) { + const exactPath = path.join(this.baseDir, `${assetId}.json`); + if (fs.existsSync(exactPath)) { + return exactPath; + } + + const target = `${assetId}.json`.toLowerCase(); + const files = fs.readdirSync(this.baseDir); + const matched = files.find((file) => file.toLowerCase() === target); + if (!matched) { + return null; + } + return path.join(this.baseDir, matched); + } + /** * Get all available assets in a dataset * @param {string} datasetType - The dataset folder name @@ -121,4 +140,4 @@ console.log('Available curves:', availableCurves); const { AssetLoader } = require('./index.js'); const customLoader = new AssetLoader(); const data = customLoader.loadCurve('hidrostal-H05K-S03R'); -*/ \ No newline at end of file +*/ diff --git a/datasets/assetData/measurement.json b/datasets/assetData/measurement.json index 7d1374e..32a7402 100644 --- a/datasets/assetData/measurement.json +++ b/datasets/assetData/measurement.json @@ -47,6 +47,69 @@ ] } ] + }, + { + "id": "Endress+Hauser", + "name": "Endress+Hauser", + "types": [ + { + "id": "flow", + "name": "Flow", + "models": [ + { "id": "Promag-W400", "name": "Promag W400", "units": ["m3/h", "l/s", "gpm"] }, + { "id": "Promag-W300", "name": "Promag W300", "units": ["m3/h", "l/s", "gpm"] } + ] + }, + { + "id": "pressure", + "name": "Pressure", + "models": [ + { "id": "Cerabar-PMC51", "name": "Cerabar PMC51", "units": ["mbar", "bar", "psi"] }, + { "id": "Cerabar-PMC71", "name": "Cerabar PMC71", "units": ["mbar", "bar", "psi"] } + ] + }, + { + "id": "level", + "name": "Level", + "models": [ + { "id": "Levelflex-FMP50", "name": "Levelflex FMP50", "units": ["m", "mm", "ft"] } + ] + } + ] + }, + { + "id": "Hach", + "name": "Hach", + "types": [ + { + "id": "dissolved-oxygen", + "name": "Dissolved Oxygen", + "models": [ + { "id": "LDO2", "name": "LDO2", "units": ["mg/L", "ppm"] } + ] + }, + { + "id": "ammonium", + "name": "Ammonium", + "models": [ + { "id": "Amtax-sc", "name": "Amtax sc", "units": ["mg/L"] } + ] + }, + { + "id": "nitrate", + "name": "Nitrate", + "models": [ + { "id": "Nitratax-sc", "name": "Nitratax sc", "units": ["mg/L"] } + ] + }, + { + "id": "tss", + "name": "TSS (Suspended Solids)", + "models": [ + { "id": "Solitax-sc", "name": "Solitax sc", "units": ["mg/L", "g/L"] } + ] + } + ] } ] } \ No newline at end of file diff --git a/index.js b/index.js index adc1aff..98db061 100644 --- a/index.js +++ b/index.js @@ -29,6 +29,7 @@ const { state } = require('./src/state/index.js'); const convert = require('./src/convert/index.js'); const MenuManager = require('./src/menu/index.js'); const { predict, interpolation } = require('./src/predict/index.js'); +const { PIDController, CascadePIDController, createPidController, createCascadePidController } = require('./src/pid/index.js'); const { loadCurve } = require('./datasets/assetData/curves/index.js'); //deprecated replace with load model data const { loadModel } = require('./datasets/assetData/modelData/index.js'); @@ -49,6 +50,10 @@ module.exports = { coolprop, convert, MenuManager, + PIDController, + CascadePIDController, + createPidController, + createCascadePidController, childRegistrationUtils, loadCurve, //deprecated replace with loadModel loadModel, diff --git a/package.json b/package.json index 030a360..85aab9e 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "./helper": "./src/helper/index.js", "./state": "./src/state/index.js", "./predict": "./src/predict/index.js", + "./pid": "./src/pid/index.js", "./nrmse": "./src/nrmse/index.js", "./outliers": "./src/outliers/index.js" }, diff --git a/src/configs/machineGroupControl.json b/src/configs/machineGroupControl.json index b087858..236a665 100644 --- a/src/configs/machineGroupControl.json +++ b/src/configs/machineGroupControl.json @@ -58,10 +58,10 @@ }, "functionality": { "softwareType": { - "default": "machineGroup", - "rules": { - "type": "string", - "description": "Logical name identifying the software type." + "default": "machinegroup", + "rules": { + "type": "string", + "description": "Logical name identifying the software type." } }, "role": { diff --git a/src/configs/pumpingStation.json b/src/configs/pumpingStation.json index 6c5ceda..c7694ef 100644 --- a/src/configs/pumpingStation.json +++ b/src/configs/pumpingStation.json @@ -59,7 +59,7 @@ }, "functionality": { "softwareType": { - "default": "pumpingStation", + "default": "pumpingstation", "rules": { "type": "string", "description": "Specified software type used to locate the proper default configuration." @@ -93,6 +93,14 @@ ] } }, + "distance": { + "default": null, + "rules": { + "type": "number", + "nullable": true, + "description": "Optional distance to parent asset for registration metadata." + } + }, "tickIntervalMs": { "default": 1000, "rules": { @@ -150,7 +158,7 @@ } }, "type": { - "default": "pumpingStation", + "default": "pumpingstation", "rules": { "type": "string", "description": "Specific asset type used to identify this configuration." @@ -316,6 +324,13 @@ "description": "Basis for minimum height check: inlet or outlet." } }, + "basinBottomRef": { + "default": 0, + "rules": { + "type": "number", + "description": "Absolute elevation reference of basin bottom." + } + }, "staticHead": { "default": 12, "rules": { @@ -463,6 +478,76 @@ } }, "flowBased": { + "flowSetpoint": { + "default": 0, + "rules": { + "type": "number", + "min": 0, + "description": "Target outflow setpoint used by flow-based control (m3/h)." + } + }, + "flowDeadband": { + "default": 0, + "rules": { + "type": "number", + "min": 0, + "description": "Allowed deadband around the outflow setpoint before corrective actions are taken (m3/h)." + } + }, + "pid": { + "default": {}, + "rules": { + "type": "object", + "schema": { + "kp": { + "default": 1.5, + "rules": { + "type": "number", + "description": "Proportional gain for flow-based PID control." + } + }, + "ki": { + "default": 0.05, + "rules": { + "type": "number", + "description": "Integral gain for flow-based PID control." + } + }, + "kd": { + "default": 0.01, + "rules": { + "type": "number", + "description": "Derivative gain for flow-based PID control." + } + }, + "derivativeFilter": { + "default": 0.2, + "rules": { + "type": "number", + "min": 0, + "max": 1, + "description": "Derivative filter coefficient (0..1)." + } + }, + "rateUp": { + "default": 30, + "rules": { + "type": "number", + "min": 0, + "description": "Maximum controller output increase rate (%/s)." + } + }, + "rateDown": { + "default": 40, + "rules": { + "type": "number", + "min": 0, + "description": "Maximum controller output decrease rate (%/s)." + } + } + } + } + }, "equalizationTargetPercent": { "default": 60, "rules": { diff --git a/src/configs/rotatingMachine.json b/src/configs/rotatingMachine.json index 76677fc..c1ad57b 100644 --- a/src/configs/rotatingMachine.json +++ b/src/configs/rotatingMachine.json @@ -110,6 +110,14 @@ "description": "Asset tag code which is a unique identifier for this asset. May be null if not assigned." } }, + "tagNumber": { + "default": null, + "rules": { + "type": "string", + "nullable": true, + "description": "Optional asset tag number for legacy integrations." + } + }, "geoLocation": { "default": {}, "rules": { @@ -175,6 +183,47 @@ "description": "The unit of measurement for this asset (e.g., 'meters', 'seconds', 'unitless')." } }, + "curveUnits": { + "default": { + "pressure": "mbar", + "flow": "m3/h", + "power": "kW", + "control": "%" + }, + "rules": { + "type": "object", + "schema": { + "pressure": { + "default": "mbar", + "rules": { + "type": "string", + "description": "Pressure unit used on the machine curve dimension axis." + } + }, + "flow": { + "default": "m3/h", + "rules": { + "type": "string", + "description": "Flow unit used in the machine curve output (nq.y)." + } + }, + "power": { + "default": "kW", + "rules": { + "type": "string", + "description": "Power unit used in the machine curve output (np.y)." + } + }, + "control": { + "default": "%", + "rules": { + "type": "string", + "description": "Control axis unit used in the curve x-dimension." + } + } + } + } + }, "accuracy": { "default": null, "rules": { @@ -445,4 +494,4 @@ } } } - \ No newline at end of file + diff --git a/src/helper/validationUtils.js b/src/helper/validationUtils.js index c442d59..41ee829 100644 --- a/src/helper/validationUtils.js +++ b/src/helper/validationUtils.js @@ -39,8 +39,21 @@ class ValidationUtils { const loggerEnabled = IloggerEnabled ?? true; const loggerLevel = IloggerLevel ?? "warn"; this.logger = new Logger(loggerEnabled, loggerLevel, 'ValidationUtils'); + this._onceLogCache = new Set(); } + _logOnce(level, onceKey, message) { + if (onceKey && this._onceLogCache.has(onceKey)) { + return; + } + if (onceKey) { + this._onceLogCache.add(onceKey); + } + if (typeof this.logger?.[level] === "function") { + this.logger[level](message); + } + } + constrain(value, min, max) { if (typeof value !== "number") { this.logger?.warn(`Value '${value}' is not a number. Defaulting to ${min}.`); @@ -96,7 +109,7 @@ class ValidationUtils { continue; } } else { - this.logger.info(`There is no value provided for ${name}.${key}. Using default value.`); + this.logger.debug(`No value provided for ${name}.${key}. Using default value.`); configValue = fieldSchema.default; } //continue; @@ -390,19 +403,52 @@ class ValidationUtils { return fieldSchema.default; } + const keyString = `${name}.${key}`; + const normalizeMode = rules.normalize || this._resolveStringNormalizeMode(keyString); + const preserveCase = normalizeMode !== "lowercase"; + // Check for uppercase characters and convert to lowercase if present - if (newConfigValue !== newConfigValue.toLowerCase()) { - this.logger.warn(`${name}.${key} contains uppercase characters. Converting to lowercase: ${newConfigValue} -> ${newConfigValue.toLowerCase()}`); + if (!preserveCase && newConfigValue !== newConfigValue.toLowerCase()) { + this._logOnce( + "info", + `normalize-lowercase:${keyString}`, + `${name}.${key} normalized to lowercase: ${newConfigValue} -> ${newConfigValue.toLowerCase()}` + ); newConfigValue = newConfigValue.toLowerCase(); } return newConfigValue; } + _isUnitLikeField(path) { + const normalized = String(path || "").toLowerCase(); + if (!normalized) return false; + return /(^|\.)([a-z0-9]*unit|units)(\.|$)/.test(normalized) + || normalized.includes(".curveunits."); + } + + _resolveStringNormalizeMode(path) { + const normalized = String(path || "").toLowerCase(); + if (!normalized) return "none"; + + if (this._isUnitLikeField(normalized)) return "none"; + if (normalized.endsWith(".name")) return "none"; + if (normalized.endsWith(".model")) return "none"; + if (normalized.endsWith(".supplier")) return "none"; + if (normalized.endsWith(".role")) return "none"; + if (normalized.endsWith(".description")) return "none"; + + if (normalized.endsWith(".softwaretype")) return "lowercase"; + if (normalized.endsWith(".type")) return "lowercase"; + if (normalized.endsWith(".category")) return "lowercase"; + + return "lowercase"; + } + validateSet(configValue, rules, fieldSchema, name, key) { // 1. Ensure we have a Set. If not, use default. if (!(configValue instanceof Set)) { - this.logger.info(`${name}.${key} is not a Set. Converting to one using default value.`); + this.logger.debug(`${name}.${key} is not a Set. Converting to one using default value.`); return new Set(fieldSchema.default); } @@ -426,9 +472,10 @@ class ValidationUtils { .slice(0, rules.maxLength || Infinity); // 4. Check if the filtered array meets the minimum length. - if (validatedArray.length < (rules.minLength || 1)) { + const minLength = Number.isInteger(rules.minLength) ? rules.minLength : 0; + if (validatedArray.length < minLength) { this.logger.warn( - `${name}.${key} contains fewer items than allowed (${rules.minLength}). Using default value.` + `${name}.${key} contains fewer items than allowed (${minLength}). Using default value.` ); return new Set(fieldSchema.default); } @@ -439,7 +486,7 @@ class ValidationUtils { validateArray(configValue, rules, fieldSchema, name, key) { if (!Array.isArray(configValue)) { - this.logger.info(`${name}.${key} is not an array. Using default value.`); + this.logger.debug(`${name}.${key} is not an array. Using default value.`); return fieldSchema.default; } @@ -460,9 +507,10 @@ class ValidationUtils { }) .slice(0, rules.maxLength || Infinity); - if (validatedArray.length < (rules.minLength || 1)) { + const minLength = Number.isInteger(rules.minLength) ? rules.minLength : 0; + if (validatedArray.length < minLength) { this.logger.warn( - `${name}.${key} contains fewer items than allowed (${rules.minLength}). Using default value.` + `${name}.${key} contains fewer items than allowed (${minLength}). Using default value.` ); return fieldSchema.default; } diff --git a/src/measurements/MeasurementContainer.js b/src/measurements/MeasurementContainer.js index 682cf08..4c83420 100644 --- a/src/measurements/MeasurementContainer.js +++ b/src/measurements/MeasurementContainer.js @@ -17,7 +17,7 @@ class MeasurementContainer { this._currentDistance = null; this._unit = null; - // Default units for each measurement type + // Default units for each measurement type (ingress/preferred) this.defaultUnits = { pressure: 'mbar', flow: 'm3/h', @@ -27,10 +27,48 @@ class MeasurementContainer { length: 'm', ...options.defaultUnits // Allow override }; + + // Canonical storage unit map (single conversion anchor per measurement type) + this.canonicalUnits = { + pressure: 'Pa', + atmPressure: 'Pa', + flow: 'm3/s', + power: 'W', + hydraulicPower: 'W', + temperature: 'K', + volume: 'm3', + length: 'm', + mass: 'kg', + energy: 'J', + ...options.canonicalUnits, + }; // Auto-conversion settings this.autoConvert = options.autoConvert !== false; // Default to true this.preferredUnits = options.preferredUnits || {}; // Per-measurement overrides + this.storeCanonical = options.storeCanonical === true; + this.strictUnitValidation = options.strictUnitValidation === true; + this.throwOnInvalidUnit = options.throwOnInvalidUnit === true; + this.requireUnitForTypes = new Set( + (options.requireUnitForTypes || []).map((t) => String(t).trim().toLowerCase()) + ); + + // Map EVOLV measurement types to convert-module measure families + this.measureMap = { + pressure: 'pressure', + atmpressure: 'pressure', + flow: 'volumeFlowRate', + power: 'power', + hydraulicpower: 'power', + reactivepower: 'reactivePower', + apparentpower: 'apparentPower', + temperature: 'temperature', + volume: 'volume', + length: 'length', + mass: 'mass', + energy: 'energy', + reactiveenergy: 'reactiveEnergy', + }; // For chaining context this._currentType = null; @@ -72,6 +110,11 @@ class MeasurementContainer { return this; } + setCanonicalUnit(measurementType, unit) { + this.canonicalUnits[measurementType] = unit; + return this; + } + // Get the target unit for a measurement type _getTargetUnit(measurementType) { return this.preferredUnits[measurementType] || @@ -79,6 +122,77 @@ class MeasurementContainer { null; } + _getCanonicalUnit(measurementType) { + return this.canonicalUnits[measurementType] || null; + } + + _normalizeType(measurementType) { + return String(measurementType || '').trim().toLowerCase(); + } + + _describeUnit(unit) { + if (typeof unit !== 'string' || unit.trim() === '') return null; + try { + return convertModule().describe(unit.trim()); + } catch (error) { + return null; + } + } + + isUnitCompatible(measurementType, unit) { + const desc = this._describeUnit(unit); + if (!desc) return false; + const normalizedType = this._normalizeType(measurementType); + const expectedMeasure = this.measureMap[normalizedType]; + if (!expectedMeasure) return true; + return desc.measure === expectedMeasure; + } + + _handleUnitViolation(message) { + if (this.throwOnInvalidUnit) { + throw new Error(message); + } + if (this.logger) { + this.logger.warn(message); + } + } + + _resolveUnitPolicy(measurementType, sourceUnit = null) { + const normalizedType = this._normalizeType(measurementType); + const rawSourceUnit = typeof sourceUnit === 'string' && sourceUnit.trim() + ? sourceUnit.trim() + : null; + const fallbackIngressUnit = this._getTargetUnit(measurementType); + const canonicalUnit = this._getCanonicalUnit(measurementType); + const resolvedSourceUnit = rawSourceUnit || fallbackIngressUnit || canonicalUnit || null; + + if (this.requireUnitForTypes.has(normalizedType) && !rawSourceUnit) { + this._handleUnitViolation(`Missing source unit for required measurement type '${measurementType}'.`); + return { valid: false }; + } + + if (resolvedSourceUnit && !this.isUnitCompatible(measurementType, resolvedSourceUnit)) { + this._handleUnitViolation(`Incompatible or unknown source unit '${resolvedSourceUnit}' for measurement type '${measurementType}'.`); + return { valid: false }; + } + + const resolvedStorageUnit = this.storeCanonical + ? (canonicalUnit || fallbackIngressUnit || resolvedSourceUnit) + : (fallbackIngressUnit || canonicalUnit || resolvedSourceUnit); + + if (resolvedStorageUnit && !this.isUnitCompatible(measurementType, resolvedStorageUnit)) { + this._handleUnitViolation(`Incompatible storage unit '${resolvedStorageUnit}' for measurement type '${measurementType}'.`); + return { valid: false }; + } + + return { + valid: true, + sourceUnit: resolvedSourceUnit, + storageUnit: resolvedStorageUnit || null, + strictValidation: this.strictUnitValidation, + }; + } + getUnit(type) { if (!type) return null; if (this.preferredUnits && this.preferredUnits[type]) return this.preferredUnits[type]; @@ -135,34 +249,40 @@ class MeasurementContainer { // ENHANCED: Update your existing value method value(val, timestamp = Date.now(), sourceUnit = null) { if (!this._ensureChainIsValid()) return this; - + + const unitPolicy = this._resolveUnitPolicy(this._currentType, sourceUnit); + if (!unitPolicy.valid) return this; + const measurement = this._getOrCreateMeasurement(); - const targetUnit = this._getTargetUnit(this._currentType); - + const targetUnit = unitPolicy.storageUnit; + let convertedValue = val; - let finalUnit = sourceUnit || targetUnit; + let finalUnit = targetUnit || unitPolicy.sourceUnit; // Auto-convert if enabled and units are specified - if (this.autoConvert && sourceUnit && targetUnit && sourceUnit !== targetUnit) { + if (this.autoConvert && unitPolicy.sourceUnit && targetUnit && unitPolicy.sourceUnit !== targetUnit) { try { - convertedValue = convertModule(val).from(sourceUnit).to(targetUnit); + convertedValue = convertModule(val).from(unitPolicy.sourceUnit).to(targetUnit); finalUnit = targetUnit; if (this.logger) { - this.logger.debug(`Auto-converted ${val} ${sourceUnit} to ${convertedValue} ${targetUnit}`); + this.logger.debug(`Auto-converted ${val} ${unitPolicy.sourceUnit} to ${convertedValue} ${targetUnit}`); } } catch (error) { - if (this.logger) { - this.logger.warn(`Auto-conversion failed from ${sourceUnit} to ${targetUnit}: ${error.message}`); + const message = `Auto-conversion failed from ${unitPolicy.sourceUnit} to ${targetUnit}: ${error.message}`; + if (this.strictUnitValidation) { + this._handleUnitViolation(message); + return this; } + if (this.logger) this.logger.warn(message); convertedValue = val; - finalUnit = sourceUnit; + finalUnit = unitPolicy.sourceUnit; } } measurement.setValue(convertedValue, timestamp); - if (finalUnit && !measurement.unit) { + if (finalUnit) { measurement.setUnit(finalUnit); } @@ -171,7 +291,7 @@ class MeasurementContainer { value: convertedValue, originalValue: val, unit: finalUnit, - sourceUnit: sourceUnit, + sourceUnit: unitPolicy.sourceUnit, timestamp, position: this._currentPosition, distance: this._currentDistance, @@ -408,21 +528,22 @@ class MeasurementContainer { .reduce((acc, v) => acc + v, 0); } - getFlattenedOutput() { + getFlattenedOutput(options = {}) { + const requestedUnits = options.requestedUnits || (options.usePreferredUnits ? this.preferredUnits : null); const out = {}; Object.entries(this.measurements).forEach(([type, variants]) => { Object.entries(variants).forEach(([variant, positions]) => { Object.entries(positions).forEach(([position, entry]) => { // Legacy single series if (entry?.getCurrentValue) { - out[`${type}.${variant}.${position}`] = entry.getCurrentValue(); + out[`${type}.${variant}.${position}`] = this._resolveOutputValue(type, entry, requestedUnits); return; } // Child-bucketed series if (entry && typeof entry === 'object') { Object.entries(entry).forEach(([childId, m]) => { if (m?.getCurrentValue) { - out[`${type}.${variant}.${position}.${childId}`] = m.getCurrentValue(); + out[`${type}.${variant}.${position}.${childId}`] = this._resolveOutputValue(type, m, requestedUnits); } }); } @@ -528,6 +649,18 @@ class MeasurementContainer { Object.keys(this.measurements[this._currentType]) : []; } + _resolveOutputValue(type, measurement, requestedUnits = null) { + const value = measurement.getCurrentValue(); + if (!requestedUnits || value === null || typeof value === 'undefined') { + return value; + } + const targetUnit = requestedUnits[type]; + if (!targetUnit) { + return value; + } + return this._convertValueToUnit(value, measurement.unit, targetUnit); + } + getPositions() { if (!this._currentType || !this._currentVariant) { if (this.logger) { @@ -553,7 +686,7 @@ class MeasurementContainer { // Helper method for value conversion _convertValueToUnit(value, fromUnit, toUnit) { - if (!value || !fromUnit || !toUnit || fromUnit === toUnit) { + if ((value === null || typeof value === 'undefined') || !fromUnit || !toUnit || fromUnit === toUnit) { return value; } @@ -572,19 +705,7 @@ class MeasurementContainer { const type = measurementType || this._currentType; if (!type) return []; - // Map measurement types to convert module measures - const measureMap = { - pressure: 'pressure', - flow: 'volumeFlowRate', - power: 'power', - temperature: 'temperature', - volume: 'volume', - length: 'length', - mass: 'mass', - energy: 'energy' - }; - - const convertMeasure = measureMap[type]; + const convertMeasure = this.measureMap[this._normalizeType(type)]; if (!convertMeasure) return []; try { diff --git a/src/nrmse/errorMetrics.js b/src/nrmse/errorMetrics.js index 07b7765..8dae919 100644 --- a/src/nrmse/errorMetrics.js +++ b/src/nrmse/errorMetrics.js @@ -1,125 +1,126 @@ -//load local dependencies const EventEmitter = require('events'); - -//load all config modules const defaultConfig = require('./nrmseConfig.json'); const ConfigUtils = require('../helper/configUtils'); class ErrorMetrics { constructor(config = {}, logger) { - - this.emitter = new EventEmitter(); // Own EventEmitter + this.emitter = new EventEmitter(); this.configUtils = new ConfigUtils(defaultConfig); this.config = this.configUtils.initConfig(config); - - // Init after config is set this.logger = logger; - // For long-term NRMSD accumulation + this.metricState = new Map(); + this.legacyMetricId = 'default'; + + // Backward-compatible fields retained for existing callers/tests. this.cumNRMSD = 0; this.cumCount = 0; } - //INCLUDE timestamps in the next update OLIFANT - meanSquaredError(predicted, measured) { - if (predicted.length !== measured.length) { - this.logger.error("Comparing MSE Arrays must have the same length."); + registerMetric(metricId, profile = {}) { + const key = String(metricId || this.legacyMetricId); + const state = this._ensureMetricState(key); + state.profile = { ...state.profile, ...profile }; + return state.profile; + } + + resetMetric(metricId = this.legacyMetricId) { + this.metricState.delete(String(metricId)); + if (metricId === this.legacyMetricId) { + this.cumNRMSD = 0; + this.cumCount = 0; + } + } + + getMetricState(metricId = this.legacyMetricId) { + return this.metricState.get(String(metricId)) || null; + } + + meanSquaredError(predicted, measured, options = {}) { + const { p, m } = this._validateSeries(predicted, measured, options); + let sumSqError = 0; + for (let i = 0; i < p.length; i += 1) { + const err = p[i] - m[i]; + sumSqError += err * err; + } + return sumSqError / p.length; + } + + rootMeanSquaredError(predicted, measured, options = {}) { + return Math.sqrt(this.meanSquaredError(predicted, measured, options)); + } + + normalizedRootMeanSquaredError(predicted, measured, processMin, processMax, options = {}) { + const range = Number(processMax) - Number(processMin); + if (!Number.isFinite(range) || range <= 0) { + this._failOrLog( + `Invalid process range: processMax (${processMax}) must be greater than processMin (${processMin}).`, + options + ); + return NaN; + } + const rmse = this.rootMeanSquaredError(predicted, measured, options); + return rmse / range; + } + + normalizeUsingRealtime(predicted, measured, options = {}) { + const { p, m } = this._validateSeries(predicted, measured, options); + const realtimeMin = Math.min(Math.min(...p), Math.min(...m)); + const realtimeMax = Math.max(Math.max(...p), Math.max(...m)); + const range = realtimeMax - realtimeMin; + if (!Number.isFinite(range) || range <= 0) { + throw new Error('Invalid process range: processMax must be greater than processMin.'); + } + const rmse = this.rootMeanSquaredError(p, m, options); + return rmse / range; + } + + longTermNRMSD(input, metricId = this.legacyMetricId, options = {}) { + const metricKey = String(metricId || this.legacyMetricId); + const state = this._ensureMetricState(metricKey); + const profile = this._resolveProfile(metricKey, options); + const value = Number(input); + if (!Number.isFinite(value)) { + this._failOrLog(`longTermNRMSD input must be finite. Received: ${input}`, options); return 0; } - - let sumSqError = 0; - for (let i = 0; i < predicted.length; i++) { - const err = predicted[i] - measured[i]; - sumSqError += err * err; - } - return sumSqError / predicted.length; - } - - rootMeanSquaredError(predicted, measured) { - return Math.sqrt(this.meanSquaredError(predicted, measured)); - } - - normalizedRootMeanSquaredError(predicted, measured, processMin, processMax) { - const range = processMax - processMin; - if (range <= 0) { - this.logger.error("Invalid process range: processMax must be greater than processMin."); - } - const rmse = this.rootMeanSquaredError(predicted, measured); - return rmse / range; - } - - longTermNRMSD(input) { - - const storedNRMSD = this.cumNRMSD; - const storedCount = this.cumCount; - const newCount = storedCount + 1; - - // Update cumulative values - this.cumCount = newCount; - - // Calculate new running average - if (storedCount === 0) { - this.cumNRMSD = input; // First value - } else { - // Running average formula: newAvg = oldAvg + (newValue - oldAvg) / newCount - this.cumNRMSD = storedNRMSD + (input - storedNRMSD) / newCount; + // Keep backward compatibility if callers manipulate cumCount/cumNRMSD directly. + if (metricKey === this.legacyMetricId && (state.sampleCount !== this.cumCount || state.longTermEwma !== this.cumNRMSD)) { + state.sampleCount = Number(this.cumCount) || 0; + state.longTermEwma = Number(this.cumNRMSD) || 0; } - if(newCount >= 100) { - // Return the current NRMSD value, not just the contribution from this sample - return this.cumNRMSD; - } - return 0; - } + state.sampleCount += 1; + const alpha = profile.ewmaAlpha; + state.longTermEwma = state.sampleCount === 1 ? value : (alpha * value) + ((1 - alpha) * state.longTermEwma); - normalizeUsingRealtime(predicted, measured) { - const realtimeMin = Math.min(Math.min(...predicted), Math.min(...measured)); - const realtimeMax = Math.max(Math.max(...predicted), Math.max(...measured)); - const range = realtimeMax - realtimeMin; - if (range <= 0) { - throw new Error("Invalid process range: processMax must be greater than processMin."); + if (metricKey === this.legacyMetricId) { + this.cumCount = state.sampleCount; + this.cumNRMSD = state.longTermEwma; } - const rmse = this.rootMeanSquaredError(predicted, measured); - return rmse / range; + + if (state.sampleCount < profile.minSamplesForLongTerm) { + return 0; + } + return state.longTermEwma; } detectImmediateDrift(nrmse) { - let ImmDrift = {}; - this.logger.debug(`checking immediate drift with thresholds : ${this.config.thresholds.NRMSE_HIGH} ${this.config.thresholds.NRMSE_MEDIUM} ${this.config.thresholds.NRMSE_LOW}`); - switch (true) { - case( nrmse > this.config.thresholds.NRMSE_HIGH ) : - ImmDrift = {level : 3 , feedback : "High immediate drift detected"}; - break; - case( nrmse > this.config.thresholds.NRMSE_MEDIUM ) : - ImmDrift = {level : 2 , feedback : "Medium immediate drift detected"}; - break; - case(nrmse > this.config.thresholds.NRMSE_LOW ): - ImmDrift = {level : 1 , feedback : "Low immediate drift detected"}; - break; - default: - ImmDrift = {level : 0 , feedback : "No drift detected"}; - } - return ImmDrift; + const thresholds = this.config.thresholds; + if (nrmse > thresholds.NRMSE_HIGH) return { level: 3, feedback: 'High immediate drift detected' }; + if (nrmse > thresholds.NRMSE_MEDIUM) return { level: 2, feedback: 'Medium immediate drift detected' }; + if (nrmse > thresholds.NRMSE_LOW) return { level: 1, feedback: 'Low immediate drift detected' }; + return { level: 0, feedback: 'No drift detected' }; } detectLongTermDrift(longTermNRMSD) { - let LongTermDrift = {}; - this.logger.debug(`checking longterm drift with thresholds : ${this.config.thresholds.LONG_TERM_HIGH} ${this.config.thresholds.LONG_TERM_MEDIUM} ${this.config.thresholds.LONG_TERM_LOW}`); - switch (true) { - case(Math.abs(longTermNRMSD) > this.config.thresholds.LONG_TERM_HIGH) : - LongTermDrift = {level : 3 , feedback : "High long-term drift detected"}; - break; - case (Math.abs(longTermNRMSD) > this.config.thresholds.LONG_TERM_MEDIUM) : - LongTermDrift = {level : 2 , feedback : "Medium long-term drift detected"}; - break; - case ( Math.abs(longTermNRMSD) > this.config.thresholds.LONG_TERM_LOW ) : - LongTermDrift = {level : 1 , feedback : "Low long-term drift detected"}; - break; - default: - LongTermDrift = {level : 0 , feedback : "No drift detected"}; - } - return LongTermDrift; + const thresholds = this.config.thresholds; + const absValue = Math.abs(longTermNRMSD); + if (absValue > thresholds.LONG_TERM_HIGH) return { level: 3, feedback: 'High long-term drift detected' }; + if (absValue > thresholds.LONG_TERM_MEDIUM) return { level: 2, feedback: 'Medium long-term drift detected' }; + if (absValue > thresholds.LONG_TERM_LOW) return { level: 1, feedback: 'Low long-term drift detected' }; + return { level: 0, feedback: 'No drift detected' }; } detectDrift(nrmse, longTermNRMSD) { @@ -128,27 +129,272 @@ class ErrorMetrics { return { ImmDrift, LongTermDrift }; } - // asses the drift - assessDrift(predicted, measured, processMin, processMax) { - // Compute NRMSE and check for immediate drift - const nrmse = this.normalizedRootMeanSquaredError(predicted, measured, processMin, processMax); - this.logger.debug(`NRMSE: ${nrmse}`); - // cmopute long-term NRMSD and add result to cumalitve NRMSD - const longTermNRMSD = this.longTermNRMSD(nrmse); - // return the drift - // Return the drift assessment object + assessDrift(predicted, measured, processMin, processMax, options = {}) { + const metricKey = String(options.metricId || this.legacyMetricId); + const profile = this._resolveProfile(metricKey, options); + const strict = this._resolveStrict(options, profile); + + const aligned = this._alignSeriesByTimestamp(predicted, measured, options, profile); + if (!aligned.valid) { + if (strict) { + throw new Error(aligned.reason); + } + return this._invalidAssessment(metricKey, aligned.reason); + } + + const nrmse = this.normalizedRootMeanSquaredError( + aligned.predicted, + aligned.measured, + processMin, + processMax, + { ...options, strictValidation: strict } + ); + if (!Number.isFinite(nrmse)) { + if (strict) { + throw new Error('NRMSE calculation returned a non-finite value.'); + } + return this._invalidAssessment(metricKey, 'non_finite_nrmse'); + } + + const longTermNRMSD = this.longTermNRMSD(nrmse, metricKey, { ...options, strictValidation: strict }); const driftAssessment = this.detectDrift(nrmse, longTermNRMSD); - return { + const state = this._ensureMetricState(metricKey); + state.lastResult = { nrmse, longTermNRMSD, immediateLevel: driftAssessment.ImmDrift.level, immediateFeedback: driftAssessment.ImmDrift.feedback, longTermLevel: driftAssessment.LongTermDrift.level, - longTermFeedback: driftAssessment.LongTermDrift.feedback + longTermFeedback: driftAssessment.LongTermDrift.feedback, + valid: true, + metricId: metricKey, + sampleCount: state.sampleCount, + longTermReady: state.sampleCount >= profile.minSamplesForLongTerm, + flags: [], + }; + return state.lastResult; + } + + assessPoint(metricId, predictedValue, measuredValue, options = {}) { + const metricKey = String(metricId || this.legacyMetricId); + const profile = this._resolveProfile(metricKey, options); + const state = this._ensureMetricState(metricKey); + const strict = this._resolveStrict(options, profile); + + const p = Number(predictedValue); + const m = Number(measuredValue); + if (!Number.isFinite(p) || !Number.isFinite(m)) { + const reason = `assessPoint requires finite numbers. predicted=${predictedValue}, measured=${measuredValue}`; + if (strict) { + throw new Error(reason); + } + return this._invalidAssessment(metricKey, reason); + } + + const predictedTimestamp = Number(options.predictedTimestamp ?? options.timestamp ?? Date.now()); + const measuredTimestamp = Number(options.measuredTimestamp ?? options.timestamp ?? Date.now()); + const delta = Math.abs(predictedTimestamp - measuredTimestamp); + if (delta > profile.alignmentToleranceMs) { + const reason = `Sample timestamp delta (${delta} ms) exceeds tolerance (${profile.alignmentToleranceMs} ms)`; + if (strict) { + throw new Error(reason); + } + return this._invalidAssessment(metricKey, reason); + } + + state.predicted.push(p); + state.measured.push(m); + state.predictedTimestamps.push(predictedTimestamp); + state.measuredTimestamps.push(measuredTimestamp); + + while (state.predicted.length > profile.windowSize) state.predicted.shift(); + while (state.measured.length > profile.windowSize) state.measured.shift(); + while (state.predictedTimestamps.length > profile.windowSize) state.predictedTimestamps.shift(); + while (state.measuredTimestamps.length > profile.windowSize) state.measuredTimestamps.shift(); + + if (state.predicted.length < 2 || state.measured.length < 2) { + return this._invalidAssessment(metricKey, 'insufficient_samples'); + } + + let processMin = Number(options.processMin); + let processMax = Number(options.processMax); + if (!Number.isFinite(processMin) || !Number.isFinite(processMax) || processMax <= processMin) { + processMin = Math.min(...state.predicted, ...state.measured); + processMax = Math.max(...state.predicted, ...state.measured); + if (!Number.isFinite(processMin) || !Number.isFinite(processMax) || processMax <= processMin) { + processMin = 0; + processMax = 1; + } + } + + return this.assessDrift(state.predicted, state.measured, processMin, processMax, { + ...options, + metricId: metricKey, + strictValidation: strict, + predictedTimestamps: state.predictedTimestamps, + measuredTimestamps: state.measuredTimestamps, + }); + } + + _ensureMetricState(metricId) { + const key = String(metricId || this.legacyMetricId); + if (!this.metricState.has(key)) { + this.metricState.set(key, { + predicted: [], + measured: [], + predictedTimestamps: [], + measuredTimestamps: [], + sampleCount: 0, + longTermEwma: 0, + profile: {}, + lastResult: null, + }); + } + return this.metricState.get(key); + } + + _resolveProfile(metricId, options = {}) { + const state = this._ensureMetricState(metricId); + const base = this.config.processing || {}; + return { + windowSize: Number(options.windowSize ?? state.profile.windowSize ?? base.windowSize ?? 50), + minSamplesForLongTerm: Number(options.minSamplesForLongTerm ?? state.profile.minSamplesForLongTerm ?? base.minSamplesForLongTerm ?? 100), + ewmaAlpha: Number(options.ewmaAlpha ?? state.profile.ewmaAlpha ?? base.ewmaAlpha ?? 0.1), + alignmentToleranceMs: Number(options.alignmentToleranceMs ?? state.profile.alignmentToleranceMs ?? base.alignmentToleranceMs ?? 2000), + strictValidation: Boolean(options.strictValidation ?? state.profile.strictValidation ?? base.strictValidation ?? true), }; } + _resolveStrict(options = {}, profile = null) { + if (Object.prototype.hasOwnProperty.call(options, 'strictValidation')) { + return Boolean(options.strictValidation); + } + if (profile && Object.prototype.hasOwnProperty.call(profile, 'strictValidation')) { + return Boolean(profile.strictValidation); + } + return Boolean(this.config.processing?.strictValidation ?? true); + } + _validateSeries(predicted, measured, options = {}) { + if (!Array.isArray(predicted) || !Array.isArray(measured)) { + this._failOrLog('predicted and measured must be arrays.', options); + return { p: [], m: [] }; + } + if (!predicted.length || !measured.length) { + this._failOrLog('predicted and measured arrays must not be empty.', options); + return { p: [], m: [] }; + } + if (predicted.length !== measured.length) { + this._failOrLog('predicted and measured arrays must have the same length.', options); + return { p: [], m: [] }; + } + + const p = predicted.map(Number); + const m = measured.map(Number); + const hasBad = p.some((v) => !Number.isFinite(v)) || m.some((v) => !Number.isFinite(v)); + if (hasBad) { + this._failOrLog('predicted and measured arrays must contain finite numeric values.', options); + return { p: [], m: [] }; + } + return { p, m }; + } + + _alignSeriesByTimestamp(predicted, measured, options = {}, profile = null) { + const strict = this._resolveStrict(options, profile); + const tolerance = Number(options.alignmentToleranceMs ?? profile?.alignmentToleranceMs ?? 2000); + const predictedTimestamps = Array.isArray(options.predictedTimestamps) ? options.predictedTimestamps.map(Number) : null; + const measuredTimestamps = Array.isArray(options.measuredTimestamps) ? options.measuredTimestamps.map(Number) : null; + + if (!predictedTimestamps || !measuredTimestamps) { + if (!Array.isArray(predicted) || !Array.isArray(measured)) { + return { valid: false, reason: 'predicted and measured must be arrays.' }; + } + if (predicted.length !== measured.length) { + const reason = `Series length mismatch without timestamps: predicted=${predicted.length}, measured=${measured.length}`; + if (strict) return { valid: false, reason }; + const n = Math.min(predicted.length, measured.length); + if (n < 2) return { valid: false, reason }; + return { + valid: true, + predicted: predicted.slice(-n).map(Number), + measured: measured.slice(-n).map(Number), + flags: ['length_mismatch_realigned'], + }; + } + try { + const { p, m } = this._validateSeries(predicted, measured, { ...options, strictValidation: true }); + return { valid: true, predicted: p, measured: m, flags: [] }; + } catch (error) { + return { valid: false, reason: error.message }; + } + } + + if (!Array.isArray(predicted) || !Array.isArray(measured)) { + return { valid: false, reason: 'predicted and measured must be arrays.' }; + } + if (predicted.length !== predictedTimestamps.length || measured.length !== measuredTimestamps.length) { + return { valid: false, reason: 'timestamp arrays must match value-array lengths.' }; + } + + const predictedSamples = predicted + .map((v, i) => ({ value: Number(v), ts: predictedTimestamps[i] })) + .filter((s) => Number.isFinite(s.value) && Number.isFinite(s.ts)) + .sort((a, b) => a.ts - b.ts); + const measuredSamples = measured + .map((v, i) => ({ value: Number(v), ts: measuredTimestamps[i] })) + .filter((s) => Number.isFinite(s.value) && Number.isFinite(s.ts)) + .sort((a, b) => a.ts - b.ts); + + const alignedPredicted = []; + const alignedMeasured = []; + let i = 0; + let j = 0; + while (i < predictedSamples.length && j < measuredSamples.length) { + const p = predictedSamples[i]; + const m = measuredSamples[j]; + const delta = p.ts - m.ts; + if (Math.abs(delta) <= tolerance) { + alignedPredicted.push(p.value); + alignedMeasured.push(m.value); + i += 1; + j += 1; + } else if (delta < 0) { + i += 1; + } else { + j += 1; + } + } + + if (alignedPredicted.length < 2 || alignedMeasured.length < 2) { + return { valid: false, reason: 'insufficient aligned samples after timestamp matching.' }; + } + + return { valid: true, predicted: alignedPredicted, measured: alignedMeasured, flags: [] }; + } + + _invalidAssessment(metricId, reason) { + return { + nrmse: NaN, + longTermNRMSD: 0, + immediateLevel: 0, + immediateFeedback: 'Drift assessment unavailable', + longTermLevel: 0, + longTermFeedback: 'Drift assessment unavailable', + valid: false, + metricId: String(metricId || this.legacyMetricId), + sampleCount: this._ensureMetricState(metricId).sampleCount, + longTermReady: false, + flags: [reason], + }; + } + + _failOrLog(message, options = {}) { + const strict = this._resolveStrict(options); + if (strict) { + throw new Error(message); + } + this.logger?.warn?.(message); + } } module.exports = ErrorMetrics; diff --git a/src/nrmse/nrmseConfig.json b/src/nrmse/nrmseConfig.json index b8eeb9a..a35f33e 100644 --- a/src/nrmse/nrmseConfig.json +++ b/src/nrmse/nrmseConfig.json @@ -1,7 +1,7 @@ { "general": { "name": { - "default": "ErrorMetrics", + "default": "errormetrics", "rules": { "type": "string", "description": "A human-readable name for the configuration." @@ -58,7 +58,7 @@ }, "functionality": { "softwareType": { - "default": "errorMetrics", + "default": "errormetrics", "rules": { "type": "string", "description": "Logical name identifying the software type." @@ -134,5 +134,47 @@ "description": "High threshold for long-term normalized root mean squared deviation." } } + }, + "processing": { + "windowSize": { + "default": 50, + "rules": { + "type": "integer", + "min": 2, + "description": "Rolling sample window size used for drift evaluation." + } + }, + "minSamplesForLongTerm": { + "default": 100, + "rules": { + "type": "integer", + "min": 1, + "description": "Minimum sample count before long-term drift is considered mature." + } + }, + "ewmaAlpha": { + "default": 0.1, + "rules": { + "type": "number", + "min": 0.001, + "max": 1, + "description": "EWMA smoothing factor for long-term drift trend." + } + }, + "alignmentToleranceMs": { + "default": 2000, + "rules": { + "type": "integer", + "min": 0, + "description": "Maximum timestamp delta allowed between predicted and measured sample pairs." + } + }, + "strictValidation": { + "default": true, + "rules": { + "type": "boolean", + "description": "When true, invalid inputs raise errors instead of producing silent outputs." + } + } } } diff --git a/src/pid/PIDController.js b/src/pid/PIDController.js index 1f07ac4..2354269 100644 --- a/src/pid/PIDController.js +++ b/src/pid/PIDController.js @@ -1,8 +1,16 @@ 'use strict'; /** - * Discrete PID controller with optional derivative filtering and integral limits. - * Sample times are expressed in milliseconds to align with Node.js timestamps. + * Production-focused discrete PID controller with modern control features: + * - auto/manual and bumpless transfer + * - freeze/unfreeze (hold output while optionally tracking process) + * - derivative filtering and derivative-on-measurement/error + * - anti-windup (clamp or back-calculation) + * - output and integral limits + * - output rate limiting + * - deadband + * - gain scheduling (array/function) + * - feedforward and dynamic tunings at runtime */ class PIDController { constructor(options = {}) { @@ -17,7 +25,19 @@ class PIDController { integralMin = null, integralMax = null, derivativeOnMeasurement = true, - autoMode = true + setpointWeight = 1, + derivativeWeight = 0, + deadband = 0, + outputRateLimitUp = Number.POSITIVE_INFINITY, + outputRateLimitDown = Number.POSITIVE_INFINITY, + antiWindupMode = 'clamp', + backCalculationGain = 0, + gainSchedule = null, + autoMode = true, + trackOnManual = true, + frozen = false, + freezeTrackMeasurement = true, + freezeTrackError = false, } = options; this.kp = 0; @@ -29,17 +49,23 @@ class PIDController { this.setOutputLimits(outputMin, outputMax); this.setIntegralLimits(integralMin, integralMax); this.setDerivativeFilter(derivativeFilter); + this.setSetpointWeights({ beta: setpointWeight, gamma: derivativeWeight }); + this.setDeadband(deadband); + this.setOutputRateLimits(outputRateLimitUp, outputRateLimitDown); + this.setAntiWindup({ mode: antiWindupMode, backCalculationGain }); + this.setGainSchedule(gainSchedule); this.derivativeOnMeasurement = Boolean(derivativeOnMeasurement); this.autoMode = Boolean(autoMode); + this.trackOnManual = Boolean(trackOnManual); + + this.frozen = Boolean(frozen); + this.freezeTrackMeasurement = Boolean(freezeTrackMeasurement); + this.freezeTrackError = Boolean(freezeTrackError); 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)) { @@ -54,9 +80,6 @@ class PIDController { 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'); @@ -66,9 +89,6 @@ class PIDController { 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'); @@ -86,9 +106,6 @@ class PIDController { 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'); @@ -106,10 +123,6 @@ class PIDController { 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'); @@ -119,94 +132,294 @@ class PIDController { 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"'); + setSetpointWeights({ beta = this.setpointWeight ?? 1, gamma = this.derivativeWeight ?? 0 } = {}) { + if (!Number.isFinite(beta) || !Number.isFinite(gamma)) { + throw new TypeError('setpoint and derivative weights must be finite numbers'); } - this.autoMode = mode === 'automatic'; + this.setpointWeight = beta; + this.derivativeWeight = gamma; + return this; + } + + setDeadband(value = this.deadband ?? 0) { + if (!Number.isFinite(value) || value < 0) { + throw new RangeError('deadband must be a non-negative finite number'); + } + + this.deadband = value; + return this; + } + + setOutputRateLimits(up = this.outputRateLimitUp, down = this.outputRateLimitDown) { + if (!Number.isFinite(up) && up !== Number.POSITIVE_INFINITY) { + throw new TypeError('outputRateLimitUp must be finite or Infinity'); + } + if (!Number.isFinite(down) && down !== Number.POSITIVE_INFINITY) { + throw new TypeError('outputRateLimitDown must be finite or Infinity'); + } + if (up <= 0 || down <= 0) { + throw new RangeError('output rate limits must be positive values'); + } + + this.outputRateLimitUp = up; + this.outputRateLimitDown = down; + return this; + } + + setAntiWindup({ mode = this.antiWindupMode ?? 'clamp', backCalculationGain = this.backCalculationGain ?? 0 } = {}) { + const normalized = String(mode || 'clamp').trim().toLowerCase(); + if (normalized !== 'clamp' && normalized !== 'backcalc') { + throw new RangeError('anti windup mode must be "clamp" or "backcalc"'); + } + if (!Number.isFinite(backCalculationGain) || backCalculationGain < 0) { + throw new RangeError('backCalculationGain must be a non-negative finite number'); + } + + this.antiWindupMode = normalized; + this.backCalculationGain = backCalculationGain; return this; } /** - * Force a manual output (typically when in manual mode). + * Gain schedule options: + * - null: disabled + * - function(input, state) => { kp, ki, kd } + * - array: [{ min, max, kp, ki, kd }, ...] */ + setGainSchedule(schedule = null) { + if (schedule == null) { + this.gainSchedule = null; + return this; + } + + if (typeof schedule === 'function') { + this.gainSchedule = schedule; + return this; + } + + if (!Array.isArray(schedule)) { + throw new TypeError('gainSchedule must be null, a function, or an array'); + } + + schedule.forEach((entry, index) => { + if (!entry || typeof entry !== 'object') { + throw new TypeError(`gainSchedule[${index}] must be an object`); + } + const { min = Number.NEGATIVE_INFINITY, max = Number.POSITIVE_INFINITY, kp, ki, kd } = entry; + if (!Number.isFinite(min) && min !== Number.NEGATIVE_INFINITY) { + throw new TypeError(`gainSchedule[${index}].min must be finite or -Infinity`); + } + if (!Number.isFinite(max) && max !== Number.POSITIVE_INFINITY) { + throw new TypeError(`gainSchedule[${index}].max must be finite or Infinity`); + } + if (min >= max) { + throw new RangeError(`gainSchedule[${index}] min must be smaller than max`); + } + [kp, ki, kd].forEach((value, gainIndex) => { + const label = ['kp', 'ki', 'kd'][gainIndex]; + if (!Number.isFinite(value)) { + throw new TypeError(`gainSchedule[${index}].${label} must be finite`); + } + }); + }); + + this.gainSchedule = schedule; + return this; + } + + setMode(mode, options = {}) { + if (mode !== 'automatic' && mode !== 'manual') { + throw new Error('mode must be either "automatic" or "manual"'); + } + + const nextAuto = mode === 'automatic'; + const previousAuto = this.autoMode; + this.autoMode = nextAuto; + + if (options && Number.isFinite(options.manualOutput)) { + this.setManualOutput(options.manualOutput); + } + + if (!previousAuto && nextAuto) { + this._initializeForAuto(options); + } + + return this; + } + + freeze(options = {}) { + this.frozen = true; + this.freezeTrackMeasurement = options.trackMeasurement !== false; + this.freezeTrackError = Boolean(options.trackError); + + if (Number.isFinite(options.output)) { + this.setManualOutput(options.output); + } + + return this; + } + + unfreeze() { + this.frozen = false; + return this; + } + + isFrozen() { + return this.frozen; + } + 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 + timestamp = null, + prevMeasurement = null, + prevError = null, + prevDerivativeInput = null, + derivativeState = 0, } = state; this.integral = this._applyIntegralLimits(Number.isFinite(integral) ? integral : 0); - this.prevError = null; - this.prevMeasurement = null; + this.prevError = Number.isFinite(prevError) ? prevError : null; + this.prevMeasurement = Number.isFinite(prevMeasurement) ? prevMeasurement : null; + this.prevDerivativeInput = Number.isFinite(prevDerivativeInput) ? prevDerivativeInput : 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; + this.derivativeState = Number.isFinite(derivativeState) ? derivativeState : 0; return this; } - /** - * Execute one control loop iteration. - */ - update(setpoint, measurement, timestamp = Date.now()) { + update(setpoint, measurement, timestamp = Date.now(), options = {}) { + if (timestamp && typeof timestamp === 'object' && options && Object.keys(options).length === 0) { + options = timestamp; + timestamp = Date.now(); + } + this._assertNumeric('setpoint', setpoint); this._assertNumeric('measurement', measurement); this._assertNumeric('timestamp', timestamp); + const opts = options || {}; + + if (opts.tunings && typeof opts.tunings === 'object') { + this.setTunings(opts.tunings); + } + + if (Number.isFinite(opts.gainInput)) { + this._applyGainSchedule(opts.gainInput, { setpoint, measurement, timestamp }); + } + + if (typeof opts.setMode === 'string') { + this.setMode(opts.setMode, opts); + } + + if (opts.freeze === true) this.freeze(opts); + if (opts.unfreeze === true) this.unfreeze(); + + if (Number.isFinite(opts.manualOutput)) { + this.setManualOutput(opts.manualOutput); + } + + const feedForward = Number.isFinite(opts.feedForward) ? opts.feedForward : 0; + const force = Boolean(opts.force); + + const error = setpoint - measurement; + if (!this.autoMode) { - this.prevError = setpoint - measurement; - this.prevMeasurement = measurement; - this.lastTimestamp = timestamp; + if (this.trackOnManual) { + this._trackProcessState(setpoint, measurement, error, timestamp); + } return this.lastOutput; } - if (this.lastTimestamp !== null && (timestamp - this.lastTimestamp) < this.sampleTime) { + if (this.frozen) { + if (this.freezeTrackMeasurement || this.freezeTrackError) { + this._trackProcessState(setpoint, measurement, error, timestamp, { + trackMeasurement: this.freezeTrackMeasurement, + trackError: this.freezeTrackError, + }); + } + return this.lastOutput; + } + + if (!force && 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 inDeadband = Math.abs(error) <= this.deadband; + if (inDeadband) { + this.prevError = error; + this.prevMeasurement = measurement; + this.prevDerivativeInput = this.derivativeOnMeasurement + ? measurement + : ((this.derivativeWeight * setpoint) - measurement); + this.lastTimestamp = timestamp; + return this.lastOutput; + } - const derivative = this._computeDerivative({ error, measurement, dtSeconds }); + const effectiveError = error; + + const pInput = (this.setpointWeight * setpoint) - measurement; + const pTerm = this.kp * pInput; + + const derivativeRaw = this._computeDerivative({ setpoint, measurement, error, dtSeconds }); this.derivativeState = this.derivativeFilter === 0 - ? derivative - : this.derivativeState + (derivative - this.derivativeState) * (1 - this.derivativeFilter); + ? derivativeRaw + : this.derivativeState + (derivativeRaw - 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); + const dTerm = this.kd * this.derivativeState; + const nextIntegral = this._applyIntegralLimits(this.integral + (effectiveError * dtSeconds)); + let unclampedOutput = pTerm + (this.ki * nextIntegral) + dTerm + feedForward; + let clampedOutput = this._clamp(unclampedOutput, this.outputMin, this.outputMax); + + if (this.antiWindupMode === 'backcalc' && this.ki !== 0 && this.backCalculationGain > 0) { + const correctedIntegral = nextIntegral + ((clampedOutput - unclampedOutput) * this.backCalculationGain * dtSeconds); + this.integral = this._applyIntegralLimits(correctedIntegral); + } else { + const saturatingHigh = clampedOutput >= this.outputMax && effectiveError > 0; + const saturatingLow = clampedOutput <= this.outputMin && effectiveError < 0; + this.integral = (saturatingHigh || saturatingLow) ? this.integral : nextIntegral; + } + + let output = pTerm + (this.ki * this.integral) + dTerm + feedForward; + output = this._clamp(output, this.outputMin, this.outputMax); + + if (this.lastTimestamp !== null) { + output = this._applyRateLimit(output, this.lastOutput, dtSeconds); + } + + if (Number.isFinite(opts.trackingOutput)) { + this._trackIntegralToOutput(opts.trackingOutput, { pTerm, dTerm, feedForward }); + output = this._clamp(opts.trackingOutput, this.outputMin, this.outputMax); + } + + this.lastOutput = output; this.prevError = error; this.prevMeasurement = measurement; + this.prevDerivativeInput = this.derivativeOnMeasurement + ? measurement + : ((this.derivativeWeight * setpoint) - measurement); this.lastTimestamp = timestamp; return this.lastOutput; } - /** - * Inspect controller state for diagnostics or persistence. - */ getState() { return { kp: this.kp, @@ -217,10 +430,18 @@ class PIDController { integralLimits: { min: this.integralMin, max: this.integralMax }, derivativeFilter: this.derivativeFilter, derivativeOnMeasurement: this.derivativeOnMeasurement, + setpointWeight: this.setpointWeight, + derivativeWeight: this.derivativeWeight, + deadband: this.deadband, + outputRateLimits: { up: this.outputRateLimitUp, down: this.outputRateLimitDown }, + antiWindupMode: this.antiWindupMode, + backCalculationGain: this.backCalculationGain, autoMode: this.autoMode, + frozen: this.frozen, integral: this.integral, + derivativeState: this.derivativeState, lastOutput: this.lastOutput, - lastTimestamp: this.lastTimestamp + lastTimestamp: this.lastTimestamp, }; } @@ -228,22 +449,110 @@ class PIDController { return this.lastOutput; } - _computeDerivative({ error, measurement, dtSeconds }) { + _initializeForAuto(options = {}) { + const setpoint = Number.isFinite(options.setpoint) ? options.setpoint : null; + const measurement = Number.isFinite(options.measurement) ? options.measurement : null; + const timestamp = Number.isFinite(options.timestamp) ? options.timestamp : Date.now(); + + if (measurement !== null) { + this.prevMeasurement = measurement; + } + if (setpoint !== null && measurement !== null) { + this.prevError = setpoint - measurement; + this.prevDerivativeInput = this.derivativeOnMeasurement + ? measurement + : ((this.derivativeWeight * setpoint) - measurement); + } + + this.lastTimestamp = timestamp; + + if (this.ki !== 0 && setpoint !== null && measurement !== null) { + const pTerm = this.kp * ((this.setpointWeight * setpoint) - measurement); + const dTerm = this.kd * this.derivativeState; + const trackedIntegral = (this.lastOutput - pTerm - dTerm) / this.ki; + this.integral = this._applyIntegralLimits(Number.isFinite(trackedIntegral) ? trackedIntegral : this.integral); + } + } + + _trackProcessState(setpoint, measurement, error, timestamp, tracking = {}) { + const trackMeasurement = tracking.trackMeasurement !== false; + const trackError = Boolean(tracking.trackError); + + if (trackMeasurement) { + this.prevMeasurement = measurement; + this.prevDerivativeInput = this.derivativeOnMeasurement + ? measurement + : ((this.derivativeWeight * setpoint) - measurement); + } + + if (trackError) { + this.prevError = error; + } + + this.lastTimestamp = timestamp; + } + + _trackIntegralToOutput(trackingOutput, terms) { + if (this.ki === 0) return; + const { pTerm, dTerm, feedForward } = terms; + const targetIntegral = (trackingOutput - pTerm - dTerm - feedForward) / this.ki; + if (Number.isFinite(targetIntegral)) { + this.integral = this._applyIntegralLimits(targetIntegral); + } + } + + _applyGainSchedule(input, state) { + if (!this.gainSchedule) return; + + if (typeof this.gainSchedule === 'function') { + const tunings = this.gainSchedule(input, this.getState(), state); + if (tunings && typeof tunings === 'object') { + this.setTunings(tunings); + } + return; + } + + const matched = this.gainSchedule.find((entry) => input >= entry.min && input < entry.max); + if (matched) { + this.setTunings({ kp: matched.kp, ki: matched.ki, kd: matched.kd }); + } + } + + _computeDerivative({ setpoint, measurement, error, dtSeconds }) { if (!(dtSeconds > 0) || !Number.isFinite(dtSeconds)) { return 0; } - if (this.derivativeOnMeasurement && this.prevMeasurement !== null) { + if (this.derivativeOnMeasurement) { + if (this.prevMeasurement === null) return 0; return -(measurement - this.prevMeasurement) / dtSeconds; } - if (this.prevError === null) { - return 0; + const derivativeInput = (this.derivativeWeight * setpoint) - measurement; + if (this.prevDerivativeInput === null) return 0; + const derivativeFromInput = (derivativeInput - this.prevDerivativeInput) / dtSeconds; + + if (Number.isFinite(derivativeFromInput)) { + return derivativeFromInput; } + if (this.prevError === null) return 0; return (error - this.prevError) / dtSeconds; } + _applyRateLimit(nextOutput, previousOutput, dtSeconds) { + const maxRise = Number.isFinite(this.outputRateLimitUp) + ? this.outputRateLimitUp * dtSeconds + : Number.POSITIVE_INFINITY; + const maxFall = Number.isFinite(this.outputRateLimitDown) + ? this.outputRateLimitDown * dtSeconds + : Number.POSITIVE_INFINITY; + + const lower = previousOutput - maxFall; + const upper = previousOutput + maxRise; + return this._clamp(nextOutput, lower, upper); + } + _applyIntegralLimits(value) { if (!Number.isFinite(value)) { return 0; @@ -266,14 +575,89 @@ class PIDController { } _clamp(value, min, max) { - if (value < min) { - return min; - } - if (value > max) { - return max; - } + if (value < min) return min; + if (value > max) return max; return value; } } -module.exports = PIDController; +/** + * Cascade PID utility: + * - primary PID controls the outer variable + * - primary output becomes setpoint for secondary PID + */ +class CascadePIDController { + constructor(options = {}) { + const { + primary = {}, + secondary = {}, + } = options; + + this.primary = primary instanceof PIDController ? primary : new PIDController(primary); + this.secondary = secondary instanceof PIDController ? secondary : new PIDController(secondary); + } + + update({ + setpoint, + primaryMeasurement, + secondaryMeasurement, + timestamp = Date.now(), + primaryOptions = {}, + secondaryOptions = {}, + } = {}) { + if (!Number.isFinite(setpoint)) { + throw new TypeError('setpoint must be a finite number'); + } + if (!Number.isFinite(primaryMeasurement)) { + throw new TypeError('primaryMeasurement must be a finite number'); + } + if (!Number.isFinite(secondaryMeasurement)) { + throw new TypeError('secondaryMeasurement must be a finite number'); + } + + const secondarySetpoint = this.primary.update(setpoint, primaryMeasurement, timestamp, primaryOptions); + const controlOutput = this.secondary.update(secondarySetpoint, secondaryMeasurement, timestamp, secondaryOptions); + + return { + primaryOutput: secondarySetpoint, + secondaryOutput: controlOutput, + state: this.getState(), + }; + } + + setMode(mode, options = {}) { + this.primary.setMode(mode, options.primary || options); + this.secondary.setMode(mode, options.secondary || options); + return this; + } + + freeze(options = {}) { + this.primary.freeze(options.primary || options); + this.secondary.freeze(options.secondary || options); + return this; + } + + unfreeze() { + this.primary.unfreeze(); + this.secondary.unfreeze(); + return this; + } + + reset(state = {}) { + this.primary.reset(state.primary || {}); + this.secondary.reset(state.secondary || {}); + return this; + } + + getState() { + return { + primary: this.primary.getState(), + secondary: this.secondary.getState(), + }; + } +} + +module.exports = { + PIDController, + CascadePIDController, +}; diff --git a/src/pid/index.js b/src/pid/index.js index 7f2c82b..486ad3e 100644 --- a/src/pid/index.js +++ b/src/pid/index.js @@ -1,11 +1,14 @@ -const PIDController = require('./PIDController'); +const { PIDController, CascadePIDController } = require('./PIDController'); /** - * Convenience factory for one-line instantiation. + * Convenience factories. */ const createPidController = (options) => new PIDController(options); +const createCascadePidController = (options) => new CascadePIDController(options); module.exports = { PIDController, - createPidController + CascadePIDController, + createPidController, + createCascadePidController, }; diff --git a/src/state/movementManager.js b/src/state/movementManager.js index f949f48..a79d881 100644 --- a/src/state/movementManager.js +++ b/src/state/movementManager.js @@ -13,12 +13,12 @@ class movementManager { this.speed = speed; this.maxSpeed = maxSpeed; - console.log(`MovementManager: Initial speed=${this.speed}, maxSpeed=${maxSpeed}`); this.interval = interval; this.timeleft = 0; // timeleft of current movement this.logger = logger; this.movementMode = config.movement.mode; + this.logger?.debug?.(`MovementManager initialized: speed=${this.speed}, maxSpeed=${this.maxSpeed}`); } getCurrentPosition() { diff --git a/test/00-barrel-contract.test.js b/test/00-barrel-contract.test.js index ea7008e..879ddc4 100644 --- a/test/00-barrel-contract.test.js +++ b/test/00-barrel-contract.test.js @@ -20,6 +20,10 @@ test('barrel exports expected public members', () => { 'coolprop', 'convert', 'MenuManager', + 'PIDController', + 'CascadePIDController', + 'createPidController', + 'createCascadePidController', 'childRegistrationUtils', 'loadCurve', 'loadModel', @@ -38,5 +42,9 @@ test('barrel types are callable where expected', () => { assert.equal(typeof barrel.outputUtils, 'function'); assert.equal(typeof barrel.MeasurementContainer, 'function'); assert.equal(typeof barrel.convert, 'function'); + assert.equal(typeof barrel.PIDController, 'function'); + assert.equal(typeof barrel.CascadePIDController, 'function'); + assert.equal(typeof barrel.createPidController, 'function'); + assert.equal(typeof barrel.createCascadePidController, 'function'); assert.equal(typeof barrel.gravity.getStandardGravity, 'function'); }); diff --git a/test/curve-loader.test.js b/test/curve-loader.test.js new file mode 100644 index 0000000..369a7db --- /dev/null +++ b/test/curve-loader.test.js @@ -0,0 +1,13 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { loadCurve } = require('../index.js'); + +test('loadCurve resolves curve ids case-insensitively', () => { + const canonical = loadCurve('hidrostal-H05K-S03R'); + const lowercase = loadCurve('hidrostal-h05k-s03r'); + + assert.ok(canonical); + assert.ok(lowercase); + assert.strictEqual(canonical, lowercase); +}); diff --git a/test/measurement-container-core.test.js b/test/measurement-container-core.test.js index 2a4554a..d9b9dc7 100644 --- a/test/measurement-container-core.test.js +++ b/test/measurement-container-core.test.js @@ -59,3 +59,39 @@ test('_convertPositionNum2Str maps signs to labels', () => { assert.equal(c._convertPositionNum2Str(1), 'downstream'); assert.equal(c._convertPositionNum2Str(-1), 'upstream'); }); + +test('storeCanonical stores anchor unit internally and can emit preferred output units', () => { + const c = new MeasurementContainer({ + windowSize: 10, + autoConvert: true, + defaultUnits: { flow: 'm3/h' }, + preferredUnits: { flow: 'm3/h' }, + canonicalUnits: { flow: 'm3/s' }, + storeCanonical: true, + }); + + c.type('flow').variant('measured').position('upstream').value(3.6, 1, 'm3/h'); + + const internal = c.type('flow').variant('measured').position('upstream').getCurrentValue(); + assert.ok(Math.abs(internal - 0.001) < 1e-9); + + const flat = c.getFlattenedOutput({ requestedUnits: { flow: 'm3/h' } }); + assert.ok(Math.abs(flat['flow.measured.upstream.default'] - 3.6) < 1e-9); +}); + +test('strict unit validation rejects missing required unit and incompatible units', () => { + const c = new MeasurementContainer({ + windowSize: 10, + strictUnitValidation: true, + throwOnInvalidUnit: true, + requireUnitForTypes: ['flow'], + }); + + assert.throws(() => { + c.type('flow').variant('measured').position('upstream').value(10, 1); + }, /Missing source unit/i); + + assert.throws(() => { + c.type('flow').variant('measured').position('upstream').value(10, 1, 'mbar'); + }, /Incompatible|unknown source unit/i); +}); diff --git a/test/nrmse.test.js b/test/nrmse.test.js index a52f053..89de0fc 100644 --- a/test/nrmse.test.js +++ b/test/nrmse.test.js @@ -13,6 +13,11 @@ test('MSE and RMSE calculations are correct', () => { assert.ok(Math.abs(m.rootMeanSquaredError(predicted, measured) - Math.sqrt(5 / 3)) < 1e-9); }); +test('MSE throws for mismatched series lengths in strict mode', () => { + const m = new ErrorMetrics({}, makeLogger()); + assert.throws(() => m.meanSquaredError([1, 2], [1]), /same length/); +}); + test('normalizeUsingRealtime throws when range is zero', () => { const m = new ErrorMetrics({}, makeLogger()); assert.throws(() => m.normalizeUsingRealtime([1, 1, 1], [1, 1, 1]), /Invalid process range/); @@ -35,3 +40,17 @@ test('assessDrift returns expected result envelope', () => { assert.ok('immediateLevel' in out); assert.ok('longTermLevel' in out); }); + +test('assessPoint keeps per-metric state and returns metric id', () => { + const m = new ErrorMetrics({}, makeLogger()); + m.registerMetric('flow', { windowSize: 5, minSamplesForLongTerm: 3, strictValidation: true }); + + m.assessPoint('flow', 100, 99, { processMin: 0, processMax: 200, timestamp: Date.now() - 2000 }); + m.assessPoint('flow', 101, 100, { processMin: 0, processMax: 200, timestamp: Date.now() - 1000 }); + const out = m.assessPoint('flow', 102, 101, { processMin: 0, processMax: 200, timestamp: Date.now() }); + + assert.equal(out.metricId, 'flow'); + assert.equal(out.valid, true); + assert.equal(typeof out.nrmse, 'number'); + assert.equal(typeof out.sampleCount, 'number'); +}); diff --git a/test/pid-controller.test.js b/test/pid-controller.test.js new file mode 100644 index 0000000..e7aa0ff --- /dev/null +++ b/test/pid-controller.test.js @@ -0,0 +1,105 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { PIDController, CascadePIDController } = require('../src/pid/index.js'); + +test('pid supports freeze/unfreeze with held output', () => { + const pid = new PIDController({ + kp: 2, + ki: 0.5, + kd: 0.1, + sampleTime: 100, + outputMin: 0, + outputMax: 100, + }); + + const t0 = Date.now(); + const first = pid.update(10, 2, t0 + 100); + pid.freeze({ output: first, trackMeasurement: true }); + const frozen = pid.update(10, 4, t0 + 200); + assert.equal(frozen, first); + + pid.unfreeze(); + const resumed = pid.update(10, 4, t0 + 300); + assert.equal(Number.isFinite(resumed), true); +}); + +test('pid supports dynamic tunings and gain scheduling', () => { + const pid = new PIDController({ + kp: 1, + ki: 0, + kd: 0, + sampleTime: 100, + outputMin: -100, + outputMax: 100, + gainSchedule: [ + { min: Number.NEGATIVE_INFINITY, max: 5, kp: 1, ki: 0, kd: 0 }, + { min: 5, max: Number.POSITIVE_INFINITY, kp: 3, ki: 0, kd: 0 }, + ], + }); + + const t0 = Date.now(); + const low = pid.update(10, 9, t0 + 100, { gainInput: 4 }); + const high = pid.update(10, 9, t0 + 200, { gainInput: 6 }); + + assert.equal(high > low, true); + + const tuned = pid.update(10, 9, t0 + 300, { tunings: { kp: 10, ki: 0, kd: 0 } }); + assert.equal(tuned > high, true); +}); + +test('pid applies deadband and output rate limits', () => { + const pid = new PIDController({ + kp: 10, + ki: 0, + kd: 0, + deadband: 0.5, + sampleTime: 100, + outputMin: 0, + outputMax: 100, + outputRateLimitUp: 5, // units per second + outputRateLimitDown: 5, // units per second + }); + + const t0 = Date.now(); + const out1 = pid.update(10, 10, t0 + 100); // inside deadband -> no action + const out2 = pid.update(20, 0, t0 + 200); // strong error but limited by rate + + assert.equal(out1, 0); + // 5 units/sec * 0.1 sec = max 0.5 rise per cycle + assert.equal(out2 <= 0.5 + 1e-9, true); +}); + +test('cascade pid computes primary and secondary outputs', () => { + const cascade = new CascadePIDController({ + primary: { + kp: 2, + ki: 0, + kd: 0, + sampleTime: 100, + outputMin: 0, + outputMax: 100, + }, + secondary: { + kp: 1, + ki: 0, + kd: 0, + sampleTime: 100, + outputMin: 0, + outputMax: 100, + }, + }); + + const t0 = Date.now(); + const result = cascade.update({ + setpoint: 10, + primaryMeasurement: 5, + secondaryMeasurement: 2, + timestamp: t0 + 100, + }); + + assert.equal(typeof result.primaryOutput, 'number'); + assert.equal(typeof result.secondaryOutput, 'number'); + assert.equal(result.primaryOutput > 0, true); + assert.equal(result.secondaryOutput > 0, true); +}); diff --git a/test/validation-utils.test.js b/test/validation-utils.test.js index 7a5c71e..c4c3450 100644 --- a/test/validation-utils.test.js +++ b/test/validation-utils.test.js @@ -25,6 +25,30 @@ const schema = { default: 'sensor', rules: { type: 'string' }, }, + asset: { + default: {}, + rules: { + type: 'object', + schema: { + unit: { + default: 'm3/h', + rules: { type: 'string' }, + }, + curveUnits: { + default: {}, + rules: { + type: 'object', + schema: { + power: { + default: 'kW', + rules: { type: 'string' }, + }, + }, + }, + }, + }, + }, + }, }; test('validateSchema applies defaults and type coercion where supported', () => { @@ -32,7 +56,7 @@ test('validateSchema applies defaults and type coercion where supported', () => const result = validation.validateSchema({ enabled: 'true', name: 'SENSOR' }, schema, 'test'); assert.equal(result.enabled, true); - assert.equal(result.name, 'sensor'); + assert.equal(result.name, 'SENSOR'); assert.equal(result.mode, 'auto'); assert.equal(result.functionality.softwareType, 'measurement'); }); @@ -60,3 +84,58 @@ test('removeUnwantedKeys handles primitive values without throwing', () => { }; assert.doesNotThrow(() => validation.removeUnwantedKeys(input)); }); + +test('unit-like fields preserve case while regular strings are normalized', () => { + const validation = new ValidationUtils(false, 'error'); + const result = validation.validateSchema( + { + name: 'RotatingMachine', + asset: { + unit: 'kW', + curveUnits: { power: 'kW' }, + }, + }, + schema, + 'machine' + ); + + assert.equal(result.name, 'RotatingMachine'); + assert.equal(result.asset.unit, 'kW'); + assert.equal(result.asset.curveUnits.power, 'kW'); +}); + +test('array with minLength 0 accepts empty arrays without fallback warning path', () => { + const validation = new ValidationUtils(false, 'error'); + const localSchema = { + functionality: { + softwareType: { + default: 'measurement', + rules: { type: 'string' }, + }, + }, + assetRegistration: { + default: { childAssets: ['default'] }, + rules: { + type: 'object', + schema: { + childAssets: { + default: ['default'], + rules: { + type: 'array', + itemType: 'string', + minLength: 0, + }, + }, + }, + }, + }, + }; + + const result = validation.validateSchema( + { assetRegistration: { childAssets: [] } }, + localSchema, + 'measurement' + ); + + assert.deepEqual(result.assetRegistration.childAssets, []); +});