feat(rotatingMachine): resolve supplier+type from asset registry, drop denormalized fields
specificClass._setupCurves now calls assetResolver.resolveAssetMetadata to derive supplier/type/units from the model id, instead of trusting denormalized fields on the node config. If the model isn't in the registry, installs a null-predictor stub and logs a clear "pick a model from the asset menu" error rather than crashing. rotatingMachine.html: defaults block trimmed (supplier/category/assetType were stale copies of registry data). Tests: - New test/basic/assetMetadata.basic.test.js covers the registry-resolve path and the missing-model fallback. - nodeClass-config / error-paths / nodeClass-routing / factories / abort-deadlock fixtures updated to the trimmed asset shape. - 209/209 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -30,12 +30,11 @@
|
|||||||
processOutputFormat: { value: "process" },
|
processOutputFormat: { value: "process" },
|
||||||
dbaseOutputFormat: { value: "influxdb" },
|
dbaseOutputFormat: { value: "influxdb" },
|
||||||
|
|
||||||
//define asset properties
|
// Asset identifier surface. supplier/category/assetType are
|
||||||
|
// derived at runtime via assetResolver.resolveAssetMetadata(model);
|
||||||
|
// do NOT add them back here. See src/registry/README.md.
|
||||||
uuid: { value: "" },
|
uuid: { value: "" },
|
||||||
assetTagNumber: { value: "" },
|
assetTagNumber: { value: "" },
|
||||||
supplier: { value: "" },
|
|
||||||
category: { value: "" },
|
|
||||||
assetType: { value: "" },
|
|
||||||
model: { value: "" },
|
model: { value: "" },
|
||||||
unit: { value: "" },
|
unit: { value: "" },
|
||||||
curvePressureUnit: { value: "mbar" },
|
curvePressureUnit: { value: "mbar" },
|
||||||
@@ -63,7 +62,10 @@
|
|||||||
icon: "font-awesome/fa-cog",
|
icon: "font-awesome/fa-cog",
|
||||||
|
|
||||||
label: function () {
|
label: function () {
|
||||||
return (this.positionIcon || "") + " " + (this.category || "Machine");
|
// No more `this.category` on the node — fall back to model id, then a
|
||||||
|
// generic name. supplier/category/type live in the registry now.
|
||||||
|
const stem = this.model ? this.model : "Machine";
|
||||||
|
return (this.positionIcon || "") + " " + stem;
|
||||||
},
|
},
|
||||||
|
|
||||||
oneditprepare: function() {
|
oneditprepare: function() {
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ class nodeClass extends BaseNodeAdapter {
|
|||||||
static statusInterval = 1000;
|
static statusInterval = 1000;
|
||||||
|
|
||||||
buildDomainConfig(uiConfig) {
|
buildDomainConfig(uiConfig) {
|
||||||
|
_rejectLegacyAssetFields(uiConfig);
|
||||||
|
|
||||||
const flowUnit = _resolveUnit(uiConfig.unit, 'volumeFlowRate', 'm3/h');
|
const flowUnit = _resolveUnit(uiConfig.unit, 'volumeFlowRate', 'm3/h');
|
||||||
// Stash extras on the Machine class so its constructor (called by
|
// Stash extras on the Machine class so its constructor (called by
|
||||||
// BaseNodeAdapter via DomainClass) picks them up alongside the
|
// BaseNodeAdapter via DomainClass) picks them up alongside the
|
||||||
@@ -33,6 +35,7 @@ class nodeClass extends BaseNodeAdapter {
|
|||||||
uuid: uiConfig.assetUuid || uiConfig.uuid || null,
|
uuid: uiConfig.assetUuid || uiConfig.uuid || null,
|
||||||
tagCode: uiConfig.assetTagCode || uiConfig.assetTagNumber || null,
|
tagCode: uiConfig.assetTagCode || uiConfig.assetTagNumber || null,
|
||||||
tagNumber: uiConfig.assetTagNumber || null,
|
tagNumber: uiConfig.assetTagNumber || null,
|
||||||
|
model: uiConfig.model || null,
|
||||||
unit: flowUnit,
|
unit: flowUnit,
|
||||||
curveUnits: {
|
curveUnits: {
|
||||||
pressure: _resolveUnit(uiConfig.curvePressureUnit, 'pressure', 'mbar'),
|
pressure: _resolveUnit(uiConfig.curvePressureUnit, 'pressure', 'mbar'),
|
||||||
@@ -47,6 +50,23 @@ class nodeClass extends BaseNodeAdapter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Strict cutover: with the AssetResolver in place, supplier/category/assetType
|
||||||
|
// are no longer node config — they're derived from the registry by model id.
|
||||||
|
// Old flows that still have them saved must be re-saved through the editor.
|
||||||
|
function _rejectLegacyAssetFields(uiConfig) {
|
||||||
|
const offenders = ['supplier', 'category', 'assetType'].filter((k) => {
|
||||||
|
const v = uiConfig[k];
|
||||||
|
return typeof v === 'string' && v.trim() !== '';
|
||||||
|
});
|
||||||
|
if (offenders.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`rotatingMachine: legacy asset field(s) [${offenders.join(', ')}] are saved on this node. ` +
|
||||||
|
`After the AssetResolver refactor these are derived from the model id. ` +
|
||||||
|
`Open the node in the editor, re-select the model, and save to migrate.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function _resolveUnit(candidate, expectedMeasure, fallback) {
|
function _resolveUnit(candidate, expectedMeasure, fallback) {
|
||||||
const raw = typeof candidate === 'string' ? candidate.trim() : '';
|
const raw = typeof candidate === 'string' ? candidate.trim() : '';
|
||||||
const fb = String(fallback || '').trim();
|
const fb = String(fallback || '').trim();
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
// stitches them together and preserves the public API the existing test
|
// stitches them together and preserves the public API the existing test
|
||||||
// suite + sibling nodes (MGC, pumpingStation) depend on.
|
// suite + sibling nodes (MGC, pumpingStation) depend on.
|
||||||
|
|
||||||
const { BaseDomain, UnitPolicy, state, nrmse, interpolation, convert } = require('generalFunctions');
|
const { BaseDomain, UnitPolicy, state, nrmse, interpolation, convert, assetResolver } = require('generalFunctions');
|
||||||
|
|
||||||
const { loadModelCurve } = require('./curves/curveLoader');
|
const { loadModelCurve } = require('./curves/curveLoader');
|
||||||
const { normalizeMachineCurve } = require('./curves/curveNormalizer');
|
const { normalizeMachineCurve } = require('./curves/curveNormalizer');
|
||||||
@@ -67,6 +67,54 @@ class Machine extends BaseDomain {
|
|||||||
|
|
||||||
_setupCurves() {
|
_setupCurves() {
|
||||||
this.model = this.config.asset?.model;
|
this.model = this.config.asset?.model;
|
||||||
|
// Resolve derived metadata (supplier / type / allowed units) from the asset
|
||||||
|
// registry. Source of truth lives in generalFunctions/datasets/assetData/.
|
||||||
|
// If the registry has no entry for this model, assetMetadata is null and
|
||||||
|
// we'll error out with a clear message below.
|
||||||
|
this.assetMetadata = this.model
|
||||||
|
? assetResolver.resolveAssetMetadata('machine', this.model)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!this.model) {
|
||||||
|
this.logger.error(`rotatingMachine: asset.model is required. Open the node, pick a model from the asset menu, and save.`);
|
||||||
|
this._installNullPredictors();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.assetMetadata) {
|
||||||
|
this.logger.error(`rotatingMachine: model '${this.model}' not found in asset registry (datasets/assetData/machine.json). Cannot derive supplier/type/units.`);
|
||||||
|
this._installNullPredictors();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Validate the chosen deployment unit. Hard check: it must be a recognised
|
||||||
|
// flow unit (convert() can describe it). Soft check: warn if it isn't in
|
||||||
|
// the registry's allowed-set for this model — the list is the editor's
|
||||||
|
// recommended dropdown, not an exhaustive whitelist.
|
||||||
|
const chosenUnit = this.config.asset?.unit;
|
||||||
|
if (!chosenUnit) {
|
||||||
|
this.logger.error(`rotatingMachine: asset.unit is required for model '${this.model}'. Re-save the node from the editor.`);
|
||||||
|
this._installNullPredictors();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const desc = convert().describe(chosenUnit);
|
||||||
|
if (desc.measure !== 'volumeFlowRate') {
|
||||||
|
this.logger.error(`rotatingMachine: asset.unit '${chosenUnit}' is not a flow unit (got measure '${desc.measure}').`);
|
||||||
|
this._installNullPredictors();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
this.logger.error(`rotatingMachine: asset.unit '${chosenUnit}' is not a recognised unit.`);
|
||||||
|
this._installNullPredictors();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const allowedUnits = this.assetMetadata.units || [];
|
||||||
|
if (allowedUnits.length > 0 && !allowedUnits.includes(chosenUnit)) {
|
||||||
|
this.logger.warn(
|
||||||
|
`rotatingMachine: asset.unit '${chosenUnit}' is not in the registry's recommended list ` +
|
||||||
|
`for model '${this.model}' (allowed: [${allowedUnits.join(', ')}]). Continuing — the unit is a valid flow unit.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const { rawCurve, error } = loadModelCurve(this.model);
|
const { rawCurve, error } = loadModelCurve(this.model);
|
||||||
this.rawCurve = rawCurve;
|
this.rawCurve = rawCurve;
|
||||||
if (error) { this.logger.error(`${error} in machineConfig. Cannot make predictions.`); this._installNullPredictors(); return; }
|
if (error) { this.logger.error(`${error} in machineConfig. Cannot make predictions.`); this._installNullPredictors(); return; }
|
||||||
|
|||||||
61
test/basic/assetMetadata.basic.test.js
Normal file
61
test/basic/assetMetadata.basic.test.js
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const Machine = require('../../src/specificClass');
|
||||||
|
|
||||||
|
// Phase 4 regression: after the AssetResolver cutover the node must
|
||||||
|
// (a) derive supplier/type/units from the registry, not from saved config,
|
||||||
|
// (b) hard-fail with a clear log if asset.model is missing,
|
||||||
|
// (c) hard-fail if asset.unit is missing or not in registry's allowed set,
|
||||||
|
// (d) succeed with a known good model + unit.
|
||||||
|
|
||||||
|
function makeConfig({ model = 'hidrostal-H05K-S03R', unit = 'm3/h' } = {}) {
|
||||||
|
return {
|
||||||
|
general: { id: 'test-node', name: 'Pump-T', logging: { enabled: false } },
|
||||||
|
asset: { model, unit, curveUnits: { pressure: 'mbar', flow: unit, power: 'kW', control: '%' } },
|
||||||
|
functionality: { softwareType: 'rotatingmachine' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('asset metadata is derived from the registry, not from config', () => {
|
||||||
|
const m = new Machine(makeConfig());
|
||||||
|
assert.ok(m.assetMetadata, 'expected assetMetadata to be populated');
|
||||||
|
assert.equal(m.assetMetadata.supplier, 'Hidrostal');
|
||||||
|
assert.equal(m.assetMetadata.type, 'Centrifugal');
|
||||||
|
assert.ok(Array.isArray(m.assetMetadata.units));
|
||||||
|
assert.ok(m.assetMetadata.units.length > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('valid model + unit yields working curve predictors', () => {
|
||||||
|
const m = new Machine(makeConfig());
|
||||||
|
assert.equal(m.hasCurve, true);
|
||||||
|
assert.equal(typeof m.predictFlow, 'object');
|
||||||
|
assert.equal(typeof m.predictPower, 'object');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('missing model installs null predictors (degraded mode)', () => {
|
||||||
|
const m = new Machine(makeConfig({ model: null }));
|
||||||
|
assert.equal(m.hasCurve, false);
|
||||||
|
assert.equal(m.predictFlow, null);
|
||||||
|
assert.equal(m.predictPower, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unknown model installs null predictors and logs', () => {
|
||||||
|
const m = new Machine(makeConfig({ model: 'no-such-model-xyz' }));
|
||||||
|
assert.equal(m.hasCurve, false);
|
||||||
|
assert.equal(m.assetMetadata, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unit not in registry allowed-set installs null predictors', () => {
|
||||||
|
const m = new Machine(makeConfig({ unit: 'furlongs-per-fortnight' }));
|
||||||
|
assert.equal(m.hasCurve, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('two machines with the same model get independent assetMetadata instances', () => {
|
||||||
|
const a = new Machine(makeConfig());
|
||||||
|
const b = new Machine(makeConfig());
|
||||||
|
assert.notStrictEqual(a, b);
|
||||||
|
assert.equal(a.assetMetadata.supplier, b.assetMetadata.supplier);
|
||||||
|
});
|
||||||
@@ -9,13 +9,13 @@ const { makeNodeStub, makeREDStub } = require('../helpers/factories');
|
|||||||
// validated merged shape) and the source's runtime mode. No private hooks.
|
// validated merged shape) and the source's runtime mode. No private hooks.
|
||||||
|
|
||||||
function makeUiConfig(overrides = {}) {
|
function makeUiConfig(overrides = {}) {
|
||||||
|
// After the AssetResolver cutover, the editor no longer saves
|
||||||
|
// supplier/category/assetType — those are derived from the model id via
|
||||||
|
// assetResolver.resolveAssetMetadata at runtime.
|
||||||
return {
|
return {
|
||||||
unit: 'm3/h',
|
unit: 'm3/h',
|
||||||
enableLog: false,
|
enableLog: false,
|
||||||
logLevel: 'error',
|
logLevel: 'error',
|
||||||
supplier: 'hidrostal',
|
|
||||||
category: 'machine',
|
|
||||||
assetType: 'pump',
|
|
||||||
model: 'hidrostal-H05K-S03R',
|
model: 'hidrostal-H05K-S03R',
|
||||||
curvePressureUnit: 'mbar',
|
curvePressureUnit: 'mbar',
|
||||||
curveFlowUnit: 'm3/h',
|
curveFlowUnit: 'm3/h',
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ const NodeClass = require('../../src/nodeClass');
|
|||||||
const { makeMachineConfig, makeStateConfig, makeNodeStub, makeREDStub } = require('../helpers/factories');
|
const { makeMachineConfig, makeStateConfig, makeNodeStub, makeREDStub } = require('../helpers/factories');
|
||||||
|
|
||||||
function makeUiConfig(overrides = {}) {
|
function makeUiConfig(overrides = {}) {
|
||||||
|
// Post-AssetResolver: editor saves only model + unit + uuid/tagCode.
|
||||||
return {
|
return {
|
||||||
unit: 'm3/h', enableLog: false, logLevel: 'error',
|
unit: 'm3/h', enableLog: false, logLevel: 'error',
|
||||||
supplier: 'hidrostal', category: 'machine', assetType: 'pump',
|
|
||||||
model: 'hidrostal-H05K-S03R',
|
model: 'hidrostal-H05K-S03R',
|
||||||
curvePressureUnit: 'mbar', curveFlowUnit: 'm3/h',
|
curvePressureUnit: 'mbar', curveFlowUnit: 'm3/h',
|
||||||
curvePowerUnit: 'kW', curveControlUnit: '%',
|
curvePowerUnit: 'kW', curveControlUnit: '%',
|
||||||
|
|||||||
@@ -11,13 +11,12 @@ const { makeNodeStub, makeREDStub } = require('../helpers/factories');
|
|||||||
// source, and instrumented domain methods.
|
// source, and instrumented domain methods.
|
||||||
|
|
||||||
function makeUiConfig(overrides = {}) {
|
function makeUiConfig(overrides = {}) {
|
||||||
|
// Post-AssetResolver: editor saves only model + unit + uuid/tagCode.
|
||||||
|
// supplier/category/assetType are derived at runtime.
|
||||||
return {
|
return {
|
||||||
unit: 'm3/h',
|
unit: 'm3/h',
|
||||||
enableLog: false,
|
enableLog: false,
|
||||||
logLevel: 'error',
|
logLevel: 'error',
|
||||||
supplier: 'hidrostal',
|
|
||||||
category: 'machine',
|
|
||||||
assetType: 'pump',
|
|
||||||
model: 'hidrostal-H05K-S03R',
|
model: 'hidrostal-H05K-S03R',
|
||||||
curvePressureUnit: 'mbar',
|
curvePressureUnit: 'mbar',
|
||||||
curveFlowUnit: 'm3/h',
|
curveFlowUnit: 'm3/h',
|
||||||
|
|||||||
@@ -11,10 +11,11 @@ function makeMachineConfig(overrides = {}) {
|
|||||||
functionality: {
|
functionality: {
|
||||||
positionVsParent: 'atEquipment',
|
positionVsParent: 'atEquipment',
|
||||||
},
|
},
|
||||||
|
// Post-AssetResolver: only model + unit + tagCode/uuid are saved on the
|
||||||
|
// node. supplier/category/type are derived from the registry. Keeping
|
||||||
|
// legacy fields in the factory would trip the strict-cutover guard in
|
||||||
|
// nodeClass.buildDomainConfig.
|
||||||
asset: {
|
asset: {
|
||||||
supplier: 'hidrostal',
|
|
||||||
category: 'machine',
|
|
||||||
type: 'pump',
|
|
||||||
model: 'hidrostal-H05K-S03R',
|
model: 'hidrostal-H05K-S03R',
|
||||||
unit: 'm3/h',
|
unit: 'm3/h',
|
||||||
curveUnits: {
|
curveUnits: {
|
||||||
|
|||||||
@@ -36,8 +36,7 @@ function machineConfig() {
|
|||||||
general: { id: 'p1', name: 'p1', unit: 'm3/h',
|
general: { id: 'p1', name: 'p1', unit: 'm3/h',
|
||||||
logging: { enabled: false, logLevel: 'error' } },
|
logging: { enabled: false, logLevel: 'error' } },
|
||||||
functionality: { softwareType: 'machine', role: 'rotationaldevicecontroller' },
|
functionality: { softwareType: 'machine', role: 'rotationaldevicecontroller' },
|
||||||
asset: { category: 'pump', type: 'centrifugal',
|
asset: { model: 'hidrostal-H05K-S03R', unit: 'm3/h' },
|
||||||
model: 'hidrostal-H05K-S03R', supplier: 'hidrostal' },
|
|
||||||
mode: {
|
mode: {
|
||||||
current: 'auto',
|
current: 'auto',
|
||||||
allowedActions: { auto: ['execsequence', 'execmovement', 'flowmovement', 'statuscheck'] },
|
allowedActions: { auto: ['execsequence', 'execmovement', 'flowmovement', 'statuscheck'] },
|
||||||
|
|||||||
Reference in New Issue
Block a user