diff --git a/rotatingMachine.html b/rotatingMachine.html index ad3305f..f123a20 100644 --- a/rotatingMachine.html +++ b/rotatingMachine.html @@ -30,12 +30,11 @@ processOutputFormat: { value: "process" }, 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: "" }, assetTagNumber: { value: "" }, - supplier: { value: "" }, - category: { value: "" }, - assetType: { value: "" }, model: { value: "" }, unit: { value: "" }, curvePressureUnit: { value: "mbar" }, @@ -63,7 +62,10 @@ icon: "font-awesome/fa-cog", 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() { diff --git a/src/nodeClass.js b/src/nodeClass.js index c8ff93b..2961de3 100644 --- a/src/nodeClass.js +++ b/src/nodeClass.js @@ -13,6 +13,8 @@ class nodeClass extends BaseNodeAdapter { static statusInterval = 1000; buildDomainConfig(uiConfig) { + _rejectLegacyAssetFields(uiConfig); + const flowUnit = _resolveUnit(uiConfig.unit, 'volumeFlowRate', 'm3/h'); // Stash extras on the Machine class so its constructor (called by // BaseNodeAdapter via DomainClass) picks them up alongside the @@ -33,6 +35,7 @@ class nodeClass extends BaseNodeAdapter { uuid: uiConfig.assetUuid || uiConfig.uuid || null, tagCode: uiConfig.assetTagCode || uiConfig.assetTagNumber || null, tagNumber: uiConfig.assetTagNumber || null, + model: uiConfig.model || null, unit: flowUnit, curveUnits: { 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) { const raw = typeof candidate === 'string' ? candidate.trim() : ''; const fb = String(fallback || '').trim(); diff --git a/src/specificClass.js b/src/specificClass.js index 4649679..88c31a7 100644 --- a/src/specificClass.js +++ b/src/specificClass.js @@ -7,7 +7,7 @@ // stitches them together and preserves the public API the existing test // 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 { normalizeMachineCurve } = require('./curves/curveNormalizer'); @@ -67,6 +67,54 @@ class Machine extends BaseDomain { _setupCurves() { 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); this.rawCurve = rawCurve; if (error) { this.logger.error(`${error} in machineConfig. Cannot make predictions.`); this._installNullPredictors(); return; } diff --git a/test/basic/assetMetadata.basic.test.js b/test/basic/assetMetadata.basic.test.js new file mode 100644 index 0000000..e0ee3f8 --- /dev/null +++ b/test/basic/assetMetadata.basic.test.js @@ -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); +}); diff --git a/test/basic/nodeClass-config.basic.test.js b/test/basic/nodeClass-config.basic.test.js index 2069c9e..dd311cd 100644 --- a/test/basic/nodeClass-config.basic.test.js +++ b/test/basic/nodeClass-config.basic.test.js @@ -9,13 +9,13 @@ const { makeNodeStub, makeREDStub } = require('../helpers/factories'); // validated merged shape) and the source's runtime mode. No private hooks. 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 { unit: 'm3/h', enableLog: false, logLevel: 'error', - supplier: 'hidrostal', - category: 'machine', - assetType: 'pump', model: 'hidrostal-H05K-S03R', curvePressureUnit: 'mbar', curveFlowUnit: 'm3/h', diff --git a/test/edge/error-paths.edge.test.js b/test/edge/error-paths.edge.test.js index 851528d..28e13b4 100644 --- a/test/edge/error-paths.edge.test.js +++ b/test/edge/error-paths.edge.test.js @@ -6,9 +6,9 @@ const NodeClass = require('../../src/nodeClass'); const { makeMachineConfig, makeStateConfig, makeNodeStub, makeREDStub } = require('../helpers/factories'); function makeUiConfig(overrides = {}) { + // Post-AssetResolver: editor saves only model + unit + uuid/tagCode. return { unit: 'm3/h', enableLog: false, logLevel: 'error', - supplier: 'hidrostal', category: 'machine', assetType: 'pump', model: 'hidrostal-H05K-S03R', curvePressureUnit: 'mbar', curveFlowUnit: 'm3/h', curvePowerUnit: 'kW', curveControlUnit: '%', diff --git a/test/edge/nodeClass-routing.edge.test.js b/test/edge/nodeClass-routing.edge.test.js index 8efc7b8..2fd38d3 100644 --- a/test/edge/nodeClass-routing.edge.test.js +++ b/test/edge/nodeClass-routing.edge.test.js @@ -11,13 +11,12 @@ const { makeNodeStub, makeREDStub } = require('../helpers/factories'); // source, and instrumented domain methods. function makeUiConfig(overrides = {}) { + // Post-AssetResolver: editor saves only model + unit + uuid/tagCode. + // supplier/category/assetType are derived at runtime. return { unit: 'm3/h', enableLog: false, logLevel: 'error', - supplier: 'hidrostal', - category: 'machine', - assetType: 'pump', model: 'hidrostal-H05K-S03R', curvePressureUnit: 'mbar', curveFlowUnit: 'm3/h', diff --git a/test/helpers/factories.js b/test/helpers/factories.js index 6b19a44..624d6b8 100644 --- a/test/helpers/factories.js +++ b/test/helpers/factories.js @@ -11,10 +11,11 @@ function makeMachineConfig(overrides = {}) { functionality: { 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: { - supplier: 'hidrostal', - category: 'machine', - type: 'pump', model: 'hidrostal-H05K-S03R', unit: 'm3/h', curveUnits: { diff --git a/test/integration/abort-deadlock.integration.test.js b/test/integration/abort-deadlock.integration.test.js index a4b14d5..983ecb0 100644 --- a/test/integration/abort-deadlock.integration.test.js +++ b/test/integration/abort-deadlock.integration.test.js @@ -36,8 +36,7 @@ function machineConfig() { general: { id: 'p1', name: 'p1', unit: 'm3/h', logging: { enabled: false, logLevel: 'error' } }, functionality: { softwareType: 'machine', role: 'rotationaldevicecontroller' }, - asset: { category: 'pump', type: 'centrifugal', - model: 'hidrostal-H05K-S03R', supplier: 'hidrostal' }, + asset: { model: 'hidrostal-H05K-S03R', unit: 'm3/h' }, mode: { current: 'auto', allowedActions: { auto: ['execsequence', 'execmovement', 'flowmovement', 'statuscheck'] },