updates
This commit is contained in:
@@ -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