updates
This commit is contained in:
@@ -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');
|
||||
*/
|
||||
*/
|
||||
|
||||
@@ -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"] }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
5
index.js
5
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,
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
13
test/curve-loader.test.js
Normal file
13
test/curve-loader.test.js
Normal file
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
105
test/pid-controller.test.js
Normal file
105
test/pid-controller.test.js
Normal file
@@ -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);
|
||||
});
|
||||
@@ -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, []);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user