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

@@ -1,41 +1,30 @@
const fs = require('fs');
const path = require('path');
'use strict';
// AquonSamplesMenu is now a thin facade over assetResolver.
// Backed by namespaces `monsterSamples` (sample codes, indexed by code)
// and `monsterSpecs` (sampling defaults + per-sample overrides).
const { assetResolver } = require('../registry');
class AquonSamplesMenu {
constructor(relPath = '../../datasets/assetData') {
this.baseDir = path.resolve(__dirname, relPath);
this.samplePath = path.resolve(this.baseDir, 'monsterSamples.json');
this.specPath = path.resolve(this.baseDir, 'specs/monster/index.json');
this.cache = new Map();
}
// relPath retained for signature compatibility with the previous on-disk
// implementation; unused — the registry owns file locations.
constructor(/* relPath */) {}
_loadJSON(filePath, cacheKey) {
if (this.cache.has(cacheKey)) {
return this.cache.get(cacheKey);
getAllMenuData() {
const samples = assetResolver
.list('monsterSamples')
.map((id) => assetResolver.resolve('monsterSamples', id))
.filter(Boolean);
const specs = assetResolver.resolve('monsterSpecs', 'all') || { defaults: {}, bySample: {} };
return {
samples,
specs: {
defaults: specs.defaults || {},
bySample: specs.bySample || {},
},
};
}
if (!fs.existsSync(filePath)) {
throw new Error(`Aquon dataset not found: ${filePath}`);
}
const raw = fs.readFileSync(filePath, 'utf8');
const parsed = JSON.parse(raw);
this.cache.set(cacheKey, parsed);
return parsed;
}
getAllMenuData() {
const samples = this._loadJSON(this.samplePath, 'samples');
const specs = this._loadJSON(this.specPath, 'specs');
return {
samples: samples.samples || [],
specs: {
defaults: specs.defaults || {},
bySample: specs.bySample || {}
}
};
}
}
module.exports = AquonSamplesMenu;

View File

@@ -624,46 +624,40 @@ class AssetMenu {
}
getSaveInjectionCode(nodeName) {
// After the AssetResolver cutover, only model + unit + tagCode are stored
// on the node. supplier / assetType / category were denormalized copies of
// registry data and are derived at runtime via
// assetResolver.resolveAssetMetadata(softwareType, model).
//
// We still READ the supplier/type DOM elements for validation (the user
// must have walked the cascade to pick a model), but we explicitly CLEAR
// them from the persisted node — so a saved flow only contains the
// identifier surface.
return `
// Asset save handler for ${nodeName}
window.EVOLV.nodes.${nodeName}.assetMenu.saveEditor = function(node) {
console.log('Saving asset properties for ${nodeName}');
const menuAsset = window.EVOLV.nodes.${nodeName}.menuData.asset || {};
const categories = menuAsset.categories || {};
const defaultCategory = menuAsset.defaultCategory || Object.keys(categories)[0] || null;
const resolveCategoryKey = () => {
if (node.softwareType && categories[node.softwareType]) {
return node.softwareType;
}
if (node.category && categories[node.category]) {
return node.category;
}
return defaultCategory || '';
};
node.category = resolveCategoryKey();
const fields = ['supplier', 'assetType', 'model', 'unit', 'assetTagNumber'];
const errors = [];
fields.forEach((field) => {
const el = document.getElementById(\`node-input-\${field}\`);
node[field] = el ? el.value : '';
});
const modelEl = document.getElementById('node-input-model');
const unitEl = document.getElementById('node-input-unit');
const tagEl = document.getElementById('node-input-assetTagNumber');
if (node.assetType && !node.unit) {
errors.push('Unit must be set when a type is specified.');
}
if (!node.unit) {
errors.push('Unit is required.');
}
node.model = modelEl ? modelEl.value : '';
node.unit = unitEl ? unitEl.value : '';
node.assetTagNumber = tagEl ? tagEl.value : '';
// Identity surface only — registry derives the rest.
delete node.supplier;
delete node.category;
delete node.assetType;
if (!node.model) errors.push('Model is required.');
if (!node.unit) errors.push('Unit is required.');
errors.forEach((msg) => RED.notify(msg, 'error'));
const saved = fields.reduce((acc, field) => {
acc[field] = node[field];
return acc;
}, {});
const saved = { model: node.model, unit: node.unit, assetTagNumber: node.assetTagNumber };
if (node.modelMetadata && typeof node.modelMetadata.id !== 'undefined') {
saved.modelId = node.modelMetadata.id;
}