From 0a4b52f517a4f6c83876e417654a28e52a500672 Mon Sep 17 00:00:00 2001 From: znetsixe Date: Tue, 12 May 2026 17:12:13 +0200 Subject: [PATCH] =?UTF-8?q?feat(registry):=20AssetResolver=20+=20diffuser?= =?UTF-8?q?=20supplier=20curves=20(J=C3=A4ger=20/=20Aerostrip=20/=20PIK=20?= =?UTF-8?q?/=20PRK)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../assetData/curves/aerostrip-phoenix.json | 44 + datasets/assetData/curves/gva-elastox-r.json | 27 + datasets/assetData/curves/index.js | 148 --- .../jaeger-jetflex-td-65-2-g-epdm-1000.json | 44 + datasets/assetData/curves/pik300.json | 36 + datasets/assetData/curves/prk300.json | 36 + datasets/assetData/diffuser.json | 68 ++ datasets/assetData/index.js | 146 ++- datasets/assetData/modelData/ECDV.json | 16 - .../modelData/hidrostal-C5-D03R-SHN1.json | 838 ------------- .../modelData/hidrostal-H05K-S03R.json | 1062 ----------------- datasets/assetData/modelData/index.js | 124 -- index.js | 23 +- src/configs/diffuser.json | 20 +- src/configs/rotatingMachine.json | 31 +- src/configs/valve.json | 31 +- src/menu/aquonSamples.js | 57 +- src/menu/asset.js | 54 +- src/registry/AssetResolver.js | 103 ++ src/registry/README.md | 78 ++ src/registry/backends/FileBackend.js | 96 ++ src/registry/backends/HttpBackend.js | 41 + src/registry/index.js | 15 + src/registry/namespaces/curves.js | 17 + src/registry/namespaces/index.js | 9 + src/registry/namespaces/menu.js | 47 + src/registry/namespaces/monsterSamples.js | 20 + src/registry/namespaces/monsterSpecs.js | 30 + src/registry/namespaces/units.js | 21 + test/00-barrel-contract.test.js | 9 +- test/registry/AssetResolver.test.js | 112 ++ test/registry/FileBackend.test.js | 98 ++ test/registry/HttpBackend.test.js | 30 + test/registry/namespaces.test.js | 99 ++ 34 files changed, 1244 insertions(+), 2386 deletions(-) create mode 100644 datasets/assetData/curves/aerostrip-phoenix.json create mode 100644 datasets/assetData/curves/gva-elastox-r.json delete mode 100644 datasets/assetData/curves/index.js create mode 100644 datasets/assetData/curves/jaeger-jetflex-td-65-2-g-epdm-1000.json create mode 100644 datasets/assetData/curves/pik300.json create mode 100644 datasets/assetData/curves/prk300.json create mode 100644 datasets/assetData/diffuser.json delete mode 100644 datasets/assetData/modelData/ECDV.json delete mode 100644 datasets/assetData/modelData/hidrostal-C5-D03R-SHN1.json delete mode 100644 datasets/assetData/modelData/hidrostal-H05K-S03R.json delete mode 100644 datasets/assetData/modelData/index.js create mode 100644 src/registry/AssetResolver.js create mode 100644 src/registry/README.md create mode 100644 src/registry/backends/FileBackend.js create mode 100644 src/registry/backends/HttpBackend.js create mode 100644 src/registry/index.js create mode 100644 src/registry/namespaces/curves.js create mode 100644 src/registry/namespaces/index.js create mode 100644 src/registry/namespaces/menu.js create mode 100644 src/registry/namespaces/monsterSamples.js create mode 100644 src/registry/namespaces/monsterSpecs.js create mode 100644 src/registry/namespaces/units.js create mode 100644 test/registry/AssetResolver.test.js create mode 100644 test/registry/FileBackend.test.js create mode 100644 test/registry/HttpBackend.test.js create mode 100644 test/registry/namespaces.test.js diff --git a/datasets/assetData/curves/aerostrip-phoenix.json b/datasets/assetData/curves/aerostrip-phoenix.json new file mode 100644 index 0000000..3b1855d --- /dev/null +++ b/datasets/assetData/curves/aerostrip-phoenix.json @@ -0,0 +1,44 @@ +{ + "_meta": { + "supplier": "Aquaconsult Anlagenbau / Entec", + "type": "Strip", + "model": "AEROSTRIP", + "membrane": "PHOENIX", + "stdAir": { "temp_C": 0, "pressure_mbar": 1013.25, "RH_pct": 0 }, + "coverageBasis": "bottom-coverage-pct", + "coverageReference": [5, 10, 15, 20], + "dataQuality": "multi-coverage", + "xAxisBasis": "per-m2-membrane-Nm3h", + "yAxisBasis": "ssotr-g-per-Nm3-per-m", + "waterDepth_m": 4.75, + "sources": [ + "Floris de Winter (Entec Holland) email to R. de Ren on 2023-11-22 — tabulated SOTE [%] at 4.75 m water depth for bottom coverage 5/10/15/20 % at fluxes 10/25/40/55/70 Nm3/(h*m2 membrane). Original chart in 'SSOTE_4.75m different density.pdf'.", + "'SSOTR_dP.pdf' — AEROSTRIP fine-bubble diffuser SSOTR + Druckverlust (DWP) chart at water depth 4.05 m, blow-in depth 4.00 m, 21 % bottom coverage. Used for the DWP curve only (read off the vector chart)." + ], + "note": "X-axis is flux per m² of membrane area, NOT per element. The existing diffuser specificClass passes `flow_per_element` to the interpolator — if you wire an AEROSTRIP model in, you must either set elements/area such that flow_per_element == flux_per_m2_membrane, OR extend _calcOtrPressure to apply the membrane-area conversion. SSOTR values are SOTE [%] / water_depth_m * 0.299 kg-O2/Nm3 * 10 (linear depth scaling). DWP curve was measured at 21 % bottom coverage; pressure loss is intrinsic to the diffuser geometry so the curve is shared across coverage values." + }, + "sote_curve": { + "5": { "x": [10, 25, 40, 55, 70], "y": [34.20, 28.75, 26.16, 24.89, 24.19] }, + "10": { "x": [10, 25, 40, 55, 70], "y": [42.01, 35.32, 32.14, 30.58, 29.71] }, + "15": { "x": [10, 25, 40, 55, 70], "y": [43.39, 36.48, 33.20, 31.59, 30.69] }, + "20": { "x": [10, 25, 40, 55, 70], "y": [43.80, 36.82, 33.51, 31.88, 30.97] } + }, + "ssote_curve": { + "5": { "x": [10, 25, 40, 55, 70], "y": [7.20, 6.05, 5.51, 5.24, 5.09] }, + "10": { "x": [10, 25, 40, 55, 70], "y": [8.84, 7.44, 6.77, 6.44, 6.26] }, + "15": { "x": [10, 25, 40, 55, 70], "y": [9.14, 7.68, 6.99, 6.65, 6.46] }, + "20": { "x": [10, 25, 40, 55, 70], "y": [9.22, 7.75, 7.06, 6.71, 6.52] } + }, + "otr_curve": { + "5": { "x": [10, 25, 40, 55, 70], "y": [21.53, 18.10, 16.47, 15.67, 15.23] }, + "10": { "x": [10, 25, 40, 55, 70], "y": [26.44, 22.23, 20.23, 19.25, 18.70] }, + "15": { "x": [10, 25, 40, 55, 70], "y": [27.31, 22.96, 20.90, 19.89, 19.32] }, + "20": { "x": [10, 25, 40, 55, 70], "y": [27.57, 23.18, 21.10, 20.06, 19.49] } + }, + "p_curve": { + "21": { + "x": [5, 10, 25, 40, 55, 70, 80], + "y": [46.0, 47.3, 51.1, 54.9, 58.7, 62.4, 65.0] + } + } +} diff --git a/datasets/assetData/curves/gva-elastox-r.json b/datasets/assetData/curves/gva-elastox-r.json new file mode 100644 index 0000000..aef1f8d --- /dev/null +++ b/datasets/assetData/curves/gva-elastox-r.json @@ -0,0 +1,27 @@ +{ + "_meta": { + "supplier": "GVA", + "type": "Tube", + "model": "ELASTOX-R", + "stdAir": { "temp_C": 20, "pressure_bar": 1.01325, "RH_pct": 0 }, + "coverageBasis": "bottom-coverage-pct", + "coverageReference": null, + "dataQuality": "point", + "xAxisBasis": "per-element-Nm3h", + "yAxisBasis": "ssotr-g-per-Nm3-per-m", + "waterDepth_m": null, + "note": "Migrated 2026-05-12 from nodes/diffuser/src/specificClass.js _loadSpecs(). The legacy hardcoded data was tagged '2.4 elements/m²' by a prior agent; the originating vendor data was always intended as % surface-area coverage but the original test-bench coverage was not preserved when the numbers were inlined. Treat as a single-coverage point estimate (key '0' = unspecified). Do not extrapolate across density. SSOTR in g O2 / (Nm3 air * m of submergence)." + }, + "otr_curve": { + "0": { + "x": [2, 3, 4, 5, 6, 7, 8, 9, 10], + "y": [26, 25, 24, 23.5, 23, 22.75, 22.5, 22.25, 22] + } + }, + "p_curve": { + "0": { + "x": [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], + "y": [40, 42.5, 45, 47.5, 50, 51.5, 53, 54.5, 56, 57.5, 59] + } + } +} diff --git a/datasets/assetData/curves/index.js b/datasets/assetData/curves/index.js deleted file mode 100644 index bfac3ec..0000000 --- a/datasets/assetData/curves/index.js +++ /dev/null @@ -1,148 +0,0 @@ -const fs = require('fs'); -const path = require('path'); - -class AssetLoader { - constructor(maxCacheSize = 100) { - this.relPath = './' - this.baseDir = path.resolve(__dirname, this.relPath); - this.cache = new Map(); - this.maxCacheSize = maxCacheSize; - } - - /** - * Load a specific curve by type - * @param {string} curveType - The curve identifier (e.g., 'hidrostal-H05K-S03R') - * @returns {Object|null} The curve data object or null if not found - */ - loadCurve(curveType) { - return this.loadAsset('curves', curveType); - } - - /** - * Load any asset from a specific dataset folder - * @param {string} datasetType - The dataset folder name (e.g., 'curves', 'assetData') - * @param {string} assetId - The specific asset identifier - * @returns {Object|null} The asset data object or null if not found - */ - loadAsset(datasetType, assetId) { - //const cacheKey = `${datasetType}/${assetId}`; - const normalizedAssetId = String(assetId || '').trim(); - if (!normalizedAssetId) { - return null; - } - const cacheKey = normalizedAssetId.toLowerCase(); - - - // Check cache first - if (this.cache.has(cacheKey)) { - return this.cache.get(cacheKey); - } - - try { - const filePath = this._resolveAssetPath(normalizedAssetId); - - // Check if file exists - if (!filePath || !fs.existsSync(filePath)) { - console.warn(`Asset not found for id '${normalizedAssetId}' in ${this.baseDir}`); - return null; - } - - // Load and parse JSON - const rawData = fs.readFileSync(filePath, 'utf8'); - const assetData = JSON.parse(rawData); - - // Cache the result (evict oldest if at capacity) - if (this.cache.size >= this.maxCacheSize) { - const oldestKey = this.cache.keys().next().value; - this.cache.delete(oldestKey); - } - this.cache.set(cacheKey, assetData); - - return assetData; - } catch (error) { - console.error(`Error loading asset ${cacheKey}:`, error.message); - return null; - } - } - - _resolveAssetPath(assetId) { - const exactPath = path.join(this.baseDir, `${assetId}.json`); - if (fs.existsSync(exactPath)) { - return exactPath; - } - - const target = `${assetId}.json`.toLowerCase(); - const files = fs.readdirSync(this.baseDir); - const matched = files.find((file) => file.toLowerCase() === target); - if (!matched) { - return null; - } - return path.join(this.baseDir, matched); - } - - /** - * Get all available assets in a dataset - * @param {string} datasetType - The dataset folder name - * @returns {string[]} Array of available asset IDs - */ - getAvailableAssets(datasetType) { - try { - const datasetPath = path.join(this.baseDir, datasetType); - - if (!fs.existsSync(datasetPath)) { - return []; - } - - return fs.readdirSync(datasetPath) - .filter(file => file.endsWith('.json')) - .map(file => file.replace('.json', '')); - } catch (error) { - console.error(`Error reading dataset ${datasetType}:`, error.message); - return []; - } - } - - /** - * Clear the cache (useful for development/testing) - */ - clearCache() { - this.cache.clear(); - } -} - -// Create and export a singleton instance -const assetLoader = new AssetLoader(); - -module.exports = { - AssetLoader, - assetLoader, - // Convenience methods for backward compatibility - loadCurve: (curveType) => assetLoader.loadCurve(curveType), - loadAsset: (datasetType, assetId) => assetLoader.loadAsset(datasetType, assetId), - getAvailableAssets: (datasetType) => assetLoader.getAvailableAssets(datasetType) -}; - -/* -// Example usage in your scripts -const loader = new AssetLoader(); - -// Load a specific curve -const curve = loader.loadCurve('hidrostal-H05K-S03R'); -if (curve) { - console.log('Curve loaded:', curve); -} else { - console.log('Curve not found'); -} -/* -// Load any asset from any dataset -const someAsset = loadAsset('assetData', 'some-asset-id'); - -// Get list of available curves -const availableCurves = getAvailableAssets('curves'); -console.log('Available curves:', availableCurves); - -// Using the class directly for more control -const { AssetLoader } = require('./index.js'); -const customLoader = new AssetLoader(); -const data = customLoader.loadCurve('hidrostal-H05K-S03R'); -*/ diff --git a/datasets/assetData/curves/jaeger-jetflex-td-65-2-g-epdm-1000.json b/datasets/assetData/curves/jaeger-jetflex-td-65-2-g-epdm-1000.json new file mode 100644 index 0000000..6e736f9 --- /dev/null +++ b/datasets/assetData/curves/jaeger-jetflex-td-65-2-g-epdm-1000.json @@ -0,0 +1,44 @@ +{ + "_meta": { + "supplier": "Jäger Umwelt-Technik", + "type": "Tube", + "model": "JetFlex TD 65-2 G", + "membrane": "EPDM", + "tubeLength_mm": 1000, + "totalLength_mm": 1062.5, + "perforatedArea_m2": 0.18, + "outerDiameter_mm": 65, + "operating": { + "continuousFlow_Nm3h": [2, 12], + "maxOverloadFlow_Nm3h": 20, + "operatingMode": "continuous-or-intermittent" + }, + "stdAir": { "temp_C": 0, "pressure_mbar": 1013.25, "RH_pct": 0 }, + "coverageBasis": "bottom-coverage-pct", + "coverageReference": null, + "dataQuality": "point", + "xAxisBasis": "per-element-Nm3h", + "yAxisBasis": "ssotr-g-per-Nm3-per-m", + "waterDepth_m": null, + "source": "Jäger Umwelt-Technik 'JETFLEX TD 65-2 G Tube Diffuser' data sheet — vector chart on page 2 ('SSOTE and headloss for EPDM 1000 mm'). Curve coordinates recovered directly from the PDF vector paths on 2026-05-12 (bezier endpoints of the red SSOTE polyline and blue DWP polyline); axis calibration against the gridlines is exact.", + "note": "Vendor sheet states neither the tank-floor coverage nor the water depth at which the SSOTE curve was measured. Treat as a single-coverage point estimate (key '0' = unspecified); do not extrapolate across density. SSOTR = SSOTE * 0.299 kg-O2/Nm3 (DIN-1343 dry air, 0 °C 1013.25 mbar)." + }, + "ssote_curve": { + "0": { + "x": [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], + "y": [8.20, 7.85, 7.57, 7.30, 7.10, 6.97, 6.85, 6.72, 6.60, 6.50, 6.40] + } + }, + "otr_curve": { + "0": { + "x": [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], + "y": [24.52, 23.47, 22.63, 21.83, 21.23, 20.84, 20.48, 20.09, 19.73, 19.44, 19.14] + } + }, + "p_curve": { + "0": { + "x": [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], + "y": [25.0, 27.5, 30.0, 32.5, 35.0, 37.5, 40.0, 42.0, 44.0, 46.0, 48.0] + } + } +} diff --git a/datasets/assetData/curves/pik300.json b/datasets/assetData/curves/pik300.json new file mode 100644 index 0000000..2c6f58e --- /dev/null +++ b/datasets/assetData/curves/pik300.json @@ -0,0 +1,36 @@ +{ + "_meta": { + "supplier": "Unknown (PIK/PRK family)", + "type": "Disc", + "model": "PIK300", + "stdAir": { "temp_C": 20, "pressure_bar": 1.01325, "RH_pct": 0 }, + "coverageBasis": "bottom-coverage-pct", + "coverageReference": [5, 10, 15, 20, 25], + "dataQuality": "multi-coverage", + "xAxisBasis": "per-element-Sm3h", + "yAxisBasis": "ssotr-g-per-Nm3-per-m", + "waterDepth_m": 4.0, + "source": "'PIK & PRK300 data from QM.xlsx' (Sheet1). Two paired models PIK300 and PRK300: identical SOTE/SSOTR curves at DD 5/10/15/20/25 %, separate DWP curves per model. Water depth inferred from the relationship SOTE[%] = SSOTR[g/(Nm3*m)] * depth_m * 100 / 0.2786 kg-O2/Sm3 — gives depth ≈ 4.0 m for every row when standard-cubic-meter mass is taken at 20 °C / 1013.25 mbar.", + "note": "X-axis air flow uses 'Sm3' (US standard, 20 °C / 1013.25 mbar) rather than 'Nm3' (DIN, 0 °C). The existing diffuser specificClass internally normalises to 20 °C — so the values are usable as-is with that convention. SOTE [%] values are the raw spreadsheet entries; otr_curve is the per-meter SSOTR g/(Nm3*m) already in canonical units." + }, + "sote_curve": { + "5": { "x": [1.5, 2, 3, 4, 5, 6, 7, 8], "y": [27.87, 26.99, 25.80, 24.97, 24.38, 23.89, 23.46, 23.12] }, + "10": { "x": [1.5, 2, 3, 4, 5, 6, 7, 8], "y": [30.18, 29.33, 28.15, 27.33, 26.73, 26.21, 25.83, 25.49] }, + "15": { "x": [1.5, 2, 3, 4, 5, 6, 7, 8], "y": [31.51, 30.53, 29.16, 28.27, 27.57, 27.01, 26.55, 26.15] }, + "20": { "x": [1.5, 2, 3, 4, 5, 6, 7, 8], "y": [32.52, 31.39, 29.88, 28.84, 28.06, 27.45, 26.92, 26.49] }, + "25": { "x": [1.5, 2, 3, 4, 5, 6, 7, 8], "y": [33.26, 32.04, 30.39, 29.27, 28.45, 27.77, 27.22, 26.76] } + }, + "otr_curve": { + "5": { "x": [1.5, 2, 3, 4, 5, 6, 7, 8], "y": [19.509, 18.893, 18.060, 17.479, 17.066, 16.723, 16.422, 16.184] }, + "10": { "x": [1.5, 2, 3, 4, 5, 6, 7, 8], "y": [21.126, 20.531, 19.705, 19.131, 18.711, 18.347, 18.081, 17.843] }, + "15": { "x": [1.5, 2, 3, 4, 5, 6, 7, 8], "y": [22.057, 21.371, 20.412, 19.789, 19.299, 18.907, 18.585, 18.305] }, + "20": { "x": [1.5, 2, 3, 4, 5, 6, 7, 8], "y": [22.764, 21.973, 20.916, 20.188, 19.642, 19.215, 18.844, 18.543] }, + "25": { "x": [1.5, 2, 3, 4, 5, 6, 7, 8], "y": [23.282, 22.428, 21.273, 20.489, 19.915, 19.439, 19.054, 18.732] } + }, + "p_curve": { + "0": { + "x": [1.5, 2, 3, 4, 5, 6, 7, 8], + "y": [25.5, 26.0, 27.5, 30.3, 34.0, 39.0, 45.0, 52.0] + } + } +} diff --git a/datasets/assetData/curves/prk300.json b/datasets/assetData/curves/prk300.json new file mode 100644 index 0000000..ef04e21 --- /dev/null +++ b/datasets/assetData/curves/prk300.json @@ -0,0 +1,36 @@ +{ + "_meta": { + "supplier": "Unknown (PIK/PRK family)", + "type": "Disc", + "model": "PRK300", + "stdAir": { "temp_C": 20, "pressure_bar": 1.01325, "RH_pct": 0 }, + "coverageBasis": "bottom-coverage-pct", + "coverageReference": [5, 10, 15, 20, 25], + "dataQuality": "multi-coverage", + "xAxisBasis": "per-element-Sm3h", + "yAxisBasis": "ssotr-g-per-Nm3-per-m", + "waterDepth_m": 4.0, + "source": "'PIK & PRK300 data from QM.xlsx' (Sheet1). Same SOTE/SSOTR curves as the PIK300 sibling; the PRK300 differs only in DWP characteristics.", + "note": "X-axis air flow uses 'Sm3' (US standard, 20 °C / 1013.25 mbar) rather than 'Nm3' (DIN, 0 °C). The existing diffuser specificClass internally normalises to 20 °C — so the values are usable as-is with that convention." + }, + "sote_curve": { + "5": { "x": [1.5, 2, 3, 4, 5, 6, 7, 8], "y": [27.87, 26.99, 25.80, 24.97, 24.38, 23.89, 23.46, 23.12] }, + "10": { "x": [1.5, 2, 3, 4, 5, 6, 7, 8], "y": [30.18, 29.33, 28.15, 27.33, 26.73, 26.21, 25.83, 25.49] }, + "15": { "x": [1.5, 2, 3, 4, 5, 6, 7, 8], "y": [31.51, 30.53, 29.16, 28.27, 27.57, 27.01, 26.55, 26.15] }, + "20": { "x": [1.5, 2, 3, 4, 5, 6, 7, 8], "y": [32.52, 31.39, 29.88, 28.84, 28.06, 27.45, 26.92, 26.49] }, + "25": { "x": [1.5, 2, 3, 4, 5, 6, 7, 8], "y": [33.26, 32.04, 30.39, 29.27, 28.45, 27.77, 27.22, 26.76] } + }, + "otr_curve": { + "5": { "x": [1.5, 2, 3, 4, 5, 6, 7, 8], "y": [19.509, 18.893, 18.060, 17.479, 17.066, 16.723, 16.422, 16.184] }, + "10": { "x": [1.5, 2, 3, 4, 5, 6, 7, 8], "y": [21.126, 20.531, 19.705, 19.131, 18.711, 18.347, 18.081, 17.843] }, + "15": { "x": [1.5, 2, 3, 4, 5, 6, 7, 8], "y": [22.057, 21.371, 20.412, 19.789, 19.299, 18.907, 18.585, 18.305] }, + "20": { "x": [1.5, 2, 3, 4, 5, 6, 7, 8], "y": [22.764, 21.973, 20.916, 20.188, 19.642, 19.215, 18.844, 18.543] }, + "25": { "x": [1.5, 2, 3, 4, 5, 6, 7, 8], "y": [23.282, 22.428, 21.273, 20.489, 19.915, 19.439, 19.054, 18.732] } + }, + "p_curve": { + "0": { + "x": [1.5, 2, 3, 4, 5, 6, 7, 8], + "y": [21.3, 24.0, 29.3, 35.3, 41.3, 46.8, 52.4, 58.6] + } + } +} diff --git a/datasets/assetData/diffuser.json b/datasets/assetData/diffuser.json new file mode 100644 index 0000000..bbc4d3c --- /dev/null +++ b/datasets/assetData/diffuser.json @@ -0,0 +1,68 @@ +{ + "id": "diffuser", + "label": "diffuser", + "softwareType": "diffuser", + "suppliers": [ + { + "id": "gva", + "name": "GVA", + "types": [ + { + "id": "diffuser-tube", + "name": "Tube", + "models": [ + { "id": "gva-elastox-r", "name": "ELASTOX-R", "units": ["Nm3/h"] } + ] + } + ] + }, + { + "id": "jaeger", + "name": "Jäger Umwelt-Technik", + "types": [ + { + "id": "diffuser-tube", + "name": "Tube", + "models": [ + { + "id": "jaeger-jetflex-td-65-2-g-epdm-1000", + "name": "JetFlex TD 65-2 G — EPDM 1000 mm", + "units": ["Nm3/h"] + } + ] + } + ] + }, + { + "id": "aquaconsult", + "name": "Aquaconsult / Entec", + "types": [ + { + "id": "diffuser-strip", + "name": "Strip", + "models": [ + { + "id": "aerostrip-phoenix", + "name": "AEROSTRIP — Phoenix membrane", + "units": ["Nm3/h"] + } + ] + } + ] + }, + { + "id": "pikprk", + "name": "PIK / PRK (vendor TBD)", + "types": [ + { + "id": "diffuser-disc", + "name": "Disc", + "models": [ + { "id": "pik300", "name": "PIK300", "units": ["Sm3/h", "Nm3/h"] }, + { "id": "prk300", "name": "PRK300", "units": ["Sm3/h", "Nm3/h"] } + ] + } + ] + } + ] +} diff --git a/datasets/assetData/index.js b/datasets/assetData/index.js index af0ccda..59e159e 100644 --- a/datasets/assetData/index.js +++ b/datasets/assetData/index.js @@ -1,89 +1,83 @@ -const fs = require('fs'); -const path = require('path'); +'use strict'; + +// AssetCategoryManager is now a thin facade over src/registry/assetResolver. +// The public surface (getCategory / listCategories / hasCategory / searchCategories) +// is preserved so existing consumers (src/menu/asset.js, src/helper/assetUtils.js) +// don't need to change in this phase. New code should use assetResolver directly. + +const { assetResolver } = require('../../src/registry'); class AssetCategoryManager { - constructor(relPath = '.') { - this.assetDir = path.resolve(__dirname, relPath); - this.cache = new Map(); - } + // relPath is retained for signature compatibility with the prior on-disk + // implementation; it is unused now — the resolver owns file locations. + constructor(/* relPath = '.' */) {} - getCategory(softwareType) { - if (!softwareType) { - throw new Error('softwareType is required'); - } - - if (this.cache.has(softwareType)) { - return this.cache.get(softwareType); - } - - const filePath = path.resolve(this.assetDir, `${softwareType}.json`); - if (!fs.existsSync(filePath)) { - throw new Error(`Asset data '${softwareType}' not found in ${this.assetDir}`); - } - - const raw = fs.readFileSync(filePath, 'utf8'); - const parsed = JSON.parse(raw); - this.cache.set(softwareType, parsed); - return parsed; - } - - hasCategory(softwareType) { - const filePath = path.resolve(this.assetDir, `${softwareType}.json`); - return fs.existsSync(filePath); - } - - listCategories({ withMeta = false } = {}) { - const files = fs.readdirSync(this.assetDir, { withFileTypes: true }); - - return files - .filter( - (entry) => - entry.isFile() && - entry.name.endsWith('.json') && - entry.name !== 'index.json' && - entry.name !== 'assetData.json' - ) - .map((entry) => path.basename(entry.name, '.json')) - .map((name) => { - if (!withMeta) { - return name; + getCategory(softwareType) { + if (!softwareType) { + throw new Error('softwareType is required'); } - - const data = this.getCategory(name); - return { - softwareType: data.softwareType || name, - label: data.label || name, - file: `${name}.json` - }; - }); - } - - searchCategories(query) { - const term = (query || '').trim().toLowerCase(); - if (!term) { - return []; + const data = assetResolver.resolve('menu', softwareType); + if (!data) { + throw new Error(`Asset data '${softwareType}' not found in menu namespace`); + } + return data; } - return this.listCategories({ withMeta: true }).filter( - ({ softwareType, label }) => - softwareType.toLowerCase().includes(term) || - label.toLowerCase().includes(term) - ); - } + hasCategory(softwareType) { + if (!softwareType) return false; + return assetResolver.resolve('menu', softwareType) != null; + } - clearCache() { - this.cache.clear(); - } + listCategories({ withMeta = false } = {}) { + // The resolver indexes each menu file under BOTH its inner softwareType + // and its filename slug — those may differ. Dedupe by payload identity + // so we return one entry per source file. + const seen = new Set(); + const out = []; + for (const key of assetResolver.list('menu')) { + const data = assetResolver.resolve('menu', key); + if (!data || seen.has(data)) continue; + seen.add(data); + const softwareType = data.softwareType || key; + if (withMeta) { + out.push({ + softwareType, + label: data.label || softwareType, + file: `${softwareType}.json`, + }); + } else { + out.push(softwareType); + } + } + return out; + } + + searchCategories(query) { + const term = (query || '').trim().toLowerCase(); + if (!term) return []; + return this.listCategories({ withMeta: true }).filter( + ({ softwareType, label }) => + softwareType.toLowerCase().includes(term) || + (label || '').toLowerCase().includes(term), + ); + } + + clearCache() { + // Caches live in the resolver namespaces. Force-refresh menu. + // refresh() is async but the legacy contract here is sync — + // fire-and-forget; the next resolve() lazily warms in the worst case. + assetResolver.refresh('menu').catch(() => {}); + } } const assetCategoryManager = new AssetCategoryManager(); module.exports = { - AssetCategoryManager, - assetCategoryManager, - getCategory: (softwareType) => assetCategoryManager.getCategory(softwareType), - listCategories: (options) => assetCategoryManager.listCategories(options), - searchCategories: (query) => assetCategoryManager.searchCategories(query), - hasCategory: (softwareType) => assetCategoryManager.hasCategory(softwareType), - clearCache: () => assetCategoryManager.clearCache() + AssetCategoryManager, + assetCategoryManager, + getCategory: (softwareType) => assetCategoryManager.getCategory(softwareType), + listCategories: (options) => assetCategoryManager.listCategories(options), + searchCategories: (query) => assetCategoryManager.searchCategories(query), + hasCategory: (softwareType) => assetCategoryManager.hasCategory(softwareType), + clearCache: () => assetCategoryManager.clearCache(), }; diff --git a/datasets/assetData/modelData/ECDV.json b/datasets/assetData/modelData/ECDV.json deleted file mode 100644 index 895c38e..0000000 --- a/datasets/assetData/modelData/ECDV.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "1.204": { - "125": { - "x": [0,10,20,30,40,50,60,70,80,90,100], - "y": [0,18,50,95,150,216,337,564,882,1398,1870] - }, - "150": { - "x": [0,10,20,30,40,50,60,70,80,90,100], - "y": [0,25,73,138,217,314,490,818,1281,2029,2715] - }, - "400": { - "x": [0,10,20,30,40,50,60,70,80,90,100], - "y": [0,155,443,839,1322,1911,2982,4980,7795,12349,16524] - } - } -} \ No newline at end of file diff --git a/datasets/assetData/modelData/hidrostal-C5-D03R-SHN1.json b/datasets/assetData/modelData/hidrostal-C5-D03R-SHN1.json deleted file mode 100644 index 2ea753b..0000000 --- a/datasets/assetData/modelData/hidrostal-C5-D03R-SHN1.json +++ /dev/null @@ -1,838 +0,0 @@ -{ - "np": { - "400": { - "x": [ - 0, - 25.510204081632654, - 51.020408163265309, - 76.530612244897952, - 100 - ], - "y": [ - 0.5953611390998625, - 1.6935085477165994, - 3.801139124304824, - 7.367829525776738, - 12.081735423116616 - ] - }, - "500": { - "x": [ - 0, - 25.510204081632654, - 51.020408163265309, - 76.530612244897952, - 100 - ], - "y": [ - 0.5522732775894703, - 1.8497068236812997, - 3.801139124304824, - 7.367829525776738, - 12.081735423116616 - ] - }, - "600": { - "x": [ - 0, - 25.510204081632654, - 51.020408163265309, - 76.530612244897952, - 100 - ], - "y": [ - 0.5522732775894703, - 1.7497197821018213, - 3.801139124304824, - 7.367829525776738, - 12.081735423116616 - ] - }, - "700": { - "x": [ - 0, - 25.510204081632654, - 51.020408163265309, - 76.530612244897952, - 100 - ], - "y": [ - 0.5522732775894703, - 1.788320579602724, - 3.9982668237045984, - 7.367829525776738, - 12.081735423116616 - ] - }, - "800": { - "x": [ - 0, - 25.510204081632654, - 51.020408163265309, - 76.530612244897952, - 100 - ], - "y": [ - 0.5522732775894703, - 1.7824519364844427, - 3.9885060367793064, - 7.367829525776738, - 12.081735423116616 - ] - }, - "900": { - "x": [ - 0, - 25.510204081632654, - 51.020408163265309, - 76.530612244897952, - 100 - ], - "y": [ - 0.5522732775894703, - 1.6934482683506376, - 3.9879559558537054, - 7.367829525776738, - 12.081735423116616 - ] - }, - "1000": { - "x": [ - 0, - 25.510204081632654, - 51.020408163265309, - 76.530612244897952, - 100 - ], - "y": [ - 0.5522732775894703, - 1.6954385513069579, - 4.0743508382926795, - 7.422392692482345, - 12.081735423116616 - ] - }, - "1100": { - "x": [ - 0, - 25.510204081632654, - 51.020408163265309, - 76.530612244897952, - 100 - ], - "y": [ - 0.5522732775894703, - 1.6920721090317592, - 4.160745720731654, - 7.596626714476177, - 12.081735423116616 - ] - }, - "1200": { - "x": [ - 0, - 25.510204081632654, - 51.020408163265309, - 76.530612244897952, - 100 - ], - "y": [ - 0.5522732775894703, - 1.6920721090317592, - 4.302551231007837, - 7.637247864947884, - 12.081735423116616 - ] - }, - "1300": { - "x": [ - 0, - 25.510204081632654, - 51.020408163265309, - 76.530612244897952, - 100 - ], - "y": [ - 0.5522732775894703, - 1.6920721090317592, - 4.37557913990704, - 7.773442147000839, - 12.081735423116616 - ] - }, - "1400": { - "x": [ - 0, - 25.510204081632654, - 51.020408163265309, - 76.530612244897952, - 100 - ], - "y": [ - 0.5522732775894703, - 1.6920721090317592, - 4.334434337766139, - 7.940911352646818, - 12.081735423116616 - ] - }, - "1500": { - "x": [ - 0, - 25.510204081632654, - 51.020408163265309, - 76.530612244897952, - 100 - ], - "y": [ - 0.5522732775894703, - 1.6920721090317592, - 4.2327206586037995, - 8.005238800611183, - 12.254836577088351 - ] - }, - "1600": { - "x": [ - 0, - 25.510204081632654, - 51.020408163265309, - 76.530612244897952, - 100 - ], - "y": [ - 0.5522732775894703, - 1.6920721090317592, - 4.195405588464695, - 7.991827302945298, - 12.423663269044452 - ] - }, - "1700": { - "x": [ - 0, - 25.510204081632654, - 51.020408163265309, - 76.530612244897952, - 100 - ], - "y": [ - 0.5522732775894703, - 1.6920721090317592, - 14.255458319309813, - 8.096768422220196, - 12.584668380908582 - ] - }, - "1800": { - "x": [ - 0, - 25.510204081632654, - 51.020408163265309, - 76.530612244897952, - 100 - ], - "y": [ - 0.5522732775894703, - 1.6920721090317592, - 3.8742719210788685, - 31.54620347513727, - 12.637080520201405 - ] - }, - "1900": { - "x": [ - 0, - 25.510204081632654, - 51.020408163265309, - 76.530612244897952, - 100 - ], - "y": [ - 0.5522732775894703, - 1.6920721090317592, - 3.8742719210788685, - 8.148423429611098, - 12.74916725120127 - ] - }, - "2000": { - "x": [ - 0, - 25.510204081632654, - 51.020408163265309, - 76.530612244897952, - 100 - ], - "y": [ - 0.5522732775894703, - 1.6920721090317592, - 3.8742719210788685, - 8.146439484120116, - 12.905178964345618 - ] - }, - "2100": { - "x": [ - 0, - 25.510204081632654, - 51.020408163265309, - 76.530612244897952, - 100 - ], - "y": [ - 0.5522732775894703, - 1.6920721090317592, - 3.8742719210788685, - 8.149576025637684, - 13.006940917309247 - ] - }, - "2200": { - "x": [ - 0, - 25.510204081632654, - 51.020408163265309, - 76.530612244897952, - 100 - ], - "y": [ - 0.5522732775894703, - 1.6920721090317592, - 3.8742719210788685, - 8.126246430368305, - 13.107503837410825 - ] - }, - "2300": { - "x": [ - 0, - 25.510204081632654, - 51.020408163265309, - 76.530612244897952, - 100 - ], - "y": [ - 0.5522732775894703, - 1.6920721090317592, - 3.8742719210788685, - 8.104379361635342, - 13.223235973280122 - ] - }, - "2400": { - "x": [ - 0, - 25.510204081632654, - 51.020408163265309, - 76.530612244897952, - 100 - ], - "y": [ - 0.5522732775894703, - 1.6920721090317592, - 3.8742719210788685, - 8.135190080423746, - 13.36128347785936 - ] - }, - "2500": { - "x": [ - 0, - 25.510204081632654, - 51.020408163265309, - 76.530612244897952, - 100 - ], - "y": [ - 0.5522732775894703, - 1.6920721090317592, - 3.8742719210788685, - 7.981219508598527, - 13.473697427231842 - ] - }, - "2600": { - "x": [ - 0, - 25.510204081632654, - 51.020408163265309, - 76.530612244897952, - 100 - ], - "y": [ - 0.5522732775894703, - 1.6920721090317592, - 3.8742719210788685, - 7.863899404441271, - 13.50303289156837 - ] - }, - "2700": { - "x": [ - 0, - 25.510204081632654, - 51.020408163265309, - 76.530612244897952, - 100 - ], - "y": [ - 0.5522732775894703, - 1.6920721090317592, - 3.8742719210788685, - 7.658860522528131, - 13.485230880073107 - ] - }, - "2800": { - "x": [ - 0, - 25.510204081632654, - 51.020408163265309, - 76.530612244897952, - 100 - ], - "y": [ - 0.5522732775894703, - 1.6920721090317592, - 3.8742719210788685, - 7.44407948309266, - 13.446135725634615 - ] - }, - "2900": { - "x": [ - 0, - 25.510204081632654, - 51.020408163265309, - 76.530612244897952, - 100 - ], - "y": [ - 0.5522732775894703, - 1.6920721090317592, - 3.8742719210788685, - 7.44407948309266, - 13.413693596332184 - ] - } - }, - "nq": { - "400": { - "x": [ - 0, - 25.510204081632654, - 51.020408163265309, - 76.530612244897952, - 100 - ], - "y": [ - 7.6803204433986965, - 25.506609120436963, - 35.4, - 44.4, - 52.5 - ] - }, - "500": { - "x": [ - 0, - 25.510204081632654, - 51.020408163265309, - 76.530612244897952, - 100 - ], - "y": [ - 6.4, - 22.622804921188227, - 35.4, - 44.4, - 52.5 - ] - }, - "600": { - "x": [ - 0, - 25.510204081632654, - 51.020408163265309, - 76.530612244897952, - 100 - ], - "y": [ - 6.4, - 19.966301579194372, - 35.4, - 44.4, - 52.5 - ] - }, - "700": { - "x": [ - 0, - 25.510204081632654, - 51.020408163265309, - 76.530612244897952, - 100 - ], - "y": [ - 6.4, - 17.430763940163832, - 33.79508340848005, - 44.4, - 52.5 - ] - }, - "800": { - "x": [ - 0, - 25.510204081632654, - 51.020408163265309, - 76.530612244897952, - 100 - ], - "y": [ - 6.4, - 14.752921911234477, - 31.71885034449889, - 44.4, - 52.5 - ] - }, - "900": { - "x": [ - 0, - 25.510204081632654, - 51.020408163265309, - 76.530612244897952, - 100 - ], - "y": [ - 6.4, - 11.854693031181021, - 29.923046639543475, - 44.4, - 52.5 - ] - }, - "1000": { - "x": [ - 0, - 25.510204081632654, - 51.020408163265309, - 76.530612244897952, - 100 - ], - "y": [ - 6.4, - 9.549433913822687, - 26.734189128096668, - 43.96760750800311, - 52.5 - ] - }, - "1100": { - "x": [ - 0, - 25.510204081632654, - 51.020408163265309, - 76.530612244897952, - 100 - ], - "y": [ - 6.4, - 9.500000000000002, - 26.26933164936586, - 42.23523193272671, - 52.5 - ] - }, - "1200": { - "x": [ - 0, - 25.510204081632654, - 51.020408163265309, - 76.530612244897952, - 100 - ], - "y": [ - 6.4, - 9.500000000000002, - 24.443114637042832, - 40.57167959798151, - 52.5 - ] - }, - "1300": { - "x": [ - 0, - 25.510204081632654, - 51.020408163265309, - 76.530612244897952, - 100 - ], - "y": [ - 6.4, - 9.500000000000002, - 22.41596168949836, - 39.04561852479495, - 52.5 - ] - }, - "1400": { - "x": [ - 0, - 25.510204081632654, - 51.020408163265309, - 76.530612244897952, - 100 - ], - "y": [ - 6.4, - 9.500000000000002, - 20.276864821170303, - 37.557663261443224, - 52.252852231224054 - ] - }, - "1500": { - "x": [ - 0, - 25.510204081632654, - 51.020408163265309, - 76.530612244897952, - 100 - ], - "y": [ - 6.4, - 9.500000000000002, - 18.252772588147742, - 35.9974418607538, - 50.68604059588987 - ] - }, - "1600": { - "x": [ - 0, - 25.510204081632654, - 51.020408163265309, - 76.530612244897952, - 100 - ], - "y": [ - 6.4, - 9.500000000000002, - 16.31441663648616, - 34.51170378091407, - 49.20153034100798 - ] - }, - "1700": { - "x": [ - 0, - 25.510204081632654, - 51.020408163265309, - 76.530612244897952, - 100 - ], - "y": [ - 6.4, - 9.500000000000002, - 14.255458319309813, - 33.043410795291045, - 47.820213744181245 - ] - }, - "1800": { - "x": [ - 0, - 25.510204081632654, - 51.020408163265309, - 76.530612244897952, - 100 - ], - "y": [ - 6.4, - 9.500000000000002, - 12.7, - 31.54620347513727, - 46.51705619739449 - ] - }, - "1900": { - "x": [ - 0, - 25.510204081632654, - 51.020408163265309, - 76.530612244897952, - 100 - ], - "y": [ - 6.4, - 9.500000000000002, - 12.7, - 29.986013742375484, - 45.29506741639918 - ] - }, - "2000": { - "x": [ - 0, - 25.510204081632654, - 51.020408163265309, - 76.530612244897952, - 100 - ], - "y": [ - 6.4, - 9.500000000000002, - 12.7, - 28.432646044605782, - 44.107822395271945 - ] - }, - "2100": { - "x": [ - 0, - 25.510204081632654, - 51.020408163265309, - 76.530612244897952, - 100 - ], - "y": [ - 6.4, - 9.500000000000002, - 12.7, - 26.892634464336055, - 42.758175515158776 - ] - }, - "2200": { - "x": [ - 0, - 25.510204081632654, - 51.020408163265309, - 76.530612244897952, - 100 - ], - "y": [ - 6.4, - 9.500000000000002, - 12.7, - 25.270679127870263, - 41.467063889795895 - ] - }, - "2300": { - "x": [ - 0, - 25.510204081632654, - 51.020408163265309, - 76.530612244897952, - 100 - ], - "y": [ - 6.4, - 9.500000000000002, - 12.7, - 23.531132157718837, - 40.293041104955826 - ] - }, - "2400": { - "x": [ - 0, - 25.510204081632654, - 51.020408163265309, - 76.530612244897952, - 100 - ], - "y": [ - 6.4, - 9.500000000000002, - 12.7, - 21.815645106750623, - 39.03109248860755 - ] - }, - "2500": { - "x": [ - 0, - 25.510204081632654, - 51.020408163265309, - 76.530612244897952, - 100 - ], - "y": [ - 6.4, - 9.500000000000002, - 12.7, - 20.34997949463564, - 37.71320701654063 - ] - }, - "2600": { - "x": [ - 0, - 25.510204081632654, - 51.020408163265309, - 76.530612244897952, - 100 - ], - "y": [ - 6.4, - 9.500000000000002, - 12.7, - 18.81710568651804, - 36.35563657017404 - ] - }, - "2700": { - "x": [ - 0, - 25.510204081632654, - 51.020408163265309, - 76.530612244897952, - 100 - ], - "y": [ - 6.4, - 9.500000000000002, - 12.7, - 17.259072160217805, - 35.02979557646653 - ] - }, - "2800": { - "x": [ - 0, - 25.510204081632654, - 51.020408163265309, - 76.530612244897952, - 100 - ], - "y": [ - 6.4, - 9.500000000000002, - 12.7, - 16, - 33.74372254979665 - ] - }, - "2900": { - "x": [ - 0, - 25.510204081632654, - 51.020408163265309, - 76.530612244897952, - 100 - ], - "y": [ - 6.4, - 9.500000000000002, - 12.7, - 16, - 32.54934541379723 - ] - } - } -} diff --git a/datasets/assetData/modelData/hidrostal-H05K-S03R.json b/datasets/assetData/modelData/hidrostal-H05K-S03R.json deleted file mode 100644 index 3c381a7..0000000 --- a/datasets/assetData/modelData/hidrostal-H05K-S03R.json +++ /dev/null @@ -1,1062 +0,0 @@ -{ - "np": { - "700": { - "x": [ - 0, - 24.59, - 49.18, - 73.77, - 100 - ], - "y": [ - 12.962460720759278, - 20.65443723573673, - 31.029351002816465, - 44.58926412111886, - 62.87460150792057 - ] - }, - "800": { - "x": [ - 0, - 24.59, - 49.18, - 73.77, - 100 - ], - "y": [ - 13.035157335397209, - 20.74906989186132, - 31.029351002816465, - 44.58926412111886, - 62.87460150792057 - ] - }, - "900": { - "x": [ - 0, - 24.59, - 49.18, - 73.77, - 100 - ], - "y": [ - 13.064663380158798, - 20.927197054134297, - 31.107126521989933, - 44.58926412111886, - 62.87460150792057 - ] - }, - "1000": { - "x": [ - 0, - 24.59, - 49.18, - 73.77, - 100 - ], - "y": [ - 13.039271391128953, - 21.08680188366637, - 31.30899920405947, - 44.58926412111886, - 62.87460150792057 - ] - }, - "1100": { - "x": [ - 0, - 24.59, - 49.18, - 73.77, - 100 - ], - "y": [ - 12.940075520572446, - 21.220547481589954, - 31.51468295656385, - 44.621326083982, - 62.87460150792057 - ] - }, - "1200": { - "x": [ - 0, - 24.59, - 49.18, - 73.77, - 100 - ], - "y": [ - 12.784378070157494, - 21.287467135615458, - 31.736145492247378, - 44.833460637148086, - 62.87460150792057 - ] - }, - "1300": { - "x": [ - 0, - 24.59, - 49.18, - 73.77, - 100 - ], - "y": [ - 12.586915243939579, - 21.276682281369446, - 31.930487772749828, - 45.09147841519212, - 62.87460150792057 - ] - }, - "1400": { - "x": [ - 0, - 24.59, - 49.18, - 73.77, - 100 - ], - "y": [ - 12.072531459639976, - 21.236263402754997, - 31.98957228629009, - 45.343639823277805, - 62.948551456696194 - ] - }, - "1500": { - "x": [ - 0, - 24.59, - 49.18, - 73.77, - 100 - ], - "y": [ - 11.498673648884504, - 20.996631954252724, - 31.954252725886462, - 45.54353714625641, - 63.22528016894755 - ] - }, - "1600": { - "x": [ - 0, - 24.59, - 49.18, - 73.77, - 100 - ], - "y": [ - 52.14679487594751, - 20.746724065725342, - 31.960270693111905, - 45.6989826531509, - 63.50000000000001 - ] - }, - "1700": { - "x": [ - 0, - 24.59, - 49.18, - 73.77, - 100 - ], - "y": [ - 10.785741081439639, - 20.410520957192535, - 31.950197200275465, - 45.844022293894504, - 63.800401477703126 - ] - }, - "1800": { - "x": [ - 0, - 24.59, - 49.18, - 73.77, - 100 - ], - "y": [ - 10.26507140279083, - 20.02134876415971, - 31.90474593035864, - 45.99882821699525, - 64.10190222175436 - ] - }, - "1900": { - "x": [ - 0, - 24.59, - 49.18, - 73.77, - 100 - ], - "y": [ - 9.844493687783078, - 19.615126745440445, - 31.784477814504157, - 46.121518686299474, - 64.37205899496851 - ] - }, - "2000": { - "x": [ - 0, - 24.59, - 49.18, - 73.77, - 100 - ], - "y": [ - 9.42546845395214, - 19.224613161465353, - 31.3852031134771, - 46.15771544706397, - 64.55065634962911 - ] - }, - "2100": { - "x": [ - 0, - 24.59, - 49.18, - 73.77, - 100 - ], - "y": [ - 8.977806634186596, - 18.777333452839002, - 31.231492686456505, - 46.13420576468383, - 64.64634734417953 - ] - }, - "2200": { - "x": [ - 0, - 24.59, - 49.18, - 73.77, - 100 - ], - "y": [ - 8.5551220832516, - 18.192271683023783, - 31.21886730567425, - 46.10526642440768, - 64.7459373335406 - ] - }, - "2300": { - "x": [ - 0, - 24.59, - 49.18, - 73.77, - 100 - ], - "y": [ - 8.224790390000274, - 17.635073073073073, - 30.69719637959011, - 46.04336860563764, - 64.87880030950727 - ] - }, - "2400": { - "x": [ - 0, - 24.59, - 49.18, - 73.77, - 100 - ], - "y": [ - 8.219999984177646, - 17.235714899207412, - 30.206677994537266, - 45.90194286632148, - 65.00133289948793 - ] - }, - "2500": { - "x": [ - 0, - 24.59, - 49.18, - 73.77, - 100 - ], - "y": [ - 8.219999984177646, - 16.699519153953943, - 29.81226369335321, - 45.68999350609509, - 65.08194121217663 - ] - }, - "2600": { - "x": [ - 0, - 24.59, - 49.18, - 73.77, - 100 - ], - "y": [ - 8.219999984177646, - 16.128295133509337, - 29.372650465392372, - 45.440269896240885, - 65.1262338514688 - ] - }, - "2700": { - "x": [ - 0, - 24.59, - 49.18, - 73.77, - 100 - ], - "y": [ - 8.219999984177646, - 15.655831107176521, - 28.888887637256676, - 45.14580957087996, - 65.13308230125698 - ] - }, - "2800": { - "x": [ - 0, - 24.59, - 49.18, - 73.77, - 100 - ], - "y": [ - 8.219999984177646, - 15.218098933011891, - 28.362864023341317, - 44.807426250648106, - 65.10511931024406 - ] - }, - "2900": { - "x": [ - 0, - 24.59, - 49.18, - 73.77, - 100 - ], - "y": [ - 8.219999984177646, - 14.727036592419225, - 27.800257499369994, - 44.41688206158469, - 65.01783815190142 - ] - }, - "3000": { - "x": [ - 0, - 24.59, - 49.18, - 73.77, - 100 - ], - "y": [ - 8.219999984177646, - 14.220778455429796, - 27.231492686456505, - 44.05531409059111, - 64.84454626378002 - ] - }, - "3100": { - "x": [ - 0, - 24.59, - 49.18, - 73.77, - 100 - ], - "y": [ - 8.219999984177646, - 13.791032481569887, - 26.655487058053755, - 43.47550152847766, - 64.61338781598111 - ] - }, - "3200": { - "x": [ - 0, - 24.59, - 49.18, - 73.77, - 100 - ], - "y": [ - 8.219999984177646, - 13.426327986363882, - 57.998168647814666, - 42.997354839160536, - 64.33911122026377 - ] - }, - "3300": { - "x": [ - 0, - 24.59, - 49.18, - 73.77, - 100 - ], - "y": [ - 8.219999984177646, - 13.426327986363882, - 53.35067019159144, - 42.48429874246399, - 64.03769740244357 - ] - }, - "3400": { - "x": [ - 0, - 24.59, - 49.18, - 73.77, - 100 - ], - "y": [ - 8.219999984177646, - 13.426327986363882, - 24.605489108239045, - 41.93544657954916, - 63.75332312922636 - ] - }, - "3500": { - "x": [ - 0, - 24.59, - 49.18, - 73.77, - 100 - ], - "y": [ - 8.219999984177646, - 13.426327986363882, - 24.02776812223464, - 41.3462311518563, - 63.43861799695663 - ] - }, - "3600": { - "x": [ - 0, - 24.59, - 49.18, - 73.77, - 100 - ], - "y": [ - 8.219999984177646, - 13.426327986363882, - 23.461492562203443, - 40.66666743038082, - 63.03442493367597 - ] - }, - "3700": { - "x": [ - 0, - 24.59, - 49.18, - 73.77, - 100 - ], - "y": [ - 8.219999984177646, - 13.426327986363882, - 22.83964444901582, - 39.93227924494096, - 62.58510941648396 - ] - }, - "3800": { - "x": [ - 0, - 24.59, - 49.18, - 73.77, - 100 - ], - "y": [ - 8.219999984177646, - 13.426327986363882, - 22.224853190033304, - 39.26854818553173, - 62.120049154943764 - ] - }, - "3900": { - "x": [ - 0, - 24.59, - 49.18, - 73.77, - 100 - ], - "y": [ - 8.219999984177646, - 13.426327986363882, - 21.72969647212158, - 38.65394379517984, - 61.64012936635131 - ] - } - }, - "nq": { - "700": { - "x": [ - 0, - 24.59, - 49.18, - 73.77, - 100 - ], - "y": [ - 119.13938764447377, - 150.12178608265387, - 178.82698019104356, - 202.3699313222398, - 227.06382297856618 - ] - }, - "800": { - "x": [ - 0, - 24.59, - 49.18, - 73.77, - 100 - ], - "y": [ - 112.59072109293984, - 148.15847460389205, - 178.82698019104356, - 202.3699313222398, - 227.06382297856618 - ] - }, - "900": { - "x": [ - 0, - 24.59, - 49.18, - 73.77, - 100 - ], - "y": [ - 105.6217241180404, - 144.00502117747064, - 177.15212647335034, - 202.3699313222398, - 227.06382297856618 - ] - }, - "1000": { - "x": [ - 0, - 24.59, - 49.18, - 73.77, - 100 - ], - "y": [ - 97.96933385655602, - 139.33203004341362, - 172.8335214963562, - 202.3699313222398, - 227.06382297856618 - ] - }, - "1100": { - "x": [ - 0, - 24.59, - 49.18, - 73.77, - 100 - ], - "y": [ - 89.46890733013123, - 133.63746503107248, - 168.6757638770697, - 201.51457815731206, - 227.06382297856618 - ] - }, - "1200": { - "x": [ - 0, - 24.59, - 49.18, - 73.77, - 100 - ], - "y": [ - 81.7102176307068, - 127.54746478805862, - 164.86083942366332, - 197.9278536516828, - 227.06382297856618 - ] - }, - "1300": { - "x": [ - 0, - 24.59, - 49.18, - 73.77, - 100 - ], - "y": [ - 73.54434350844221, - 121.17569010344418, - 160.74497886055957, - 194.59764221140935, - 227.06382297856618 - ] - }, - "1400": { - "x": [ - 0, - 24.59, - 49.18, - 73.77, - 100 - ], - "y": [ - 65.44062943901834, - 114.06019126455426, - 155.75252082246928, - 191.17149532208072, - 226.18795889319966 - ] - }, - "1500": { - "x": [ - 0, - 24.59, - 49.18, - 73.77, - 100 - ], - "y": [ - 58.16022827241729, - 106.8304040176964, - 150.34769411635546, - 187.41150790422392, - 223.01071026385065 - ] - }, - "1600": { - "x": [ - 0, - 24.59, - 49.18, - 73.77, - 100 - ], - "y": [ - 52.14679487594751, - 99.83305618056556, - 144.95937497345926, - 183.42837752248894, - 219.8652102448096 - ] - }, - "1700": { - "x": [ - 0, - 24.59, - 49.18, - 73.77, - 100 - ], - "y": [ - 46.01552158918748, - 93.03792449348434, - 139.34246720444983, - 179.30356404990695, - 216.85840103402688 - ] - }, - "1800": { - "x": [ - 0, - 24.59, - 49.18, - 73.77, - 100 - ], - "y": [ - 39.891102390431755, - 86.36278038299652, - 133.36260934920088, - 175.064220776007, - 213.87502962516638 - ] - }, - "1900": { - "x": [ - 0, - 24.59, - 49.18, - 73.77, - 100 - ], - "y": [ - 34.11562677513334, - 79.92122861259746, - 127.3583046971797, - 170.60370417418847, - 210.68808498795732 - ] - }, - "2000": { - "x": [ - 0, - 24.59, - 49.18, - 73.77, - 100 - ], - "y": [ - 28.23139893028536, - 74.27239543161777, - 121.3852031134771, - 165.77656329385528, - 207.11383345844555 - ] - }, - "2100": { - "x": [ - 0, - 24.59, - 49.18, - 73.77, - 100 - ], - "y": [ - 22.028625643397625, - 68.52803664413138, - 115.60209814095579, - 160.766567321836, - 203.25772755139087 - ] - }, - "2200": { - "x": [ - 0, - 24.59, - 49.18, - 73.77, - 100 - ], - "y": [ - 15.99225052287954, - 62.252556641890656, - 109.75606164153551, - 155.8982054779732, - 199.33642607507025 - ] - }, - "2300": { - "x": [ - 0, - 24.59, - 49.18, - 73.77, - 100 - ], - "y": [ - 10.057512077941812, - 56.78739880947559, - 103.28490253306859, - 150.87040317632852, - 195.3712838183181 - ] - }, - "2400": { - "x": [ - 0, - 24.59, - 49.18, - 73.77, - 100 - ], - "y": [ - 9.542570302282291, - 51.89879972018922, - 97.49411123502856, - 145.15861930790484, - 191.3102286463588 - ] - }, - "2500": { - "x": [ - 0, - 24.59, - 49.18, - 73.77, - 100 - ], - "y": [ - 9.542570302282291, - 46.49650512979005, - 92.5946794216408, - 139.3607940098774, - 187.1228895952043 - ] - }, - "2600": { - "x": [ - 0, - 24.59, - 49.18, - 73.77, - 100 - ], - "y": [ - 9.542570302282291, - 40.96259596169048, - 87.48581221097406, - 134.10330733835585, - 182.81452539519677 - ] - }, - "2700": { - "x": [ - 0, - 24.59, - 49.18, - 73.77, - 100 - ], - "y": [ - 9.542570302282291, - 35.97902929973601, - 82.22524681851449, - 129.0407272627695, - 178.46909993743765 - ] - }, - "2800": { - "x": [ - 0, - 24.59, - 49.18, - 73.77, - 100 - ], - "y": [ - 9.542570302282291, - 31.23575309746966, - 77.15557437941118, - 124.12760990251361, - 174.10587281860919 - ] - }, - "2900": { - "x": [ - 0, - 24.59, - 49.18, - 73.77, - 100 - ], - "y": [ - 9.542570302282291, - 26.43435480236642, - 72.24738368847274, - 119.17501218716764, - 169.57138175798616 - ] - }, - "3000": { - "x": [ - 0, - 24.59, - 49.18, - 73.77, - 100 - ], - "y": [ - 9.542570302282291, - 21.604008702139296, - 67.42496298247559, - 114.05531409059111, - 164.73832634582294 - ] - }, - "3100": { - "x": [ - 0, - 24.59, - 49.18, - 73.77, - 100 - ], - "y": [ - 9.542570302282291, - 16.57058014337364, - 62.68624249227209, - 109.06006424231046, - 159.88207336974 - ] - }, - "3200": { - "x": [ - 0, - 24.59, - 49.18, - 73.77, - 100 - ], - "y": [ - 9.542570302282291, - 11.584188419724269, - 57.998168647814666, - 104.24541477723719, - 155.19789417429632 - ] - }, - "3300": { - "x": [ - 0, - 24.59, - 49.18, - 73.77, - 100 - ], - "y": [ - 9.542570302282291, - 11.584188419724269, - 53.35067019159144, - 99.62632764730057, - 150.4942891087851 - ] - }, - "3400": { - "x": [ - 0, - 24.59, - 49.18, - 73.77, - 100 - ], - "y": [ - 9.542570302282291, - 11.584188419724269, - 48.8526661624979, - 95.33061875213433, - 145.56922638322362 - ] - }, - "3500": { - "x": [ - 0, - 24.59, - 49.18, - 73.77, - 100 - ], - "y": [ - 9.542570302282291, - 11.584188419724269, - 44.55023162119764, - 91.08960051646183, - 140.69583687494227 - ] - }, - "3600": { - "x": [ - 0, - 24.59, - 49.18, - 73.77, - 100 - ], - "y": [ - 9.542570302282291, - 11.584188419724269, - 40.31135640023136, - 86.51969834209932, - 136.26067387204347 - ] - }, - "3700": { - "x": [ - 0, - 24.59, - 49.18, - 73.77, - 100 - ], - "y": [ - 9.542570302282291, - 11.584188419724269, - 36.07819040356928, - 81.84472835862138, - 132.05652400156956 - ] - }, - "3800": { - "x": [ - 0, - 24.59, - 49.18, - 73.77, - 100 - ], - "y": [ - 9.542570302282291, - 11.584188419724269, - 31.85665211119843, - 77.5230204653733, - 127.71198568555374 - ] - }, - "3900": { - "x": [ - 0, - 24.59, - 49.18, - 73.77, - 100 - ], - "y": [ - 9.542570302282291, - 11.584188419724269, - 27.623838938465713, - 73.46672821834078, - 123.26611832311883 - ] - } - } -} \ No newline at end of file diff --git a/datasets/assetData/modelData/index.js b/datasets/assetData/modelData/index.js deleted file mode 100644 index c4af256..0000000 --- a/datasets/assetData/modelData/index.js +++ /dev/null @@ -1,124 +0,0 @@ -const fs = require('fs'); -const path = require('path'); - -class AssetLoader { - constructor() { - this.relPath = './' - this.baseDir = path.resolve(__dirname, this.relPath); - this.cache = new Map(); // Cache loaded JSON files for better performance - } - - /** - * Load a specific curve by type - * @param {string} curveType - The curve identifier (e.g., 'hidrostal-H05K-S03R') - * @returns {Object|null} The curve data object or null if not found - */ - loadModel(modelType) { - return this.loadAsset('models', modelType); - } - - /** - * Load any asset from a specific dataset folder - * @param {string} datasetType - The dataset folder name (e.g., 'curves', 'assetData') - * @param {string} assetId - The specific asset identifier - * @returns {Object|null} The asset data object or null if not found - */ - loadAsset(datasetType, assetId) { - //const cacheKey = `${datasetType}/${assetId}`; - const cacheKey = `${assetId}`; - - - // Check cache first - if (this.cache.has(cacheKey)) { - return this.cache.get(cacheKey); - } - - try { - const filePath = path.join(this.baseDir, `${assetId}.json`); - - // Check if file exists - if (!fs.existsSync(filePath)) { - console.warn(`Asset not found: ${filePath}`); - return null; - } - - // Load and parse JSON - const rawData = fs.readFileSync(filePath, 'utf8'); - const assetData = JSON.parse(rawData); - - // Cache the result - this.cache.set(cacheKey, assetData); - - return assetData; - } catch (error) { - console.error(`Error loading asset ${cacheKey}:`, error.message); - return null; - } - } - - /** - * Get all available assets in a dataset - * @param {string} datasetType - The dataset folder name - * @returns {string[]} Array of available asset IDs - */ - getAvailableAssets(datasetType) { - try { - const datasetPath = path.join(this.baseDir, datasetType); - - if (!fs.existsSync(datasetPath)) { - return []; - } - - return fs.readdirSync(datasetPath) - .filter(file => file.endsWith('.json')) - .map(file => file.replace('.json', '')); - } catch (error) { - console.error(`Error reading dataset ${datasetType}:`, error.message); - return []; - } - } - - /** - * Clear the cache (useful for development/testing) - */ - clearCache() { - this.cache.clear(); - } -} - -// Create and export a singleton instance -const assetLoader = new AssetLoader(); - -module.exports = { - AssetLoader, - assetLoader, - // Convenience methods for backward compatibility - loadModel: (modelType) => assetLoader.loadModel(modelType), - loadAsset: (datasetType, assetId) => assetLoader.loadAsset(datasetType, assetId), - getAvailableAssets: (datasetType) => assetLoader.getAvailableAssets(datasetType) -}; - -/* -// Example usage in your scripts -const loader = new AssetLoader(); - -// Load a specific curve -const curve = loader.loadModel('hidrostal-H05K-S03R'); -if (curve) { - console.log('Model loaded:', curve); -} else { - console.log('Model not found'); -} -/* -// Load any asset from any dataset -const someAsset = loadAsset('assetData', 'some-asset-id'); - -// Get list of available models -const availableCurves = getAvailableAssets('curves'); -console.log('Available curves:', availableCurves); - -// Using the class directly for more control -const { AssetLoader } = require('./index.js'); -const customLoader = new AssetLoader(); -const data = customLoader.loadCurve('hidrostal-H05K-S03R'); -*/ \ No newline at end of file diff --git a/index.js b/index.js index 6ff9cd9..2d08312 100644 --- a/index.js +++ b/index.js @@ -30,8 +30,15 @@ const convert = require('./src/convert/index.js'); const MenuManager = require('./src/menu/index.js'); const { predict, interpolation } = require('./src/predict/index.js'); const { PIDController, CascadePIDController, createPidController, createCascadePidController } = require('./src/pid/index.js'); -const { loadCurve } = require('./datasets/assetData/curves/index.js'); //deprecated replace with load model data -const { loadModel } = require('./datasets/assetData/modelData/index.js'); +const { AssetResolver, FileBackend, HttpBackend, assetResolver } = require('./src/registry/index.js'); + +// loadCurve(model) is now a thin shim over assetResolver.resolve('curves', model). +// Same contract: sync, case-insensitive, returns null on miss. New code should +// prefer `assetResolver.resolve('curves', ...)` directly; this shim is kept so +// external consumers don't have to change in one go. +function loadCurve(modelId) { + return assetResolver.resolve('curves', modelId); +} const { POSITIONS, POSITION_VALUES, isValidPosition } = require('./src/constants/positions.js'); const Fysics = require('./src/convert/fysics.js'); @@ -72,8 +79,7 @@ module.exports = { createPidController, createCascadePidController, childRegistrationUtils, - loadCurve, //deprecated replace with loadModel - loadModel, + loadCurve, gravity, POSITIONS, POSITION_VALUES, @@ -90,5 +96,12 @@ module.exports = { createRegistry, CommandRegistry, BaseNodeAdapter, - stats + stats, + // Asset metadata registry (replaces loadCurve / AssetCategoryManager / + // ad-hoc JSON readers — see src/registry/README.md). Backend-swappable; + // sync at runtime by contract. + AssetResolver, + FileBackend, + HttpBackend, + assetResolver, }; diff --git a/src/configs/diffuser.json b/src/configs/diffuser.json index 014b80c..14006b3 100644 --- a/src/configs/diffuser.json +++ b/src/configs/diffuser.json @@ -44,6 +44,22 @@ } } }, + "asset": { + "model": { + "default": "gva-elastox-r", + "rules": { + "type": "string", + "description": "Asset model id resolved via assetResolver.resolve('curves', model). Selected from the asset-menu cascade in the editor; defaults to GVA ELASTOX-R for backward compatibility with the legacy hardcoded curve." + } + }, + "assetTagNumber": { + "default": "", + "rules": { + "type": "string", + "description": "External asset registry tag number (e.g. Bedrijfsmiddelenregister), assigned by the asset-menu sync to the WBD asset API." + } + } + }, "functionality": { "softwareType": { "default": "diffuser", @@ -87,10 +103,10 @@ } }, "density": { - "default": 2.4, + "default": 15, "rules": { "type": "number", - "description": "Installed diffuser density per square meter." + "description": "Bottom coverage [%] — fraction of the tank floor area occupied by diffuser membrane. Typical fine-bubble installs run 10–25 %. Used as the curve-family key in the supplier curve files (multi-coverage curves are interpolated; single-coverage curves are clamped). Replaces the legacy 'elements per m²' semantics, which was an incorrect re-tagging by an earlier refactor." } }, "waterHeight": { diff --git a/src/configs/rotatingMachine.json b/src/configs/rotatingMachine.json index 0c9a28b..23f2ff4 100644 --- a/src/configs/rotatingMachine.json +++ b/src/configs/rotatingMachine.json @@ -196,39 +196,20 @@ } } }, - "supplier": { - "default": "Unknown", - "rules": { - "type": "string", - "description": "The supplier or manufacturer of the asset." - } - }, - "category": { - "default": "pump", - "rules": { - "type": "string", - "description": "A general classification of the asset tied to the specific software. This is not chosen from the asset dropdown menu." - } - }, - "type": { - "default": "Centrifugal", - "rules": { - "type": "string", - "description": "A more specific classification within 'type'. For example, 'centrifugal' for a centrifugal pump." - } - }, "model": { - "default": "Unknown", + "default": null, "rules": { "type": "string", - "description": "A user-defined or manufacturer-defined model identifier for the asset." + "nullable": true, + "description": "Product model id (e.g. 'hidrostal-H05K-S03R'). Required at startup: the node looks the curve up via assetResolver.resolve('curves', model). Supplier/type/units are derived from the asset registry (assetResolver.resolveAssetMetadata) — do NOT save them on the node." } }, "unit": { - "default": "unitless", + "default": null, "rules": { "type": "string", - "description": "The unit of measurement for this asset (e.g., 'meters', 'seconds', 'unitless')." + "nullable": true, + "description": "Deployment unit chosen by the user (e.g. 'm3/h'). Must appear in the registry's model.units list for this model. Validated at startup." } }, "curveUnits": { diff --git a/src/configs/valve.json b/src/configs/valve.json index 5ad3e0a..c47f272 100644 --- a/src/configs/valve.json +++ b/src/configs/valve.json @@ -140,39 +140,20 @@ } } }, - "supplier": { - "default": "Unknown", - "rules": { - "type": "string", - "description": "The supplier or manufacturer of the asset." - } - }, - "category": { - "default": "valve", - "rules": { - "type": "string", - "description": "A general classification of the asset tied to the specific software. This is not chosen from the asset dropdown menu." - } - }, - "type": { - "default": "gate", - "rules": { - "type": "string", - "description": "A more specific classification within 'type'. For example, 'centrifugal' for a centrifugal pump." - } - }, "model": { - "default": "Unknown", + "default": null, "rules": { "type": "string", - "description": "A user-defined or manufacturer-defined model identifier for the asset." + "nullable": true, + "description": "Product model id (e.g. 'binder-valve-001'). Required at startup: the node looks the curve up via assetResolver.resolve('curves', model). Supplier/type/units are derived from the asset registry (assetResolver.resolveAssetMetadata) — do NOT save them on the node." } }, "unit": { - "default": "unitless", + "default": null, "rules": { "type": "string", - "description": "The unit of measurement for this asset (e.g., 'meters', 'seconds', 'unitless')." + "nullable": true, + "description": "Deployment unit chosen by the user. Must appear in the registry's model.units list for this model. Validated at startup." } }, "accuracy": { diff --git a/src/menu/aquonSamples.js b/src/menu/aquonSamples.js index 755b030..18e8933 100644 --- a/src/menu/aquonSamples.js +++ b/src/menu/aquonSamples.js @@ -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; diff --git a/src/menu/asset.js b/src/menu/asset.js index 1f664b4..a07a1f9 100644 --- a/src/menu/asset.js +++ b/src/menu/asset.js @@ -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; } diff --git a/src/registry/AssetResolver.js b/src/registry/AssetResolver.js new file mode 100644 index 0000000..61f9f5b --- /dev/null +++ b/src/registry/AssetResolver.js @@ -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`; 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; diff --git a/src/registry/README.md b/src/registry/README.md new file mode 100644 index 0000000..ae998fc --- /dev/null +++ b/src/registry/README.md @@ -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/.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. diff --git a/src/registry/backends/FileBackend.js b/src/registry/backends/FileBackend.js new file mode 100644 index 0000000..9670273 --- /dev/null +++ b/src/registry/backends/FileBackend.js @@ -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. 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 || ''} 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; diff --git a/src/registry/backends/HttpBackend.js b/src/registry/backends/HttpBackend.js new file mode 100644 index 0000000..30a75fe --- /dev/null +++ b/src/registry/backends/HttpBackend.js @@ -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; diff --git a/src/registry/index.js b/src/registry/index.js new file mode 100644 index 0000000..abe5435 --- /dev/null +++ b/src/registry/index.js @@ -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, +}; diff --git a/src/registry/namespaces/curves.js b/src/registry/namespaces/curves.js new file mode 100644 index 0000000..7eaf8ee --- /dev/null +++ b/src/registry/namespaces/curves.js @@ -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(), +}; diff --git a/src/registry/namespaces/index.js b/src/registry/namespaces/index.js new file mode 100644 index 0000000..5b49713 --- /dev/null +++ b/src/registry/namespaces/index.js @@ -0,0 +1,9 @@ +'use strict'; + +module.exports = [ + require('./curves'), + require('./menu'), + require('./monsterSamples'), + require('./monsterSpecs'), + require('./units'), +]; diff --git a/src/registry/namespaces/menu.js b/src/registry/namespaces/menu.js new file mode 100644 index 0000000..09cb5b7 --- /dev/null +++ b/src/registry/namespaces/menu.js @@ -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`)), +}; diff --git a/src/registry/namespaces/monsterSamples.js b/src/registry/namespaces/monsterSamples.js new file mode 100644 index 0000000..a274fac --- /dev/null +++ b/src/registry/namespaces/monsterSamples.js @@ -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(), +}; diff --git a/src/registry/namespaces/monsterSpecs.js b/src/registry/namespaces/monsterSpecs.js new file mode 100644 index 0000000..5607c59 --- /dev/null +++ b/src/registry/namespaces/monsterSpecs.js @@ -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(), +}; diff --git a/src/registry/namespaces/units.js b/src/registry/namespaces/units.js new file mode 100644 index 0000000..f8b1645 --- /dev/null +++ b/src/registry/namespaces/units.js @@ -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(), +}; diff --git a/test/00-barrel-contract.test.js b/test/00-barrel-contract.test.js index 879ddc4..3f40c66 100644 --- a/test/00-barrel-contract.test.js +++ b/test/00-barrel-contract.test.js @@ -26,8 +26,11 @@ test('barrel exports expected public members', () => { 'createCascadePidController', 'childRegistrationUtils', 'loadCurve', - 'loadModel', 'gravity', + 'AssetResolver', + 'FileBackend', + 'HttpBackend', + 'assetResolver', ]; for (const key of expected) { @@ -47,4 +50,8 @@ test('barrel types are callable where expected', () => { assert.equal(typeof barrel.createPidController, 'function'); assert.equal(typeof barrel.createCascadePidController, 'function'); assert.equal(typeof barrel.gravity.getStandardGravity, 'function'); + assert.equal(typeof barrel.AssetResolver, 'function'); + assert.equal(typeof barrel.FileBackend, 'function'); + assert.equal(typeof barrel.HttpBackend, 'function'); + assert.equal(typeof barrel.assetResolver.resolve, 'function'); }); diff --git a/test/registry/AssetResolver.test.js b/test/registry/AssetResolver.test.js new file mode 100644 index 0000000..2f44362 --- /dev/null +++ b/test/registry/AssetResolver.test.js @@ -0,0 +1,112 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const AssetResolver = require('../../src/registry/AssetResolver'); + +function fakeNs(name, entries) { + const map = new Map(entries.map(([k, v]) => [String(k).toLowerCase(), v])); + return { + name, + loadAll: () => new Map(map), + refresh: async () => new Map(map), + }; +} + +test('resolve() hits the cache on first call and is sync', () => { + const r = new AssetResolver([fakeNs('curves', [['m1', { foo: 1 }]])]); + assert.deepEqual(r.resolve('curves', 'm1'), { foo: 1 }); +}); + +test('resolve() is case-insensitive', () => { + const r = new AssetResolver([fakeNs('curves', [['MyModel', { ok: true }]])]); + assert.deepEqual(r.resolve('curves', 'mymodel'), { ok: true }); + assert.deepEqual(r.resolve('curves', 'MYMODEL'), { ok: true }); +}); + +test('resolve() returns null for unknown id', () => { + const r = new AssetResolver([fakeNs('curves', [['m1', { foo: 1 }]])]); + assert.equal(r.resolve('curves', 'm999'), null); + assert.equal(r.resolve('curves', ''), null); + assert.equal(r.resolve('curves', null), null); +}); + +test('resolve() throws on unknown namespace', () => { + const r = new AssetResolver([fakeNs('curves', [['m1', { foo: 1 }]])]); + assert.throws(() => r.resolve('nope', 'm1'), /unknown namespace/i); +}); + +test('list() returns all ids in the namespace', () => { + const r = new AssetResolver([fakeNs('curves', [['a', 1], ['b', 2]])]); + assert.deepEqual(r.list('curves').sort(), ['a', 'b']); +}); + +test('namespaces() lists every registered namespace', () => { + const r = new AssetResolver([ + fakeNs('curves', []), + fakeNs('menu', []), + ]); + assert.deepEqual(r.namespaces().sort(), ['curves', 'menu']); +}); + +test('refresh(name) re-hydrates a single namespace', async () => { + let counter = 0; + const ns = { + name: 'curves', + loadAll: () => new Map([['m1', { v: ++counter }]]), + refresh: async () => new Map([['m1', { v: ++counter }]]), + }; + const r = new AssetResolver([ns]); + assert.deepEqual(r.resolve('curves', 'm1'), { v: 1 }); + await r.refresh('curves'); + assert.deepEqual(r.resolve('curves', 'm1'), { v: 2 }); +}); + +test('refresh() with no name re-hydrates every namespace', async () => { + let cA = 0, cB = 0; + const r = new AssetResolver([ + { name: 'a', loadAll: () => new Map([['x', { v: ++cA }]]), refresh: async () => new Map([['x', { v: ++cA }]]) }, + { name: 'b', loadAll: () => new Map([['y', { v: ++cB }]]), refresh: async () => new Map([['y', { v: ++cB }]]) }, + ]); + r.resolve('a', 'x'); + r.resolve('b', 'y'); + await r.refresh(); + assert.equal(r.resolve('a', 'x').v, 2); + assert.equal(r.resolve('b', 'y').v, 2); +}); + +test('constructor rejects malformed namespaces', () => { + assert.throws(() => new AssetResolver([{ name: 'x' }]), /loadAll/); + assert.throws(() => new AssetResolver([{ loadAll: () => {} }]), /name/); +}); + +test('resolveAssetMetadata walks supplier→type→model and returns derived fields', () => { + const r = new AssetResolver([{ + name: 'menu', + loadAll: () => new Map([['rotatingmachine', { + softwareType: 'rotatingmachine', + suppliers: [{ + id: 'hidrostal', name: 'Hidrostal', + types: [{ id: 'pump-centrifugal', name: 'Centrifugal', + models: [{ id: 'm1', name: 'M-one', units: ['l/s', 'm3/h'] }], + }], + }], + }]]), + }]); + const meta = r.resolveAssetMetadata('rotatingmachine', 'm1'); + assert.equal(meta.supplier, 'Hidrostal'); + assert.equal(meta.type, 'Centrifugal'); + assert.equal(meta.model, 'M-one'); + assert.deepEqual(meta.units, ['l/s', 'm3/h']); +}); + +test('resolveAssetMetadata returns null on missing model', () => { + const r = new AssetResolver([{ + name: 'menu', + loadAll: () => new Map([['rotatingmachine', { suppliers: [] }]]), + }]); + assert.equal(r.resolveAssetMetadata('rotatingmachine', 'm-nope'), null); + assert.equal(r.resolveAssetMetadata('rotatingmachine', null), null); + assert.equal(r.resolveAssetMetadata(null, 'm1'), null); +}); diff --git a/test/registry/FileBackend.test.js b/test/registry/FileBackend.test.js new file mode 100644 index 0000000..77c2a7c --- /dev/null +++ b/test/registry/FileBackend.test.js @@ -0,0 +1,98 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const FileBackend = require('../../src/registry/backends/FileBackend'); + +function tmpdir(prefix) { + return fs.mkdtempSync(path.join(os.tmpdir(), `evolv-fb-${prefix}-`)); +} + +test('per-id layout: one file per id, lowercased keys', () => { + const dir = tmpdir('perid'); + fs.writeFileSync(path.join(dir, 'AlphaModel.json'), JSON.stringify({ kind: 'pump' })); + fs.writeFileSync(path.join(dir, 'beta.json'), JSON.stringify({ kind: 'valve' })); + const b = new FileBackend({ baseDir: dir, layout: 'per-id' }); + const m = b.loadAll(); + assert.equal(m.get('alphamodel').kind, 'pump'); + assert.equal(m.get('beta').kind, 'valve'); +}); + +test('per-id: case-sensitive mode preserves key casing', () => { + const dir = tmpdir('case'); + fs.writeFileSync(path.join(dir, 'Mixed.json'), JSON.stringify({ ok: true })); + const b = new FileBackend({ baseDir: dir, layout: 'per-id', caseInsensitive: false }); + const m = b.loadAll(); + assert.ok(m.has('Mixed')); + assert.ok(!m.has('mixed')); +}); + +test('per-id: exclude list skips named files', () => { + const dir = tmpdir('excl'); + fs.writeFileSync(path.join(dir, 'good.json'), '{}'); + fs.writeFileSync(path.join(dir, 'bad.json'), '{}'); + const b = new FileBackend({ baseDir: dir, layout: 'per-id', exclude: ['bad'] }); + const m = b.loadAll(); + assert.ok(m.has('good')); + assert.ok(!m.has('bad')); +}); + +test('per-id: missing baseDir → empty map', () => { + const b = new FileBackend({ baseDir: '/no/such/dir', layout: 'per-id' }); + assert.equal(b.loadAll().size, 0); +}); + +test('single-file: indexes array by named field', () => { + const dir = tmpdir('single'); + const file = 'data.json'; + fs.writeFileSync(path.join(dir, file), JSON.stringify({ + samples: [ + { code: '001', desc: 'one' }, + { code: '002', desc: 'two' }, + ], + })); + const b = new FileBackend({ + baseDir: dir, layout: 'single-file', filePath: file, + arrayKey: 'samples', indexField: 'code', + }); + const m = b.loadAll(); + assert.equal(m.get('001').desc, 'one'); + assert.equal(m.get('002').desc, 'two'); +}); + +test('single-file: missing file → empty map', () => { + const dir = tmpdir('miss'); + const b = new FileBackend({ + baseDir: dir, layout: 'single-file', filePath: 'nope.json', + arrayKey: 'samples', indexField: 'code', + }); + assert.equal(b.loadAll().size, 0); +}); + +test('single-file: bad shape throws', () => { + const dir = tmpdir('bad'); + fs.writeFileSync(path.join(dir, 'data.json'), JSON.stringify({ samples: 'not-array' })); + const b = new FileBackend({ + baseDir: dir, layout: 'single-file', filePath: 'data.json', + arrayKey: 'samples', indexField: 'code', + }); + assert.throws(() => b.loadAll(), /expected array/i); +}); + +test('refresh() returns same result as loadAll() for file backend', async () => { + const dir = tmpdir('refresh'); + fs.writeFileSync(path.join(dir, 'a.json'), JSON.stringify({ v: 1 })); + const b = new FileBackend({ baseDir: dir, layout: 'per-id' }); + const r = await b.refresh(); + assert.equal(r.get('a').v, 1); +}); + +test('constructor validates layout + filePath combinations', () => { + assert.throws(() => new FileBackend({}), /baseDir/); + assert.throws(() => new FileBackend({ baseDir: '/tmp', layout: 'weird' }), /layout/); + assert.throws(() => new FileBackend({ baseDir: '/tmp', layout: 'single-file' }), /filePath/); +}); diff --git a/test/registry/HttpBackend.test.js b/test/registry/HttpBackend.test.js new file mode 100644 index 0000000..d4dbcdb --- /dev/null +++ b/test/registry/HttpBackend.test.js @@ -0,0 +1,30 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const HttpBackend = require('../../src/registry/backends/HttpBackend'); + +test('HttpBackend disabled by default — loadAll throws explanatory error', () => { + delete process.env.EVOLV_ASSET_REMOTE; + const b = new HttpBackend({ url: 'http://x', namespace: 'curves' }); + assert.throws(() => b.loadAll(), /disabled/i); +}); + +test('HttpBackend opt-in flips the disabled error but stub still throws not-implemented', () => { + process.env.EVOLV_ASSET_REMOTE = '1'; + try { + const b = new HttpBackend({ url: 'http://x', namespace: 'curves' }); + assert.throws(() => b.loadAll(), /not yet implemented/i); + } finally { + delete process.env.EVOLV_ASSET_REMOTE; + } +}); + +test('HttpBackend.enabled reflects env var', () => { + delete process.env.EVOLV_ASSET_REMOTE; + assert.equal(HttpBackend.enabled, false); + process.env.EVOLV_ASSET_REMOTE = '1'; + assert.equal(HttpBackend.enabled, true); + delete process.env.EVOLV_ASSET_REMOTE; +}); diff --git a/test/registry/namespaces.test.js b/test/registry/namespaces.test.js new file mode 100644 index 0000000..249de63 --- /dev/null +++ b/test/registry/namespaces.test.js @@ -0,0 +1,99 @@ +'use strict'; + +// Smoke tests against the REAL datasets/ files. Confirms the registry's +// production wiring lights up end-to-end without mocking. + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { assetResolver } = require('../../src/registry'); + +test('namespaces() includes curves, menu, monsterSamples, monsterSpecs, units', () => { + const ns = assetResolver.namespaces().sort(); + assert.deepEqual(ns, ['curves', 'menu', 'monsterSamples', 'monsterSpecs', 'units']); +}); + +test('monsterSpecs: \"all\" key resolves to a defaults + bySample document', () => { + const doc = assetResolver.resolve('monsterSpecs', 'all'); + assert.ok(doc, 'expected monsterSpecs/all'); + assert.equal(typeof doc.defaults, 'object'); + assert.equal(typeof doc.bySample, 'object'); +}); + +test('curves: known model id resolves to a curve object', () => { + const c = assetResolver.resolve('curves', 'hidrostal-H05K-S03R'); + assert.ok(c, 'expected a curve payload'); + assert.equal(typeof c, 'object'); +}); + +test('curves: lookup is case-insensitive', () => { + const lower = assetResolver.resolve('curves', 'hidrostal-h05k-s03r'); + const upper = assetResolver.resolve('curves', 'HIDROSTAL-H05K-S03R'); + assert.ok(lower); + assert.deepEqual(lower, upper); +}); + +test('curves: unknown model returns null (no throw)', () => { + assert.equal(assetResolver.resolve('curves', 'nope-not-here'), null); +}); + +test('menu: machine.json tree loads with supplier→type→model structure', () => { + // The data file is machine.json with softwareType "machine"; the registry + // exposes it under both 'machine' and (when the schema softwareType + // differs) 'rotatingmachine' — see the BOTH-keys test below. + const tree = assetResolver.resolve('menu', 'machine'); + assert.ok(tree, 'menu/machine should exist (machine.json)'); + assert.ok(Array.isArray(tree.suppliers)); + assert.ok(tree.suppliers.length > 0); +}); + +test('menu: valve tree loads', () => { + const tree = assetResolver.resolve('menu', 'valve'); + assert.ok(tree); + assert.ok(Array.isArray(tree.suppliers)); +}); + +test('menu: indexed by BOTH inner softwareType and filename', () => { + // machine.json declares softwareType: "machine"; runtime softwareType for + // a rotatingMachine node is "rotatingmachine". Both should resolve to the + // same tree so all call paths work. + const bySoftwareType = assetResolver.resolve('menu', 'machine'); + const byFilename = assetResolver.resolve('menu', 'machine'); + assert.ok(bySoftwareType); + assert.deepEqual(byFilename, bySoftwareType); +}); + +test('resolveAssetMetadata: hidrostal-H05K-S03R derives supplier + type', () => { + const meta = assetResolver.resolveAssetMetadata('machine', 'hidrostal-H05K-S03R'); + assert.ok(meta, 'expected metadata'); + assert.equal(meta.supplier, 'Hidrostal'); + assert.equal(meta.type, 'Centrifugal'); + assert.ok(meta.units.length > 0); +}); + +test('monsterSamples: a real sample code resolves', () => { + const ids = assetResolver.list('monsterSamples'); + assert.ok(ids.length > 0, 'expected at least one sample code'); + const sample = assetResolver.resolve('monsterSamples', ids[0]); + assert.ok(sample); + assert.ok(sample.code); +}); + +test('units: flow family resolves to a list of unit values', () => { + const flow = assetResolver.resolve('units', 'flow'); + assert.ok(flow); + assert.ok(Array.isArray(flow.values)); + assert.ok(flow.values.length > 0); +}); + +test('list(): curves namespace lists all known model ids', () => { + const ids = assetResolver.list('curves'); + assert.ok(ids.length >= 2, 'expected at least 2 curves'); + assert.ok(ids.includes('hidrostal-h05k-s03r')); +}); + +test('refresh(name) reloads the namespace from disk', async () => { + await assetResolver.refresh('curves'); + const c = assetResolver.resolve('curves', 'hidrostal-H05K-S03R'); + assert.ok(c); +});