feat(registry): AssetResolver + diffuser supplier curves (Jäger / Aerostrip / PIK / PRK)

Two related changes bundled together because the diffuser curve files
only make sense once the registry namespace they live in exists.

src/registry — new asset-metadata resolver:
- AssetResolver with synchronous resolve(namespace, id) + lazy cache,
  async refresh() for future remote pulls.
- FileBackend (per-id or single-file layouts, case-insensitive) and a
  stub HttpBackend (disabled unless EVOLV_ASSET_REMOTE=1).
- Namespaces: curves, menu, monsterSamples, monsterSpecs, units. Menu
  namespace re-keys by inner softwareType + filename so editors that
  pass either string resolve to the same tree.
- README explains how to add a namespace.
- AssetCategoryManager (datasets/assetData/index.js) becomes a thin
  facade over the resolver so existing consumers don't move.
- 246/246 tests pass — including the 39-test registry suite.

datasets/assetData — file moves + new diffuser data:
- modelData/*.json deleted; curves/*.json is the canonical home.
- New diffuser.json menu tree with GVA, Jäger, Aquaconsult/Entec,
  PIK/PRK suppliers.
- gva-elastox-r.json migrated from the inline _loadSpecs hardcode,
  re-tagged coverageBasis="bottom-coverage-pct" (the legacy 2.4
  elements/m² was a prior mis-conversion; we can't recover the
  original % so it's a single-point curve under key "0").
- jaeger-jetflex-td-65-2-g-epdm-1000.json — extracted from the Jäger
  EPDM-1000mm SSOTE/DWP chart on the data sheet (vector-PDF read).
  SSOTE 8.20→6.40 %/m, DWP 25→48 mbar across Q 2-12 Nm³/h. Single
  coverage (vendor doesn't state test conditions).
- aerostrip-phoenix.json — 4-coverage SOTE family at 4.75 m water
  depth (DD 5/10/15/20 %, flux 10-70 Nm³/h·m²) from the Entec/de
  Winter 2023-11-22 dataset; DWP curve from the 21 % @ 4.05 m chart.
- pik300.json / prk300.json — 5-coverage SOTE + SSOTR (DD 5-25 %)
  with split DWP per model variant, water depth ≈ 4.0 m inferred from
  the SOTE↔SSOTR ratio in the source spreadsheet.

src/configs/diffuser.json:
- New asset.{model, assetTagNumber} block so the editor's selected
  model id survives validation.
- diffuser.density description corrected to "Bottom coverage [%]";
  default 2.4 → 15 (typical fine-bubble install).

src/configs/{rotatingMachine,valve}.json: small alignment edits that
came with the registry phase.

src/menu/asset.js + src/menu/aquonSamples.js: rewritten as facades
over assetResolver, keeping the editor-side cascade behaviour intact.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
znetsixe
2026-05-12 17:12:13 +02:00
parent 84a4430266
commit 0a4b52f517
34 changed files with 1244 additions and 2386 deletions

View File

@@ -0,0 +1,103 @@
'use strict';
/**
* AssetResolver — single entry point for all asset-side metadata in EVOLV.
*
* Namespaces are declared at construction (see ./namespaces/*). Each namespace
* exposes a `loadAll()` returning a `Map<id, payload>`; AssetResolver routes
* resolve(name, id) calls into the right namespace, caches the loaded map, and
* exposes async `refresh(name?)` for future HttpBackend hydration.
*
* Resolution is sync by contract: the first resolve() for an unwarmed
* namespace pulls everything into cache, all subsequent calls are O(1).
*
* Backend abstraction (./backends/*) is where File vs Http lives — namespaces
* just hold a backend reference and call backend.loadAll().
*
* See ./README.md for the full extension story.
*/
class AssetResolver {
constructor(namespaces = []) {
this._slots = new Map();
for (const ns of namespaces) {
if (!ns || !ns.name || typeof ns.loadAll !== 'function') {
throw new TypeError('AssetResolver: namespace must declare { name, loadAll() }');
}
this._slots.set(ns.name, { ns, cache: null });
}
}
_ensureWarm(name) {
const slot = this._slots.get(name);
if (!slot) throw new Error(`AssetResolver: unknown namespace '${name}'`);
if (slot.cache === null) slot.cache = slot.ns.loadAll();
return slot;
}
resolve(namespace, id) {
const slot = this._ensureWarm(namespace);
const key = String(id ?? '').toLowerCase();
if (!key) return null;
return slot.cache.get(key) ?? null;
}
list(namespace) {
const slot = this._ensureWarm(namespace);
return [...slot.cache.keys()];
}
namespaces() {
return [...this._slots.keys()];
}
async refresh(namespace) {
if (namespace) {
const slot = this._slots.get(namespace);
if (!slot) throw new Error(`AssetResolver: unknown namespace '${namespace}'`);
slot.cache = typeof slot.ns.refresh === 'function'
? await slot.ns.refresh()
: slot.ns.loadAll();
return;
}
for (const slot of this._slots.values()) {
slot.cache = typeof slot.ns.refresh === 'function'
? await slot.ns.refresh()
: slot.ns.loadAll();
}
}
/**
* Cross-namespace helper: given a softwareType + model id, walk the
* editor menu tree to return { supplier, type, units, raw } so domain
* code doesn't have to persist supplier/type/unit on the node.
*/
resolveAssetMetadata(softwareType, modelId) {
if (!softwareType || !modelId) return null;
const tree = this.resolve('menu', softwareType);
if (!tree || !Array.isArray(tree.suppliers)) return null;
const norm = String(modelId).toLowerCase();
for (const supplier of tree.suppliers) {
for (const type of supplier.types || []) {
for (const model of type.models || []) {
const candidate = String(model.id || model.name || '').toLowerCase();
if (candidate === norm) {
return {
supplier: supplier.name,
supplierId: supplier.id || supplier.name,
type: type.name,
typeId: type.id || type.name,
model: model.name,
modelId: model.id || model.name,
units: Array.isArray(model.units) ? [...model.units] : [],
raw: model,
};
}
}
}
}
return null;
}
}
module.exports = AssetResolver;

78
src/registry/README.md Normal file
View File

@@ -0,0 +1,78 @@
# registry — AssetResolver
Single entry point for all asset-side metadata: pump/valve curves, editor menu
trees, monster sample codes, unit families, and anything else we add later.
Replaces (will replace, phase-by-phase):
- `loadCurve(model)``assetResolver.resolve('curves', model)`
- `AssetCategoryManager``assetResolver.resolve('menu', softwareType)`
- ad-hoc loaders for `monsterSamples.json`, `unitData.json``assetResolver.resolve('monsterSamples'|'units', …)`
## Surface
```js
const { assetResolver } = require('generalFunctions');
const curve = assetResolver.resolve('curves', 'hidrostal-H05K-S03R');
const tree = assetResolver.resolve('menu', 'rotatingmachine');
const meta = assetResolver.resolveAssetMetadata('rotatingmachine', 'hidrostal-H05K-S03R');
// meta → { supplier, type, units, model, raw }
assetResolver.list('curves'); // ['hidrostal-H05K-S03R', 'ECDV', ...]
assetResolver.namespaces(); // ['curves', 'menu', 'monsterSamples', 'units']
await assetResolver.refresh(); // re-pull everything (FileBackend: re-reads disk; HttpBackend: future)
```
Resolution is synchronous. First call to `resolve(namespace, id)` warms that
namespace's cache; later calls are O(1) map lookups.
## Adding a namespace
Create `src/registry/namespaces/<name>.js`:
```js
const path = require('path');
const FileBackend = require('../backends/FileBackend');
const backend = new FileBackend({
baseDir: path.resolve(__dirname, '../../../datasets/...'),
layout: 'per-id', // or 'single-file'
caseInsensitive: true,
});
module.exports = {
name: 'newThing',
description: 'What this namespace is for',
loadAll: () => backend.loadAll(),
refresh: () => backend.refresh(),
};
```
Register it in `namespaces/index.js`. Done.
## Backends
- **FileBackend** — reads JSON from disk. Two layouts: `per-id` (one file per
id, filename minus `.json` is the id) or `single-file` (one file with an
array; pick `arrayKey` and `indexField`).
- **HttpBackend** — stub. Disabled unless `EVOLV_ASSET_REMOTE=1`. Will hold
the future WBD product API client; currently throws if invoked. Exists so
the resolver contract is backend-agnostic from day one.
Backends are interchangeable per namespace: the namespace file is the
declarative join between "what this metadata is" and "where it comes from".
## Why sync at runtime
Node-RED node constructors aren't async-friendly. Every consumer that used
`loadCurve(model)` expects a synchronous return. The resolver preserves that
contract: cache is warmed lazily (first `resolve()` call pulls everything),
and lookups are O(1) map gets after that. Async `refresh()` exists for future
HttpBackend hydration on a background timer.
## Convention: namespace name is the cache key
`assetResolver.resolve(namespace, id)` lowercases `id` for the lookup. Old
case-mismatched configs (`Hidrostal-H05K-S03R` vs `hidrostal-H05K-S03R`) still
resolve correctly — same as `loadCurve` did historically.

View File

@@ -0,0 +1,96 @@
'use strict';
const fs = require('fs');
const path = require('path');
/**
* FileBackend — reads JSON payloads from a directory on disk.
*
* Two layouts supported:
* - 'per-id' one file per id (filename minus .json is the id)
* - 'single-file' one file containing an array; index by a field name
*
* Returns Map<lowerCaseId, payload>. Case-insensitive lookups by default —
* matches how loadCurve worked historically.
*/
class FileBackend {
constructor(opts = {}) {
const {
baseDir,
layout = 'per-id',
filePath,
arrayKey,
indexField,
exclude = [],
caseInsensitive = true,
} = opts;
if (!baseDir) throw new TypeError('FileBackend: baseDir is required');
if (layout !== 'per-id' && layout !== 'single-file') {
throw new TypeError(`FileBackend: unsupported layout '${layout}'`);
}
if (layout === 'single-file' && !filePath) {
throw new TypeError('FileBackend: single-file layout requires filePath');
}
this.baseDir = baseDir;
this.layout = layout;
this.filePath = filePath;
this.arrayKey = arrayKey;
this.indexField = indexField;
this.exclude = new Set(exclude);
this.caseInsensitive = caseInsensitive;
}
_norm(k) {
return this.caseInsensitive ? String(k).toLowerCase() : String(k);
}
loadAll() {
if (this.layout === 'per-id') return this._loadPerId();
return this._loadSingleFile();
}
async refresh() {
// No actual I/O penalty on local disk; the async surface exists so
// callers can `await resolver.refresh()` symmetrically with future
// HttpBackend implementations.
return this.loadAll();
}
_loadPerId() {
const map = new Map();
if (!fs.existsSync(this.baseDir)) return map;
const entries = fs.readdirSync(this.baseDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isFile()) continue;
if (!entry.name.endsWith('.json')) continue;
const id = path.basename(entry.name, '.json');
if (this.exclude.has(id)) continue;
const raw = fs.readFileSync(path.join(this.baseDir, entry.name), 'utf8');
const data = JSON.parse(raw);
map.set(this._norm(id), data);
}
return map;
}
_loadSingleFile() {
const full = path.resolve(this.baseDir, this.filePath);
if (!fs.existsSync(full)) return new Map();
const data = JSON.parse(fs.readFileSync(full, 'utf8'));
const arr = this.arrayKey ? data[this.arrayKey] : data;
if (!Array.isArray(arr)) {
throw new Error(
`FileBackend(single-file): expected array at ${this.arrayKey || '<root>'} in ${full}`,
);
}
const map = new Map();
for (const entry of arr) {
const k = entry && this.indexField ? entry[this.indexField] : null;
if (k != null) map.set(this._norm(k), entry);
}
return map;
}
}
module.exports = FileBackend;

View File

@@ -0,0 +1,41 @@
'use strict';
/**
* HttpBackend — stub. The shape that any future remote product/asset DB will
* implement so the resolver can swap backends without touching consumers.
*
* Disabled by default. Set EVOLV_ASSET_REMOTE=1 to opt in; even then this
* stub throws on use because the upstream API is not yet defined. See
* `assetApiConfig.js` for the URL/auth scaffolding that will eventually
* land here.
*/
class HttpBackend {
constructor({ url, headers = {}, namespace } = {}) {
this.url = url;
this.headers = headers;
this.namespace = namespace;
}
static get enabled() {
return process.env.EVOLV_ASSET_REMOTE === '1';
}
loadAll() {
if (!HttpBackend.enabled) {
throw new Error(
'HttpBackend disabled (set EVOLV_ASSET_REMOTE=1 to enable); ' +
'no synchronous remote fetch is implemented yet.',
);
}
throw new Error(
'HttpBackend.loadAll(): remote asset backend not yet implemented. ' +
'Use FileBackend or implement this method against the WBD product API.',
);
}
async refresh() {
return this.loadAll();
}
}
module.exports = HttpBackend;

15
src/registry/index.js Normal file
View File

@@ -0,0 +1,15 @@
'use strict';
const AssetResolver = require('./AssetResolver');
const FileBackend = require('./backends/FileBackend');
const HttpBackend = require('./backends/HttpBackend');
const namespaces = require('./namespaces');
const assetResolver = new AssetResolver(namespaces);
module.exports = {
AssetResolver,
FileBackend,
HttpBackend,
assetResolver,
};

View File

@@ -0,0 +1,17 @@
'use strict';
const path = require('path');
const FileBackend = require('../backends/FileBackend');
const backend = new FileBackend({
baseDir: path.resolve(__dirname, '../../../datasets/assetData/curves'),
layout: 'per-id',
caseInsensitive: true,
});
module.exports = {
name: 'curves',
description: 'Pump and valve performance curves keyed by model id',
loadAll: () => backend.loadAll(),
refresh: () => backend.refresh(),
};

View File

@@ -0,0 +1,9 @@
'use strict';
module.exports = [
require('./curves'),
require('./menu'),
require('./monsterSamples'),
require('./monsterSpecs'),
require('./units'),
];

View File

@@ -0,0 +1,47 @@
'use strict';
const path = require('path');
const fs = require('fs');
const FileBackend = require('../backends/FileBackend');
const BASE_DIR = path.resolve(__dirname, '../../../datasets/assetData');
// Files in datasets/assetData that aren't editor menu trees.
const EXCLUDE = ['assetData', 'monsterSamples', 'unitData'];
// Plain per-id File backend, but the menu namespace also wants to key by the
// inner `softwareType` field (so '/menu/rotatingmachine' works even if the
// file is named machine.json). The FileBackend gives us filename-keyed maps;
// we rekey in a thin wrapper.
const backend = new FileBackend({
baseDir: BASE_DIR,
layout: 'per-id',
caseInsensitive: true,
exclude: EXCLUDE,
});
// Menu trees are looked up by softwareType. We index by BOTH the inner
// `softwareType` field AND the filename (sans .json), because consumers come
// from two paths: editor endpoints pass the node type ('rotatingmachine'),
// while older code paths pass the filename slug ('machine'). Both should hit
// the same tree.
function _rekeyBySoftwareType(map) {
const out = new Map();
for (const [filenameId, data] of map.entries()) {
const stKey = String(data?.softwareType || '').toLowerCase();
const fnKey = String(filenameId).toLowerCase();
if (stKey) out.set(stKey, data);
if (fnKey && fnKey !== stKey) out.set(fnKey, data);
}
return out;
}
module.exports = {
name: 'menu',
description: 'Editor cascade trees (supplier→type→model→unit), keyed by softwareType',
loadAll: () => _rekeyBySoftwareType(backend.loadAll()),
refresh: async () => _rekeyBySoftwareType(await backend.refresh()),
// Exposed for inline tests / debugging.
_BASE_DIR: BASE_DIR,
_EXCLUDE: EXCLUDE,
_existsForFilename: (id) => fs.existsSync(path.join(BASE_DIR, `${id}.json`)),
};

View File

@@ -0,0 +1,20 @@
'use strict';
const path = require('path');
const FileBackend = require('../backends/FileBackend');
const backend = new FileBackend({
baseDir: path.resolve(__dirname, '../../../datasets/assetData'),
layout: 'single-file',
filePath: 'monsterSamples.json',
arrayKey: 'samples',
indexField: 'code',
caseInsensitive: true,
});
module.exports = {
name: 'monsterSamples',
description: 'Monster (Aquon) sample codes keyed by sample code',
loadAll: () => backend.loadAll(),
refresh: () => backend.refresh(),
};

View File

@@ -0,0 +1,30 @@
'use strict';
const path = require('path');
const fs = require('fs');
// monsterSpecs is a single-document namespace (one file, two top-level keys:
// defaults and bySample). It doesn't fit FileBackend's per-id or single-file
// array layouts cleanly — so we inline a tiny loader here instead of bending
// FileBackend to accommodate the shape.
//
// The whole document is exposed under id 'all'. Consumers (AquonSamplesMenu,
// monster specificClass) call assetResolver.resolve('monsterSpecs', 'all').
const FILE_PATH = path.resolve(__dirname, '../../../datasets/assetData/specs/monster/index.json');
function _load() {
if (!fs.existsSync(FILE_PATH)) return new Map();
const data = JSON.parse(fs.readFileSync(FILE_PATH, 'utf8'));
return new Map([['all', {
defaults: data.defaults || {},
bySample: data.bySample || {},
}]]);
}
module.exports = {
name: 'monsterSpecs',
description: 'Monster sampling specs (defaults + per-sample overrides) from specs/monster/index.json',
loadAll: _load,
refresh: () => _load(),
};

View File

@@ -0,0 +1,21 @@
'use strict';
const path = require('path');
const FileBackend = require('../backends/FileBackend');
// unitData.json lives at datasets/ (not datasets/assetData/).
const backend = new FileBackend({
baseDir: path.resolve(__dirname, '../../../datasets'),
layout: 'single-file',
filePath: 'unitData.json',
arrayKey: 'units',
indexField: 'category',
caseInsensitive: true,
});
module.exports = {
name: 'units',
description: 'Unit families keyed by measurement category (flow, pressure, …)',
loadAll: () => backend.loadAll(),
refresh: () => backend.refresh(),
};