Compare commits
22 Commits
main
...
ab481357d2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab481357d2 | ||
|
|
49c77f262f | ||
|
|
34a4ef0610 | ||
|
|
af02d36b07 | ||
|
|
f8f71a4f1c | ||
|
|
c59da5ca98 | ||
|
|
0a4b52f517 | ||
|
|
84a4430266 | ||
|
|
1b6b43349f | ||
|
|
c7e561e593 | ||
|
|
f21e2aa8bb | ||
|
|
5ea968eabc | ||
|
|
f11754635b | ||
|
|
ff9aec8702 | ||
|
|
30c5dc8508 | ||
|
|
95c5e684e4 | ||
|
|
8ebf31dd39 | ||
|
|
92eb8d2f15 | ||
|
|
7372d12088 | ||
|
|
62f389a51f | ||
|
|
57b77f905a | ||
|
|
47faf94048 |
116
CONTRACT.md
Normal file
116
CONTRACT.md
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
# generalFunctions — Library Contract
|
||||||
|
|
||||||
|
> The public API surface that every EVOLV node depends on. Different shape from
|
||||||
|
> per-node `CONTRACT.md` files: nodes contract on `msg.topic`, this library
|
||||||
|
> contracts on **what `require('generalFunctions')` exports**.
|
||||||
|
|
||||||
|
For deep contracts on the post-refactor platform shapes (`BaseDomain`,
|
||||||
|
`BaseNodeAdapter`, command registry, `UnitPolicy`, `ChildRouter`,
|
||||||
|
`LatestWinsGate`, `HealthStatus`, `statusBadge`), see the platform-level
|
||||||
|
[`.claude/refactor/CONTRACTS.md`](../../.claude/refactor/CONTRACTS.md) in the
|
||||||
|
EVOLV superproject. This file is the index and stability tag per export.
|
||||||
|
|
||||||
|
**Stability tags:**
|
||||||
|
- `stable` — API change requires a deprecation cycle and a CONTRACT update here.
|
||||||
|
- `experimental` — may change without warning; do not depend on the exact shape in production code paths.
|
||||||
|
- `deprecated` — kept for backwards compatibility, slated for removal.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Platform base classes (post-refactor)
|
||||||
|
|
||||||
|
| Export | Kind | Stability | Source | Spec |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `BaseDomain` | class | stable | `src/domain/BaseDomain.js` | [.claude/refactor/CONTRACTS.md §3](../../.claude/refactor/CONTRACTS.md) — extend for all specific domain classes |
|
||||||
|
| `BaseNodeAdapter` | class | stable | `src/nodered/BaseNodeAdapter.js` | [.claude/refactor/CONTRACTS.md §2](../../.claude/refactor/CONTRACTS.md) — extend for all nodeClass adapters |
|
||||||
|
| `CommandRegistry` / `createRegistry` | class / factory | stable | `src/nodered/commandRegistry.js` | [.claude/refactor/CONTRACTS.md §4](../../.claude/refactor/CONTRACTS.md) — builds `Map<topic\|alias, descriptor>` |
|
||||||
|
| `ChildRouter` | class | stable | `src/domain/ChildRouter.js` | [.claude/refactor/CONTRACTS.md §5](../../.claude/refactor/CONTRACTS.md) — declarative parent-side child routing |
|
||||||
|
| `UnitPolicy` | class | stable | `src/domain/UnitPolicy.js` | [.claude/refactor/CONTRACTS.md §6](../../.claude/refactor/CONTRACTS.md) — canonical-unit declaration + render |
|
||||||
|
| `statusBadge` | function | stable | `src/nodered/statusBadge.js` | [.claude/refactor/CONTRACTS.md §7](../../.claude/refactor/CONTRACTS.md) — Node-RED status text/colour |
|
||||||
|
| `StatusUpdater` | class | stable | `src/nodered/statusUpdater.js` | Drives `node.status()` every tick |
|
||||||
|
| `HealthStatus` | class | stable | `src/domain/HealthStatus.js` | [.claude/refactor/CONTRACTS.md §9](../../.claude/refactor/CONTRACTS.md) — prediction-quality / drift state |
|
||||||
|
| `LatestWinsGate` | class | stable | `src/domain/LatestWinsGate.js` | Idempotent-setter gate; prevents redundant dispatches |
|
||||||
|
|
||||||
|
## Output formatting
|
||||||
|
|
||||||
|
| Export | Kind | Stability | Source | Notes |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `outputUtils` | object | stable | `src/helper/` (re-export) | `.formatMsg(payload, mode)`; `mode ∈ {'process','influxdb'}`; delta compression on `'process'` |
|
||||||
|
| `logger` | object | stable | `src/helper/` (re-export) | Structured logger — use instead of `console.log` |
|
||||||
|
|
||||||
|
## Measurements
|
||||||
|
|
||||||
|
| Export | Kind | Stability | Source | Notes |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `MeasurementContainer` | class | stable | `src/measurements/` | Chainable `.type().variant().position(childId)` store; emits `<type>.<variant>.<position>` on its `emitter` |
|
||||||
|
| `POSITIONS`, `POSITION_VALUES`, `isValidPosition` | const + helper | stable | `src/constants/positions.js` | Canonical position labels (`upstream`/`downstream`/`atequipment`/…) |
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Export | Kind | Stability | Source | Notes |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `configManager` | class | stable | `src/configs/` | Loads node-specific JSON schemas from `src/configs/<n>.json`; serves admin endpoint |
|
||||||
|
| `configUtils` | object | stable | `src/helper/` | Schema helpers used by `configManager` |
|
||||||
|
| `assetApiConfig` | object | stable | `src/configs/assetApiConfig.js` | Asset-registry HTTP backend config |
|
||||||
|
| `validation`, `assertions` | object | stable | `src/helper/` | Runtime validation primitives |
|
||||||
|
| `MenuManager` | class | stable | `src/menu/` | Dynamic editor dropdown endpoints |
|
||||||
|
|
||||||
|
## Child registration
|
||||||
|
|
||||||
|
| Export | Kind | Stability | Source | Notes |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `childRegistrationUtils` | object | stable | `src/helper/` | The handshake utilities `BaseNodeAdapter` uses for parent-child wiring |
|
||||||
|
|
||||||
|
## Conversion & physics
|
||||||
|
|
||||||
|
| Export | Kind | Stability | Source | Notes |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `convert` | object | stable | `src/convert/` | Unit conversions (used by `UnitPolicy`) |
|
||||||
|
| `Fysics` | class | stable | `src/convert/fysics.js` | Fluid/hydraulic helpers |
|
||||||
|
| `coolprop` | object | stable | `src/coolprop-node/src/index.js` | Thermodynamic property calculations |
|
||||||
|
| `gravity` | object | stable | `src/helper/` | Gravity constants and helpers |
|
||||||
|
|
||||||
|
## Control & prediction
|
||||||
|
|
||||||
|
| Export | Kind | Stability | Source | Notes |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `PIDController` | class | stable | `src/pid/` | Standard PID; positional and velocity forms |
|
||||||
|
| `CascadePIDController` | class | stable | `src/pid/` | Cascaded outer/inner PID |
|
||||||
|
| `createPidController`, `createCascadePidController` | factory | stable | `src/pid/` | Convenience builders from config |
|
||||||
|
| `predict` | object | stable | `src/predict/` | Series prediction / smoothing |
|
||||||
|
| `interpolation` | object | stable | `src/predict/` | 1-D and 3-D interpolation primitives |
|
||||||
|
| `nrmse` | function | stable | `src/nrmse/` | Normalised RMSE metric (with profile variants) |
|
||||||
|
| `stats` | object | stable | `src/stats/` | Mean/variance/quantile helpers |
|
||||||
|
| `state` | object | stable | `src/state/` | Generic state-machine helpers |
|
||||||
|
|
||||||
|
## Asset registry
|
||||||
|
|
||||||
|
| Export | Kind | Stability | Source | Notes |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `assetResolver` | singleton | stable | `src/registry/` | `.resolve(category, modelId)` — sync, case-insensitive, returns `null` on miss |
|
||||||
|
| `AssetResolver` | class | stable | `src/registry/` | Resolver type (for testing / alt backends) |
|
||||||
|
| `FileBackend`, `HttpBackend` | class | stable | `src/registry/` | Resolver backends |
|
||||||
|
| `loadCurve` | function | **deprecated** | `index.js` (shim) | Thin shim over `assetResolver.resolve('curves', ...)`. New code uses the resolver directly. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Adding a new export
|
||||||
|
|
||||||
|
1. Implement the module under `src/<concern>/`.
|
||||||
|
2. Re-export it from `index.js` (alphabetical within the concern block).
|
||||||
|
3. Add a row to the appropriate table above with the stability tag.
|
||||||
|
4. If the export is a new platform shape (a new base class or cross-node protocol),
|
||||||
|
add a section to [.claude/refactor/CONTRACTS.md](../../.claude/refactor/CONTRACTS.md) in the superproject.
|
||||||
|
5. Add a test under `test/`.
|
||||||
|
|
||||||
|
## Removing an export
|
||||||
|
|
||||||
|
1. Mark it **deprecated** in this file (keep the row, change the tag, add a "removed-in" line).
|
||||||
|
2. Update every consumer in `nodes/*` to use the replacement.
|
||||||
|
3. Bump submodule pin in the superproject for each touched node.
|
||||||
|
4. After one release on `development` with no consumers, remove the export and its row.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Source of truth for the export list: `index.js` (barrel). If this file and the
|
||||||
|
barrel disagree, the barrel wins — fix this file in the same PR.*
|
||||||
46
datasets/assetData/curves/aerostrip-phoenix.json
Normal file
46
datasets/assetData/curves/aerostrip-phoenix.json
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"_meta": {
|
||||||
|
"supplier": "Aquaconsult Anlagenbau / Entec",
|
||||||
|
"type": "Strip",
|
||||||
|
"model": "AEROSTRIP",
|
||||||
|
"membrane": "PHOENIX",
|
||||||
|
"membraneArea_m2_per_element": 1.0,
|
||||||
|
"membraneArea_m2_per_element_note": "Aerostrip strips are sized continuously rather than as discrete fixed-area elements. Setting per-element area to 1.0 m² is a normalisation choice: configure the diffuser node with `elements` equal to the total installed strip membrane area in m².",
|
||||||
|
"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 Nm³/(h*m² 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": "SSOTR values are SOTE [%] / water_depth_m × 0.299 kg-O₂/Nm³ × 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 (single 'p_curve' entry under key '0')."
|
||||||
|
},
|
||||||
|
"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": {
|
||||||
|
"0": {
|
||||||
|
"x": [5, 10, 25, 40, 55, 70, 80],
|
||||||
|
"y": [46.0, 47.3, 51.1, 54.9, 58.7, 62.4, 65.0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
29
datasets/assetData/curves/gva-elastox-r.json
Normal file
29
datasets/assetData/curves/gva-elastox-r.json
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"_meta": {
|
||||||
|
"supplier": "GVA",
|
||||||
|
"type": "Tube",
|
||||||
|
"model": "ELASTOX-R",
|
||||||
|
"membraneArea_m2_per_element": 0.18,
|
||||||
|
"membraneArea_m2_per_element_source": "placeholder — mirror of Jäger JetFlex TD-65-2-G EPDM 1000 mm (0.18 m²) until a real GVA ELASTOX-R sheet is supplied. Change here when the real value is known; specificClass reads it from this _meta field.",
|
||||||
|
"stdAir": { "temp_C": 0, "pressure_mbar": 1013.25, "RH_pct": 0 },
|
||||||
|
"coverageBasis": "bottom-coverage-pct",
|
||||||
|
"coverageReference": null,
|
||||||
|
"dataQuality": "point",
|
||||||
|
"xAxisBasis": "per-m2-membrane-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 '2.4 elements/m²' tag was a prior mis-conversion of the % bottom-coverage convention. Single-coverage point estimate (key '0' = unspecified). Native data was per-element Nm³/h; converted to per-m²-membrane Nm³/(h·m²) by dividing by the placeholder 0.18 m² element area — those numbers will shift the moment we get a real GVA sheet."
|
||||||
|
},
|
||||||
|
"otr_curve": {
|
||||||
|
"0": {
|
||||||
|
"x": [11.11, 16.67, 22.22, 27.78, 33.33, 38.89, 44.44, 50.00, 55.56],
|
||||||
|
"y": [26, 25, 24, 23.5, 23, 22.75, 22.5, 22.25, 22]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"p_curve": {
|
||||||
|
"0": {
|
||||||
|
"x": [11.11, 16.67, 22.22, 27.78, 33.33, 38.89, 44.44, 50.00, 55.56, 61.11, 66.67],
|
||||||
|
"y": [40, 42.5, 45, 47.5, 50, 51.5, 53, 54.5, 56, 57.5, 59]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
|
||||||
*/
|
|
||||||
@@ -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,
|
||||||
|
"outerDiameter_mm": 65,
|
||||||
|
"membraneArea_m2_per_element": 0.18,
|
||||||
|
"operating": {
|
||||||
|
"continuousFlow_Nm3h_per_element": [2, 12],
|
||||||
|
"maxOverloadFlow_Nm3h_per_element": 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-m2-membrane-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 tank-floor coverage nor water depth at which the SSOTE curve was measured — single-coverage point estimate (key '0' = unspecified), do not extrapolate across density. Native x-axis in the sheet is Nm³/h per tube; converted to canonical Nm³/(h·m² membrane) by dividing by perforated area 0.18 m²."
|
||||||
|
},
|
||||||
|
"ssote_curve": {
|
||||||
|
"0": {
|
||||||
|
"x": [11.11, 16.67, 22.22, 27.78, 33.33, 38.89, 44.44, 50.00, 55.56, 61.11, 66.67],
|
||||||
|
"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": [11.11, 16.67, 22.22, 27.78, 33.33, 38.89, 44.44, 50.00, 55.56, 61.11, 66.67],
|
||||||
|
"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": [11.11, 16.67, 22.22, 27.78, 33.33, 38.89, 44.44, 50.00, 55.56, 61.11, 66.67],
|
||||||
|
"y": [25.0, 27.5, 30.0, 32.5, 35.0, 37.5, 40.0, 42.0, 44.0, 46.0, 48.0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
39
datasets/assetData/curves/pik300.json
Normal file
39
datasets/assetData/curves/pik300.json
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"_meta": {
|
||||||
|
"supplier": "Sulzer ABS",
|
||||||
|
"type": "Disc",
|
||||||
|
"model": "PIK300",
|
||||||
|
"membrane": "Perforated EPDM",
|
||||||
|
"membraneArea_m2_per_element": 0.07,
|
||||||
|
"membraneArea_m2_per_element_note": "Sulzer ABS PIK/PRK 300 mm fine-bubble disc, ~295 mm active membrane diameter (π × 0.1475² ≈ 0.068 m², rounded to 0.07 m²). Confirm against Sulzer spec sheet if available.",
|
||||||
|
"stdAir": { "temp_C": 0, "pressure_mbar": 1013.25, "RH_pct": 0 },
|
||||||
|
"coverageBasis": "bottom-coverage-pct",
|
||||||
|
"coverageReference": [5, 10, 15, 20, 25],
|
||||||
|
"dataQuality": "multi-coverage",
|
||||||
|
"xAxisBasis": "per-m2-membrane-Nm3h",
|
||||||
|
"yAxisBasis": "ssotr-g-per-Nm3-per-m",
|
||||||
|
"waterDepth_m": 4.0,
|
||||||
|
"source": "'PIK & PRK300 data from QM.xlsx' (Sheet1). Sister model PRK300 shares the SOTE/SSOTR curves; only DWP differs (PIK300 = perforated EPDM, PRK300 = perforated PUR).",
|
||||||
|
"note": "Native data was Sm³/h/disc on X and g O₂/(Sm³·m) on Y (US standard, 20 °C, 1.204 kg/Sm³ → 278.6 g O₂/Sm³). Converted to canonical Nm³ basis (DIN-1343, 0 °C, 1.293 kg/Nm³ → 299.0 g O₂/Nm³): X × 0.9319 / 0.07, Y × 1.0732. Water depth ≈ 4.0 m falls out of the SOTE↔SSOTR ratio under Sm³ conventions — verify if precision matters."
|
||||||
|
},
|
||||||
|
"sote_curve": {
|
||||||
|
"5": { "x": [19.97, 26.62, 39.93, 53.24, 66.56, 79.87, 93.18, 106.49], "y": [27.87, 26.99, 25.80, 24.97, 24.38, 23.89, 23.46, 23.12] },
|
||||||
|
"10": { "x": [19.97, 26.62, 39.93, 53.24, 66.56, 79.87, 93.18, 106.49], "y": [30.18, 29.33, 28.15, 27.33, 26.73, 26.21, 25.83, 25.49] },
|
||||||
|
"15": { "x": [19.97, 26.62, 39.93, 53.24, 66.56, 79.87, 93.18, 106.49], "y": [31.51, 30.53, 29.16, 28.27, 27.57, 27.01, 26.55, 26.15] },
|
||||||
|
"20": { "x": [19.97, 26.62, 39.93, 53.24, 66.56, 79.87, 93.18, 106.49], "y": [32.52, 31.39, 29.88, 28.84, 28.06, 27.45, 26.92, 26.49] },
|
||||||
|
"25": { "x": [19.97, 26.62, 39.93, 53.24, 66.56, 79.87, 93.18, 106.49], "y": [33.26, 32.04, 30.39, 29.27, 28.45, 27.77, 27.22, 26.76] }
|
||||||
|
},
|
||||||
|
"otr_curve": {
|
||||||
|
"5": { "x": [19.97, 26.62, 39.93, 53.24, 66.56, 79.87, 93.18, 106.49], "y": [20.937, 20.276, 19.382, 18.759, 18.316, 17.947, 17.624, 17.369] },
|
||||||
|
"10": { "x": [19.97, 26.62, 39.93, 53.24, 66.56, 79.87, 93.18, 106.49], "y": [22.673, 22.034, 21.148, 20.532, 20.081, 19.690, 19.405, 19.149] },
|
||||||
|
"15": { "x": [19.97, 26.62, 39.93, 53.24, 66.56, 79.87, 93.18, 106.49], "y": [23.672, 22.936, 21.907, 21.238, 20.712, 20.291, 19.946, 19.645] },
|
||||||
|
"20": { "x": [19.97, 26.62, 39.93, 53.24, 66.56, 79.87, 93.18, 106.49], "y": [24.431, 23.582, 22.447, 21.666, 21.080, 20.622, 20.224, 19.901] },
|
||||||
|
"25": { "x": [19.97, 26.62, 39.93, 53.24, 66.56, 79.87, 93.18, 106.49], "y": [24.987, 24.070, 22.831, 21.989, 21.373, 20.862, 20.449, 20.104] }
|
||||||
|
},
|
||||||
|
"p_curve": {
|
||||||
|
"0": {
|
||||||
|
"x": [19.97, 26.62, 39.93, 53.24, 66.56, 79.87, 93.18, 106.49],
|
||||||
|
"y": [25.5, 26.0, 27.5, 30.3, 34.0, 39.0, 45.0, 52.0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
39
datasets/assetData/curves/prk300.json
Normal file
39
datasets/assetData/curves/prk300.json
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"_meta": {
|
||||||
|
"supplier": "Sulzer ABS",
|
||||||
|
"type": "Disc",
|
||||||
|
"model": "PRK300",
|
||||||
|
"membrane": "Perforated PUR",
|
||||||
|
"membraneArea_m2_per_element": 0.07,
|
||||||
|
"membraneArea_m2_per_element_note": "Sulzer ABS PIK/PRK 300 mm fine-bubble disc, ~295 mm active membrane diameter (π × 0.1475² ≈ 0.068 m², rounded to 0.07 m²). Confirm against Sulzer spec sheet if available.",
|
||||||
|
"stdAir": { "temp_C": 0, "pressure_mbar": 1013.25, "RH_pct": 0 },
|
||||||
|
"coverageBasis": "bottom-coverage-pct",
|
||||||
|
"coverageReference": [5, 10, 15, 20, 25],
|
||||||
|
"dataQuality": "multi-coverage",
|
||||||
|
"xAxisBasis": "per-m2-membrane-Nm3h",
|
||||||
|
"yAxisBasis": "ssotr-g-per-Nm3-per-m",
|
||||||
|
"waterDepth_m": 4.0,
|
||||||
|
"source": "'PIK & PRK300 data from QM.xlsx' (Sheet1). SOTE/SSOTR curves identical to the sibling PIK300; the only difference is the DWP curve (PRK = perforated PUR vs PIK = perforated EPDM).",
|
||||||
|
"note": "Native data was Sm³/h/disc on X and g O₂/(Sm³·m) on Y. Converted to canonical Nm³ basis (DIN-1343): X × 0.9319 / 0.07, Y × 1.0732."
|
||||||
|
},
|
||||||
|
"sote_curve": {
|
||||||
|
"5": { "x": [19.97, 26.62, 39.93, 53.24, 66.56, 79.87, 93.18, 106.49], "y": [27.87, 26.99, 25.80, 24.97, 24.38, 23.89, 23.46, 23.12] },
|
||||||
|
"10": { "x": [19.97, 26.62, 39.93, 53.24, 66.56, 79.87, 93.18, 106.49], "y": [30.18, 29.33, 28.15, 27.33, 26.73, 26.21, 25.83, 25.49] },
|
||||||
|
"15": { "x": [19.97, 26.62, 39.93, 53.24, 66.56, 79.87, 93.18, 106.49], "y": [31.51, 30.53, 29.16, 28.27, 27.57, 27.01, 26.55, 26.15] },
|
||||||
|
"20": { "x": [19.97, 26.62, 39.93, 53.24, 66.56, 79.87, 93.18, 106.49], "y": [32.52, 31.39, 29.88, 28.84, 28.06, 27.45, 26.92, 26.49] },
|
||||||
|
"25": { "x": [19.97, 26.62, 39.93, 53.24, 66.56, 79.87, 93.18, 106.49], "y": [33.26, 32.04, 30.39, 29.27, 28.45, 27.77, 27.22, 26.76] }
|
||||||
|
},
|
||||||
|
"otr_curve": {
|
||||||
|
"5": { "x": [19.97, 26.62, 39.93, 53.24, 66.56, 79.87, 93.18, 106.49], "y": [20.937, 20.276, 19.382, 18.759, 18.316, 17.947, 17.624, 17.369] },
|
||||||
|
"10": { "x": [19.97, 26.62, 39.93, 53.24, 66.56, 79.87, 93.18, 106.49], "y": [22.673, 22.034, 21.148, 20.532, 20.081, 19.690, 19.405, 19.149] },
|
||||||
|
"15": { "x": [19.97, 26.62, 39.93, 53.24, 66.56, 79.87, 93.18, 106.49], "y": [23.672, 22.936, 21.907, 21.238, 20.712, 20.291, 19.946, 19.645] },
|
||||||
|
"20": { "x": [19.97, 26.62, 39.93, 53.24, 66.56, 79.87, 93.18, 106.49], "y": [24.431, 23.582, 22.447, 21.666, 21.080, 20.622, 20.224, 19.901] },
|
||||||
|
"25": { "x": [19.97, 26.62, 39.93, 53.24, 66.56, 79.87, 93.18, 106.49], "y": [24.987, 24.070, 22.831, 21.989, 21.373, 20.862, 20.449, 20.104] }
|
||||||
|
},
|
||||||
|
"p_curve": {
|
||||||
|
"0": {
|
||||||
|
"x": [19.97, 26.62, 39.93, 53.24, 66.56, 79.87, 93.18, 106.49],
|
||||||
|
"y": [21.3, 24.0, 29.3, 35.3, 41.3, 46.8, 52.4, 58.6]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
68
datasets/assetData/diffuser.json
Normal file
68
datasets/assetData/diffuser.json
Normal file
@@ -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-entec",
|
||||||
|
"name": "Aquaconsult Anlagenbau (Entec)",
|
||||||
|
"types": [
|
||||||
|
{
|
||||||
|
"id": "diffuser-strip",
|
||||||
|
"name": "Strip",
|
||||||
|
"models": [
|
||||||
|
{
|
||||||
|
"id": "aerostrip-phoenix",
|
||||||
|
"name": "AEROSTRIP — Phoenix membrane",
|
||||||
|
"units": ["Nm3/h"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sulzer",
|
||||||
|
"name": "Sulzer ABS",
|
||||||
|
"types": [
|
||||||
|
{
|
||||||
|
"id": "diffuser-disc",
|
||||||
|
"name": "Disc",
|
||||||
|
"models": [
|
||||||
|
{ "id": "pik300", "name": "PIK300 — perforated EPDM", "units": ["Nm3/h"] },
|
||||||
|
{ "id": "prk300", "name": "PRK300 — perforated PUR", "units": ["Nm3/h"] }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,78 +1,72 @@
|
|||||||
const fs = require('fs');
|
'use strict';
|
||||||
const path = require('path');
|
|
||||||
|
// 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 {
|
class AssetCategoryManager {
|
||||||
constructor(relPath = '.') {
|
// relPath is retained for signature compatibility with the prior on-disk
|
||||||
this.assetDir = path.resolve(__dirname, relPath);
|
// implementation; it is unused now — the resolver owns file locations.
|
||||||
this.cache = new Map();
|
constructor(/* relPath = '.' */) {}
|
||||||
}
|
|
||||||
|
|
||||||
getCategory(softwareType) {
|
getCategory(softwareType) {
|
||||||
if (!softwareType) {
|
if (!softwareType) {
|
||||||
throw new Error('softwareType is required');
|
throw new Error('softwareType is required');
|
||||||
}
|
}
|
||||||
|
const data = assetResolver.resolve('menu', softwareType);
|
||||||
if (this.cache.has(softwareType)) {
|
if (!data) {
|
||||||
return this.cache.get(softwareType);
|
throw new Error(`Asset data '${softwareType}' not found in menu namespace`);
|
||||||
}
|
}
|
||||||
|
return data;
|
||||||
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) {
|
hasCategory(softwareType) {
|
||||||
const filePath = path.resolve(this.assetDir, `${softwareType}.json`);
|
if (!softwareType) return false;
|
||||||
return fs.existsSync(filePath);
|
return assetResolver.resolve('menu', softwareType) != null;
|
||||||
}
|
}
|
||||||
|
|
||||||
listCategories({ withMeta = false } = {}) {
|
listCategories({ withMeta = false } = {}) {
|
||||||
const files = fs.readdirSync(this.assetDir, { withFileTypes: true });
|
// The resolver indexes each menu file under BOTH its inner softwareType
|
||||||
|
// and its filename slug — those may differ. Dedupe by payload identity
|
||||||
return files
|
// so we return one entry per source file.
|
||||||
.filter(
|
const seen = new Set();
|
||||||
(entry) =>
|
const out = [];
|
||||||
entry.isFile() &&
|
for (const key of assetResolver.list('menu')) {
|
||||||
entry.name.endsWith('.json') &&
|
const data = assetResolver.resolve('menu', key);
|
||||||
entry.name !== 'index.json' &&
|
if (!data || seen.has(data)) continue;
|
||||||
entry.name !== 'assetData.json'
|
seen.add(data);
|
||||||
)
|
const softwareType = data.softwareType || key;
|
||||||
.map((entry) => path.basename(entry.name, '.json'))
|
if (withMeta) {
|
||||||
.map((name) => {
|
out.push({
|
||||||
if (!withMeta) {
|
softwareType,
|
||||||
return name;
|
label: data.label || softwareType,
|
||||||
}
|
file: `${softwareType}.json`,
|
||||||
|
|
||||||
const data = this.getCategory(name);
|
|
||||||
return {
|
|
||||||
softwareType: data.softwareType || name,
|
|
||||||
label: data.label || name,
|
|
||||||
file: `${name}.json`
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
out.push(softwareType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
searchCategories(query) {
|
searchCategories(query) {
|
||||||
const term = (query || '').trim().toLowerCase();
|
const term = (query || '').trim().toLowerCase();
|
||||||
if (!term) {
|
if (!term) return [];
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.listCategories({ withMeta: true }).filter(
|
return this.listCategories({ withMeta: true }).filter(
|
||||||
({ softwareType, label }) =>
|
({ softwareType, label }) =>
|
||||||
softwareType.toLowerCase().includes(term) ||
|
softwareType.toLowerCase().includes(term) ||
|
||||||
label.toLowerCase().includes(term)
|
(label || '').toLowerCase().includes(term),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
clearCache() {
|
clearCache() {
|
||||||
this.cache.clear();
|
// 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(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,5 +79,5 @@ module.exports = {
|
|||||||
listCategories: (options) => assetCategoryManager.listCategories(options),
|
listCategories: (options) => assetCategoryManager.listCategories(options),
|
||||||
searchCategories: (query) => assetCategoryManager.searchCategories(query),
|
searchCategories: (query) => assetCategoryManager.searchCategories(query),
|
||||||
hasCategory: (softwareType) => assetCategoryManager.hasCategory(softwareType),
|
hasCategory: (softwareType) => assetCategoryManager.hasCategory(softwareType),
|
||||||
clearCache: () => assetCategoryManager.clearCache()
|
clearCache: () => assetCategoryManager.clearCache(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "machine",
|
|
||||||
"label": "machine",
|
|
||||||
"softwareType": "machine",
|
|
||||||
"suppliers": [
|
|
||||||
{
|
|
||||||
"id": "hidrostal",
|
|
||||||
"name": "Hidrostal",
|
|
||||||
"types": [
|
|
||||||
{
|
|
||||||
"id": "pump-centrifugal",
|
|
||||||
"name": "Centrifugal",
|
|
||||||
"models": [
|
|
||||||
{ "id": "hidrostal-H05K-S03R", "name": "hidrostal-H05K-S03R", "units": ["l/s","m3/h"] },
|
|
||||||
{ "id": "hidrostal-C5-D03R-SHN1", "name": "hidrostal-C5-D03R-SHN1", "units": ["l/s"] }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -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]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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');
|
|
||||||
*/
|
|
||||||
34
datasets/assetData/rotatingmachine.json
Normal file
34
datasets/assetData/rotatingmachine.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"id": "rotatingmachine",
|
||||||
|
"label": "rotatingMachine",
|
||||||
|
"softwareType": "rotatingmachine",
|
||||||
|
"suppliers": [
|
||||||
|
{
|
||||||
|
"id": "hidrostal",
|
||||||
|
"name": "Hidrostal",
|
||||||
|
"types": [
|
||||||
|
{
|
||||||
|
"id": "pump-centrifugal",
|
||||||
|
"name": "Centrifugal",
|
||||||
|
"models": [
|
||||||
|
{
|
||||||
|
"id": "hidrostal-H05K-S03R",
|
||||||
|
"name": "hidrostal-H05K-S03R",
|
||||||
|
"units": [
|
||||||
|
"l/s",
|
||||||
|
"m3/h"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "hidrostal-C5-D03R-SHN1",
|
||||||
|
"name": "hidrostal-C5-D03R-SHN1",
|
||||||
|
"units": [
|
||||||
|
"l/s"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
50
index.js
50
index.js
@@ -30,11 +30,33 @@ const convert = require('./src/convert/index.js');
|
|||||||
const MenuManager = require('./src/menu/index.js');
|
const MenuManager = require('./src/menu/index.js');
|
||||||
const { predict, interpolation } = require('./src/predict/index.js');
|
const { predict, interpolation } = require('./src/predict/index.js');
|
||||||
const { PIDController, CascadePIDController, createPidController, createCascadePidController } = require('./src/pid/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 { AssetResolver, FileBackend, HttpBackend, assetResolver } = require('./src/registry/index.js');
|
||||||
const { loadModel } = require('./datasets/assetData/modelData/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 { POSITIONS, POSITION_VALUES, isValidPosition } = require('./src/constants/positions.js');
|
||||||
const Fysics = require('./src/convert/fysics.js');
|
const Fysics = require('./src/convert/fysics.js');
|
||||||
|
|
||||||
|
// Refactor platform infrastructure (additive — see .claude/refactor/CONTRACTS.md).
|
||||||
|
// Domain-side
|
||||||
|
const UnitPolicy = require('./src/domain/UnitPolicy.js');
|
||||||
|
const ChildRouter = require('./src/domain/ChildRouter.js');
|
||||||
|
const LatestWinsGate = require('./src/domain/LatestWinsGate.js');
|
||||||
|
const HealthStatus = require('./src/domain/HealthStatus.js');
|
||||||
|
const BaseDomain = require('./src/domain/BaseDomain.js');
|
||||||
|
// Node-RED-side
|
||||||
|
const { statusBadge } = require('./src/nodered/statusBadge.js');
|
||||||
|
const { StatusUpdater } = require('./src/nodered/statusUpdater.js');
|
||||||
|
const { createRegistry, CommandRegistry } = require('./src/nodered/commandRegistry.js');
|
||||||
|
const BaseNodeAdapter = require('./src/nodered/BaseNodeAdapter.js');
|
||||||
|
// Stats helpers
|
||||||
|
const stats = require('./src/stats/index.js');
|
||||||
|
|
||||||
// Export everything
|
// Export everything
|
||||||
module.exports = {
|
module.exports = {
|
||||||
predict,
|
predict,
|
||||||
@@ -57,11 +79,29 @@ module.exports = {
|
|||||||
createPidController,
|
createPidController,
|
||||||
createCascadePidController,
|
createCascadePidController,
|
||||||
childRegistrationUtils,
|
childRegistrationUtils,
|
||||||
loadCurve, //deprecated replace with loadModel
|
loadCurve,
|
||||||
loadModel,
|
|
||||||
gravity,
|
gravity,
|
||||||
POSITIONS,
|
POSITIONS,
|
||||||
POSITION_VALUES,
|
POSITION_VALUES,
|
||||||
isValidPosition,
|
isValidPosition,
|
||||||
Fysics
|
Fysics,
|
||||||
|
// refactor infra (Phase 1)
|
||||||
|
UnitPolicy,
|
||||||
|
ChildRouter,
|
||||||
|
LatestWinsGate,
|
||||||
|
HealthStatus,
|
||||||
|
BaseDomain,
|
||||||
|
statusBadge,
|
||||||
|
StatusUpdater,
|
||||||
|
createRegistry,
|
||||||
|
CommandRegistry,
|
||||||
|
BaseNodeAdapter,
|
||||||
|
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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "node --test test/*.test.js src/nrmse/errorMetric.test.js"
|
"test": "node --test test/ src/nrmse/errorMetric.test.js"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|||||||
315
scripts/wikiGen.js
Normal file
315
scripts/wikiGen.js
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* wikiGen.js — shared wiki auto-generation helper for every EVOLV node.
|
||||||
|
*
|
||||||
|
* Two subcommands:
|
||||||
|
*
|
||||||
|
* node wikiGen.js contract <commands-module> [--write <wiki-path>]
|
||||||
|
* node wikiGen.js datamodel <specificClass-module> [--write <wiki-path>]
|
||||||
|
*
|
||||||
|
* `contract` walks the descriptor array exported by `src/commands/index.js`
|
||||||
|
* and emits a markdown table mapping canonical topic → aliases → payload
|
||||||
|
* schema → effect description.
|
||||||
|
*
|
||||||
|
* `datamodel` instantiates the domain with a minimal stub config, calls
|
||||||
|
* `getOutput()` once, and emits a markdown table of (key, type, sample value).
|
||||||
|
* If construction fails (because the domain needs a live runtime that isn't
|
||||||
|
* trivially stubbable), the script falls back to a hand-curated partial at
|
||||||
|
* `<repo>/wiki/_partial-datamodel.md.template` instead of crashing.
|
||||||
|
*
|
||||||
|
* When `--write <wiki-path>` is given, the output is spliced between the
|
||||||
|
* matching `<!-- BEGIN AUTOGEN: <marker> -->` / `<!-- END AUTOGEN: ... -->`
|
||||||
|
* markers in that file. Otherwise it prints to stdout.
|
||||||
|
*
|
||||||
|
* See `.claude/refactor/WIKI_TEMPLATE.md` (sections 5 and 8) and CONTRACTS.md
|
||||||
|
* for the canonical topic naming and registry shape this script consumes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// ── CLI parsing ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const [, , subcmd, target, ...rest] = argv;
|
||||||
|
const opts = { subcmd, target, write: null };
|
||||||
|
for (let i = 0; i < rest.length; i++) {
|
||||||
|
if (rest[i] === '--write' && rest[i + 1]) {
|
||||||
|
opts.write = rest[i + 1];
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return opts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function usage() {
|
||||||
|
process.stderr.write([
|
||||||
|
'Usage:',
|
||||||
|
' node wikiGen.js contract <path-to-commands/index.js> [--write <wiki-path>]',
|
||||||
|
' node wikiGen.js datamodel <path-to-specificClass.js> [--write <wiki-path>]',
|
||||||
|
'',
|
||||||
|
].join('\n'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Shared helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function resolveAbs(p) {
|
||||||
|
return path.isAbsolute(p) ? p : path.resolve(process.cwd(), p);
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeSchema(schema) {
|
||||||
|
if (!schema) return '_unspecified_';
|
||||||
|
const t = schema.type;
|
||||||
|
if (!t) return '_unspecified_';
|
||||||
|
if (t === 'any') return '`any`';
|
||||||
|
if (t === 'object') {
|
||||||
|
const props = schema.properties || {};
|
||||||
|
const keys = Object.keys(props);
|
||||||
|
if (!keys.length) return '`object`';
|
||||||
|
const parts = keys.map((k) => {
|
||||||
|
const subType = props[k]?.type ?? 'any';
|
||||||
|
return `${k}:${subType}`;
|
||||||
|
});
|
||||||
|
return '`{ ' + parts.join(', ') + ' }`';
|
||||||
|
}
|
||||||
|
return '`' + t + '`';
|
||||||
|
}
|
||||||
|
|
||||||
|
function topicEffectFallback(topic) {
|
||||||
|
// Try to derive a short, plain-English effect from the canonical topic
|
||||||
|
// when the descriptor doesn't carry a description field. Keep it terse —
|
||||||
|
// a maintainer can override by adding `description` to the descriptor.
|
||||||
|
const prefixes = {
|
||||||
|
'set.': 'Replaces the named state value with the supplied payload.',
|
||||||
|
'cmd.': 'Triggers an action / sequence — not idempotent.',
|
||||||
|
'data.': 'Pushes a value into the node\'s measurement stream.',
|
||||||
|
'query.': 'Read-only query; node replies on the same msg.',
|
||||||
|
'child.': 'Parent/child plumbing — registers or unregisters a child node.',
|
||||||
|
};
|
||||||
|
for (const [pfx, line] of Object.entries(prefixes)) {
|
||||||
|
if (topic.startsWith(pfx)) return line;
|
||||||
|
}
|
||||||
|
return '_(see handler)_';
|
||||||
|
}
|
||||||
|
|
||||||
|
function spliceAutogen(filePath, marker, body) {
|
||||||
|
const begin = `<!-- BEGIN AUTOGEN: ${marker} -->`;
|
||||||
|
const end = `<!-- END AUTOGEN: ${marker} -->`;
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
throw new Error(`wikiGen: --write target '${filePath}' does not exist`);
|
||||||
|
}
|
||||||
|
const src = fs.readFileSync(filePath, 'utf8');
|
||||||
|
const bIdx = src.indexOf(begin);
|
||||||
|
const eIdx = src.indexOf(end);
|
||||||
|
if (bIdx < 0 || eIdx < 0 || eIdx < bIdx) {
|
||||||
|
throw new Error(`wikiGen: markers '${marker}' not found in ${filePath}`);
|
||||||
|
}
|
||||||
|
const before = src.slice(0, bIdx + begin.length);
|
||||||
|
const after = src.slice(eIdx);
|
||||||
|
const out = before + '\n\n' + body.trimEnd() + '\n\n' + after;
|
||||||
|
fs.writeFileSync(filePath, out, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Subcommand: contract ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function describeUnits(units) {
|
||||||
|
// Descriptor.units is the validated `{ measure, default }` pair the
|
||||||
|
// commandRegistry stores; render it as `<measure> (default <unit>)` so
|
||||||
|
// a reader sees both the dimension and the canonical default that the
|
||||||
|
// node coerces to. Em-dash for unit-less topics keeps the column tidy.
|
||||||
|
if (!units || typeof units !== 'object') return '—';
|
||||||
|
const { measure, default: def } = units;
|
||||||
|
if (!measure || !def) return '—';
|
||||||
|
return '`' + measure + '` (default `' + def + '`)';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderContract(commandsPath) {
|
||||||
|
const abs = resolveAbs(commandsPath);
|
||||||
|
// eslint-disable-next-line import/no-dynamic-require, global-require
|
||||||
|
const registry = require(abs);
|
||||||
|
if (!Array.isArray(registry)) {
|
||||||
|
throw new Error(`wikiGen contract: ${abs} does not export an array of descriptors`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = [];
|
||||||
|
lines.push('| Canonical topic | Aliases | Payload | Unit | Effect |');
|
||||||
|
lines.push('|---|---|---|---|---|');
|
||||||
|
for (const d of registry) {
|
||||||
|
const topic = '`' + d.topic + '`';
|
||||||
|
const aliases = (d.aliases && d.aliases.length)
|
||||||
|
? d.aliases.map((a) => '`' + a + '`').join(', ')
|
||||||
|
: '_(none)_';
|
||||||
|
const payload = describeSchema(d.payloadSchema);
|
||||||
|
const unit = describeUnits(d.units);
|
||||||
|
const effect = d.description ? String(d.description) : topicEffectFallback(d.topic);
|
||||||
|
lines.push(`| ${topic} | ${aliases} | ${payload} | ${unit} | ${effect} |`);
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Subcommand: datamodel ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function inferSampleType(v) {
|
||||||
|
if (v === null) return 'null';
|
||||||
|
if (Array.isArray(v)) return 'array';
|
||||||
|
return typeof v;
|
||||||
|
}
|
||||||
|
|
||||||
|
function trySampleValue(v) {
|
||||||
|
if (v === null || v === undefined) return '`null`';
|
||||||
|
const t = typeof v;
|
||||||
|
if (t === 'number' || t === 'boolean') return '`' + String(v) + '`';
|
||||||
|
if (t === 'string') return '`"' + v + '"`';
|
||||||
|
if (Array.isArray(v)) return '`[…]`';
|
||||||
|
if (t === 'object') return '`{…}`';
|
||||||
|
return '`' + String(v) + '`';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heuristic unit map for top-level snapshot keys that aren't structured as
|
||||||
|
// MeasurementContainer keys (e.g. `heightBasin`, `surfaceArea`). Best-effort
|
||||||
|
// — the canonical place for unit semantics is the node's config schema; the
|
||||||
|
// table below is just enough to keep the auto-generated data-model readable.
|
||||||
|
const FLAT_KEY_UNITS = {
|
||||||
|
heightBasin: 'm',
|
||||||
|
basinHeight: 'm',
|
||||||
|
inflowLevel: 'm',
|
||||||
|
outflowLevel: 'm',
|
||||||
|
overflowLevel: 'm',
|
||||||
|
startLevel: 'm',
|
||||||
|
stopLevel: 'm',
|
||||||
|
minLevel: 'm',
|
||||||
|
maxLevel: 'm',
|
||||||
|
surfaceArea: 'm2',
|
||||||
|
volEmptyBasin: 'm3',
|
||||||
|
maxVol: 'm3',
|
||||||
|
maxVolAtOverflow:'m3',
|
||||||
|
minVol: 'm3',
|
||||||
|
minVolAtInflow: 'm3',
|
||||||
|
minVolAtOutflow: 'm3',
|
||||||
|
percControl: '%',
|
||||||
|
timeleft: 's',
|
||||||
|
};
|
||||||
|
|
||||||
|
function inferUnitFromKey(key) {
|
||||||
|
// MeasurementContainer-shaped keys take precedence: `{type}.{variant}.{position}.{childId}`.
|
||||||
|
const parts = key.split('.');
|
||||||
|
if (parts.length >= 3) {
|
||||||
|
const type = parts[0];
|
||||||
|
const map = {
|
||||||
|
flow: 'm3/s',
|
||||||
|
pressure: 'Pa',
|
||||||
|
power: 'W',
|
||||||
|
temperature: 'K',
|
||||||
|
level: 'm',
|
||||||
|
volume: 'm3',
|
||||||
|
volumePercent: '%',
|
||||||
|
netFlowRate: 'm3/s',
|
||||||
|
};
|
||||||
|
if (map[type]) return map[type];
|
||||||
|
}
|
||||||
|
return FLAT_KEY_UNITS[key] || '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDatamodel(specificClassPath) {
|
||||||
|
const abs = resolveAbs(specificClassPath);
|
||||||
|
// eslint-disable-next-line import/no-dynamic-require, global-require
|
||||||
|
const Domain = require(abs);
|
||||||
|
|
||||||
|
// Minimum viable stub config — the BaseDomain pipeline pulls per-key
|
||||||
|
// defaults from the JSON schema, so this only needs to supply the bits
|
||||||
|
// that BaseDomain reads from `userConfig` directly.
|
||||||
|
const stubConfig = {
|
||||||
|
general: {
|
||||||
|
name: `wikiGen-${Domain.name || 'domain'}`,
|
||||||
|
id: `wikiGen-${Domain.name || 'domain'}-id`,
|
||||||
|
logging: { enabled: false, logLevel: 'error' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Look for a hand-curated fallback alongside the wiki. Path is
|
||||||
|
// `<node>/wiki/_partial-datamodel.md.template` relative to the
|
||||||
|
// *commands*-or-specificClass file's repo root.
|
||||||
|
const repoRoot = findRepoRoot(abs);
|
||||||
|
const fallback = repoRoot
|
||||||
|
? path.join(repoRoot, 'wiki', '_partial-datamodel.md.template')
|
||||||
|
: null;
|
||||||
|
|
||||||
|
let out;
|
||||||
|
try {
|
||||||
|
const instance = new Domain(stubConfig);
|
||||||
|
out = instance.getOutput ? instance.getOutput() : null;
|
||||||
|
if (!out || typeof out !== 'object') {
|
||||||
|
throw new Error('getOutput() returned a non-object');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
process.stderr.write(`wikiGen datamodel: live instantiation failed: ${err.message}\n`);
|
||||||
|
if (fallback && fs.existsSync(fallback)) {
|
||||||
|
process.stderr.write(`wikiGen datamodel: using hand-curated fallback ${fallback}\n`);
|
||||||
|
return fs.readFileSync(fallback, 'utf8').trimEnd();
|
||||||
|
}
|
||||||
|
process.stderr.write('wikiGen datamodel: no hand-curated fallback found — emitting placeholder\n');
|
||||||
|
return [
|
||||||
|
'| Key | Type | Unit | Sample |',
|
||||||
|
'|---|---|---|---|',
|
||||||
|
`| _live instantiation failed; provide ${fallback ? `\`wiki/_partial-datamodel.md.template\`` : 'a hand-curated template'}_ | — | — | — |`,
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = [];
|
||||||
|
lines.push('| Key | Type | Unit | Sample |');
|
||||||
|
lines.push('|---|---|---|---|');
|
||||||
|
for (const k of Object.keys(out).sort()) {
|
||||||
|
const v = out[k];
|
||||||
|
lines.push(`| \`${k}\` | ${inferSampleType(v)} | ${inferUnitFromKey(k)} | ${trySampleValue(v)} |`);
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function findRepoRoot(startPath) {
|
||||||
|
let dir = path.dirname(startPath);
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
if (fs.existsSync(path.join(dir, 'package.json'))) return dir;
|
||||||
|
const parent = path.dirname(dir);
|
||||||
|
if (parent === dir) return null;
|
||||||
|
dir = parent;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Entry point ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const opts = parseArgs(process.argv);
|
||||||
|
if (!opts.subcmd || !opts.target) {
|
||||||
|
usage();
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let body;
|
||||||
|
let marker;
|
||||||
|
if (opts.subcmd === 'contract') {
|
||||||
|
body = renderContract(opts.target);
|
||||||
|
marker = 'topic-contract';
|
||||||
|
} else if (opts.subcmd === 'datamodel') {
|
||||||
|
body = renderDatamodel(opts.target);
|
||||||
|
marker = 'data-model';
|
||||||
|
} else {
|
||||||
|
usage();
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.write) {
|
||||||
|
spliceAutogen(resolveAbs(opts.write), marker, body);
|
||||||
|
process.stderr.write(`wikiGen: wrote ${marker} block into ${opts.write}\n`);
|
||||||
|
} else {
|
||||||
|
process.stdout.write(body + '\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
main();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { renderContract, renderDatamodel, spliceAutogen, describeSchema, describeUnits };
|
||||||
@@ -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": {
|
"functionality": {
|
||||||
"softwareType": {
|
"softwareType": {
|
||||||
"default": "diffuser",
|
"default": "diffuser",
|
||||||
@@ -86,11 +102,19 @@
|
|||||||
"description": "Number of diffuser elements in the zone."
|
"description": "Number of diffuser elements in the zone."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"density": {
|
"membraneAreaPerElement": {
|
||||||
"default": 2.4,
|
"default": null,
|
||||||
"rules": {
|
"rules": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
"description": "Installed diffuser density per square meter."
|
"nullable": true,
|
||||||
|
"description": "Membrane area per element [m²] used to convert total airflow to canonical specific flux Nm³/(h·m² membrane) before curve lookup. Defaults to the selected curve's _meta.membraneArea_m2_per_element (Jäger 0.18, Sulzer 0.07, Aerostrip 1.0 normalisation). Set explicitly only to override the curve metadata."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"density": {
|
||||||
|
"default": 15,
|
||||||
|
"rules": {
|
||||||
|
"type": "number",
|
||||||
|
"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": {
|
"waterHeight": {
|
||||||
@@ -106,6 +130,34 @@
|
|||||||
"type": "number",
|
"type": "number",
|
||||||
"description": "Alpha factor used for oxygen transfer correction."
|
"description": "Alpha factor used for oxygen transfer correction."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"headerPressure": {
|
||||||
|
"default": 0,
|
||||||
|
"rules": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Header gauge pressure above atmospheric (mbar)."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"localAtmPressure": {
|
||||||
|
"default": 1013.25,
|
||||||
|
"rules": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Local atmospheric pressure (mbar)."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"waterDensity": {
|
||||||
|
"default": 997,
|
||||||
|
"rules": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Water density used in head-pressure calculation (kg/m3)."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zoneVolume": {
|
||||||
|
"default": 0,
|
||||||
|
"rules": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Aeration zone volume used to convert oxygen output to reactor OTR (m3)."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,25 +117,60 @@ class ConfigManager {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add asset section if UI provides asset fields
|
// Asset section is emitted per-key: only fields the editor actually
|
||||||
if (uiConfig.supplier || uiConfig.category || uiConfig.assetType || uiConfig.model) {
|
// set propagate to the domain config. Schemas that omit a key (e.g.
|
||||||
config.asset = {
|
// rotatingMachine deliberately drops asset.supplier/category/type
|
||||||
uuid: uiConfig.uuid || uiConfig.assetUuid || null,
|
// because those come from the asset registry at runtime) no longer
|
||||||
tagCode: uiConfig.tagCode || uiConfig.assetTagCode || null,
|
// get those keys injected and then stripped by ValidationUtils with
|
||||||
supplier: uiConfig.supplier || 'Unknown',
|
// a warning. Empty strings from HTML defaults stay falsy → omitted →
|
||||||
category: uiConfig.category || 'sensor',
|
// schema default applies.
|
||||||
type: uiConfig.assetType || 'Unknown',
|
const asset = {};
|
||||||
model: uiConfig.model || 'Unknown',
|
const uuid = uiConfig.uuid || uiConfig.assetUuid;
|
||||||
unit: uiConfig.unit || 'unitless'
|
const tagCode = uiConfig.tagCode || uiConfig.assetTagCode;
|
||||||
};
|
if (uuid) asset.uuid = uuid;
|
||||||
}
|
if (tagCode) asset.tagCode = tagCode;
|
||||||
|
if (uiConfig.supplier) asset.supplier = uiConfig.supplier;
|
||||||
|
if (uiConfig.category) asset.category = uiConfig.category;
|
||||||
|
if (uiConfig.assetType) asset.type = uiConfig.assetType;
|
||||||
|
if (uiConfig.model) asset.model = uiConfig.model;
|
||||||
|
if (uiConfig.unit) asset.unit = uiConfig.unit;
|
||||||
|
if (Object.keys(asset).length > 0) config.asset = asset;
|
||||||
|
|
||||||
// Merge domain-specific sections
|
// Merge domain-specific sections. Must be a DEEP merge: domainConfig
|
||||||
Object.assign(config, domainConfig);
|
// commonly returns subsets of `general` / `asset` (e.g. {general:
|
||||||
|
// {unit}}, {asset: {curveUnits}}) and a shallow assign would wipe out
|
||||||
|
// sibling keys this method just populated — notably `general.id`
|
||||||
|
// (nodeId) and `asset.model`, causing child-registration id collisions
|
||||||
|
// and curve-lookup failures downstream.
|
||||||
|
ConfigManager._deepMerge(config, domainConfig);
|
||||||
|
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static _isPlainObject(v) {
|
||||||
|
return Object.prototype.toString.call(v) === '[object Object]';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-place recursive merge. Arrays and primitives in `src` replace `dst`;
|
||||||
|
* plain objects are merged key-by-key so siblings on `dst` survive.
|
||||||
|
*/
|
||||||
|
static _deepMerge(dst, src) {
|
||||||
|
if (!ConfigManager._isPlainObject(src)) return dst;
|
||||||
|
for (const key of Object.keys(src)) {
|
||||||
|
const v = src[key];
|
||||||
|
if (Array.isArray(v)) {
|
||||||
|
dst[key] = [...v];
|
||||||
|
} else if (ConfigManager._isPlainObject(v)) {
|
||||||
|
if (!ConfigManager._isPlainObject(dst[key])) dst[key] = {};
|
||||||
|
ConfigManager._deepMerge(dst[key], v);
|
||||||
|
} else {
|
||||||
|
dst[key] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dst;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Migrate a config object from one version to another by applying
|
* Migrate a config object from one version to another by applying
|
||||||
* registered migration functions in sequence.
|
* registered migration functions in sequence.
|
||||||
|
|||||||
@@ -91,6 +91,63 @@
|
|||||||
],
|
],
|
||||||
"description": "Defines the position of the measurement relative to its parent equipment or system."
|
"description": "Defines the position of the measurement relative to its parent equipment or system."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"distance": {
|
||||||
|
"default": null,
|
||||||
|
"rules": {
|
||||||
|
"type": "number",
|
||||||
|
"nullable": true,
|
||||||
|
"description": "Optional spatial offset from the parent equipment reference. Populated from the editor when hasDistance is enabled; null otherwise."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"distanceUnit": {
|
||||||
|
"default": "m",
|
||||||
|
"rules": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Unit for the functionality.distance offset (e.g. 'm', 'cm')."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"distanceDescription": {
|
||||||
|
"default": "",
|
||||||
|
"rules": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Free-text description of what the distance offset represents."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"output": {
|
||||||
|
"process": {
|
||||||
|
"default": "process",
|
||||||
|
"rules": {
|
||||||
|
"type": "enum",
|
||||||
|
"values": [
|
||||||
|
{ "value": "process", "description": "Delta-compressed process message (default)." },
|
||||||
|
{ "value": "json", "description": "Raw JSON payload." },
|
||||||
|
{ "value": "csv", "description": "CSV-formatted payload." }
|
||||||
|
],
|
||||||
|
"description": "Format of the process payload emitted on output port 0."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dbase": {
|
||||||
|
"default": "influxdb",
|
||||||
|
"rules": {
|
||||||
|
"type": "enum",
|
||||||
|
"values": [
|
||||||
|
{ "value": "influxdb", "description": "InfluxDB line-protocol payload (default)." },
|
||||||
|
{ "value": "json", "description": "Raw JSON payload." },
|
||||||
|
{ "value": "csv", "description": "CSV-formatted payload." }
|
||||||
|
],
|
||||||
|
"description": "Format of the telemetry payload emitted on output port 1."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"planner": {
|
||||||
|
"useRendezvous": {
|
||||||
|
"default": true,
|
||||||
|
"rules": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "If true, every dispatch is routed through the rendezvous planner regardless of control strategy: per-pump moves are delayed so all pumps reach their setpoint at the same wall-clock instant t* = max(eta_i). If false, all flowmovement commands fire immediately and each pump ramps at its own speed (legacy behaviour)."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"mode": {
|
"mode": {
|
||||||
@@ -107,10 +164,6 @@
|
|||||||
"value": "priorityControl",
|
"value": "priorityControl",
|
||||||
"description": "Machines are controlled sequentially from minimum to maximum output until each is maxed out, then additional machines are added."
|
"description": "Machines are controlled sequentially from minimum to maximum output until each is maxed out, then additional machines are added."
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"value": "prioritypercentagecontrol",
|
|
||||||
"description": "Machines are controlled sequentially from minimum to maximum output until each is maxed out, then additional machines are added based on a percentage of the total demand."
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"value": "maintenance",
|
"value": "maintenance",
|
||||||
"description": "The group is in maintenance mode with limited actions (monitoring only)."
|
"description": "The group is in maintenance mode with limited actions (monitoring only)."
|
||||||
@@ -140,14 +193,6 @@
|
|||||||
"description": "Actions allowed in priorityControl mode."
|
"description": "Actions allowed in priorityControl mode."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"prioritypercentagecontrol": {
|
|
||||||
"default": ["statusCheck", "execSequentialControl", "balanceLoad", "emergencyStop"],
|
|
||||||
"rules": {
|
|
||||||
"type": "set",
|
|
||||||
"itemType": "string",
|
|
||||||
"description": "Actions allowed in manualOverride mode."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"maintenance": {
|
"maintenance": {
|
||||||
"default": ["statusCheck"],
|
"default": ["statusCheck"],
|
||||||
"rules": {
|
"rules": {
|
||||||
@@ -165,7 +210,7 @@
|
|||||||
"rules": {
|
"rules": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"schema": {
|
"schema": {
|
||||||
"optimalcontrol": {
|
"optimalControl": {
|
||||||
"default": ["parent", "GUI", "physical", "API"],
|
"default": ["parent", "GUI", "physical", "API"],
|
||||||
"rules": {
|
"rules": {
|
||||||
"type": "set",
|
"type": "set",
|
||||||
@@ -173,7 +218,7 @@
|
|||||||
"description": "Command sources allowed in optimalControl mode."
|
"description": "Command sources allowed in optimalControl mode."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"prioritycontrol": {
|
"priorityControl": {
|
||||||
"default": ["parent", "GUI", "physical", "API"],
|
"default": ["parent", "GUI", "physical", "API"],
|
||||||
"rules": {
|
"rules": {
|
||||||
"type": "set",
|
"type": "set",
|
||||||
@@ -181,36 +226,17 @@
|
|||||||
"description": "Command sources allowed in priorityControl mode."
|
"description": "Command sources allowed in priorityControl mode."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"prioritypercentagecontrol": {
|
"maintenance": {
|
||||||
"default": ["parent", "GUI", "physical", "API"],
|
"default": ["parent", "GUI"],
|
||||||
"rules": {
|
"rules": {
|
||||||
"type": "set",
|
"type": "set",
|
||||||
"itemType": "string",
|
"itemType": "string",
|
||||||
"description": "Command sources allowed "
|
"description": "Command sources allowed in maintenance mode. Status/inspection only — physical/HMI and API writes are dropped."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "Specifies the valid command sources recognized by the machine group controller for each mode."
|
"description": "Specifies the valid command sources recognized by the machine group controller for each mode."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"scaling": {
|
|
||||||
"current": {
|
|
||||||
"default": "normalized",
|
|
||||||
"rules": {
|
|
||||||
"type": "enum",
|
|
||||||
"values": [
|
|
||||||
{
|
|
||||||
"value": "normalized",
|
|
||||||
"description": "Scales the demand between 0–100% of the total flow capacity, interpolating to calculate the effective demand."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"value": "absolute",
|
|
||||||
"description": "Uses the absolute demand value directly, capped between the min and max machine flow capacities."
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "The scaling mode for demand calculations."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,10 +96,37 @@
|
|||||||
"default": null,
|
"default": null,
|
||||||
"rules": {
|
"rules": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
|
"nullable": true,
|
||||||
"description": "Defines the position of the measurement relative to its parent equipment or system."
|
"description": "Defines the position of the measurement relative to its parent equipment or system."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"output": {
|
||||||
|
"process": {
|
||||||
|
"default": "process",
|
||||||
|
"rules": {
|
||||||
|
"type": "enum",
|
||||||
|
"values": [
|
||||||
|
{ "value": "process", "description": "Delta-compressed process message (default)." },
|
||||||
|
{ "value": "json", "description": "Raw JSON payload." },
|
||||||
|
{ "value": "csv", "description": "CSV-formatted payload." }
|
||||||
|
],
|
||||||
|
"description": "Format of the process payload emitted on output port 0."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dbase": {
|
||||||
|
"default": "influxdb",
|
||||||
|
"rules": {
|
||||||
|
"type": "enum",
|
||||||
|
"values": [
|
||||||
|
{ "value": "influxdb", "description": "InfluxDB line-protocol payload (default)." },
|
||||||
|
{ "value": "json", "description": "Raw JSON payload." },
|
||||||
|
{ "value": "csv", "description": "CSV-formatted payload." }
|
||||||
|
],
|
||||||
|
"description": "Format of the telemetry payload emitted on output port 1."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"asset": {
|
"asset": {
|
||||||
"uuid": {
|
"uuid": {
|
||||||
"default": null,
|
"default": null,
|
||||||
@@ -439,6 +466,16 @@
|
|||||||
"description": "Channel map used in digital mode. Each entry is a self-contained pipeline definition: {key, type, position, unit, scaling?, smoothing?, outlierDetection?, distance?}. Ignored in analog mode."
|
"description": "Channel map used in digital mode. Each entry is a self-contained pipeline definition: {key, type, position, unit, scaling?, smoothing?, outlierDetection?, distance?}. Ignored in analog mode."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"calibration": {
|
||||||
|
"stabilityThreshold": {
|
||||||
|
"default": 0.01,
|
||||||
|
"rules": {
|
||||||
|
"type": "number",
|
||||||
|
"min": 0,
|
||||||
|
"description": "Absolute standard-deviation ceiling (in scaling-units, i.e. the same range as absMin..absMax) below which the rolling window is considered stable enough to trust for calibration / repeatability. A buffer with stdDev <= threshold is treated as stable; anything above aborts calibrate() and evaluateRepeatability() with a warning. Default 0.01 fits the [50,100] absMin/absMax default range; tighten or relax to match your sensor's expected noise floor."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"outlierDetection": {
|
"outlierDetection": {
|
||||||
"enabled": {
|
"enabled": {
|
||||||
"default": false,
|
"default": false,
|
||||||
|
|||||||
@@ -251,6 +251,34 @@
|
|||||||
"type": "number",
|
"type": "number",
|
||||||
"description": "Minimum inner diameter of the intake tubing in millimeters."
|
"description": "Minimum inner diameter of the intake tubing in millimeters."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"nominalFlowMin": {
|
||||||
|
"default": 0,
|
||||||
|
"rules": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Lower bound of expected inflow rate (m3/h). Used together with flowMax to scale the rain-driven flow prediction."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"flowMax": {
|
||||||
|
"default": 0,
|
||||||
|
"rules": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Upper bound of expected inflow rate (m3/h). Used together with nominalFlowMin to scale the rain-driven flow prediction."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"maxRainRef": {
|
||||||
|
"default": 10,
|
||||||
|
"rules": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Reference rain index that maps to the flowMax end of the prediction band."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"minSampleIntervalSec": {
|
||||||
|
"default": 60,
|
||||||
|
"rules": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Cooldown between consecutive sample pulses (seconds). Pulses raised faster than this are recorded as missedSamples."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -267,14 +267,14 @@
|
|||||||
},
|
},
|
||||||
"basin": {
|
"basin": {
|
||||||
"volume": {
|
"volume": {
|
||||||
"default": "1",
|
"default": 50,
|
||||||
"rules": {
|
"rules": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
"description": "Total volume of empty basin in m3"
|
"description": "Total volume of empty basin in m3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"height": {
|
"height": {
|
||||||
"default": "1",
|
"default": 4,
|
||||||
"rules": {
|
"rules": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
"description": "Total height of basin in m"
|
"description": "Total height of basin in m"
|
||||||
@@ -288,11 +288,11 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"inflowLevel": {
|
"inflowLevel": {
|
||||||
"default": 2,
|
"default": 1.5,
|
||||||
"rules": {
|
"rules": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
"min": 0,
|
"min": 0,
|
||||||
"description": "Bottom/invert height of the inlet pipe measured from the basin floor (m)."
|
"description": "Bottom/invert height of the inlet pipe measured from the basin floor (m). Acts as the ramp foot in levelbased control: demand stays at 0 % below inflowLevel and scales 0 → 100 % across [inflowLevel, maxLevel]."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"outflowLevel": {
|
"outflowLevel": {
|
||||||
@@ -304,7 +304,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"overflowLevel": {
|
"overflowLevel": {
|
||||||
"default": 2.5,
|
"default": 3.8,
|
||||||
"rules": {
|
"rules": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
"min": 0,
|
"min": 0,
|
||||||
@@ -486,7 +486,7 @@
|
|||||||
},
|
},
|
||||||
"levelbased": {
|
"levelbased": {
|
||||||
"minLevel": {
|
"minLevel": {
|
||||||
"default": 1,
|
"default": 0.3,
|
||||||
"rules": {
|
"rules": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
"min": 0,
|
"min": 0,
|
||||||
@@ -498,7 +498,7 @@
|
|||||||
"rules": {
|
"rules": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
"min": 0,
|
"min": 0,
|
||||||
"description": "Pump-on threshold and ramp foot. Below this level demand is 0 %; at or above it demand scales 0 → 100 % across [startLevel, maxLevel] using the configured curve (linear or log). When enableShiftedRamp is on, this also serves as the bottom of the held-then-ramp curve during draining."
|
"description": "Pump-on threshold (engagement edge for stopLevel hysteresis). Demand stays at 0 % between startLevel and inflowLevel — the ramp foot is inflowLevel, not startLevel. The ramp itself scales 0 → 100 % across [inflowLevel, maxLevel]. When enableShiftedRamp is on, startLevel also serves as the bottom of the held-then-ramp curve during draining."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"stopLevel": {
|
"stopLevel": {
|
||||||
@@ -507,11 +507,11 @@
|
|||||||
"type": "number",
|
"type": "number",
|
||||||
"nullable": true,
|
"nullable": true,
|
||||||
"min": 0,
|
"min": 0,
|
||||||
"description": "Optional pump-off threshold. When set, PS sends an explicit turnOffAllMachines command to MGC the moment level drops below stopLevel. Independent of the ramp scaling — does NOT shift where the ramp starts. Pair with a startLevel above stopLevel to get hysteresis (pumps engage at startLevel rising, disengage at stopLevel falling). Must be ≥ minLevel and ≤ startLevel."
|
"description": "Optional pump-off threshold. When set, PS sends an explicit turnOffAllMachines command to MGC the moment level drops below stopLevel. Independent of the ramp scaling — does NOT shift where the ramp starts. Pair with a startLevel above stopLevel to get hysteresis (pumps engage at startLevel rising, disengage at stopLevel falling). Must be ≥ minLevel and ≤ startLevel. NOTE: schema default stays null so omitting stopLevel keeps the hysteresis inactive (matching levelBased.js); the editor HTML provides a realistic 0.5 m default for drag-in UX."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"maxLevel": {
|
"maxLevel": {
|
||||||
"default": 4,
|
"default": 3.8,
|
||||||
"rules": {
|
"rules": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
"min": 0,
|
"min": 0,
|
||||||
|
|||||||
@@ -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": {
|
"model": {
|
||||||
"default": "Unknown",
|
"default": null,
|
||||||
"rules": {
|
"rules": {
|
||||||
"type": "string",
|
"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": {
|
"unit": {
|
||||||
"default": "unitless",
|
"default": null,
|
||||||
"rules": {
|
"rules": {
|
||||||
"type": "string",
|
"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": {
|
"curveUnits": {
|
||||||
|
|||||||
@@ -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": {
|
"model": {
|
||||||
"default": "Unknown",
|
"default": null,
|
||||||
"rules": {
|
"rules": {
|
||||||
"type": "string",
|
"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": {
|
"unit": {
|
||||||
"default": "unitless",
|
"default": null,
|
||||||
"rules": {
|
"rules": {
|
||||||
"type": "string",
|
"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": {
|
"accuracy": {
|
||||||
|
|||||||
@@ -301,4 +301,26 @@ convert = function (value) {
|
|||||||
return new Converter(value);
|
return new Converter(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Top-level helper: list accepted unit names for a measure.
|
||||||
|
* Cached per measure. Unknown measures return [].
|
||||||
|
*/
|
||||||
|
var _possibilitiesCache = Object.create(null);
|
||||||
|
convert.possibilities = function (measure) {
|
||||||
|
if (!measure || typeof measure !== 'string') return [];
|
||||||
|
if (_possibilitiesCache[measure]) return _possibilitiesCache[measure].slice();
|
||||||
|
if (!measures[measure]) {
|
||||||
|
_possibilitiesCache[measure] = [];
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
var units = Converter.prototype.possibilities.call({ origin: { measure: measure } }, measure);
|
||||||
|
var deduped = Array.from(new Set(units)).sort();
|
||||||
|
_possibilitiesCache[measure] = deduped;
|
||||||
|
return deduped.slice();
|
||||||
|
};
|
||||||
|
|
||||||
|
convert.measures = function () {
|
||||||
|
return keys(measures).slice();
|
||||||
|
};
|
||||||
|
|
||||||
module.exports = convert;
|
module.exports = convert;
|
||||||
|
|||||||
139
src/domain/BaseDomain.js
Normal file
139
src/domain/BaseDomain.js
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
/**
|
||||||
|
* BaseDomain — shared specificClass scaffolding.
|
||||||
|
*
|
||||||
|
* Consolidates the constructor boilerplate that every domain (pumpingStation,
|
||||||
|
* measurement, MGC, rotatingMachine, …) repeats today: configManager →
|
||||||
|
* configUtils → logger → MeasurementContainer → childRegistrationUtils →
|
||||||
|
* ChildRouter. Subclasses declare `static name` (matches the JSON config in
|
||||||
|
* generalFunctions/src/configs/<name>.json) and optionally `static unitPolicy`
|
||||||
|
* (a UnitPolicy.declare(...) instance), then implement `configure()` to wire
|
||||||
|
* concern-modules.
|
||||||
|
*
|
||||||
|
* See CONTRACTS.md §3.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const EventEmitter = require('events');
|
||||||
|
|
||||||
|
const configManager = require('../configs/index.js');
|
||||||
|
const configUtils = require('../helper/configUtils.js');
|
||||||
|
const Logger = require('../helper/logger.js');
|
||||||
|
const childRegistrationUtils = require('../helper/childRegistrationUtils.js');
|
||||||
|
const { MeasurementContainer } = require('../measurements/index.js');
|
||||||
|
const ChildRouter = require('./ChildRouter.js');
|
||||||
|
|
||||||
|
class BaseDomain {
|
||||||
|
constructor(userConfig = {}) {
|
||||||
|
const ctor = this.constructor;
|
||||||
|
if (ctor === BaseDomain) {
|
||||||
|
throw new Error('BaseDomain is abstract; subclass it and declare static name');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emitter = new EventEmitter();
|
||||||
|
|
||||||
|
this.configManager = new configManager();
|
||||||
|
this.defaultConfig = this.configManager.getConfig(ctor.name);
|
||||||
|
this.configUtils = new configUtils(this.defaultConfig);
|
||||||
|
this.config = this.configUtils.initConfig(userConfig);
|
||||||
|
|
||||||
|
const loggingCfg = this.config?.general?.logging || {};
|
||||||
|
this.logger = new Logger(
|
||||||
|
loggingCfg.enabled,
|
||||||
|
loggingCfg.logLevel,
|
||||||
|
this.config?.general?.name
|
||||||
|
);
|
||||||
|
|
||||||
|
// Read static unitPolicy via the constructor — `this.constructor`
|
||||||
|
// resolves to the leaf subclass even when this base ctor is the caller.
|
||||||
|
this.unitPolicy = ctor.unitPolicy ?? null;
|
||||||
|
if (this.unitPolicy && typeof this.unitPolicy.setLogger === 'function') {
|
||||||
|
this.unitPolicy.setLogger(this.logger);
|
||||||
|
}
|
||||||
|
|
||||||
|
const containerOptions = this.unitPolicy?.containerOptions
|
||||||
|
? this.unitPolicy.containerOptions()
|
||||||
|
: { autoConvert: true };
|
||||||
|
this.measurements = new MeasurementContainer(containerOptions, this.logger);
|
||||||
|
if (this.config?.general?.id) this.measurements.setChildId(this.config.general.id);
|
||||||
|
if (this.config?.general?.name) this.measurements.setChildName(this.config.general.name);
|
||||||
|
|
||||||
|
this.childRegistrationUtils = new childRegistrationUtils(this);
|
||||||
|
this.router = new ChildRouter(this);
|
||||||
|
|
||||||
|
// childRegistrationUtils calls back into mainClass.registerChild after
|
||||||
|
// storing the child. Routing through `this.router` keeps subclasses free
|
||||||
|
// of register-switch boilerplate while preserving the existing handshake.
|
||||||
|
this.registerChild = (child, softwareType) => {
|
||||||
|
this.router.dispatchRegister(child, softwareType);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof this.configure === 'function') this.configure();
|
||||||
|
if (typeof this._init === 'function') this._init();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install a read-only getter that flattens `this.child[softwareType]`
|
||||||
|
* (across all categories, or filtered by `category`) into a single
|
||||||
|
* id-keyed object. Lets subclasses expose readable accessors like
|
||||||
|
* `this.machines` while the registry remains the source of truth.
|
||||||
|
*/
|
||||||
|
declareChildGetter(name, softwareType, category) {
|
||||||
|
const key = String(softwareType || '').toLowerCase();
|
||||||
|
Object.defineProperty(this, name, {
|
||||||
|
configurable: true,
|
||||||
|
enumerable: true,
|
||||||
|
get: () => {
|
||||||
|
const slice = this.child?.[key];
|
||||||
|
if (!slice) return {};
|
||||||
|
const cats = category ? [slice[category] || []] : Object.values(slice);
|
||||||
|
const out = {};
|
||||||
|
for (const list of cats) {
|
||||||
|
if (!Array.isArray(list)) continue;
|
||||||
|
for (const c of list) {
|
||||||
|
const id = c?.config?.general?.id || c?.config?.general?.name;
|
||||||
|
if (id != null) out[id] = c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Frozen view passed to concern-modules so they don't reach into `this`.
|
||||||
|
* Subclasses may override to add domain-specific keys.
|
||||||
|
*/
|
||||||
|
context() {
|
||||||
|
return Object.freeze({
|
||||||
|
config: this.config,
|
||||||
|
logger: this.logger,
|
||||||
|
measurements: this.measurements,
|
||||||
|
emitter: this.emitter,
|
||||||
|
child: this.child,
|
||||||
|
unitPolicy: this.unitPolicy,
|
||||||
|
router: this.router,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Default output shape — subclasses extend with concern-module snapshots. */
|
||||||
|
getOutput() {
|
||||||
|
return this.measurements.getFlattenedOutput?.() || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subclasses MUST override. Grey placeholder so adapters never crash. */
|
||||||
|
getStatusBadge() {
|
||||||
|
return { fill: 'grey', shape: 'ring', text: 'no status' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convenience for event-driven nodes — see CONTRACTS.md §3. */
|
||||||
|
notifyOutputChanged() {
|
||||||
|
this.emitter.emit('output-changed');
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.router?.tearDown();
|
||||||
|
this.emitter.removeAllListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = BaseDomain;
|
||||||
164
src/domain/ChildRouter.js
Normal file
164
src/domain/ChildRouter.js
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
/**
|
||||||
|
* ChildRouter — declarative parent-side child registration & event routing.
|
||||||
|
*
|
||||||
|
* Replaces the per-node `registerChild` switch + manual
|
||||||
|
* `child.measurements.emitter.on(...)` wiring repeated in pumpingStation,
|
||||||
|
* rotatingMachine and machineGroupControl.
|
||||||
|
*
|
||||||
|
* See CONTRACTS.md §5. Built on top of `childRegistrationUtils`, which
|
||||||
|
* already canonicalises softwareType (e.g. rotatingmachine → machine).
|
||||||
|
*
|
||||||
|
* Wildcard / partial-filter subscriptions enumerate every concrete
|
||||||
|
* `<type>.<variant>.<position>` event name the filter matches and attach a
|
||||||
|
* plain `emitter.on(...)` per combination. No emit patching — multi-parent
|
||||||
|
* stacks compose cleanly because each parent owns its own listeners.
|
||||||
|
*/
|
||||||
|
const { POSITION_VALUES } = require('../constants/positions');
|
||||||
|
|
||||||
|
const SOFTWARE_TYPE_ALIASES = {
|
||||||
|
rotatingmachine: 'machine',
|
||||||
|
machinegroupcontrol: 'machinegroup',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Canonical measurement-type set used to enumerate position-only and
|
||||||
|
// match-everything filters. Sourced from MeasurementContainer.measureMap
|
||||||
|
// plus the EVOLV-specific synthetic types the nodes routinely emit
|
||||||
|
// (level / volumePercent / efficiency / Ncog / netFlowRate). Keep in sync
|
||||||
|
// with MeasurementContainer if new types land there.
|
||||||
|
const KNOWN_TYPES = Object.freeze([
|
||||||
|
'flow',
|
||||||
|
'pressure',
|
||||||
|
'atmPressure',
|
||||||
|
'power',
|
||||||
|
'hydraulicPower',
|
||||||
|
'reactivePower',
|
||||||
|
'apparentPower',
|
||||||
|
'temperature',
|
||||||
|
'level',
|
||||||
|
'volume',
|
||||||
|
'volumePercent',
|
||||||
|
'length',
|
||||||
|
'mass',
|
||||||
|
'energy',
|
||||||
|
'reactiveEnergy',
|
||||||
|
'efficiency',
|
||||||
|
'Ncog',
|
||||||
|
'netFlowRate',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function canonicalType(rawType) {
|
||||||
|
const t = String(rawType || '').toLowerCase();
|
||||||
|
return SOFTWARE_TYPE_ALIASES[t] || t;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lowerPosition(p) {
|
||||||
|
return String(p).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
class ChildRouter {
|
||||||
|
constructor(domain) {
|
||||||
|
this.domain = domain;
|
||||||
|
this.logger = domain?.logger || null;
|
||||||
|
|
||||||
|
this._registerSubs = new Map(); // softwareType -> Array<fn>
|
||||||
|
this._measurementSubs = new Map(); // softwareType -> Array<{filter, fn}>
|
||||||
|
this._predictionSubs = new Map(); // softwareType -> Array<{filter, fn}>
|
||||||
|
|
||||||
|
// Every plain emitter listener we attach, so tearDown can remove them.
|
||||||
|
this._listeners = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── declaration API ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
onRegister(softwareType, fn) {
|
||||||
|
if (typeof fn !== 'function') {
|
||||||
|
throw new TypeError('ChildRouter.onRegister: fn must be a function');
|
||||||
|
}
|
||||||
|
const key = canonicalType(softwareType);
|
||||||
|
if (!this._registerSubs.has(key)) this._registerSubs.set(key, []);
|
||||||
|
this._registerSubs.get(key).push(fn);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMeasurement(softwareType, filter, fn) {
|
||||||
|
return this._addEventSub(this._measurementSubs, softwareType, filter, fn, 'onMeasurement');
|
||||||
|
}
|
||||||
|
|
||||||
|
onPrediction(softwareType, filter, fn) {
|
||||||
|
return this._addEventSub(this._predictionSubs, softwareType, filter, fn, 'onPrediction');
|
||||||
|
}
|
||||||
|
|
||||||
|
_addEventSub(table, softwareType, filter, fn, label) {
|
||||||
|
if (typeof filter === 'function' && fn === undefined) {
|
||||||
|
fn = filter;
|
||||||
|
filter = {};
|
||||||
|
}
|
||||||
|
if (typeof fn !== 'function') {
|
||||||
|
throw new TypeError(`ChildRouter.${label}: fn must be a function`);
|
||||||
|
}
|
||||||
|
const key = canonicalType(softwareType);
|
||||||
|
if (!table.has(key)) table.set(key, []);
|
||||||
|
table.get(key).push({ filter: filter || {}, fn });
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── dispatch ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
dispatchRegister(child, softwareType) {
|
||||||
|
const key = canonicalType(softwareType);
|
||||||
|
|
||||||
|
const regHandlers = this._registerSubs.get(key) || [];
|
||||||
|
for (const fn of regHandlers) {
|
||||||
|
try { fn.call(this.domain, child, key); }
|
||||||
|
catch (err) { this._logHandlerError('onRegister', key, err); }
|
||||||
|
}
|
||||||
|
|
||||||
|
const emitter = child?.measurements?.emitter;
|
||||||
|
if (!emitter || typeof emitter.on !== 'function') return;
|
||||||
|
|
||||||
|
this._attachVariantListeners(child, key, emitter, 'measured', this._measurementSubs);
|
||||||
|
this._attachVariantListeners(child, key, emitter, 'predicted', this._predictionSubs);
|
||||||
|
}
|
||||||
|
|
||||||
|
_attachVariantListeners(child, key, emitter, variant, table) {
|
||||||
|
const subs = table.get(key) || [];
|
||||||
|
for (const { filter, fn } of subs) {
|
||||||
|
const types = filter.type ? [filter.type] : KNOWN_TYPES;
|
||||||
|
const positions = filter.position ? [lowerPosition(filter.position)] : POSITION_VALUES.map(lowerPosition);
|
||||||
|
const handlerLabel = variant === 'measured' ? 'onMeasurement' : 'onPrediction';
|
||||||
|
|
||||||
|
for (const type of types) {
|
||||||
|
for (const pos of positions) {
|
||||||
|
const eventName = `${type}.${variant}.${pos}`;
|
||||||
|
const listener = (data) => this._invoke(fn, data, child, handlerLabel);
|
||||||
|
emitter.on(eventName, listener);
|
||||||
|
this._listeners.push({ emitter, eventName, listener });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_invoke(fn, eventData, child, handlerLabel) {
|
||||||
|
try { fn.call(this.domain, eventData, child); }
|
||||||
|
catch (err) { this._logHandlerError(handlerLabel, '', err); }
|
||||||
|
}
|
||||||
|
|
||||||
|
_logHandlerError(kind, key, err) {
|
||||||
|
if (this.logger?.warn) {
|
||||||
|
this.logger.warn(`ChildRouter ${kind}${key ? `[${key}]` : ''} handler threw: ${err?.message || err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── teardown ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
tearDown() {
|
||||||
|
for (const { emitter, eventName, listener } of this._listeners) {
|
||||||
|
if (typeof emitter.off === 'function') emitter.off(eventName, listener);
|
||||||
|
else if (typeof emitter.removeListener === 'function') emitter.removeListener(eventName, listener);
|
||||||
|
}
|
||||||
|
this._listeners = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = ChildRouter;
|
||||||
|
module.exports.KNOWN_TYPES = KNOWN_TYPES;
|
||||||
102
src/domain/HealthStatus.js
Normal file
102
src/domain/HealthStatus.js
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* HealthStatus — standardised health/quality datum.
|
||||||
|
* Contract: see .claude/refactor/CONTRACTS.md §9.
|
||||||
|
*
|
||||||
|
* Shape (always frozen):
|
||||||
|
* { level: 0|1|2|3, flags: string[], message: string, source: string|null }
|
||||||
|
*
|
||||||
|
* level 0 = nominal, 3 = unusable. Returned objects are frozen plain
|
||||||
|
* objects (not class instances) so they round-trip cleanly through
|
||||||
|
* JSON / InfluxDB serialisation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const LABELS = ['nominal', 'minor', 'major', 'critical'];
|
||||||
|
|
||||||
|
function _freeze(level, flags, message, source) {
|
||||||
|
return Object.freeze({
|
||||||
|
level,
|
||||||
|
flags: Object.freeze(flags.slice()),
|
||||||
|
message,
|
||||||
|
source: source == null ? null : String(source),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function _coerceDegradedLevel(level) {
|
||||||
|
const n = Math.trunc(Number(level));
|
||||||
|
if (!Number.isFinite(n) || n < 1) return 1;
|
||||||
|
if (n > 3) return 3;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _coerceFlags(flags) {
|
||||||
|
if (!Array.isArray(flags)) return [];
|
||||||
|
const out = [];
|
||||||
|
for (const f of flags) {
|
||||||
|
if (f == null) continue;
|
||||||
|
out.push(String(f));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ok(message, source) {
|
||||||
|
return _freeze(
|
||||||
|
0,
|
||||||
|
[],
|
||||||
|
typeof message === 'string' && message.length > 0 ? message : 'nominal',
|
||||||
|
source != null ? source : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function degraded(level, flags, message, source) {
|
||||||
|
const lvl = _coerceDegradedLevel(level);
|
||||||
|
const f = _coerceFlags(flags);
|
||||||
|
const m = typeof message === 'string' && message.length > 0
|
||||||
|
? message
|
||||||
|
: LABELS[lvl];
|
||||||
|
return _freeze(lvl, f, m, source != null ? source : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge multiple statuses into one node-level status. Worst level wins
|
||||||
|
// for level/message/source; flags are concatenated and de-duped.
|
||||||
|
function compose(statuses) {
|
||||||
|
if (!Array.isArray(statuses) || statuses.length === 0) return ok();
|
||||||
|
|
||||||
|
let worst = null;
|
||||||
|
const seen = new Set();
|
||||||
|
const flags = [];
|
||||||
|
|
||||||
|
for (const s of statuses) {
|
||||||
|
if (!s || typeof s !== 'object') continue;
|
||||||
|
const lvl = Number.isFinite(s.level) ? s.level : 0;
|
||||||
|
if (worst === null || lvl > worst.level) {
|
||||||
|
worst = { level: lvl, message: s.message, source: s.source ?? null };
|
||||||
|
}
|
||||||
|
if (Array.isArray(s.flags)) {
|
||||||
|
for (const f of s.flags) {
|
||||||
|
if (f == null) continue;
|
||||||
|
const k = String(f);
|
||||||
|
if (!seen.has(k)) {
|
||||||
|
seen.add(k);
|
||||||
|
flags.push(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (worst === null) return ok();
|
||||||
|
|
||||||
|
const message = typeof worst.message === 'string' && worst.message.length > 0
|
||||||
|
? worst.message
|
||||||
|
: LABELS[Math.max(0, Math.min(3, worst.level))];
|
||||||
|
return _freeze(worst.level, flags, message, worst.source);
|
||||||
|
}
|
||||||
|
|
||||||
|
function label(level) {
|
||||||
|
const n = Math.trunc(Number(level));
|
||||||
|
if (!Number.isFinite(n) || n < 0 || n > 3) return 'unknown';
|
||||||
|
return LABELS[n];
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { ok, degraded, compose, label };
|
||||||
116
src/domain/LatestWinsGate.js
Normal file
116
src/domain/LatestWinsGate.js
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Serialises an async dispatch so that high-frequency callers cannot stack
|
||||||
|
// up overlapping invocations. Intermediate values are dropped — only the
|
||||||
|
// most recent fire()/fireAndWait() during an in-flight dispatch is replayed
|
||||||
|
// afterwards. Extracted from machineGroupControl's _dispatchInFlight +
|
||||||
|
// _delayedCall pattern so MGC, pumpingStation, valveGroupControl etc. can
|
||||||
|
// share it.
|
||||||
|
//
|
||||||
|
// fire(value) — never blocks; returns void.
|
||||||
|
// fireAndWait(value) — returns a promise that settles when THIS value's
|
||||||
|
// dispatch runs to completion. If a later fireAndWait
|
||||||
|
// arrives during the in-flight call and supersedes
|
||||||
|
// this one in the pending slot, the returned promise
|
||||||
|
// RESOLVES with { superseded: true } instead of
|
||||||
|
// rejecting — callers can branch on a sentinel
|
||||||
|
// without try/catch. The dispatch's own return value
|
||||||
|
// (when not superseded) is forwarded as the resolution.
|
||||||
|
|
||||||
|
const SUPERSEDED = Object.freeze({ superseded: true });
|
||||||
|
|
||||||
|
class LatestWinsGate {
|
||||||
|
constructor(asyncDispatchFn, options = {}) {
|
||||||
|
if (typeof asyncDispatchFn !== 'function') {
|
||||||
|
throw new TypeError('LatestWinsGate requires an async dispatch function');
|
||||||
|
}
|
||||||
|
this._dispatch = asyncDispatchFn;
|
||||||
|
this._logger = options.logger || null;
|
||||||
|
this._inFlight = false;
|
||||||
|
this._pending = null; // { value, ctx, settle? } | null
|
||||||
|
this._drainResolvers = []; // resolved when idle again
|
||||||
|
this.lastError = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 0 = idle, 1 = running with no pending, 2 = running with pending.
|
||||||
|
get size() {
|
||||||
|
if (!this._inFlight) return 0;
|
||||||
|
return this._pending ? 2 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Never blocks. If a dispatch is in flight, the latest value is parked;
|
||||||
|
// older parked values are silently overwritten.
|
||||||
|
fire(value, ctx) {
|
||||||
|
if (this._inFlight) {
|
||||||
|
this._supersedePending();
|
||||||
|
this._pending = { value, ctx, settle: null };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._run(value, ctx, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns a promise that resolves when THIS fire's dispatch settles.
|
||||||
|
// If this fire gets overwritten while parked, resolves with the
|
||||||
|
// SUPERSEDED sentinel ({ superseded: true }) — callers branch on
|
||||||
|
// result.superseded === true without try/catch.
|
||||||
|
fireAndWait(value, ctx) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const settle = resolve;
|
||||||
|
if (this._inFlight) {
|
||||||
|
this._supersedePending();
|
||||||
|
this._pending = { value, ctx, settle };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._run(value, ctx, settle);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
drain() {
|
||||||
|
if (!this._inFlight && !this._pending) return Promise.resolve();
|
||||||
|
return new Promise((resolve) => { this._drainResolvers.push(resolve); });
|
||||||
|
}
|
||||||
|
|
||||||
|
_supersedePending() {
|
||||||
|
const prev = this._pending;
|
||||||
|
if (prev && typeof prev.settle === 'function') prev.settle(SUPERSEDED);
|
||||||
|
this._pending = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_run(value, ctx, settle) {
|
||||||
|
this._inFlight = true;
|
||||||
|
// Kick the dispatch on a microtask so fire()/fireAndWait() always
|
||||||
|
// return synchronously, even if _dispatch resolves immediately.
|
||||||
|
Promise.resolve()
|
||||||
|
.then(() => this._dispatch(value, ctx))
|
||||||
|
.then((result) => {
|
||||||
|
if (typeof settle === 'function') settle(result);
|
||||||
|
}, (err) => {
|
||||||
|
this.lastError = err;
|
||||||
|
if (this._logger && typeof this._logger.error === 'function') {
|
||||||
|
this._logger.error(err);
|
||||||
|
}
|
||||||
|
// Resolve (not reject) so fireAndWait callers don't need
|
||||||
|
// try/catch. Dispatch errors stay observable via lastError.
|
||||||
|
if (typeof settle === 'function') settle(undefined);
|
||||||
|
})
|
||||||
|
.then(() => this._afterDispatch());
|
||||||
|
}
|
||||||
|
|
||||||
|
_afterDispatch() {
|
||||||
|
this._inFlight = false;
|
||||||
|
if (this._pending) {
|
||||||
|
const { value, ctx, settle } = this._pending;
|
||||||
|
this._pending = null;
|
||||||
|
this._run(value, ctx, settle);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Idle — release any drain() waiters.
|
||||||
|
const waiters = this._drainResolvers;
|
||||||
|
this._drainResolvers = [];
|
||||||
|
for (const r of waiters) r();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LatestWinsGate.SUPERSEDED = SUPERSEDED;
|
||||||
|
|
||||||
|
module.exports = LatestWinsGate;
|
||||||
163
src/domain/UnitPolicy.js
Normal file
163
src/domain/UnitPolicy.js
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
const convert = require('../convert/index.js');
|
||||||
|
|
||||||
|
// Map MeasurementContainer measurement-type names to convert-module
|
||||||
|
// "measure" families. Mirrors MeasurementContainer.measureMap so a policy
|
||||||
|
// declared with the type names domains use ('flow', 'pressure', ...) can be
|
||||||
|
// validated against the same convert-module families MeasurementContainer
|
||||||
|
// uses internally.
|
||||||
|
const TYPE_TO_MEASURE = Object.freeze({
|
||||||
|
pressure: 'pressure',
|
||||||
|
atmpressure: 'pressure',
|
||||||
|
flow: 'volumeFlowRate',
|
||||||
|
power: 'power',
|
||||||
|
hydraulicpower: 'power',
|
||||||
|
reactivepower: 'reactivePower',
|
||||||
|
apparentpower: 'apparentPower',
|
||||||
|
temperature: 'temperature',
|
||||||
|
volume: 'volume',
|
||||||
|
length: 'length',
|
||||||
|
mass: 'mass',
|
||||||
|
energy: 'energy',
|
||||||
|
reactiveenergy: 'reactiveEnergy',
|
||||||
|
});
|
||||||
|
|
||||||
|
const DEFAULT_REQUIRED_TYPES = Object.freeze(['flow', 'pressure', 'power', 'temperature']);
|
||||||
|
|
||||||
|
class UnitPolicy {
|
||||||
|
constructor({ canonical, output, curve, requireUnitForTypes, logger } = {}) {
|
||||||
|
this._canonical = freezeShallow(canonical);
|
||||||
|
this._output = freezeShallow(output);
|
||||||
|
this._curve = curve ? freezeShallow(curve) : null;
|
||||||
|
this._requireUnitForTypes = Object.freeze(
|
||||||
|
Array.isArray(requireUnitForTypes) ? [...requireUnitForTypes] : [...DEFAULT_REQUIRED_TYPES]
|
||||||
|
);
|
||||||
|
this._logger = logger || null;
|
||||||
|
// Warn-once memo: same (label, candidate) pair only logs the first time.
|
||||||
|
this._warned = new Set();
|
||||||
|
|
||||||
|
// Dual-shape accessors: each of canonical/output/curve is BOTH a method
|
||||||
|
// (legacy `policy.canonical('flow')`) AND a frozen property bag
|
||||||
|
// (`policy.canonical.flow`). The function carries the frozen map's own
|
||||||
|
// properties via Object.defineProperty so consumers can pick either form.
|
||||||
|
this.canonical = makeAccessor(this._canonical);
|
||||||
|
this.output = makeAccessor(this._output);
|
||||||
|
this.curve = makeAccessor(this._curve || {});
|
||||||
|
}
|
||||||
|
|
||||||
|
static declare(spec = {}) {
|
||||||
|
if (!spec.canonical || typeof spec.canonical !== 'object') {
|
||||||
|
throw new Error('UnitPolicy.declare: canonical units map is required');
|
||||||
|
}
|
||||||
|
if (!spec.output || typeof spec.output !== 'object') {
|
||||||
|
throw new Error('UnitPolicy.declare: output units map is required');
|
||||||
|
}
|
||||||
|
return new UnitPolicy(spec);
|
||||||
|
}
|
||||||
|
|
||||||
|
setLogger(logger) {
|
||||||
|
this._logger = logger || null;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a user-supplied unit string against `expectedMeasure`. On any
|
||||||
|
* mismatch return `fallback` and warn once for this (label, candidate)
|
||||||
|
* pair. On success return the trimmed candidate.
|
||||||
|
*/
|
||||||
|
resolve(candidate, expectedMeasure, fallback, label = 'unit') {
|
||||||
|
const fallbackUnit = String(fallback || '').trim();
|
||||||
|
const raw = typeof candidate === 'string' ? candidate.trim() : '';
|
||||||
|
if (!raw) return fallbackUnit;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const desc = convert().describe(raw);
|
||||||
|
const measure = resolveMeasure(expectedMeasure);
|
||||||
|
if (measure && desc.measure !== measure) {
|
||||||
|
throw new Error(`expected ${measure} but got ${desc.measure}`);
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
} catch (error) {
|
||||||
|
this._warnOnce(label, raw, `Invalid ${label} unit '${raw}' (${error.message}). Falling back to '${fallbackUnit}'.`);
|
||||||
|
return fallbackUnit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strict numeric conversion. Throws if value is not finite.
|
||||||
|
* No-ops (still returning a Number) when from/to are missing or equal.
|
||||||
|
*/
|
||||||
|
convert(value, fromUnit, toUnit, contextLabel = 'unit conversion') {
|
||||||
|
const numeric = Number(value);
|
||||||
|
if (!Number.isFinite(numeric)) {
|
||||||
|
throw new Error(`${contextLabel}: value '${value}' is not finite`);
|
||||||
|
}
|
||||||
|
if (!fromUnit || !toUnit || fromUnit === toUnit) return numeric;
|
||||||
|
return convert(numeric).from(fromUnit).to(toUnit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the option bag for `new MeasurementContainer(options, logger)`.
|
||||||
|
* Exact shape required by MeasurementContainer; see
|
||||||
|
* src/measurements/MeasurementContainer.js constructor.
|
||||||
|
*/
|
||||||
|
containerOptions() {
|
||||||
|
const defaultUnits = { ...this._output };
|
||||||
|
const preferredUnits = { ...this._output };
|
||||||
|
const canonicalUnits = { ...this._canonical };
|
||||||
|
return {
|
||||||
|
defaultUnits,
|
||||||
|
preferredUnits,
|
||||||
|
canonicalUnits,
|
||||||
|
storeCanonical: true,
|
||||||
|
strictUnitValidation: true,
|
||||||
|
throwOnInvalidUnit: true,
|
||||||
|
requireUnitForTypes: [...this._requireUnitForTypes],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
_warnOnce(label, candidate, message) {
|
||||||
|
const key = `${label}::${candidate}`;
|
||||||
|
if (this._warned.has(key)) return;
|
||||||
|
this._warned.add(key);
|
||||||
|
if (this._logger && typeof this._logger.warn === 'function') {
|
||||||
|
this._logger.warn(message);
|
||||||
|
} else {
|
||||||
|
// Last-resort fallback so misconfigurations don't go silent in
|
||||||
|
// domains that haven't wired a logger yet.
|
||||||
|
console.warn(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function freezeShallow(obj) {
|
||||||
|
return Object.freeze({ ...(obj || {}) });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a function that doubles as a frozen property bag. `accessor(type)`
|
||||||
|
// returns the unit for that type (legacy method shape). `accessor.flow` etc.
|
||||||
|
// return the unit directly (new property shape). Own-properties are
|
||||||
|
// non-writable, non-configurable; attempts to assign / delete / redefine
|
||||||
|
// throw in strict mode — proving the bag is genuinely frozen.
|
||||||
|
function makeAccessor(map) {
|
||||||
|
const fn = (type) => map[type] || null;
|
||||||
|
for (const key of Object.keys(map)) {
|
||||||
|
Object.defineProperty(fn, key, {
|
||||||
|
value: map[key],
|
||||||
|
writable: false,
|
||||||
|
enumerable: true,
|
||||||
|
configurable: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Object.freeze(fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accepts either the convert-module measure family ('volumeFlowRate') or one
|
||||||
|
// of our type names ('flow') and returns the convert-module measure.
|
||||||
|
function resolveMeasure(expected) {
|
||||||
|
if (!expected) return null;
|
||||||
|
const lower = String(expected).trim().toLowerCase();
|
||||||
|
if (TYPE_TO_MEASURE[lower]) return TYPE_TO_MEASURE[lower];
|
||||||
|
return expected;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = UnitPolicy;
|
||||||
@@ -37,7 +37,10 @@ class OutputUtils {
|
|||||||
const changedFields = this.checkForChanges(output,format);
|
const changedFields = this.checkForChanges(output,format);
|
||||||
|
|
||||||
if (Object.keys(changedFields).length > 0) {
|
if (Object.keys(changedFields).length > 0) {
|
||||||
const measurement = config.general.name;
|
// Fall back to `<softwareType>_<id>` when `general.name` is unset —
|
||||||
|
// the original convention before name became a registered config field.
|
||||||
|
const measurement = config.general.name
|
||||||
|
|| `${config.functionality?.softwareType}_${config.general.id}`;
|
||||||
const flatTags = this.flattenTags(this.extractRelevantConfig(config));
|
const flatTags = this.flattenTags(this.extractRelevantConfig(config));
|
||||||
const formatterName = this.resolveFormatterName(config, format);
|
const formatterName = this.resolveFormatterName(config, format);
|
||||||
const formatter = getFormatter(formatterName);
|
const formatter = getFormatter(formatterName);
|
||||||
|
|||||||
@@ -233,6 +233,13 @@ class ValidationUtils {
|
|||||||
return fieldSchema.default;
|
return fieldSchema.default;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Public wrapper for the curve validator — exposes the helper so
|
||||||
|
// callers (and tests) can validate a raw curve without going
|
||||||
|
// through validateSchema.
|
||||||
|
validateCurve(input, defaultCurve) {
|
||||||
|
return validateCurve(input, defaultCurve, this.logger);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = ValidationUtils;
|
module.exports = ValidationUtils;
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ function validateArray(configValue, rules, fieldSchema, name, key, logger) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.slice(0, rules.maxLength || Infinity);
|
.slice(0, rules.maxLength || Infinity);
|
||||||
if (validatedArray.length < (rules.minLength || 1)) {
|
if (validatedArray.length < (rules.minLength ?? 1)) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`${name}.${key} contains fewer items than allowed (${rules.minLength}). Using default value.`
|
`${name}.${key} contains fewer items than allowed (${rules.minLength}). Using default value.`
|
||||||
);
|
);
|
||||||
@@ -41,7 +41,7 @@ function validateSet(configValue, rules, fieldSchema, name, key, logger) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.slice(0, rules.maxLength || Infinity);
|
.slice(0, rules.maxLength || Infinity);
|
||||||
if (validatedArray.length < (rules.minLength || 1)) {
|
if (validatedArray.length < (rules.minLength ?? 1)) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`${name}.${key} contains fewer items than allowed (${rules.minLength}). Using default value.`
|
`${name}.${key} contains fewer items than allowed (${rules.minLength}). Using default value.`
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,39 +1,28 @@
|
|||||||
const fs = require('fs');
|
'use strict';
|
||||||
const path = require('path');
|
|
||||||
|
// 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 {
|
class AquonSamplesMenu {
|
||||||
constructor(relPath = '../../datasets/assetData') {
|
// relPath retained for signature compatibility with the previous on-disk
|
||||||
this.baseDir = path.resolve(__dirname, relPath);
|
// implementation; unused — the registry owns file locations.
|
||||||
this.samplePath = path.resolve(this.baseDir, 'monsterSamples.json');
|
constructor(/* relPath */) {}
|
||||||
this.specPath = path.resolve(this.baseDir, 'specs/monster/index.json');
|
|
||||||
this.cache = new Map();
|
|
||||||
}
|
|
||||||
|
|
||||||
_loadJSON(filePath, cacheKey) {
|
|
||||||
if (this.cache.has(cacheKey)) {
|
|
||||||
return this.cache.get(cacheKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
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() {
|
getAllMenuData() {
|
||||||
const samples = this._loadJSON(this.samplePath, 'samples');
|
const samples = assetResolver
|
||||||
const specs = this._loadJSON(this.specPath, 'specs');
|
.list('monsterSamples')
|
||||||
|
.map((id) => assetResolver.resolve('monsterSamples', id))
|
||||||
|
.filter(Boolean);
|
||||||
|
const specs = assetResolver.resolve('monsterSpecs', 'all') || { defaults: {}, bySample: {} };
|
||||||
return {
|
return {
|
||||||
samples: samples.samples || [],
|
samples,
|
||||||
specs: {
|
specs: {
|
||||||
defaults: specs.defaults || {},
|
defaults: specs.defaults || {},
|
||||||
bySample: specs.bySample || {}
|
bySample: specs.bySample || {},
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ class AssetMenu {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const softwareType = category.softwareType || key;
|
||||||
return {
|
return {
|
||||||
...category,
|
...category,
|
||||||
label: category.label || category.softwareType || key,
|
label: category.label || category.softwareType || key,
|
||||||
@@ -28,11 +29,18 @@ class AssetMenu {
|
|||||||
types: (supplier.types || []).map((type) => ({
|
types: (supplier.types || []).map((type) => ({
|
||||||
...type,
|
...type,
|
||||||
id: type.id || type.name,
|
id: type.id || type.name,
|
||||||
models: (type.models || []).map((model) => ({
|
models: (type.models || []).map((model) => {
|
||||||
|
const id = model.id || model.name;
|
||||||
|
// Enrich each model with a slim preview curve (or null) so the
|
||||||
|
// editor wizard can draw a sparkline without a round-trip.
|
||||||
|
const previewCurve = this.buildPreviewCurve(softwareType, id, model.name);
|
||||||
|
return {
|
||||||
...model,
|
...model,
|
||||||
id: model.id || model.name,
|
id,
|
||||||
units: model.units || []
|
units: model.units || [],
|
||||||
}))
|
previewCurve: previewCurve || null
|
||||||
|
};
|
||||||
|
})
|
||||||
}))
|
}))
|
||||||
}))
|
}))
|
||||||
};
|
};
|
||||||
@@ -55,7 +63,17 @@ class AssetMenu {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return keys[0];
|
// Previously fell back to keys[0] (alphabetically first category),
|
||||||
|
// which meant a softwareType mismatch silently showed the wrong asset
|
||||||
|
// tree — e.g. rotatingMachine (softwareType='rotatingmachine') with
|
||||||
|
// no matching registry file saw 'diffuser' models in the dropdown.
|
||||||
|
// Return null so the menu renders empty and the operator sees a clear
|
||||||
|
// 'No suppliers available' placeholder instead of a wrong category.
|
||||||
|
console.warn(
|
||||||
|
`[AssetMenu] No asset category matches softwareType='${this.softwareType}' or nodeName='${nodeName}'. ` +
|
||||||
|
`Available categories: [${keys.join(', ')}]. Menu will render empty.`
|
||||||
|
);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
getAllMenuData(nodeName) {
|
getAllMenuData(nodeName) {
|
||||||
@@ -76,12 +94,359 @@ class AssetMenu {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Client-side wizard layer: chips, combobox, spec strip, curve mini-chart.
|
||||||
|
// Listens to change events on the hidden <select>s that wireEvents already
|
||||||
|
// populates — so cascade/reset logic stays in one place.
|
||||||
|
getVisualInjectionCode(nodeName) {
|
||||||
|
return `
|
||||||
|
// Asset wizard visuals for ${nodeName}
|
||||||
|
(function injectAssetWizardCss() {
|
||||||
|
const id = 'evolv-asset-wizard-css';
|
||||||
|
if (document.getElementById(id)) return;
|
||||||
|
const css = [
|
||||||
|
// Asset wizard — tightened layout (smaller radius/padding, no
|
||||||
|
// uppercase label transform, single-line chip text) so the strip
|
||||||
|
// reads as a compact form control instead of a row of pill cards.
|
||||||
|
'.evolv-asset-hidden-natives { position:absolute !important; left:-9999px !important; height:0 !important; overflow:hidden; }',
|
||||||
|
'.evolv-asset-wizard { display:flex; flex-direction:column; gap:8px; margin:6px 0 4px 0; max-width:460px; }',
|
||||||
|
'.evolv-asset-chips { display:flex; flex-wrap:wrap; gap:4px; align-items:center; }',
|
||||||
|
'.evolv-asset-chip {',
|
||||||
|
' display:inline-flex; align-items:baseline; gap:6px;',
|
||||||
|
' border:1px solid #d0d0d0; border-radius:4px; background:#fff;',
|
||||||
|
' padding:3px 8px; cursor:pointer; user-select:none;',
|
||||||
|
' font:inherit; color:#333; height:26px; box-sizing:border-box;',
|
||||||
|
' transition:border-color 80ms ease-out, background 80ms ease-out;',
|
||||||
|
'}',
|
||||||
|
'.evolv-asset-chip:hover { border-color:#86bbdd; background:#f5fafd; }',
|
||||||
|
'.evolv-asset-chip[aria-selected="true"] { border-color:#1F4E79; background:#eaf4fb; }',
|
||||||
|
'.evolv-asset-chip[disabled] { opacity:0.5; cursor:not-allowed; }',
|
||||||
|
'.evolv-asset-chip-icon { color:#607484; font-size:11px; align-self:center; }',
|
||||||
|
'.evolv-asset-chip-text { display:inline-flex; align-items:baseline; gap:5px; line-height:1; }',
|
||||||
|
'.evolv-asset-chip-label { font-size:11px; font-weight:normal; color:#888; letter-spacing:0; text-transform:none; }',
|
||||||
|
'.evolv-asset-chip-label::after { content:":"; color:#bbb; margin-left:1px; }',
|
||||||
|
'.evolv-asset-chip-value { font-size:12px; font-weight:600; color:#1F4E79; max-width:160px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }',
|
||||||
|
'.evolv-asset-chip-value[data-empty="true"] { color:#aaa; font-weight:400; font-style:italic; }',
|
||||||
|
'.evolv-asset-chip-sep { color:#bbb; font-size:13px; line-height:1; user-select:none; padding:0 2px; }',
|
||||||
|
'.evolv-asset-combobox { display:flex; flex-direction:column; gap:4px; border:1px solid #d0d0d0; border-radius:3px; background:#fff; padding:6px; }',
|
||||||
|
'.evolv-asset-combobox-search { width:100%; box-sizing:border-box; padding:5px 7px; border:1px solid #ccc; border-radius:3px; font:inherit; font-size:12px; }',
|
||||||
|
'.evolv-asset-combobox-search:focus { outline:none; border-color:#1F4E79; box-shadow:0 0 0 2px rgba(31,78,121,0.15); }',
|
||||||
|
'.evolv-asset-combobox-list { max-height:200px; overflow-y:auto; }',
|
||||||
|
'.evolv-asset-combobox-option {',
|
||||||
|
' padding:5px 8px; cursor:pointer; border-radius:2px;',
|
||||||
|
' font-size:12px; color:#333;',
|
||||||
|
'}',
|
||||||
|
'.evolv-asset-combobox-option:hover,',
|
||||||
|
'.evolv-asset-combobox-option.evolv-asset-combobox-option-active { background:#eaf4fb; color:#1F4E79; }',
|
||||||
|
'.evolv-asset-combobox-empty { padding:5px 8px; color:#888; font-size:11px; font-style:italic; }',
|
||||||
|
'.evolv-asset-summary { display:grid; grid-template-columns:1fr 220px; gap:10px; border:1px solid #e2e2e2; border-radius:3px; padding:8px 10px; background:#fafafa; align-items:center; }',
|
||||||
|
'.evolv-asset-specs { font-size:11.5px; color:#333; display:flex; flex-direction:column; gap:2px; }',
|
||||||
|
'.evolv-asset-spec-row { display:flex; gap:6px; }',
|
||||||
|
'.evolv-asset-spec-key { color:#888; min-width:74px; }',
|
||||||
|
'.evolv-asset-spec-val { color:#1F4E79; font-weight:600; }',
|
||||||
|
'.evolv-asset-curve { width:220px; height:110px; }',
|
||||||
|
'.evolv-asset-curve svg { width:100%; height:100%; display:block; }',
|
||||||
|
'.evolv-asset-curve-empty { display:flex; align-items:center; justify-content:center; color:#aaa; font-size:11px; font-style:italic; text-align:center; }',
|
||||||
|
'.evolv-asset-tag-row { margin-top:2px; align-items:center; }',
|
||||||
|
'.evolv-asset-tag-row > label { width:110px; white-space:nowrap; }',
|
||||||
|
'.evolv-asset-tag-row input[type=text] { width:auto !important; max-width:200px; min-width:140px; font-size:12px; padding:3px 6px; }',
|
||||||
|
'@media (max-width:560px) {',
|
||||||
|
' .evolv-asset-chips { flex-direction:column; align-items:stretch; }',
|
||||||
|
' .evolv-asset-chip-sep { display:none; }',
|
||||||
|
' .evolv-asset-chip { width:100%; justify-content:flex-start; }',
|
||||||
|
' .evolv-asset-summary { grid-template-columns:1fr; }',
|
||||||
|
' .evolv-asset-curve { width:100%; }',
|
||||||
|
'}'
|
||||||
|
].join('\\n');
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.id = id;
|
||||||
|
style.textContent = css;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
})();
|
||||||
|
|
||||||
|
window.EVOLV.nodes.${nodeName}.assetMenu.initVisuals = function(node) {
|
||||||
|
const wizard = document.getElementById('evolv-asset-wizard');
|
||||||
|
if (!wizard) return;
|
||||||
|
|
||||||
|
const stageMap = { supplier: 'node-input-supplier', type: 'node-input-assetType', model: 'node-input-model', unit: 'node-input-unit' };
|
||||||
|
const downstreamOf = { supplier: ['type','model','unit'], type: ['model','unit'], model: ['unit'], unit: [] };
|
||||||
|
const getSelect = (stage) => document.getElementById(stageMap[stage]);
|
||||||
|
|
||||||
|
const chips = Array.from(wizard.querySelectorAll('.evolv-asset-chip'));
|
||||||
|
const combobox = document.getElementById('evolv-asset-combobox');
|
||||||
|
const search = combobox ? combobox.querySelector('.evolv-asset-combobox-search') : null;
|
||||||
|
const list = combobox ? combobox.querySelector('.evolv-asset-combobox-list') : null;
|
||||||
|
const summary = document.getElementById('evolv-asset-summary');
|
||||||
|
const specsEl = document.getElementById('evolv-asset-specs');
|
||||||
|
const curveEl = document.getElementById('evolv-asset-curve');
|
||||||
|
|
||||||
|
let activeStage = null;
|
||||||
|
let activeIndex = -1;
|
||||||
|
|
||||||
|
// Update the chip value text from the live <select>. Empty selects
|
||||||
|
// show the placeholder; populated selects show the option label.
|
||||||
|
function syncChip(stage) {
|
||||||
|
const chip = chips.find((c) => c.getAttribute('data-stage') === stage);
|
||||||
|
if (!chip) return;
|
||||||
|
const select = getSelect(stage);
|
||||||
|
const valueEl = chip.querySelector('.evolv-asset-chip-value');
|
||||||
|
const labelDefault = stage === 'supplier' ? 'Select…' : '—';
|
||||||
|
if (!select || !select.value) {
|
||||||
|
valueEl.textContent = labelDefault;
|
||||||
|
valueEl.setAttribute('data-empty', 'true');
|
||||||
|
chip.disabled = false; // stage is reachable but empty
|
||||||
|
} else {
|
||||||
|
const opt = select.options[select.selectedIndex];
|
||||||
|
valueEl.textContent = (opt && opt.textContent) ? opt.textContent : select.value;
|
||||||
|
valueEl.removeAttribute('data-empty');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncAllChips() {
|
||||||
|
['supplier','type','model','unit'].forEach(syncChip);
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshAriaSelected() {
|
||||||
|
chips.forEach((c) => c.setAttribute('aria-selected', c.getAttribute('data-stage') === activeStage ? 'true' : 'false'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCombobox() {
|
||||||
|
activeStage = null;
|
||||||
|
combobox.hidden = true;
|
||||||
|
refreshAriaSelected();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openStage(stage) {
|
||||||
|
const select = getSelect(stage);
|
||||||
|
if (!select) return;
|
||||||
|
|
||||||
|
// Skip if the parent stage hasn't been resolved (e.g. type before supplier).
|
||||||
|
// The parent select would have an empty value in that case.
|
||||||
|
const parentOrder = ['supplier','type','model','unit'];
|
||||||
|
const idx = parentOrder.indexOf(stage);
|
||||||
|
for (let i = 0; i < idx; i += 1) {
|
||||||
|
const parentSel = getSelect(parentOrder[i]);
|
||||||
|
if (!parentSel || !parentSel.value) {
|
||||||
|
if (window.RED && window.RED.notify) {
|
||||||
|
window.RED.notify('Pick ' + parentOrder[i] + ' first.', 'info');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
activeStage = stage;
|
||||||
|
combobox.hidden = false;
|
||||||
|
search.value = '';
|
||||||
|
search.placeholder = 'Filter ' + stage + '…';
|
||||||
|
renderList('');
|
||||||
|
refreshAriaSelected();
|
||||||
|
// Move focus to the search box so keyboard users get an immediate
|
||||||
|
// typing context after clicking a chip.
|
||||||
|
setTimeout(() => search.focus(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStageOptions(stage) {
|
||||||
|
const select = getSelect(stage);
|
||||||
|
if (!select) return [];
|
||||||
|
return Array.from(select.options)
|
||||||
|
.filter((o) => o.value !== '' && !o.disabled)
|
||||||
|
.map((o) => ({ value: o.value, label: o.textContent || o.value }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderList(filter) {
|
||||||
|
if (!activeStage || !list) return;
|
||||||
|
const items = getStageOptions(activeStage);
|
||||||
|
const lc = String(filter || '').toLowerCase();
|
||||||
|
const matches = items.filter((it) => it.label.toLowerCase().includes(lc) || it.value.toLowerCase().includes(lc));
|
||||||
|
list.innerHTML = '';
|
||||||
|
activeIndex = matches.length ? 0 : -1;
|
||||||
|
|
||||||
|
if (!matches.length) {
|
||||||
|
const empty = document.createElement('div');
|
||||||
|
empty.className = 'evolv-asset-combobox-empty';
|
||||||
|
empty.textContent = items.length ? 'No matches.' : 'Nothing available — pick the previous stage first.';
|
||||||
|
list.appendChild(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
matches.forEach((it, i) => {
|
||||||
|
const opt = document.createElement('div');
|
||||||
|
opt.className = 'evolv-asset-combobox-option';
|
||||||
|
if (i === 0) opt.classList.add('evolv-asset-combobox-option-active');
|
||||||
|
opt.setAttribute('role', 'option');
|
||||||
|
opt.setAttribute('data-value', it.value);
|
||||||
|
opt.textContent = it.label;
|
||||||
|
opt.addEventListener('mousedown', (e) => { e.preventDefault(); pickValue(it.value); });
|
||||||
|
opt.addEventListener('mouseenter', () => {
|
||||||
|
activeIndex = i;
|
||||||
|
list.querySelectorAll('.evolv-asset-combobox-option').forEach((el, j) => el.classList.toggle('evolv-asset-combobox-option-active', j === i));
|
||||||
|
});
|
||||||
|
list.appendChild(opt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickValue(value) {
|
||||||
|
const select = getSelect(activeStage);
|
||||||
|
if (!select) return;
|
||||||
|
// Reset downstream selects so the cascade refreshes cleanly.
|
||||||
|
(downstreamOf[activeStage] || []).forEach((s) => {
|
||||||
|
const ds = getSelect(s);
|
||||||
|
if (ds) { ds.value = ''; ds.dispatchEvent(new Event('change', { bubbles: true })); }
|
||||||
|
});
|
||||||
|
select.value = value;
|
||||||
|
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
syncAllChips();
|
||||||
|
updateSummary();
|
||||||
|
closeCombobox();
|
||||||
|
|
||||||
|
// Auto-advance to the next empty stage so the flow feels guided.
|
||||||
|
const order = ['supplier','type','model','unit'];
|
||||||
|
const i = order.indexOf(activeStage);
|
||||||
|
for (let n = i + 1; n < order.length; n += 1) {
|
||||||
|
const next = getSelect(order[n]);
|
||||||
|
if (next && (!next.value || next.options.length > 1)) {
|
||||||
|
openStage(order[n]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSummary() {
|
||||||
|
const modelSel = getSelect('model');
|
||||||
|
if (!modelSel || !modelSel.value) {
|
||||||
|
if (summary) summary.hidden = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (summary) summary.hidden = false;
|
||||||
|
|
||||||
|
// Lookup the chosen model in the menuData tree to pull metadata + previewCurve.
|
||||||
|
const data = window.EVOLV.nodes.${nodeName}.menuData.asset || {};
|
||||||
|
const categories = data.categories || {};
|
||||||
|
let chosenModel = null;
|
||||||
|
Object.keys(categories).forEach((catKey) => {
|
||||||
|
const cat = categories[catKey];
|
||||||
|
(cat.suppliers || []).forEach((sup) => (sup.types || []).forEach((t) => (t.models || []).forEach((m) => {
|
||||||
|
if (String(m.id || m.name) === String(modelSel.value)) chosenModel = m;
|
||||||
|
})));
|
||||||
|
});
|
||||||
|
|
||||||
|
renderSpecs(chosenModel);
|
||||||
|
renderCurve(chosenModel && chosenModel.previewCurve);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSpecs(model) {
|
||||||
|
if (!specsEl) return;
|
||||||
|
specsEl.innerHTML = '';
|
||||||
|
if (!model) return;
|
||||||
|
const rows = [];
|
||||||
|
if (model.name) rows.push({ key: 'Name', val: model.name });
|
||||||
|
if (model.id && model.id !== model.name) rows.push({ key: 'ID', val: model.id });
|
||||||
|
if (Array.isArray(model.units) && model.units.length) rows.push({ key: 'Units', val: model.units.join(', ') });
|
||||||
|
// Pull any leftover scalar keys (rated_kW, voltage, etc.) — heuristic.
|
||||||
|
Object.keys(model).forEach((k) => {
|
||||||
|
if (['name','id','units','previewCurve','product_model_id','product_model_uuid'].indexOf(k) >= 0) return;
|
||||||
|
const v = model[k];
|
||||||
|
if (v == null) return;
|
||||||
|
if (typeof v === 'object') return;
|
||||||
|
rows.push({ key: k, val: String(v) });
|
||||||
|
});
|
||||||
|
rows.slice(0, 5).forEach((r) => {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'evolv-asset-spec-row';
|
||||||
|
row.innerHTML = '<span class="evolv-asset-spec-key">' + r.key + '</span><span class="evolv-asset-spec-val">' + r.val + '</span>';
|
||||||
|
specsEl.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCurve(curve) {
|
||||||
|
if (!curveEl) return;
|
||||||
|
curveEl.innerHTML = '';
|
||||||
|
if (!curve || !Array.isArray(curve.x) || !Array.isArray(curve.y) || curve.x.length < 2) {
|
||||||
|
const empty = document.createElement('div');
|
||||||
|
empty.className = 'evolv-asset-curve-empty';
|
||||||
|
empty.textContent = 'no curve available';
|
||||||
|
curveEl.appendChild(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const W = 200, H = 90, P = 6;
|
||||||
|
const xs = curve.x, ys = curve.y;
|
||||||
|
const xMin = Math.min.apply(null, xs), xMax = Math.max.apply(null, xs);
|
||||||
|
const yMin = Math.min.apply(null, ys), yMax = Math.max.apply(null, ys);
|
||||||
|
const xRange = xMax - xMin || 1, yRange = yMax - yMin || 1;
|
||||||
|
const px = (x) => P + (W - 2*P) * (x - xMin) / xRange;
|
||||||
|
const py = (y) => (H - P) - (H - 2*P) * (y - yMin) / yRange;
|
||||||
|
const pts = xs.map((x, i) => px(x).toFixed(1) + ',' + py(ys[i]).toFixed(1)).join(' ');
|
||||||
|
const svg = [
|
||||||
|
'<svg viewBox="0 0 ' + W + ' ' + H + '" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">',
|
||||||
|
' <rect x="0" y="0" width="' + W + '" height="' + H + '" fill="#fff" stroke="#e5e5e5"/>',
|
||||||
|
' <polyline fill="none" stroke="#1F4E79" stroke-width="1.6" points="' + pts + '"/>',
|
||||||
|
' <g font-size="8" fill="#888" font-family="Arial, sans-serif">',
|
||||||
|
' <text x="' + P + '" y="9">' + (curve.yLabel || '') + '</text>',
|
||||||
|
' <text x="' + (W - P) + '" y="' + (H - 2) + '" text-anchor="end">' + (curve.xLabel || '') + '</text>',
|
||||||
|
(curve.legend ? '<text x="' + (W - P) + '" y="9" text-anchor="end" fill="#1F4E79">' + curve.legend + '</text>' : ''),
|
||||||
|
' </g>',
|
||||||
|
'</svg>'
|
||||||
|
].join('');
|
||||||
|
curveEl.innerHTML = svg;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Wire chip clicks + select-change → chip refresh -------------
|
||||||
|
chips.forEach((chip) => {
|
||||||
|
chip.addEventListener('click', () => {
|
||||||
|
const stage = chip.getAttribute('data-stage');
|
||||||
|
if (activeStage === stage) {
|
||||||
|
closeCombobox();
|
||||||
|
} else {
|
||||||
|
openStage(stage);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
['supplier','type','model','unit'].forEach((stage) => {
|
||||||
|
const sel = getSelect(stage);
|
||||||
|
if (sel) sel.addEventListener('change', () => { syncChip(stage); if (stage === 'model' || stage === 'unit') updateSummary(); });
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Combobox interactions -------------------------------------
|
||||||
|
if (search) {
|
||||||
|
search.addEventListener('input', () => renderList(search.value));
|
||||||
|
search.addEventListener('keydown', (e) => {
|
||||||
|
const optEls = Array.from(list.querySelectorAll('.evolv-asset-combobox-option'));
|
||||||
|
if (!optEls.length) return;
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
activeIndex = (activeIndex + 1) % optEls.length;
|
||||||
|
optEls.forEach((el, i) => el.classList.toggle('evolv-asset-combobox-option-active', i === activeIndex));
|
||||||
|
optEls[activeIndex].scrollIntoView({ block: 'nearest' });
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
activeIndex = (activeIndex - 1 + optEls.length) % optEls.length;
|
||||||
|
optEls.forEach((el, i) => el.classList.toggle('evolv-asset-combobox-option-active', i === activeIndex));
|
||||||
|
optEls[activeIndex].scrollIntoView({ block: 'nearest' });
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (activeIndex >= 0 && optEls[activeIndex]) {
|
||||||
|
pickValue(optEls[activeIndex].getAttribute('data-value'));
|
||||||
|
}
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
closeCombobox();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial render — fires after loadData has populated the natives.
|
||||||
|
syncAllChips();
|
||||||
|
updateSummary();
|
||||||
|
};
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
getClientInitCode(nodeName) {
|
getClientInitCode(nodeName) {
|
||||||
const htmlCode = this.getHtmlInjectionCode(nodeName);
|
const htmlCode = this.getHtmlInjectionCode(nodeName);
|
||||||
const dataCode = this.getDataInjectionCode(nodeName);
|
const dataCode = this.getDataInjectionCode(nodeName);
|
||||||
const eventsCode = this.getEventInjectionCode(nodeName);
|
const eventsCode = this.getEventInjectionCode(nodeName);
|
||||||
const syncCode = this.getSyncInjectionCode(nodeName);
|
const syncCode = this.getSyncInjectionCode(nodeName);
|
||||||
const saveCode = this.getSaveInjectionCode(nodeName);
|
const saveCode = this.getSaveInjectionCode(nodeName);
|
||||||
|
const visualCode = this.getVisualInjectionCode(nodeName);
|
||||||
|
|
||||||
return `
|
return `
|
||||||
// --- AssetMenu for ${nodeName} ---
|
// --- AssetMenu for ${nodeName} ---
|
||||||
@@ -93,14 +458,19 @@ class AssetMenu {
|
|||||||
${eventsCode}
|
${eventsCode}
|
||||||
${syncCode}
|
${syncCode}
|
||||||
${saveCode}
|
${saveCode}
|
||||||
|
${visualCode}
|
||||||
|
|
||||||
window.EVOLV.nodes.${nodeName}.assetMenu.initEditor = function(node) {
|
window.EVOLV.nodes.${nodeName}.assetMenu.initEditor = function(node) {
|
||||||
console.log('Initializing asset properties for ${nodeName}');
|
console.log('Initializing asset properties for ${nodeName}');
|
||||||
this.injectHtml();
|
this.injectHtml();
|
||||||
this.wireEvents(node);
|
this.wireEvents(node);
|
||||||
this.loadData(node).catch((error) =>
|
const self = this;
|
||||||
console.error('Asset menu load failed:', error)
|
this.loadData(node)
|
||||||
);
|
.then(() => { if (self.initVisuals) self.initVisuals(node); })
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Asset menu load failed:', error);
|
||||||
|
if (self.initVisuals) self.initVisuals(node);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -253,6 +623,26 @@ class AssetMenu {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const suppliers = activeCategory ? activeCategory.suppliers : [];
|
const suppliers = activeCategory ? activeCategory.suppliers : [];
|
||||||
|
|
||||||
|
// The save handler intentionally discards node.supplier / node.assetType
|
||||||
|
// (denormalized copies of registry data — only node.model + node.unit
|
||||||
|
// are persisted identity). So on reopen we re-derive them from the
|
||||||
|
// saved model id by walking the registry tree. Without this the
|
||||||
|
// cascade always boots at "Select..." even when a model is saved.
|
||||||
|
if (node.model && (!node.supplier || !node.assetType)) {
|
||||||
|
for (const supplier of suppliers) {
|
||||||
|
const match = (supplier.types || []).find((type) =>
|
||||||
|
(type.models || []).some((model) =>
|
||||||
|
String(model.id || model.name) === String(node.model))
|
||||||
|
);
|
||||||
|
if (match) {
|
||||||
|
node.supplier = supplier.id || supplier.name;
|
||||||
|
node.assetType = match.id || match.name;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
populate(
|
populate(
|
||||||
elems.supplier,
|
elems.supplier,
|
||||||
suppliers,
|
suppliers,
|
||||||
@@ -577,35 +967,165 @@ class AssetMenu {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getHtmlTemplate() {
|
getHtmlTemplate() {
|
||||||
|
// Wizard layout:
|
||||||
|
// 1. Section heading + chip strip (Supplier › Type › Model › Unit).
|
||||||
|
// Chips are clickable buttons; clicking re-opens that stage's combobox
|
||||||
|
// and resets everything to its right.
|
||||||
|
// 2. Active-stage combobox: search input + filtered option list.
|
||||||
|
// 3. Spec strip + curve mini-chart (visible once a Model is picked).
|
||||||
|
// 4. Asset Tag row (still read-only, auto-resolved by syncAsset).
|
||||||
|
// 5. Hidden native <select>s (canonical save targets — Node-RED reads
|
||||||
|
// these on save; chip clicks mirror values into them).
|
||||||
return `
|
return `
|
||||||
<!-- Asset Properties -->
|
|
||||||
<hr />
|
<hr />
|
||||||
<h3>Asset selection</h3>
|
<h3>Asset selection</h3>
|
||||||
<div class="form-row">
|
<div class="evolv-asset-wizard" id="evolv-asset-wizard">
|
||||||
<label for="node-input-supplier"><i class="fa fa-industry"></i> Supplier</label>
|
<div class="evolv-asset-chips" role="tablist" aria-label="Asset selection stages">
|
||||||
<select id="node-input-supplier" style="width:70%;"></select>
|
<button type="button" class="evolv-asset-chip" data-stage="supplier" aria-selected="false">
|
||||||
|
<span class="evolv-asset-chip-icon"><i class="fa fa-industry"></i></span>
|
||||||
|
<span class="evolv-asset-chip-text">
|
||||||
|
<span class="evolv-asset-chip-label">Supplier</span>
|
||||||
|
<span class="evolv-asset-chip-value" data-empty="true">Select…</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<span class="evolv-asset-chip-sep" aria-hidden="true">›</span>
|
||||||
|
<button type="button" class="evolv-asset-chip" data-stage="type" aria-selected="false">
|
||||||
|
<span class="evolv-asset-chip-icon"><i class="fa fa-puzzle-piece"></i></span>
|
||||||
|
<span class="evolv-asset-chip-text">
|
||||||
|
<span class="evolv-asset-chip-label">Type</span>
|
||||||
|
<span class="evolv-asset-chip-value" data-empty="true">—</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<span class="evolv-asset-chip-sep" aria-hidden="true">›</span>
|
||||||
|
<button type="button" class="evolv-asset-chip" data-stage="model" aria-selected="false">
|
||||||
|
<span class="evolv-asset-chip-icon"><i class="fa fa-wrench"></i></span>
|
||||||
|
<span class="evolv-asset-chip-text">
|
||||||
|
<span class="evolv-asset-chip-label">Model</span>
|
||||||
|
<span class="evolv-asset-chip-value" data-empty="true">—</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<span class="evolv-asset-chip-sep" aria-hidden="true">›</span>
|
||||||
|
<button type="button" class="evolv-asset-chip" data-stage="unit" aria-selected="false">
|
||||||
|
<span class="evolv-asset-chip-icon"><i class="fa fa-balance-scale"></i></span>
|
||||||
|
<span class="evolv-asset-chip-text">
|
||||||
|
<span class="evolv-asset-chip-label">Unit</span>
|
||||||
|
<span class="evolv-asset-chip-value" data-empty="true">—</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
|
||||||
<label for="node-input-assetType"><i class="fa fa-puzzle-piece"></i> Type</label>
|
<div class="evolv-asset-combobox" id="evolv-asset-combobox" hidden>
|
||||||
<select id="node-input-assetType" style="width:70%;"></select>
|
<input type="text" class="evolv-asset-combobox-search" placeholder="Type to filter…" autocomplete="off" />
|
||||||
|
<div class="evolv-asset-combobox-list" role="listbox"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
|
||||||
<label for="node-input-model"><i class="fa fa-wrench"></i> Model</label>
|
<div class="evolv-asset-summary" id="evolv-asset-summary" hidden>
|
||||||
<select id="node-input-model" style="width:70%;"></select>
|
<div class="evolv-asset-specs" id="evolv-asset-specs"></div>
|
||||||
|
<div class="evolv-asset-curve" id="evolv-asset-curve"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
|
||||||
<label for="node-input-unit"><i class="fa fa-balance-scale"></i> Unit</label>
|
<div class="form-row evolv-asset-tag-row">
|
||||||
<select id="node-input-unit" style="width:70%;"></select>
|
|
||||||
</div>
|
|
||||||
<div class="form-row">
|
|
||||||
<label for="node-input-assetTagNumber"><i class="fa fa-hashtag"></i> Asset Tag</label>
|
<label for="node-input-assetTagNumber"><i class="fa fa-hashtag"></i> Asset Tag</label>
|
||||||
<input type="text" id="node-input-assetTagNumber" readonly style="width:70%;" />
|
<input type="text" id="node-input-assetTagNumber" readonly />
|
||||||
<div class="form-tips" id="node-input-assetTagNumber-hint">Not registered yet</div>
|
<div class="form-tips" id="node-input-assetTagNumber-hint">Not registered yet</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="evolv-asset-hidden-natives" aria-hidden="true">
|
||||||
|
<select id="node-input-supplier"></select>
|
||||||
|
<select id="node-input-assetType"></select>
|
||||||
|
<select id="node-input-model"></select>
|
||||||
|
<select id="node-input-unit"></select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<hr />
|
<hr />
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build a slim preview curve `{x[], y[], xLabel, yLabel}` per model so the
|
||||||
|
// editor wizard can render a sparkline without round-tripping. Picks a
|
||||||
|
// representative slice for each software type's curve format.
|
||||||
|
buildPreviewCurve(softwareType, modelId, modelName) {
|
||||||
|
if (!modelId && !modelName) return null;
|
||||||
|
let loadCurve;
|
||||||
|
try {
|
||||||
|
// Lazy require — keep AssetMenu importable in environments that don't
|
||||||
|
// ship the curves dataset (e.g. unit tests with mocked managers).
|
||||||
|
loadCurve = require('../../index.js').loadCurve;
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (typeof loadCurve !== 'function') return null;
|
||||||
|
|
||||||
|
// Try id first, then name (legacy curve files are named after the
|
||||||
|
// model name rather than id — e.g. ECDV.json).
|
||||||
|
let curve = null;
|
||||||
|
try { curve = loadCurve(modelId) || (modelName ? loadCurve(modelName) : null); } catch (e) { curve = null; }
|
||||||
|
if (!curve) return null;
|
||||||
|
|
||||||
|
const type = String(softwareType || '').toLowerCase();
|
||||||
|
|
||||||
|
// Helpers — pick a "middle" key from an object whose keys are numeric strings.
|
||||||
|
const middleKey = (obj) => {
|
||||||
|
const keys = Object.keys(obj || {});
|
||||||
|
if (!keys.length) return null;
|
||||||
|
const sorted = keys.slice().sort((a, b) => Number(a) - Number(b));
|
||||||
|
return sorted[Math.floor(sorted.length / 2)];
|
||||||
|
};
|
||||||
|
const maxKey = (obj) => {
|
||||||
|
const keys = Object.keys(obj || {});
|
||||||
|
if (!keys.length) return null;
|
||||||
|
return keys.slice().sort((a, b) => Number(b) - Number(a))[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (type === 'rotatingmachine') {
|
||||||
|
// { np: { rpm: { x:[%speed], y:[..] } } } — pick top RPM slice.
|
||||||
|
const np = curve.np || curve;
|
||||||
|
const rpm = maxKey(np);
|
||||||
|
if (!rpm || !np[rpm] || !Array.isArray(np[rpm].x)) return null;
|
||||||
|
return {
|
||||||
|
x: np[rpm].x.slice(),
|
||||||
|
y: np[rpm].y.slice(),
|
||||||
|
xLabel: 'Speed (%)',
|
||||||
|
yLabel: 'Power',
|
||||||
|
legend: rpm + ' rpm'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (type === 'valve') {
|
||||||
|
// { density: { dp: { x:[%opening], y:[m3/h] } } } — pick mid density/dp.
|
||||||
|
const densityKey = middleKey(curve);
|
||||||
|
if (!densityKey) return null;
|
||||||
|
const dpMap = curve[densityKey] || {};
|
||||||
|
const dpKey = middleKey(dpMap);
|
||||||
|
if (!dpKey || !dpMap[dpKey] || !Array.isArray(dpMap[dpKey].x)) return null;
|
||||||
|
return {
|
||||||
|
x: dpMap[dpKey].x.slice(),
|
||||||
|
y: dpMap[dpKey].y.slice(),
|
||||||
|
xLabel: 'Opening (%)',
|
||||||
|
yLabel: 'Flow (m³/h)',
|
||||||
|
legend: 'ρ=' + densityKey + ' · Δp=' + dpKey
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (type === 'diffuser') {
|
||||||
|
// { sote_curve: { coverage: { x:[flux], y:[%] } }, ... } — pick mid coverage on sote_curve.
|
||||||
|
const sote = curve.sote_curve || curve.SOTE_curve || curve;
|
||||||
|
const covKey = middleKey(sote);
|
||||||
|
if (!covKey || !sote[covKey] || !Array.isArray(sote[covKey].x)) return null;
|
||||||
|
return {
|
||||||
|
x: sote[covKey].x.slice(),
|
||||||
|
y: sote[covKey].y.slice(),
|
||||||
|
xLabel: 'Flux (Nm³/h·m²)',
|
||||||
|
yLabel: 'SOTE (%)',
|
||||||
|
legend: covKey + '% coverage'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// measurement + unknowns: no representative curve yet.
|
||||||
|
return null;
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
getHtmlInjectionCode(nodeName) {
|
getHtmlInjectionCode(nodeName) {
|
||||||
const htmlTemplate = this.getHtmlTemplate()
|
const htmlTemplate = this.getHtmlTemplate()
|
||||||
.replace(/`/g, '\\`')
|
.replace(/`/g, '\\`')
|
||||||
@@ -624,46 +1144,40 @@ class AssetMenu {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getSaveInjectionCode(nodeName) {
|
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 `
|
return `
|
||||||
// Asset save handler for ${nodeName}
|
// Asset save handler for ${nodeName}
|
||||||
window.EVOLV.nodes.${nodeName}.assetMenu.saveEditor = function(node) {
|
window.EVOLV.nodes.${nodeName}.assetMenu.saveEditor = function(node) {
|
||||||
console.log('Saving asset properties for ${nodeName}');
|
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 = [];
|
const errors = [];
|
||||||
|
|
||||||
fields.forEach((field) => {
|
const modelEl = document.getElementById('node-input-model');
|
||||||
const el = document.getElementById(\`node-input-\${field}\`);
|
const unitEl = document.getElementById('node-input-unit');
|
||||||
node[field] = el ? el.value : '';
|
const tagEl = document.getElementById('node-input-assetTagNumber');
|
||||||
});
|
|
||||||
|
|
||||||
if (node.assetType && !node.unit) {
|
node.model = modelEl ? modelEl.value : '';
|
||||||
errors.push('Unit must be set when a type is specified.');
|
node.unit = unitEl ? unitEl.value : '';
|
||||||
}
|
node.assetTagNumber = tagEl ? tagEl.value : '';
|
||||||
if (!node.unit) {
|
|
||||||
errors.push('Unit is required.');
|
// 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'));
|
errors.forEach((msg) => RED.notify(msg, 'error'));
|
||||||
|
|
||||||
const saved = fields.reduce((acc, field) => {
|
const saved = { model: node.model, unit: node.unit, assetTagNumber: node.assetTagNumber };
|
||||||
acc[field] = node[field];
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
if (node.modelMetadata && typeof node.modelMetadata.id !== 'undefined') {
|
if (node.modelMetadata && typeof node.modelMetadata.id !== 'undefined') {
|
||||||
saved.modelId = node.modelMetadata.id;
|
saved.modelId = node.modelMetadata.id;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,243 +0,0 @@
|
|||||||
// asset.js
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
|
|
||||||
class AssetMenu {
|
|
||||||
/** Define path where to find data of assets in constructor for now */
|
|
||||||
constructor(relPath = '../../datasets/assetData') {
|
|
||||||
this.baseDir = path.resolve(__dirname, relPath);
|
|
||||||
this.assetData = this._loadJSON('assetData');
|
|
||||||
}
|
|
||||||
|
|
||||||
_loadJSON(...segments) {
|
|
||||||
const filePath = path.resolve(this.baseDir, ...segments) + '.json';
|
|
||||||
try {
|
|
||||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
||||||
} catch (err) {
|
|
||||||
throw new Error(`Failed to load ${filePath}: ${err.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ADD THIS METHOD
|
|
||||||
* Compiles all menu data from the file system into a single nested object.
|
|
||||||
* This is run once on the server to pre-load everything.
|
|
||||||
* @returns {object} A comprehensive object with all menu options.
|
|
||||||
*/
|
|
||||||
getAllMenuData() {
|
|
||||||
// load the raw JSON once
|
|
||||||
const data = this._loadJSON('assetData');
|
|
||||||
const allData = {};
|
|
||||||
|
|
||||||
data.suppliers.forEach(sup => {
|
|
||||||
allData[sup.name] = {};
|
|
||||||
sup.categories.forEach(cat => {
|
|
||||||
allData[sup.name][cat.name] = {};
|
|
||||||
cat.types.forEach(type => {
|
|
||||||
// here: store the full array of model objects, not just names
|
|
||||||
allData[sup.name][cat.name][type.name] = type.models;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return allData;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert the static initEditor function to a string that can be served to the client
|
|
||||||
* @param {string} nodeName - The name of the node type
|
|
||||||
* @returns {string} JavaScript code as a string
|
|
||||||
*/
|
|
||||||
getClientInitCode(nodeName) {
|
|
||||||
// step 1: get the two helper strings
|
|
||||||
const htmlCode = this.getHtmlInjectionCode(nodeName);
|
|
||||||
const dataCode = this.getDataInjectionCode(nodeName);
|
|
||||||
const eventsCode = this.getEventInjectionCode(nodeName);
|
|
||||||
const saveCode = this.getSaveInjectionCode(nodeName);
|
|
||||||
|
|
||||||
|
|
||||||
return `
|
|
||||||
// --- AssetMenu for ${nodeName} ---
|
|
||||||
window.EVOLV.nodes.${nodeName}.assetMenu =
|
|
||||||
window.EVOLV.nodes.${nodeName}.assetMenu || {};
|
|
||||||
|
|
||||||
${htmlCode}
|
|
||||||
${dataCode}
|
|
||||||
${eventsCode}
|
|
||||||
${saveCode}
|
|
||||||
|
|
||||||
// wire it all up when the editor loads
|
|
||||||
window.EVOLV.nodes.${nodeName}.assetMenu.initEditor = function(node) {
|
|
||||||
// ------------------ BELOW sequence is important! -------------------------------
|
|
||||||
console.log('Initializing asset properties for ${nodeName}…');
|
|
||||||
this.injectHtml();
|
|
||||||
// load the data and wire up events
|
|
||||||
// this will populate the fields and set up the event listeners
|
|
||||||
this.wireEvents(node);
|
|
||||||
// this will load the initial data into the fields
|
|
||||||
// this is important to ensure the fields are populated correctly
|
|
||||||
this.loadData(node);
|
|
||||||
};
|
|
||||||
`;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
getDataInjectionCode(nodeName) {
|
|
||||||
return `
|
|
||||||
// Asset Data loader for ${nodeName}
|
|
||||||
window.EVOLV.nodes.${nodeName}.assetMenu.loadData = function(node) {
|
|
||||||
const data = window.EVOLV.nodes.${nodeName}.menuData.asset;
|
|
||||||
const elems = {
|
|
||||||
supplier: document.getElementById('node-input-supplier'),
|
|
||||||
category: document.getElementById('node-input-category'),
|
|
||||||
type: document.getElementById('node-input-assetType'),
|
|
||||||
model: document.getElementById('node-input-model'),
|
|
||||||
unit: document.getElementById('node-input-unit')
|
|
||||||
};
|
|
||||||
function populate(el, opts, sel) {
|
|
||||||
const old = el.value;
|
|
||||||
el.innerHTML = '<option value="">Select…</option>';
|
|
||||||
(opts||[]).forEach(o=>{
|
|
||||||
const opt = document.createElement('option');
|
|
||||||
opt.value = o; opt.textContent = o;
|
|
||||||
el.appendChild(opt);
|
|
||||||
});
|
|
||||||
el.value = sel||"";
|
|
||||||
if(el.value!==old) el.dispatchEvent(new Event('change'));
|
|
||||||
}
|
|
||||||
// initial population
|
|
||||||
populate(elems.supplier, Object.keys(data), node.supplier);
|
|
||||||
};
|
|
||||||
`
|
|
||||||
}
|
|
||||||
|
|
||||||
getEventInjectionCode(nodeName) {
|
|
||||||
return `
|
|
||||||
// Asset Event wiring for ${nodeName}
|
|
||||||
window.EVOLV.nodes.${nodeName}.assetMenu.wireEvents = function(node) {
|
|
||||||
const data = window.EVOLV.nodes.${nodeName}.menuData.asset;
|
|
||||||
const elems = {
|
|
||||||
supplier: document.getElementById('node-input-supplier'),
|
|
||||||
category: document.getElementById('node-input-category'),
|
|
||||||
type: document.getElementById('node-input-assetType'),
|
|
||||||
model: document.getElementById('node-input-model'),
|
|
||||||
unit: document.getElementById('node-input-unit')
|
|
||||||
};
|
|
||||||
function populate(el, opts, sel) {
|
|
||||||
const old = el.value;
|
|
||||||
el.innerHTML = '<option value="">Select…</option>';
|
|
||||||
(opts||[]).forEach(o=>{
|
|
||||||
const opt = document.createElement('option');
|
|
||||||
opt.value = o; opt.textContent = o;
|
|
||||||
el.appendChild(opt);
|
|
||||||
});
|
|
||||||
el.value = sel||"";
|
|
||||||
if(el.value!==old) el.dispatchEvent(new Event('change'));
|
|
||||||
}
|
|
||||||
elems.supplier.addEventListener('change', ()=>{
|
|
||||||
populate(elems.category,
|
|
||||||
elems.supplier.value? Object.keys(data[elems.supplier.value]||{}) : [],
|
|
||||||
node.category);
|
|
||||||
});
|
|
||||||
elems.category.addEventListener('change', ()=>{
|
|
||||||
const s=elems.supplier.value, c=elems.category.value;
|
|
||||||
populate(elems.type,
|
|
||||||
(s&&c)? Object.keys(data[s][c]||{}) : [],
|
|
||||||
node.assetType);
|
|
||||||
});
|
|
||||||
elems.type.addEventListener('change', ()=>{
|
|
||||||
const s=elems.supplier.value, c=elems.category.value, t=elems.type.value;
|
|
||||||
const md = (s&&c&&t)? data[s][c][t]||[] : [];
|
|
||||||
populate(elems.model, md.map(m=>m.name), node.model);
|
|
||||||
});
|
|
||||||
elems.model.addEventListener('change', ()=>{
|
|
||||||
const s=elems.supplier.value, c=elems.category.value, t=elems.type.value, m=elems.model.value;
|
|
||||||
const md = (s&&c&&t)? data[s][c][t]||[] : [];
|
|
||||||
const entry = md.find(x=>x.name===m);
|
|
||||||
populate(elems.unit, entry? entry.units : [], node.unit);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
`
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate HTML template for asset fields
|
|
||||||
*/
|
|
||||||
getHtmlTemplate() {
|
|
||||||
return `
|
|
||||||
<!-- Asset Properties -->
|
|
||||||
<hr />
|
|
||||||
<h3>Asset selection</h3>
|
|
||||||
<div class="form-row">
|
|
||||||
<label for="node-input-supplier"><i class="fa fa-industry"></i> Supplier</label>
|
|
||||||
<select id="node-input-supplier" style="width:70%;"></select>
|
|
||||||
</div>
|
|
||||||
<div class="form-row">
|
|
||||||
<label for="node-input-category"><i class="fa fa-sitemap"></i> Category</label>
|
|
||||||
<select id="node-input-category" style="width:70%;"></select>
|
|
||||||
</div>
|
|
||||||
<div class="form-row">
|
|
||||||
<label for="node-input-assetType"><i class="fa fa-puzzle-piece"></i> Type</label>
|
|
||||||
<select id="node-input-assetType" style="width:70%;"></select>
|
|
||||||
</div>
|
|
||||||
<div class="form-row">
|
|
||||||
<label for="node-input-model"><i class="fa fa-wrench"></i> Model</label>
|
|
||||||
<select id="node-input-model" style="width:70%;"></select>
|
|
||||||
</div>
|
|
||||||
<div class="form-row">
|
|
||||||
<label for="node-input-unit"><i class="fa fa-balance-scale"></i> Unit</label>
|
|
||||||
<select id="node-input-unit" style="width:70%;"></select>
|
|
||||||
</div>
|
|
||||||
<hr />
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get client-side HTML injection code
|
|
||||||
*/
|
|
||||||
getHtmlInjectionCode(nodeName) {
|
|
||||||
const htmlTemplate = this.getHtmlTemplate().replace(/`/g, '\\`').replace(/\$/g, '\\$');
|
|
||||||
|
|
||||||
return `
|
|
||||||
// Asset HTML injection for ${nodeName}
|
|
||||||
window.EVOLV.nodes.${nodeName}.assetMenu.injectHtml = function() {
|
|
||||||
const placeholder = document.getElementById('asset-fields-placeholder');
|
|
||||||
if (placeholder && !placeholder.hasChildNodes()) {
|
|
||||||
placeholder.innerHTML = \`${htmlTemplate}\`;
|
|
||||||
console.log('Asset HTML injected successfully');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the JS that injects the saveEditor function
|
|
||||||
*/
|
|
||||||
getSaveInjectionCode(nodeName) {
|
|
||||||
return `
|
|
||||||
// Asset Save injection for ${nodeName}
|
|
||||||
window.EVOLV.nodes.${nodeName}.assetMenu.saveEditor = function(node) {
|
|
||||||
console.log('Saving asset properties for ${nodeName}…');
|
|
||||||
const fields = ['supplier','category','assetType','model','unit'];
|
|
||||||
const errors = [];
|
|
||||||
fields.forEach(f => {
|
|
||||||
const el = document.getElementById(\`node-input-\${f}\`);
|
|
||||||
node[f] = el ? el.value : '';
|
|
||||||
});
|
|
||||||
if (node.assetType && !node.unit) errors.push('Unit must be set when type is specified.');
|
|
||||||
if (!node.unit) errors.push('Unit is required.');
|
|
||||||
errors.forEach(e=>RED.notify(e,'error'));
|
|
||||||
|
|
||||||
// --- DEBUG: show exactly what was saved ---
|
|
||||||
const saved = fields.reduce((o,f) => { o[f] = node[f]; return o; }, {});
|
|
||||||
console.log('→ assetMenu.saveEditor result:', saved);
|
|
||||||
|
|
||||||
return errors.length===0;
|
|
||||||
};
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = AssetMenu;
|
|
||||||
359
src/menu/iconHelpers.js
Normal file
359
src/menu/iconHelpers.js
Normal file
@@ -0,0 +1,359 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// iconHelpers.js — shared visual layer for EVOLV editor menus.
|
||||||
|
//
|
||||||
|
// The other menu modules (logger, physicalPosition, …) render their HTML
|
||||||
|
// as plain Node-RED form rows with native <select>/<input> controls. This
|
||||||
|
// module emits a single client-side helper bundle (`window.EVOLV.iconHelpers`)
|
||||||
|
// that those menus call from their `initVisuals(node)` step to upgrade the
|
||||||
|
// native controls in-place to icon cards.
|
||||||
|
//
|
||||||
|
// The native controls stay in the DOM (hidden) so Node-RED's load/save
|
||||||
|
// path is untouched — clicks on the cards mirror back into the original
|
||||||
|
// <select>/<input>.
|
||||||
|
|
||||||
|
class IconHelpers {
|
||||||
|
static getClientInitCode() {
|
||||||
|
// Single IIFE so multiple menus on the same editor session share one
|
||||||
|
// copy of the helpers + one <style> tag.
|
||||||
|
return `
|
||||||
|
window.EVOLV = window.EVOLV || {};
|
||||||
|
if (!window.EVOLV.iconHelpers) {
|
||||||
|
window.EVOLV.iconHelpers = (function () {
|
||||||
|
const BLUE = '#1F4E79';
|
||||||
|
const STEEL = '#607484';
|
||||||
|
const UNIT = '#50a8d9';
|
||||||
|
const RED = '#B03A2E';
|
||||||
|
const AMBER = '#B7791F';
|
||||||
|
|
||||||
|
// ---- CSS (injected once) -----------------------------------
|
||||||
|
const CSS_ID = 'evolv-icon-pickers-css';
|
||||||
|
if (!document.getElementById(CSS_ID)) {
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.id = CSS_ID;
|
||||||
|
style.textContent = [
|
||||||
|
'.evolv-icon-picker { display:flex; gap:6px; flex-wrap:wrap; margin:6px 0 8px 0; }',
|
||||||
|
'.evolv-icon-option {',
|
||||||
|
' width:72px; height:72px; box-sizing:border-box;',
|
||||||
|
' border:2px solid #d0d0d0; border-radius:4px; background:#fafafa;',
|
||||||
|
' padding:4px; cursor:pointer; user-select:none;',
|
||||||
|
' display:flex; flex-direction:column; align-items:center; justify-content:center; gap:2px;',
|
||||||
|
' transition:border-color 80ms ease-out, background 80ms ease-out, opacity 80ms ease-out;',
|
||||||
|
'}',
|
||||||
|
'.evolv-icon-option:hover { border-color:#86bbdd; background:#f5fafd; }',
|
||||||
|
'.evolv-icon-option:focus { outline:2px solid #1F4E79; outline-offset:2px; }',
|
||||||
|
'.evolv-icon-option-on { border-color:#50a8d9; background:#eaf4fb; }',
|
||||||
|
'.evolv-icon-glyph { width:100%; height:46px; display:flex; align-items:center; justify-content:center; }',
|
||||||
|
'.evolv-icon-option svg { width:100%; height:100%; display:block; }',
|
||||||
|
'.evolv-icon-label { font-size:10px; line-height:1; font-weight:600; color:#333; white-space:nowrap; letter-spacing:0; }',
|
||||||
|
'.evolv-icon-option:not(.evolv-icon-option-on) .evolv-icon-label { color:#888; }',
|
||||||
|
'.evolv-icon-option-on .evolv-off-cross { display:none; }',
|
||||||
|
'.evolv-native-hidden { position:absolute !important; opacity:0 !important; width:1px !important; height:1px !important; pointer-events:none !important; }',
|
||||||
|
'.evolv-native-row-compact label { display:none; }',
|
||||||
|
'.evolv-compact-row { display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin:6px 0 8px 0; }',
|
||||||
|
'.evolv-log-toggle:not(.evolv-icon-option-on) svg .evolv-log-symbol,',
|
||||||
|
'.evolv-distance-toggle:not(.evolv-icon-option-on) svg .evolv-ruler-body { opacity:0.45; filter:grayscale(1); }',
|
||||||
|
].join('\\n');
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- SVG library (inline, no external assets) --------------
|
||||||
|
const SVG = {
|
||||||
|
error: \`
|
||||||
|
<svg viewBox="0 0 80 58" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||||
|
<rect x="18" y="11" width="44" height="36" rx="3" fill="#fff" stroke="\${STEEL}" stroke-width="2.4"/>
|
||||||
|
<circle cx="40" cy="29" r="13" fill="#fff" stroke="\${RED}" stroke-width="3"/>
|
||||||
|
<line x1="34" y1="23" x2="46" y2="35" stroke="\${RED}" stroke-width="3.4" stroke-linecap="round"/>
|
||||||
|
<line x1="46" y1="23" x2="34" y2="35" stroke="\${RED}" stroke-width="3.4" stroke-linecap="round"/>
|
||||||
|
</svg>\`,
|
||||||
|
warn: \`
|
||||||
|
<svg viewBox="0 0 80 58" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||||
|
<rect x="18" y="11" width="44" height="36" rx="3" fill="#fff" stroke="\${STEEL}" stroke-width="2.4"/>
|
||||||
|
<path d="M40 17 L54 41 H26 Z" fill="#fff" stroke="\${AMBER}" stroke-width="3" stroke-linejoin="round"/>
|
||||||
|
<line x1="40" y1="25" x2="40" y2="33" stroke="\${AMBER}" stroke-width="3" stroke-linecap="round"/>
|
||||||
|
<circle cx="40" cy="38" r="2.2" fill="\${AMBER}"/>
|
||||||
|
</svg>\`,
|
||||||
|
info: \`
|
||||||
|
<svg viewBox="0 0 80 58" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||||
|
<rect x="18" y="11" width="44" height="36" rx="3" fill="#fff" stroke="\${STEEL}" stroke-width="2.4"/>
|
||||||
|
<circle cx="40" cy="29" r="13" fill="#fff" stroke="\${BLUE}" stroke-width="3"/>
|
||||||
|
<line x1="40" y1="28" x2="40" y2="37" stroke="\${BLUE}" stroke-width="3.2" stroke-linecap="round"/>
|
||||||
|
<circle cx="40" cy="22" r="2.4" fill="\${BLUE}"/>
|
||||||
|
</svg>\`,
|
||||||
|
debug: \`
|
||||||
|
<svg viewBox="0 0 80 58" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||||
|
<rect x="13" y="12" width="54" height="34" rx="3" fill="#fff" stroke="\${STEEL}" stroke-width="2.4"/>
|
||||||
|
<path d="M20 34 H29 L33 24 L40 39 L46 29 H59" fill="none" stroke="\${BLUE}" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<circle cx="59" cy="29" r="3" fill="\${UNIT}" stroke="#fff" stroke-width="1"/>
|
||||||
|
</svg>\`,
|
||||||
|
logToggle: \`
|
||||||
|
<svg viewBox="0 0 80 58" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||||
|
<g class="evolv-log-symbol">
|
||||||
|
<rect x="10" y="10" width="60" height="38" rx="3" fill="#1F2933" stroke="\${STEEL}" stroke-width="2.4"/>
|
||||||
|
<path d="M18 22 L26 29 L18 36" fill="none" stroke="#7ED957" stroke-width="2.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<line x1="30" y1="38" x2="50" y2="38" stroke="#7ED957" stroke-width="2.6" stroke-linecap="round"/>
|
||||||
|
</g>
|
||||||
|
<g class="evolv-off-cross" stroke="\${RED}" stroke-width="4.5" stroke-linecap="round">
|
||||||
|
<line x1="14" y1="12" x2="66" y2="46"/>
|
||||||
|
</g>
|
||||||
|
</svg>\`,
|
||||||
|
// Position icons — depict the PARENT equipment (pump volute +
|
||||||
|
// motor stub) plus a sensor marker located in the suction pipe
|
||||||
|
// (upstream), atop the equipment (atEquipment), or in the
|
||||||
|
// discharge pipe (downstream). Flow direction: left → right.
|
||||||
|
upstream: \`
|
||||||
|
<svg viewBox="0 0 80 58" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||||
|
<!-- suction pipe + flow arrow -->
|
||||||
|
<rect x="2" y="26" width="40" height="10" fill="#dde7f0" stroke="\${BLUE}" stroke-width="1.6"/>
|
||||||
|
<line x1="6" y1="31" x2="34" y2="31" stroke="\${BLUE}" stroke-width="1.6"/>
|
||||||
|
<polygon points="32,27 32,35 39,31" fill="\${BLUE}"/>
|
||||||
|
<!-- sensor marker on suction pipe -->
|
||||||
|
<line x1="20" y1="14" x2="20" y2="26" stroke="\${RED}" stroke-width="1.8"/>
|
||||||
|
<circle cx="20" cy="11" r="5" fill="#fff" stroke="\${RED}" stroke-width="2"/>
|
||||||
|
<circle cx="20" cy="11" r="1.6" fill="\${RED}"/>
|
||||||
|
<!-- pump (volute) + impeller hint + motor stub -->
|
||||||
|
<circle cx="60" cy="31" r="13" fill="#fff" stroke="\${STEEL}" stroke-width="2"/>
|
||||||
|
<path d="M 60 22 Q 68 26 68 31 Q 68 36 60 40 Q 52 36 52 31 Q 52 26 60 22" fill="none" stroke="#86bbdd" stroke-width="1.3"/>
|
||||||
|
<rect x="55" y="13" width="10" height="6" fill="\${STEEL}" stroke="#333" stroke-width="0.8"/>
|
||||||
|
</svg>\`,
|
||||||
|
atEquipment: \`
|
||||||
|
<svg viewBox="0 0 80 58" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||||
|
<!-- inlet stub -->
|
||||||
|
<rect x="2" y="26" width="22" height="10" fill="#dde7f0" stroke="\${BLUE}" stroke-width="1.6"/>
|
||||||
|
<line x1="4" y1="31" x2="20" y2="31" stroke="\${BLUE}" stroke-width="1.6"/>
|
||||||
|
<polygon points="18,27 18,35 24,31" fill="\${BLUE}"/>
|
||||||
|
<!-- outlet stub -->
|
||||||
|
<rect x="56" y="26" width="22" height="10" fill="#dde7f0" stroke="\${BLUE}" stroke-width="1.6"/>
|
||||||
|
<line x1="58" y1="31" x2="74" y2="31" stroke="\${BLUE}" stroke-width="1.6"/>
|
||||||
|
<polygon points="72,27 72,35 78,31" fill="\${BLUE}"/>
|
||||||
|
<!-- pump (volute) + impeller hint -->
|
||||||
|
<circle cx="40" cy="31" r="13" fill="#fff" stroke="\${STEEL}" stroke-width="2"/>
|
||||||
|
<path d="M 40 22 Q 48 26 48 31 Q 48 36 40 40 Q 32 36 32 31 Q 32 26 40 22" fill="none" stroke="#86bbdd" stroke-width="1.3"/>
|
||||||
|
<!-- sensor marker AT equipment (top, on the volute itself) -->
|
||||||
|
<line x1="40" y1="6" x2="40" y2="18" stroke="\${RED}" stroke-width="1.8"/>
|
||||||
|
<circle cx="40" cy="6" r="5" fill="#fff" stroke="\${RED}" stroke-width="2"/>
|
||||||
|
<circle cx="40" cy="6" r="1.6" fill="\${RED}"/>
|
||||||
|
</svg>\`,
|
||||||
|
downstream: \`
|
||||||
|
<svg viewBox="0 0 80 58" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||||
|
<!-- pump (volute) + impeller hint + motor stub -->
|
||||||
|
<circle cx="20" cy="31" r="13" fill="#fff" stroke="\${STEEL}" stroke-width="2"/>
|
||||||
|
<path d="M 20 22 Q 28 26 28 31 Q 28 36 20 40 Q 12 36 12 31 Q 12 26 20 22" fill="none" stroke="#86bbdd" stroke-width="1.3"/>
|
||||||
|
<rect x="15" y="13" width="10" height="6" fill="\${STEEL}" stroke="#333" stroke-width="0.8"/>
|
||||||
|
<!-- discharge pipe + flow arrow -->
|
||||||
|
<rect x="38" y="26" width="40" height="10" fill="#dde7f0" stroke="\${BLUE}" stroke-width="1.6"/>
|
||||||
|
<line x1="42" y1="31" x2="70" y2="31" stroke="\${BLUE}" stroke-width="1.6"/>
|
||||||
|
<polygon points="68,27 68,35 75,31" fill="\${BLUE}"/>
|
||||||
|
<!-- sensor marker on discharge pipe -->
|
||||||
|
<line x1="60" y1="14" x2="60" y2="26" stroke="\${RED}" stroke-width="1.8"/>
|
||||||
|
<circle cx="60" cy="11" r="5" fill="#fff" stroke="\${RED}" stroke-width="2"/>
|
||||||
|
<circle cx="60" cy="11" r="1.6" fill="\${RED}"/>
|
||||||
|
</svg>\`,
|
||||||
|
// Output-format icons — used by the shared
|
||||||
|
// renderOutputFormatPicker helper so every node renders the
|
||||||
|
// process/json/csv/influxdb dropdowns with the same visuals.
|
||||||
|
outputProcess: \`
|
||||||
|
<svg viewBox="0 0 80 58" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||||
|
<rect x="10" y="14" width="20" height="30" rx="2" fill="#f7fafc" stroke="\${STEEL}" stroke-width="2.4"/>
|
||||||
|
<rect x="50" y="14" width="20" height="30" rx="2" fill="#f7fafc" stroke="\${STEEL}" stroke-width="2.4"/>
|
||||||
|
<line x1="30" y1="29" x2="46" y2="29" stroke="\${BLUE}" stroke-width="3" stroke-linecap="round"/>
|
||||||
|
<path d="M42 24 L48 29 L42 34" fill="none" stroke="\${BLUE}" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>\`,
|
||||||
|
outputJson: \`
|
||||||
|
<svg viewBox="0 0 80 58" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||||
|
<g fill="none" stroke="\${BLUE}" stroke-width="3.4" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M30 14 C22 16 22 26 27 29 C22 32 22 42 30 44"/>
|
||||||
|
<path d="M50 14 C58 16 58 26 53 29 C58 32 58 42 50 44"/>
|
||||||
|
</g>
|
||||||
|
<g fill="\${STEEL}">
|
||||||
|
<circle cx="36" cy="29" r="2.2"/>
|
||||||
|
<circle cx="44" cy="29" r="2.2"/>
|
||||||
|
</g>
|
||||||
|
</svg>\`,
|
||||||
|
outputCsv: \`
|
||||||
|
<svg viewBox="0 0 80 58" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||||
|
<rect x="12" y="12" width="56" height="34" rx="2" fill="#fff" stroke="\${STEEL}" stroke-width="2.4"/>
|
||||||
|
<line x1="12" y1="22" x2="68" y2="22" stroke="\${STEEL}" stroke-width="2"/>
|
||||||
|
<g stroke="\${STEEL}" stroke-width="1.6">
|
||||||
|
<line x1="12" y1="34" x2="68" y2="34"/>
|
||||||
|
<line x1="31" y1="12" x2="31" y2="46"/>
|
||||||
|
<line x1="49" y1="12" x2="49" y2="46"/>
|
||||||
|
</g>
|
||||||
|
</svg>\`,
|
||||||
|
outputInflux: \`
|
||||||
|
<svg viewBox="0 0 80 58" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||||
|
<ellipse cx="40" cy="15" rx="22" ry="6" fill="#f7fafc" stroke="\${STEEL}" stroke-width="2.4"/>
|
||||||
|
<path d="M18 15 V42 C18 46 28 49 40 49 C52 49 62 46 62 42 V15" fill="#f7fafc" stroke="\${STEEL}" stroke-width="2.4"/>
|
||||||
|
<path d="M18 28 C26 32 54 32 62 28" fill="none" stroke="\${STEEL}" stroke-width="1.6" opacity="0.6"/>
|
||||||
|
<path d="M22 39 L30 32 L38 41 L46 34 L54 38" fill="none" stroke="\${BLUE}" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>\`,
|
||||||
|
distance: \`
|
||||||
|
<svg viewBox="0 0 80 58" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||||
|
<g class="evolv-ruler-body">
|
||||||
|
<rect x="12" y="22" width="56" height="14" rx="1.5" fill="#fff" stroke="\${STEEL}" stroke-width="2.4"/>
|
||||||
|
<g stroke="\${STEEL}" stroke-width="1.8" stroke-linecap="round">
|
||||||
|
<line x1="20" y1="22" x2="20" y2="30"/>
|
||||||
|
<line x1="28" y1="22" x2="28" y2="27"/>
|
||||||
|
<line x1="36" y1="22" x2="36" y2="30"/>
|
||||||
|
<line x1="44" y1="22" x2="44" y2="27"/>
|
||||||
|
<line x1="52" y1="22" x2="52" y2="30"/>
|
||||||
|
<line x1="60" y1="22" x2="60" y2="27"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g class="evolv-off-cross" stroke="\${RED}" stroke-width="4.5" stroke-linecap="round">
|
||||||
|
<line x1="16" y1="14" x2="64" y2="46"/>
|
||||||
|
</g>
|
||||||
|
</svg>\`,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Helpers -----------------------------------------------
|
||||||
|
function dispatchChange(el) {
|
||||||
|
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderSelectPicker: replace a native <select> with a row of
|
||||||
|
// icon cards. labels object maps option.value → display string.
|
||||||
|
function renderSelectPicker(select, holder, icons, labels) {
|
||||||
|
if (!select || !holder || holder.dataset.evolvReady === '1') return;
|
||||||
|
holder.dataset.evolvReady = '1';
|
||||||
|
select.classList.add('evolv-native-hidden');
|
||||||
|
|
||||||
|
const options = Array.from(select.options).map((option) => ({
|
||||||
|
value: option.value,
|
||||||
|
title: option.textContent || option.value,
|
||||||
|
label: (labels && labels[option.value]) || option.textContent || option.value,
|
||||||
|
svg: icons[option.value],
|
||||||
|
})).filter((option) => option.svg);
|
||||||
|
|
||||||
|
holder.innerHTML = options.map((option) => (
|
||||||
|
'<div class="evolv-icon-option" data-value="' + option.value + '" role="radio" tabindex="0"' +
|
||||||
|
' aria-label="' + option.title + '" aria-checked="false" title="' + option.title + '">' +
|
||||||
|
' <div class="evolv-icon-glyph">' + option.svg + '</div>' +
|
||||||
|
' <div class="evolv-icon-label">' + option.label + '</div>' +
|
||||||
|
'</div>'
|
||||||
|
)).join('');
|
||||||
|
|
||||||
|
const buttons = Array.from(holder.querySelectorAll('.evolv-icon-option'));
|
||||||
|
function sync() {
|
||||||
|
const current = select.value || (options[0] && options[0].value) || '';
|
||||||
|
for (const button of buttons) {
|
||||||
|
const on = button.getAttribute('data-value') === current;
|
||||||
|
button.classList.toggle('evolv-icon-option-on', on);
|
||||||
|
button.setAttribute('aria-checked', String(on));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function pick(value) {
|
||||||
|
select.value = value;
|
||||||
|
dispatchChange(select);
|
||||||
|
sync();
|
||||||
|
}
|
||||||
|
for (const button of buttons) {
|
||||||
|
button.addEventListener('click', () => pick(button.getAttribute('data-value')));
|
||||||
|
button.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === ' ' || event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
pick(button.getAttribute('data-value'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
select.addEventListener('change', sync);
|
||||||
|
sync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderToggle: replace a checkbox with a single icon card whose
|
||||||
|
// label flips between {on, off}. Passing a string for label
|
||||||
|
// uses the same string for both states.
|
||||||
|
function renderToggle(checkbox, holder, svg, label) {
|
||||||
|
if (!checkbox || !holder || holder.dataset.evolvReady === '1') return;
|
||||||
|
holder.dataset.evolvReady = '1';
|
||||||
|
checkbox.classList.add('evolv-native-hidden');
|
||||||
|
const labels = typeof label === 'string' ? { on: label, off: label } : label;
|
||||||
|
holder.innerHTML =
|
||||||
|
'<div class="evolv-icon-glyph">' + svg + '</div>' +
|
||||||
|
'<div class="evolv-icon-label">' + labels.off + '</div>';
|
||||||
|
const labelEl = holder.querySelector('.evolv-icon-label');
|
||||||
|
|
||||||
|
function sync() {
|
||||||
|
const on = checkbox.checked;
|
||||||
|
holder.classList.toggle('evolv-icon-option-on', on);
|
||||||
|
holder.setAttribute('aria-checked', String(on));
|
||||||
|
if (labelEl) labelEl.textContent = on ? labels.on : labels.off;
|
||||||
|
}
|
||||||
|
function toggle() {
|
||||||
|
checkbox.checked = !checkbox.checked;
|
||||||
|
dispatchChange(checkbox);
|
||||||
|
sync();
|
||||||
|
}
|
||||||
|
holder.addEventListener('click', toggle);
|
||||||
|
holder.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === ' ' || event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
toggle();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
checkbox.addEventListener('change', sync);
|
||||||
|
sync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderOutputFormatPicker: shared widget for the process &
|
||||||
|
// dbase output-format <select>s carried by most EVOLV nodes.
|
||||||
|
// Encapsulates the icon set + labels so every node renders the
|
||||||
|
// same visuals. Pass the native <select> and an empty holder
|
||||||
|
// <div class="evolv-icon-picker">.
|
||||||
|
const OUTPUT_FORMAT_ICONS = {
|
||||||
|
process: SVG.outputProcess,
|
||||||
|
json: SVG.outputJson,
|
||||||
|
csv: SVG.outputCsv,
|
||||||
|
influxdb: SVG.outputInflux,
|
||||||
|
};
|
||||||
|
const OUTPUT_FORMAT_LABELS = {
|
||||||
|
process: 'Process',
|
||||||
|
json: 'JSON',
|
||||||
|
csv: 'CSV',
|
||||||
|
influxdb: 'Influx',
|
||||||
|
};
|
||||||
|
function renderOutputFormatPicker(select, holder) {
|
||||||
|
renderSelectPicker(select, holder, OUTPUT_FORMAT_ICONS, OUTPUT_FORMAT_LABELS);
|
||||||
|
}
|
||||||
|
|
||||||
|
// upgradeOutputFormatSelects: idempotent platform-wide upgrade.
|
||||||
|
// Scans the open editor dialog for the two canonical output-format
|
||||||
|
// selects and replaces each with the icon picker. Skips selects
|
||||||
|
// that are already upgraded (class evolv-native-hidden) or that
|
||||||
|
// already have a sibling picker placed by the node's HTML.
|
||||||
|
// Called from MenuManager's initEditor wrapper so every node
|
||||||
|
// inherits the picker without per-node template edits.
|
||||||
|
function upgradeOutputFormatSelects() {
|
||||||
|
const specs = [
|
||||||
|
{ id: 'node-input-processOutputFormat', aria: 'Process output format' },
|
||||||
|
{ id: 'node-input-dbaseOutputFormat', aria: 'Database output format' }
|
||||||
|
];
|
||||||
|
specs.forEach((spec) => {
|
||||||
|
const select = document.getElementById(spec.id);
|
||||||
|
if (!select) return;
|
||||||
|
if (select.classList && select.classList.contains('evolv-native-hidden')) return;
|
||||||
|
const parent = select.parentNode;
|
||||||
|
if (!parent) return;
|
||||||
|
// Skip if a sibling picker already exists (manual wiring).
|
||||||
|
const siblings = parent.children || [];
|
||||||
|
for (let i = 0; i < siblings.length; i += 1) {
|
||||||
|
const sib = siblings[i];
|
||||||
|
if (sib !== select && sib.classList && sib.classList.contains('evolv-icon-picker')) return;
|
||||||
|
}
|
||||||
|
const holder = document.createElement('div');
|
||||||
|
holder.className = 'evolv-icon-picker';
|
||||||
|
holder.setAttribute('role', 'radiogroup');
|
||||||
|
holder.setAttribute('aria-label', spec.aria);
|
||||||
|
parent.appendChild(holder);
|
||||||
|
renderOutputFormatPicker(select, holder);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { SVG, renderSelectPicker, renderToggle, renderOutputFormatPicker, upgradeOutputFormatSelects };
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = IconHelpers;
|
||||||
@@ -3,6 +3,7 @@ const AssetMenu = require('./asset.js');
|
|||||||
const LoggerMenu = require('./logger.js');
|
const LoggerMenu = require('./logger.js');
|
||||||
const PhysicalPositionMenu = require('./physicalPosition.js');
|
const PhysicalPositionMenu = require('./physicalPosition.js');
|
||||||
const AquonSamplesMenu = require('./aquonSamples.js');
|
const AquonSamplesMenu = require('./aquonSamples.js');
|
||||||
|
const IconHelpers = require('./iconHelpers.js');
|
||||||
const ConfigManager = require('../configs');
|
const ConfigManager = require('../configs');
|
||||||
|
|
||||||
class MenuManager {
|
class MenuManager {
|
||||||
@@ -138,6 +139,9 @@ class MenuManager {
|
|||||||
window.EVOLV.nodes = window.EVOLV.nodes || {};
|
window.EVOLV.nodes = window.EVOLV.nodes || {};
|
||||||
window.EVOLV.nodes.${nodeName} = window.EVOLV.nodes.${nodeName} || {};
|
window.EVOLV.nodes.${nodeName} = window.EVOLV.nodes.${nodeName} || {};
|
||||||
|
|
||||||
|
// Shared icon-picker helpers (no-op if already loaded by another node)
|
||||||
|
${IconHelpers.getClientInitCode()}
|
||||||
|
|
||||||
// Initialize menu namespaces
|
// Initialize menu namespaces
|
||||||
${menuTypes.map(type => `window.EVOLV.nodes.${nodeName}.${type}Menu = window.EVOLV.nodes.${nodeName}.${type}Menu || {};`).join('\n ')}
|
${menuTypes.map(type => `window.EVOLV.nodes.${nodeName}.${type}Menu = window.EVOLV.nodes.${nodeName}.${type}Menu || {};`).join('\n ')}
|
||||||
|
|
||||||
@@ -163,12 +167,26 @@ class MenuManager {
|
|||||||
try {
|
try {
|
||||||
${menuTypes.map(type => `
|
${menuTypes.map(type => `
|
||||||
try {
|
try {
|
||||||
|
// initEditor is responsible for calling initVisuals
|
||||||
|
// at the right time (after any async data load).
|
||||||
if (window.EVOLV.nodes.${nodeName}.${type}Menu && window.EVOLV.nodes.${nodeName}.${type}Menu.initEditor) {
|
if (window.EVOLV.nodes.${nodeName}.${type}Menu && window.EVOLV.nodes.${nodeName}.${type}Menu.initEditor) {
|
||||||
window.EVOLV.nodes.${nodeName}.${type}Menu.initEditor(node);
|
window.EVOLV.nodes.${nodeName}.${type}Menu.initEditor(node);
|
||||||
}
|
}
|
||||||
} catch (${type}Error) {
|
} catch (${type}Error) {
|
||||||
console.error('Error initializing ${type} menu for ${nodeName}:', ${type}Error);
|
console.error('Error initializing ${type} menu for ${nodeName}:', ${type}Error);
|
||||||
}`).join('')}
|
}`).join('')}
|
||||||
|
|
||||||
|
// Platform-wide: upgrade output-format <select>s
|
||||||
|
// (process/dbase) to icon pickers. Idempotent — no-op
|
||||||
|
// for nodes whose HTML already wires the picker, and
|
||||||
|
// skips when the selects aren't present.
|
||||||
|
try {
|
||||||
|
if (window.EVOLV && window.EVOLV.iconHelpers && window.EVOLV.iconHelpers.upgradeOutputFormatSelects) {
|
||||||
|
window.EVOLV.iconHelpers.upgradeOutputFormatSelects();
|
||||||
|
}
|
||||||
|
} catch (outputUpgradeError) {
|
||||||
|
console.error('Error upgrading output-format selects for ${nodeName}:', outputUpgradeError);
|
||||||
|
}
|
||||||
} catch (editorError) {
|
} catch (editorError) {
|
||||||
console.error('Error in main editor initialization for ${nodeName}:', editorError);
|
console.error('Error in main editor initialization for ${nodeName}:', editorError);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,12 +103,68 @@ getHtmlInjectionCode(nodeName) {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5) Compose everything into one client‐side payload
|
// 5) Client-side: upgrade native controls to icon cards.
|
||||||
|
//
|
||||||
|
// Runs after wireEvents (which has already hooked the checkbox + select).
|
||||||
|
// Adds a small toggle card next to the native checkbox and a 4-icon
|
||||||
|
// picker row next to the native select; the natives are then hidden.
|
||||||
|
getVisualInjectionCode(nodeName) {
|
||||||
|
return `
|
||||||
|
// Logger visual upgrade for ${nodeName}
|
||||||
|
window.EVOLV.nodes.${nodeName}.loggerMenu.initVisuals = function(node) {
|
||||||
|
const helpers = window.EVOLV && window.EVOLV.iconHelpers;
|
||||||
|
if (!helpers) return;
|
||||||
|
|
||||||
|
// --- Log toggle (replaces native checkbox + label) ----------
|
||||||
|
const checkbox = document.getElementById('node-input-enableLog');
|
||||||
|
if (checkbox) {
|
||||||
|
const row = checkbox.closest('.form-row');
|
||||||
|
if (row && !document.getElementById('evolv-log-toggle-' + node.id)) {
|
||||||
|
row.classList.add('evolv-native-row-compact');
|
||||||
|
const holder = document.createElement('div');
|
||||||
|
holder.id = 'evolv-log-toggle-' + node.id;
|
||||||
|
holder.className = 'evolv-icon-option evolv-log-toggle';
|
||||||
|
holder.setAttribute('role', 'switch');
|
||||||
|
holder.setAttribute('tabindex', '0');
|
||||||
|
holder.setAttribute('aria-label', 'Logging');
|
||||||
|
holder.setAttribute('aria-checked', 'false');
|
||||||
|
holder.setAttribute('title', 'Logging');
|
||||||
|
row.appendChild(holder);
|
||||||
|
helpers.renderToggle(checkbox, holder, helpers.SVG.logToggle, { on: 'Log', off: 'Off' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Log-level picker (replaces native select) --------------
|
||||||
|
const select = document.getElementById('node-input-logLevel');
|
||||||
|
if (select) {
|
||||||
|
const row = document.getElementById('row-logLevel');
|
||||||
|
if (row && !document.getElementById('evolv-log-level-picker-' + node.id)) {
|
||||||
|
row.classList.add('evolv-native-row-compact');
|
||||||
|
const holder = document.createElement('div');
|
||||||
|
holder.id = 'evolv-log-level-picker-' + node.id;
|
||||||
|
holder.className = 'evolv-icon-picker';
|
||||||
|
holder.setAttribute('role', 'radiogroup');
|
||||||
|
holder.setAttribute('aria-label', 'Log level');
|
||||||
|
row.appendChild(holder);
|
||||||
|
helpers.renderSelectPicker(
|
||||||
|
select,
|
||||||
|
holder,
|
||||||
|
{ error: helpers.SVG.error, warn: helpers.SVG.warn, info: helpers.SVG.info, debug: helpers.SVG.debug },
|
||||||
|
{ error: 'Error', warn: 'Warn', info: 'Info', debug: 'Debug' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6) Compose everything into one client‐side payload
|
||||||
getClientInitCode(nodeName) {
|
getClientInitCode(nodeName) {
|
||||||
const dataCode = this.getDataInjectionCode(nodeName);
|
const dataCode = this.getDataInjectionCode(nodeName);
|
||||||
const eventCode = this.getEventInjectionCode(nodeName);
|
const eventCode = this.getEventInjectionCode(nodeName);
|
||||||
const saveCode = this.getSaveInjectionCode(nodeName);
|
const saveCode = this.getSaveInjectionCode(nodeName);
|
||||||
const htmlCode = this.getHtmlInjectionCode(nodeName);
|
const htmlCode = this.getHtmlInjectionCode(nodeName);
|
||||||
|
const visualCode = this.getVisualInjectionCode(nodeName);
|
||||||
|
|
||||||
return `
|
return `
|
||||||
// --- LoggerMenu for ${nodeName} ---
|
// --- LoggerMenu for ${nodeName} ---
|
||||||
@@ -119,13 +175,16 @@ getHtmlInjectionCode(nodeName) {
|
|||||||
${dataCode}
|
${dataCode}
|
||||||
${eventCode}
|
${eventCode}
|
||||||
${saveCode}
|
${saveCode}
|
||||||
|
${visualCode}
|
||||||
|
|
||||||
// oneditprepare calls this
|
// oneditprepare calls this. Visual upgrade runs last so the natives
|
||||||
|
// are already populated + wired.
|
||||||
window.EVOLV.nodes.${nodeName}.loggerMenu.initEditor = function(node) {
|
window.EVOLV.nodes.${nodeName}.loggerMenu.initEditor = function(node) {
|
||||||
// ------------------ BELOW sequence is important! -------------------------------
|
// ------------------ BELOW sequence is important! -------------------------------
|
||||||
this.injectHtml();
|
this.injectHtml();
|
||||||
this.loadData(node);
|
this.loadData(node);
|
||||||
this.wireEvents(node);
|
this.wireEvents(node);
|
||||||
|
if (this.initVisuals) this.initVisuals(node);
|
||||||
};
|
};
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -245,12 +245,69 @@ getSaveInjectionCode(nodeName) {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7) Compose everything into one client bundle
|
// 7) Client-side: upgrade native controls to icon cards.
|
||||||
|
//
|
||||||
|
// Runs after wireEvents. Wraps the position <select> with a 3-card row
|
||||||
|
// (upstream / atEquipment / downstream) and the hasDistance checkbox
|
||||||
|
// with a single toggle card. The native controls are hidden but stay
|
||||||
|
// in the DOM as save targets.
|
||||||
|
getVisualInjectionCode(nodeName) {
|
||||||
|
return `
|
||||||
|
// PhysicalPosition visual upgrade for ${nodeName}
|
||||||
|
window.EVOLV.nodes.${nodeName}.positionMenu.initVisuals = function(node) {
|
||||||
|
const helpers = window.EVOLV && window.EVOLV.iconHelpers;
|
||||||
|
if (!helpers) return;
|
||||||
|
|
||||||
|
// --- Position picker (replaces native <select>) -------------
|
||||||
|
const select = document.getElementById('node-input-positionVsParent');
|
||||||
|
if (select) {
|
||||||
|
const row = select.closest('.form-row');
|
||||||
|
if (row && !document.getElementById('evolv-position-picker-' + node.id)) {
|
||||||
|
row.classList.add('evolv-native-row-compact');
|
||||||
|
const holder = document.createElement('div');
|
||||||
|
holder.id = 'evolv-position-picker-' + node.id;
|
||||||
|
holder.className = 'evolv-icon-picker';
|
||||||
|
holder.setAttribute('role', 'radiogroup');
|
||||||
|
holder.setAttribute('aria-label', 'Physical position vs parent');
|
||||||
|
row.appendChild(holder);
|
||||||
|
helpers.renderSelectPicker(
|
||||||
|
select,
|
||||||
|
holder,
|
||||||
|
{ upstream: helpers.SVG.upstream, atEquipment: helpers.SVG.atEquipment, downstream: helpers.SVG.downstream },
|
||||||
|
{ upstream: 'Upstream', atEquipment: 'At', downstream: 'Downstream' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Distance toggle (replaces native checkbox) -------------
|
||||||
|
const checkbox = document.getElementById('node-input-hasDistance');
|
||||||
|
if (checkbox) {
|
||||||
|
const row = checkbox.closest('.form-row');
|
||||||
|
if (row && !document.getElementById('evolv-distance-toggle-' + node.id)) {
|
||||||
|
row.classList.add('evolv-native-row-compact');
|
||||||
|
const holder = document.createElement('div');
|
||||||
|
holder.id = 'evolv-distance-toggle-' + node.id;
|
||||||
|
holder.className = 'evolv-icon-option evolv-distance-toggle';
|
||||||
|
holder.setAttribute('role', 'switch');
|
||||||
|
holder.setAttribute('tabindex', '0');
|
||||||
|
holder.setAttribute('aria-label', 'Distance');
|
||||||
|
holder.setAttribute('aria-checked', 'false');
|
||||||
|
holder.setAttribute('title', 'Distance');
|
||||||
|
row.appendChild(holder);
|
||||||
|
helpers.renderToggle(checkbox, holder, helpers.SVG.distance, { on: 'Distance', off: 'Off' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8) Compose everything into one client bundle
|
||||||
getClientInitCode(nodeName) {
|
getClientInitCode(nodeName) {
|
||||||
const htmlCode = this.getHtmlInjectionCode(nodeName);
|
const htmlCode = this.getHtmlInjectionCode(nodeName);
|
||||||
const dataCode = this.getDataInjectionCode(nodeName);
|
const dataCode = this.getDataInjectionCode(nodeName);
|
||||||
const eventCode = this.getEventInjectionCode(nodeName);
|
const eventCode = this.getEventInjectionCode(nodeName);
|
||||||
const saveCode = this.getSaveInjectionCode(nodeName);
|
const saveCode = this.getSaveInjectionCode(nodeName);
|
||||||
|
const visualCode = this.getVisualInjectionCode(nodeName);
|
||||||
|
|
||||||
return `
|
return `
|
||||||
// --- PhysicalPositionMenu for ${nodeName} ---
|
// --- PhysicalPositionMenu for ${nodeName} ---
|
||||||
@@ -261,12 +318,15 @@ getSaveInjectionCode(nodeName) {
|
|||||||
${dataCode}
|
${dataCode}
|
||||||
${eventCode}
|
${eventCode}
|
||||||
${saveCode}
|
${saveCode}
|
||||||
|
${visualCode}
|
||||||
|
|
||||||
// hook into oneditprepare
|
// hook into oneditprepare. Visual upgrade runs last so the natives
|
||||||
|
// are already populated + wired.
|
||||||
window.EVOLV.nodes.${nodeName}.positionMenu.initEditor = function(node) {
|
window.EVOLV.nodes.${nodeName}.positionMenu.initEditor = function(node) {
|
||||||
this.injectHtml();
|
this.injectHtml();
|
||||||
this.loadData(node);
|
this.loadData(node);
|
||||||
this.wireEvents(node);
|
this.wireEvents(node);
|
||||||
|
if (this.initVisuals) this.initVisuals(node);
|
||||||
};
|
};
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
211
src/nodered/BaseNodeAdapter.js
Normal file
211
src/nodered/BaseNodeAdapter.js
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
/**
|
||||||
|
* BaseNodeAdapter — shared nodeClass scaffolding.
|
||||||
|
*
|
||||||
|
* Consolidates the boilerplate every node's nodeClass.js repeats today
|
||||||
|
* (config build → domain instantiate → registration delay → tick loop →
|
||||||
|
* status loop → input dispatch → close handler). Subclasses declare what
|
||||||
|
* varies (DomainClass, commands, output strategy) via static fields and
|
||||||
|
* override `buildDomainConfig(uiConfig, nodeId)` to produce the per-node
|
||||||
|
* config slice.
|
||||||
|
*
|
||||||
|
* See CONTRACTS.md §2; OPEN_QUESTIONS.md (event-driven default + tick
|
||||||
|
* fire-and-forget resolution, 2026-05-10).
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const ConfigManager = require('../configs/index.js');
|
||||||
|
const OutputUtils = require('../helper/outputUtils.js');
|
||||||
|
const { createRegistry } = require('./commandRegistry.js');
|
||||||
|
const { StatusUpdater } = require('./statusUpdater.js');
|
||||||
|
const convert = require('../convert');
|
||||||
|
|
||||||
|
const REGISTRATION_DELAY_MS = 100;
|
||||||
|
|
||||||
|
function _buildImplicitUnitsCommand(getCommands, getNodeName) {
|
||||||
|
return {
|
||||||
|
topic: 'query.units',
|
||||||
|
payloadSchema: { type: 'any' },
|
||||||
|
description: 'Returns the unit spec (measure, default, accepted) for every topic that declares units.',
|
||||||
|
handler: (source, msg, ctx) => {
|
||||||
|
const units = {};
|
||||||
|
for (const d of getCommands()) {
|
||||||
|
if (!d.units) continue;
|
||||||
|
const accepted = (convert && typeof convert.possibilities === 'function')
|
||||||
|
? convert.possibilities(d.units.measure) : [];
|
||||||
|
units[d.topic] = {
|
||||||
|
measure: d.units.measure,
|
||||||
|
default: d.units.default,
|
||||||
|
accepted,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const reply = Object.assign({}, msg, {
|
||||||
|
topic: 'query.units',
|
||||||
|
payload: { node: getNodeName(), units },
|
||||||
|
});
|
||||||
|
if (ctx && typeof ctx.send === 'function') ctx.send([reply, null, null]);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class BaseNodeAdapter {
|
||||||
|
constructor(uiConfig, RED, nodeInstance, nameOfNode) {
|
||||||
|
const ctor = this.constructor;
|
||||||
|
if (ctor === BaseNodeAdapter) {
|
||||||
|
throw new Error('BaseNodeAdapter is abstract; subclass it and declare static DomainClass + commands');
|
||||||
|
}
|
||||||
|
if (typeof ctor.DomainClass !== 'function') {
|
||||||
|
throw new Error(`${ctor.name}: static DomainClass is required (a class to instantiate)`);
|
||||||
|
}
|
||||||
|
if (!Array.isArray(ctor.commands)) {
|
||||||
|
throw new Error(`${ctor.name}: static commands is required (array of descriptors; use [] for none)`);
|
||||||
|
}
|
||||||
|
if (typeof this.buildDomainConfig !== 'function') {
|
||||||
|
throw new Error(`${ctor.name}: must implement buildDomainConfig(uiConfig, nodeId)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.node = nodeInstance;
|
||||||
|
this.RED = RED;
|
||||||
|
this.name = nameOfNode;
|
||||||
|
|
||||||
|
const cfgMgr = new ConfigManager();
|
||||||
|
this.defaultConfig = cfgMgr.getConfig(this.name);
|
||||||
|
this.config = cfgMgr.buildConfig(
|
||||||
|
this.name,
|
||||||
|
uiConfig,
|
||||||
|
this.node.id,
|
||||||
|
this.buildDomainConfig(uiConfig, this.node.id) || {},
|
||||||
|
);
|
||||||
|
|
||||||
|
this.source = new ctor.DomainClass(this.config);
|
||||||
|
// Sibling-node lookup uses RED.nodes.getNode(id).source — see existing
|
||||||
|
// pumpingStation/measurement nodeClass _attachInputHandler patterns.
|
||||||
|
this.node.source = this.source;
|
||||||
|
|
||||||
|
this._output = new OutputUtils();
|
||||||
|
const userHasUnitsQuery = ctor.commands.some(
|
||||||
|
(c) => c && (c.topic === 'query.units' || (Array.isArray(c.aliases) && c.aliases.includes('query.units'))));
|
||||||
|
const mergedCommands = userHasUnitsQuery
|
||||||
|
? ctor.commands
|
||||||
|
: ctor.commands.concat([_buildImplicitUnitsCommand(
|
||||||
|
() => this._commands.list(),
|
||||||
|
() => this.name,
|
||||||
|
)]);
|
||||||
|
this._commands = createRegistry(mergedCommands, { logger: this.source?.logger });
|
||||||
|
|
||||||
|
this._tickInterval = null;
|
||||||
|
this._outputChangedListener = null;
|
||||||
|
this._scheduleRegistration();
|
||||||
|
this._wireOutputs();
|
||||||
|
|
||||||
|
this._statusUpdater = new StatusUpdater({
|
||||||
|
node: this.node,
|
||||||
|
source: this.source,
|
||||||
|
intervalMs: ctor.statusInterval ?? 1000,
|
||||||
|
logger: this.source?.logger,
|
||||||
|
});
|
||||||
|
this._statusUpdater.start();
|
||||||
|
|
||||||
|
this._attachInputHandler();
|
||||||
|
this._attachCloseHandler();
|
||||||
|
|
||||||
|
if (typeof this.extraSetup === 'function') this.extraSetup();
|
||||||
|
}
|
||||||
|
|
||||||
|
_scheduleRegistration() {
|
||||||
|
// Delayed so siblings have finished constructing before the parent
|
||||||
|
// receives the registration message.
|
||||||
|
setTimeout(() => {
|
||||||
|
this.node.send([
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
topic: 'child.register',
|
||||||
|
payload: this.node.id,
|
||||||
|
positionVsParent: this.config?.functionality?.positionVsParent ?? 'atEquipment',
|
||||||
|
distance: this.config?.functionality?.distance ?? null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}, REGISTRATION_DELAY_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
_wireOutputs() {
|
||||||
|
const ctor = this.constructor;
|
||||||
|
const interval = ctor.tickInterval;
|
||||||
|
if (typeof interval === 'number' && interval > 0) {
|
||||||
|
this._tickInterval = setInterval(() => {
|
||||||
|
// Fire-and-forget per OPEN_QUESTIONS 2026-05-10. Domain owns
|
||||||
|
// its own serialisation via LatestWinsGate when needed.
|
||||||
|
try { this.source.tick?.(); }
|
||||||
|
catch (err) { this.source?.logger?.error?.(`tick threw: ${err.message}`); }
|
||||||
|
this._emitOutputs();
|
||||||
|
}, interval);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Event-driven default: domain emits 'output-changed' when its
|
||||||
|
// public output state shifts; adapter pushes outputs in response.
|
||||||
|
const emitter = this.source?.emitter;
|
||||||
|
if (emitter && typeof emitter.on === 'function') {
|
||||||
|
this._outputChangedListener = () => this._emitOutputs();
|
||||||
|
emitter.on('output-changed', this._outputChangedListener);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_emitOutputs() {
|
||||||
|
if (typeof this.source.getOutput !== 'function') return;
|
||||||
|
const raw = this.source.getOutput();
|
||||||
|
const cfg = this.source.config || this.config;
|
||||||
|
const processMsg = this._output.formatMsg(raw, cfg, 'process');
|
||||||
|
const influxMsg = this._output.formatMsg(raw, cfg, 'influxdb');
|
||||||
|
this.node.send([processMsg, influxMsg, null]);
|
||||||
|
}
|
||||||
|
|
||||||
|
_attachInputHandler() {
|
||||||
|
this.node.on('input', async (msg, send, done) => {
|
||||||
|
try {
|
||||||
|
await this._commands.dispatch(msg, this.source, {
|
||||||
|
node: this.node,
|
||||||
|
RED: this.RED,
|
||||||
|
send,
|
||||||
|
logger: this.source?.logger,
|
||||||
|
});
|
||||||
|
if (typeof this.extraInputDispatch === 'function') {
|
||||||
|
await this.extraInputDispatch(msg, send, done);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.source?.logger?.error?.(err.message);
|
||||||
|
} finally {
|
||||||
|
if (typeof done === 'function') done();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_attachCloseHandler() {
|
||||||
|
this.node.on('close', (done) => {
|
||||||
|
try {
|
||||||
|
if (this._tickInterval) {
|
||||||
|
clearInterval(this._tickInterval);
|
||||||
|
this._tickInterval = null;
|
||||||
|
}
|
||||||
|
if (this._outputChangedListener && this.source?.emitter?.off) {
|
||||||
|
this.source.emitter.off('output-changed', this._outputChangedListener);
|
||||||
|
this._outputChangedListener = null;
|
||||||
|
}
|
||||||
|
this._statusUpdater?.stop();
|
||||||
|
this.source?.close?.();
|
||||||
|
if (typeof this.extraClose === 'function') this.extraClose();
|
||||||
|
try { this.node.status({}); } catch (_) { /* best effort */ }
|
||||||
|
} catch (err) {
|
||||||
|
this.source?.logger?.error?.(`close handler threw: ${err.message}`);
|
||||||
|
} finally {
|
||||||
|
if (typeof done === 'function') done();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defaults overridable via subclass static fields.
|
||||||
|
BaseNodeAdapter.tickInterval = null;
|
||||||
|
BaseNodeAdapter.statusInterval = 1000;
|
||||||
|
|
||||||
|
module.exports = BaseNodeAdapter;
|
||||||
237
src/nodered/commandRegistry.js
Normal file
237
src/nodered/commandRegistry.js
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Declarative dispatch for a node's input topics. Each node declares its
|
||||||
|
// commands as an array of descriptors; the registry builds an O(1) lookup
|
||||||
|
// keyed by canonical topic + alias, validates the payload against a small
|
||||||
|
// shape schema, and invokes the handler. Replaces the per-node ~100-line
|
||||||
|
// `switch (msg.topic)` block in nodeClass._attachInputHandler.
|
||||||
|
//
|
||||||
|
// Lightweight on purpose: the schema is a typeof-check ladder, not full
|
||||||
|
// JSON-Schema. Anything richer belongs in the handler itself, which has
|
||||||
|
// access to logger via ctx.
|
||||||
|
|
||||||
|
const convert = require('../convert');
|
||||||
|
|
||||||
|
const SCALAR_TYPES = new Set(['string', 'number', 'boolean', 'object', 'any', 'none']);
|
||||||
|
|
||||||
|
function _acceptedList(measure) {
|
||||||
|
if (convert && typeof convert.possibilities === 'function') {
|
||||||
|
const list = convert.possibilities(measure);
|
||||||
|
if (Array.isArray(list) && list.length) return list.join(', ');
|
||||||
|
}
|
||||||
|
return '(see convert docs)';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _describeUnit(unit) {
|
||||||
|
try { return convert().describe(unit); } catch (_) { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function _extractValueAndUnit(msg) {
|
||||||
|
if (!msg || typeof msg !== 'object') return null;
|
||||||
|
const p = msg.payload;
|
||||||
|
if (typeof p === 'number') return { value: p, unit: msg.unit };
|
||||||
|
if (p && typeof p === 'object' && typeof p.value === 'number') {
|
||||||
|
return { value: p.value, unit: p.unit ?? msg.unit };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
class CommandRegistry {
|
||||||
|
constructor(commands, options = {}) {
|
||||||
|
if (!Array.isArray(commands)) {
|
||||||
|
throw new TypeError('CommandRegistry requires an array of command descriptors');
|
||||||
|
}
|
||||||
|
this._logger = options.logger || null;
|
||||||
|
this._byKey = new Map(); // topic-or-alias -> descriptor
|
||||||
|
this._canonicalByAlias = new Map();
|
||||||
|
this._descriptors = [];
|
||||||
|
this._deprecationCounts = new Map();
|
||||||
|
this._deprecationLogged = new Set();
|
||||||
|
for (const cmd of commands) this._register(cmd);
|
||||||
|
}
|
||||||
|
|
||||||
|
_register(cmd) {
|
||||||
|
if (!cmd || typeof cmd.topic !== 'string' || cmd.topic.length === 0) {
|
||||||
|
throw new TypeError('command descriptor requires a non-empty string topic');
|
||||||
|
}
|
||||||
|
if (typeof cmd.handler !== 'function') {
|
||||||
|
throw new TypeError(`command '${cmd.topic}' requires a handler function`);
|
||||||
|
}
|
||||||
|
if (this._byKey.has(cmd.topic)) {
|
||||||
|
throw new Error(`duplicate command topic '${cmd.topic}'`);
|
||||||
|
}
|
||||||
|
const aliases = Array.isArray(cmd.aliases) ? cmd.aliases.slice() : [];
|
||||||
|
for (const alias of aliases) {
|
||||||
|
if (typeof alias !== 'string' || alias.length === 0) {
|
||||||
|
throw new TypeError(`command '${cmd.topic}' has an invalid alias`);
|
||||||
|
}
|
||||||
|
if (this._byKey.has(alias)) {
|
||||||
|
throw new Error(`alias '${alias}' for '${cmd.topic}' collides with existing topic or alias`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const units = this._validateUnits(cmd);
|
||||||
|
const descriptor = {
|
||||||
|
topic: cmd.topic,
|
||||||
|
aliases,
|
||||||
|
payloadSchema: cmd.payloadSchema || null,
|
||||||
|
description: typeof cmd.description === 'string' ? cmd.description : null,
|
||||||
|
units,
|
||||||
|
handler: cmd.handler,
|
||||||
|
};
|
||||||
|
this._byKey.set(cmd.topic, descriptor);
|
||||||
|
for (const alias of aliases) {
|
||||||
|
this._byKey.set(alias, descriptor);
|
||||||
|
this._canonicalByAlias.set(alias, cmd.topic);
|
||||||
|
}
|
||||||
|
this._descriptors.push(descriptor);
|
||||||
|
}
|
||||||
|
|
||||||
|
_validateUnits(cmd) {
|
||||||
|
if (cmd.units === undefined || cmd.units === null) return null;
|
||||||
|
const { measure, default: def } = cmd.units;
|
||||||
|
if (typeof measure !== 'string' || measure.length === 0 ||
|
||||||
|
typeof def !== 'string' || def.length === 0) {
|
||||||
|
throw new TypeError(
|
||||||
|
`command '${cmd.topic}' units requires { measure: string, default: string }`);
|
||||||
|
}
|
||||||
|
return { measure, default: def };
|
||||||
|
}
|
||||||
|
|
||||||
|
has(topic) {
|
||||||
|
return typeof topic === 'string' && this._byKey.has(topic);
|
||||||
|
}
|
||||||
|
|
||||||
|
canonical(topic) {
|
||||||
|
if (typeof topic !== 'string') return topic;
|
||||||
|
return this._canonicalByAlias.get(topic) || topic;
|
||||||
|
}
|
||||||
|
|
||||||
|
list() {
|
||||||
|
// Strip handler so callers can safely log / serialise the result
|
||||||
|
// (handler functions are noisy and not contract-relevant).
|
||||||
|
return this._descriptors.map((d) => ({
|
||||||
|
topic: d.topic,
|
||||||
|
aliases: d.aliases.slice(),
|
||||||
|
payloadSchema: d.payloadSchema,
|
||||||
|
description: d.description,
|
||||||
|
units: d.units ? { measure: d.units.measure, default: d.units.default } : null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
deprecationStats() {
|
||||||
|
const out = {};
|
||||||
|
for (const [alias, count] of this._deprecationCounts) out[alias] = count;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async dispatch(msg, source, ctx) {
|
||||||
|
const log = this._loggerFor(ctx);
|
||||||
|
const topic = msg && typeof msg.topic === 'string' ? msg.topic : null;
|
||||||
|
if (!topic) {
|
||||||
|
log.warn?.('commandRegistry: msg has no topic; ignoring');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const descriptor = this._byKey.get(topic);
|
||||||
|
if (!descriptor) {
|
||||||
|
log.warn?.(`commandRegistry: unknown topic '${topic}'`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (topic !== descriptor.topic) this._noteAlias(topic, descriptor.topic, log);
|
||||||
|
if (descriptor.units) this._normaliseUnits(descriptor, msg, log);
|
||||||
|
if (!this._validatePayload(descriptor, msg, log)) return;
|
||||||
|
return descriptor.handler(source, msg, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
_noteAlias(alias, canonical, log) {
|
||||||
|
const prev = this._deprecationCounts.get(alias) || 0;
|
||||||
|
this._deprecationCounts.set(alias, prev + 1);
|
||||||
|
if (this._deprecationLogged.has(alias)) return;
|
||||||
|
this._deprecationLogged.add(alias);
|
||||||
|
log.warn?.(`topic '${alias}' is deprecated; use '${canonical}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
_normaliseUnits(descriptor, msg, log) {
|
||||||
|
const { measure, default: defaultUnit } = descriptor.units;
|
||||||
|
const extracted = _extractValueAndUnit(msg);
|
||||||
|
if (!extracted) return; // unknown shape — let payload validator handle it
|
||||||
|
let { value, unit } = extracted;
|
||||||
|
if (unit === undefined || unit === null || unit === '') {
|
||||||
|
// No unit supplied — assume default, silent.
|
||||||
|
msg.payload = value;
|
||||||
|
msg.unit = defaultUnit;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const desc = _describeUnit(unit);
|
||||||
|
if (!desc) {
|
||||||
|
log.warn?.(`${descriptor.topic}: unknown unit '${unit}'. Accepted: ${_acceptedList(measure)}. Treating ${value} as ${defaultUnit}.`);
|
||||||
|
msg.payload = value;
|
||||||
|
msg.unit = defaultUnit;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (desc.measure !== measure) {
|
||||||
|
log.warn?.(`${descriptor.topic}: unit '${unit}' is ${desc.measure}, expected ${measure}. Accepted: ${_acceptedList(measure)}. Treating ${value} as ${defaultUnit}.`);
|
||||||
|
msg.payload = value;
|
||||||
|
msg.unit = defaultUnit;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
msg.payload = convert(value).from(unit).to(defaultUnit);
|
||||||
|
msg.unit = defaultUnit;
|
||||||
|
} catch (err) {
|
||||||
|
log.warn?.(`${descriptor.topic}: failed to convert ${value} ${unit} -> ${defaultUnit} (${err.message}). Treating as ${defaultUnit}.`);
|
||||||
|
msg.payload = value;
|
||||||
|
msg.unit = defaultUnit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_validatePayload(descriptor, msg, log) {
|
||||||
|
const schema = descriptor.payloadSchema;
|
||||||
|
if (!schema) return true;
|
||||||
|
const payload = msg.payload;
|
||||||
|
const type = schema.type || 'any';
|
||||||
|
if (!SCALAR_TYPES.has(type)) {
|
||||||
|
log.warn?.(`commandRegistry: command '${descriptor.topic}' has unknown schema type '${type}'`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (type === 'any') return true;
|
||||||
|
if (type === 'none') {
|
||||||
|
if (payload !== undefined && payload !== null) {
|
||||||
|
log.warn?.(`${descriptor.topic}: payload ignored — this is a trigger-only topic`);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// typeof null === 'object' — explicit null fails an object schema.
|
||||||
|
if (type === 'object') {
|
||||||
|
if (payload === null || typeof payload !== 'object') {
|
||||||
|
log.warn?.(`commandRegistry: '${descriptor.topic}' expected object payload, got ${payload === null ? 'null' : typeof payload}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else if (typeof payload !== type) {
|
||||||
|
log.warn?.(`commandRegistry: '${descriptor.topic}' expected ${type} payload, got ${typeof payload}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (type === 'object' && schema.properties && typeof schema.properties === 'object') {
|
||||||
|
for (const [key, expected] of Object.entries(schema.properties)) {
|
||||||
|
if (!(key in payload)) continue; // missing keys allowed
|
||||||
|
if (typeof payload[key] !== expected) {
|
||||||
|
log.warn?.(`commandRegistry: '${descriptor.topic}' payload.${key} expected ${expected}, got ${typeof payload[key]}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
_loggerFor(ctx) {
|
||||||
|
const candidate = (ctx && ctx.logger) || this._logger;
|
||||||
|
return candidate || NOOP_LOGGER;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const NOOP_LOGGER = { warn() {}, error() {}, info() {}, debug() {} };
|
||||||
|
|
||||||
|
function createRegistry(commands, options) {
|
||||||
|
return new CommandRegistry(commands, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createRegistry, CommandRegistry };
|
||||||
96
src/nodered/statusBadge.js
Normal file
96
src/nodered/statusBadge.js
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* statusBadge — small helpers that build Node-RED status objects
|
||||||
|
* ({ fill, shape, text }) consistently across every node.
|
||||||
|
*
|
||||||
|
* See CONTRACTS.md §7. Domains compose badges via these helpers so the
|
||||||
|
* editor look-and-feel converges instead of every node rolling its own
|
||||||
|
* emoji + colour rules.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const MAX_TEXT = 60;
|
||||||
|
const SEPARATOR = ' | ';
|
||||||
|
|
||||||
|
const DEFAULT_BADGE = { fill: 'green', shape: 'dot' };
|
||||||
|
const ERROR_BADGE = { fill: 'red', shape: 'ring' };
|
||||||
|
const IDLE_BADGE = { fill: 'blue', shape: 'dot' };
|
||||||
|
const UNKNOWN_BADGE = { fill: 'grey', shape: 'ring' };
|
||||||
|
|
||||||
|
// Truncate to MAX_TEXT keeping room for the ellipsis. Editor clips the
|
||||||
|
// rest visually anyway, but we want the cut to be deterministic so
|
||||||
|
// snapshot tests don't drift across Node-RED versions.
|
||||||
|
function _clip(text) {
|
||||||
|
if (text == null) return '';
|
||||||
|
const s = String(text);
|
||||||
|
if (s.length <= MAX_TEXT) return s;
|
||||||
|
return s.slice(0, MAX_TEXT - 1) + '…';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _joinParts(parts) {
|
||||||
|
if (!Array.isArray(parts) || parts.length === 0) return '';
|
||||||
|
const kept = parts.filter((p) => p != null && p !== false && p !== '');
|
||||||
|
if (kept.length === 0) return '';
|
||||||
|
return kept.map(String).join(SEPARATOR);
|
||||||
|
}
|
||||||
|
|
||||||
|
function compose(parts, opts) {
|
||||||
|
const text = _clip(_joinParts(parts));
|
||||||
|
return {
|
||||||
|
fill: (opts && opts.fill) || DEFAULT_BADGE.fill,
|
||||||
|
shape: (opts && opts.shape) || DEFAULT_BADGE.shape,
|
||||||
|
text,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function error(message) {
|
||||||
|
return {
|
||||||
|
fill: ERROR_BADGE.fill,
|
||||||
|
shape: ERROR_BADGE.shape,
|
||||||
|
text: _clip(`⚠ ${message == null ? '' : message}`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function idle(label) {
|
||||||
|
return {
|
||||||
|
fill: IDLE_BADGE.fill,
|
||||||
|
shape: IDLE_BADGE.shape,
|
||||||
|
text: _clip(`⏸️ ${label == null ? '' : label}`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up a state-template badge and optionally compose extra parts
|
||||||
|
// into its text. Missing template falls back to a grey "unknown state"
|
||||||
|
// badge — silent so caller can still surface the bad state through logs.
|
||||||
|
function byState(stateMap, currentState, opts) {
|
||||||
|
const template = stateMap && stateMap[currentState];
|
||||||
|
if (!template) {
|
||||||
|
return {
|
||||||
|
fill: UNKNOWN_BADGE.fill,
|
||||||
|
shape: UNKNOWN_BADGE.shape,
|
||||||
|
text: _clip(`unknown state: ${currentState == null ? '' : currentState}`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const baseText = template.text == null ? '' : String(template.text);
|
||||||
|
const extras = opts && Array.isArray(opts.compose) ? opts.compose : [];
|
||||||
|
const merged = extras.length > 0
|
||||||
|
? _joinParts([baseText, ...extras])
|
||||||
|
: baseText;
|
||||||
|
return {
|
||||||
|
fill: template.fill || DEFAULT_BADGE.fill,
|
||||||
|
shape: template.shape || DEFAULT_BADGE.shape,
|
||||||
|
text: _clip(merged),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(string, opts) {
|
||||||
|
return {
|
||||||
|
fill: (opts && opts.fill) || DEFAULT_BADGE.fill,
|
||||||
|
shape: (opts && opts.shape) || DEFAULT_BADGE.shape,
|
||||||
|
text: _clip(string == null ? '' : string),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusBadge = { compose, error, idle, byState, text };
|
||||||
|
|
||||||
|
module.exports = { statusBadge, MAX_TEXT };
|
||||||
90
src/nodered/statusUpdater.js
Normal file
90
src/nodered/statusUpdater.js
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* StatusUpdater — periodic Node-RED status badge poller.
|
||||||
|
*
|
||||||
|
* Replaces the per-node `_statusInterval` boilerplate (e.g. pumpingStation
|
||||||
|
* nodeClass lines 160-171) with one class. The adapter constructs it once
|
||||||
|
* with a `node` (Node-RED handle) and a `source` (the domain), and the
|
||||||
|
* loop drives `node.status(source.getStatusBadge())` at a fixed cadence.
|
||||||
|
*
|
||||||
|
* Errors thrown from the domain become a red error badge instead of
|
||||||
|
* crashing the interval — operators see the failure in the editor.
|
||||||
|
*
|
||||||
|
* See CONTRACTS.md §7 for the badge shape; statusBadge.js for the helpers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { statusBadge } = require('./statusBadge');
|
||||||
|
|
||||||
|
const CLEAR_BADGE = {};
|
||||||
|
|
||||||
|
class StatusUpdater {
|
||||||
|
constructor({ node, source, intervalMs, logger } = {}) {
|
||||||
|
if (!node || typeof node.status !== 'function') {
|
||||||
|
throw new Error('StatusUpdater: node must expose a .status(badge) method');
|
||||||
|
}
|
||||||
|
if (!source || typeof source.getStatusBadge !== 'function') {
|
||||||
|
throw new Error('StatusUpdater: source must expose a .getStatusBadge() method');
|
||||||
|
}
|
||||||
|
this._node = node;
|
||||||
|
this._source = source;
|
||||||
|
this._intervalMs = Number.isFinite(intervalMs) ? intervalMs : 0;
|
||||||
|
this._logger = logger || null;
|
||||||
|
this._timer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isRunning() {
|
||||||
|
return this._timer !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
// intervalMs=0 keeps unit tests / headless harnesses silent.
|
||||||
|
if (this._intervalMs <= 0) return;
|
||||||
|
if (this._timer !== null) return;
|
||||||
|
this._timer = setInterval(() => this._tick(), this._intervalMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
if (this._timer !== null) {
|
||||||
|
clearInterval(this._timer);
|
||||||
|
this._timer = null;
|
||||||
|
}
|
||||||
|
// Wipe the badge so a stale label doesn't linger in the editor
|
||||||
|
// after the node is closed/redeployed.
|
||||||
|
try { this._node.status(CLEAR_BADGE); } catch (_) { /* best effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
_tick() {
|
||||||
|
let badge;
|
||||||
|
try {
|
||||||
|
badge = this._source.getStatusBadge();
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err && err.message ? err.message : String(err);
|
||||||
|
if (this._logger && typeof this._logger.error === 'function') {
|
||||||
|
this._logger.error(`StatusUpdater: getStatusBadge threw: ${msg}`);
|
||||||
|
}
|
||||||
|
this._safeApply(statusBadge.error(msg));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (badge == null) {
|
||||||
|
this._safeApply(CLEAR_BADGE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._safeApply(badge);
|
||||||
|
}
|
||||||
|
|
||||||
|
_safeApply(badge) {
|
||||||
|
try {
|
||||||
|
this._node.status(badge);
|
||||||
|
} catch (err) {
|
||||||
|
// node.status itself failing is exotic (e.g. node already
|
||||||
|
// closed). Log once per tick; the next tick will retry.
|
||||||
|
if (this._logger && typeof this._logger.error === 'function') {
|
||||||
|
const msg = err && err.message ? err.message : String(err);
|
||||||
|
this._logger.error(`StatusUpdater: node.status threw: ${msg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { StatusUpdater };
|
||||||
@@ -71,14 +71,22 @@ class Predict {
|
|||||||
// Capture share-source BEFORE config validation strips it (ConfigUtils
|
// Capture share-source BEFORE config validation strips it (ConfigUtils
|
||||||
// mutates the input config to drop unknown keys, which would remove
|
// mutates the input config to drop unknown keys, which would remove
|
||||||
// shareInputsFrom because it's not in predictConfig.json's schema).
|
// shareInputsFrom because it's not in predictConfig.json's schema).
|
||||||
|
// Detach on a shallow clone so validateSchema doesn't see the key at all
|
||||||
|
// — leaving it on the input would emit a `[interpolation] Unknown key
|
||||||
|
// 'shareInputsFrom'` warning per group-predictor on every curve refresh.
|
||||||
const _sharedSource = (config && config.shareInputsFrom instanceof Predict)
|
const _sharedSource = (config && config.shareInputsFrom instanceof Predict)
|
||||||
? config.shareInputsFrom
|
? config.shareInputsFrom
|
||||||
: null;
|
: null;
|
||||||
|
let _initConfig = config;
|
||||||
|
if (_initConfig && 'shareInputsFrom' in _initConfig) {
|
||||||
|
_initConfig = { ..._initConfig };
|
||||||
|
delete _initConfig.shareInputsFrom;
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize dependencies
|
// Initialize dependencies
|
||||||
this.emitter = new EventEmitter(); // Own EventEmitter
|
this.emitter = new EventEmitter(); // Own EventEmitter
|
||||||
this.configUtils = new ConfigUtils(defaultConfig);
|
this.configUtils = new ConfigUtils(defaultConfig);
|
||||||
this.config = this.configUtils.initConfig(config);
|
this.config = this.configUtils.initConfig(_initConfig);
|
||||||
|
|
||||||
// Init after config is set
|
// Init after config is set
|
||||||
this.logger = new Logger(this.config.general.logging.enabled,this.config.general.logging.logLevel, this.config.general.name);
|
this.logger = new Logger(this.config.general.logging.enabled,this.config.general.logging.logLevel, this.config.general.name);
|
||||||
|
|||||||
103
src/registry/AssetResolver.js
Normal file
103
src/registry/AssetResolver.js
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AssetResolver — single entry point for all asset-side metadata in EVOLV.
|
||||||
|
*
|
||||||
|
* Namespaces are declared at construction (see ./namespaces/*). Each namespace
|
||||||
|
* exposes a `loadAll()` returning a `Map<id, payload>`; AssetResolver routes
|
||||||
|
* resolve(name, id) calls into the right namespace, caches the loaded map, and
|
||||||
|
* exposes async `refresh(name?)` for future HttpBackend hydration.
|
||||||
|
*
|
||||||
|
* Resolution is sync by contract: the first resolve() for an unwarmed
|
||||||
|
* namespace pulls everything into cache, all subsequent calls are O(1).
|
||||||
|
*
|
||||||
|
* Backend abstraction (./backends/*) is where File vs Http lives — namespaces
|
||||||
|
* just hold a backend reference and call backend.loadAll().
|
||||||
|
*
|
||||||
|
* See ./README.md for the full extension story.
|
||||||
|
*/
|
||||||
|
|
||||||
|
class AssetResolver {
|
||||||
|
constructor(namespaces = []) {
|
||||||
|
this._slots = new Map();
|
||||||
|
for (const ns of namespaces) {
|
||||||
|
if (!ns || !ns.name || typeof ns.loadAll !== 'function') {
|
||||||
|
throw new TypeError('AssetResolver: namespace must declare { name, loadAll() }');
|
||||||
|
}
|
||||||
|
this._slots.set(ns.name, { ns, cache: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_ensureWarm(name) {
|
||||||
|
const slot = this._slots.get(name);
|
||||||
|
if (!slot) throw new Error(`AssetResolver: unknown namespace '${name}'`);
|
||||||
|
if (slot.cache === null) slot.cache = slot.ns.loadAll();
|
||||||
|
return slot;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(namespace, id) {
|
||||||
|
const slot = this._ensureWarm(namespace);
|
||||||
|
const key = String(id ?? '').toLowerCase();
|
||||||
|
if (!key) return null;
|
||||||
|
return slot.cache.get(key) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
list(namespace) {
|
||||||
|
const slot = this._ensureWarm(namespace);
|
||||||
|
return [...slot.cache.keys()];
|
||||||
|
}
|
||||||
|
|
||||||
|
namespaces() {
|
||||||
|
return [...this._slots.keys()];
|
||||||
|
}
|
||||||
|
|
||||||
|
async refresh(namespace) {
|
||||||
|
if (namespace) {
|
||||||
|
const slot = this._slots.get(namespace);
|
||||||
|
if (!slot) throw new Error(`AssetResolver: unknown namespace '${namespace}'`);
|
||||||
|
slot.cache = typeof slot.ns.refresh === 'function'
|
||||||
|
? await slot.ns.refresh()
|
||||||
|
: slot.ns.loadAll();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const slot of this._slots.values()) {
|
||||||
|
slot.cache = typeof slot.ns.refresh === 'function'
|
||||||
|
? await slot.ns.refresh()
|
||||||
|
: slot.ns.loadAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cross-namespace helper: given a softwareType + model id, walk the
|
||||||
|
* editor menu tree to return { supplier, type, units, raw } so domain
|
||||||
|
* code doesn't have to persist supplier/type/unit on the node.
|
||||||
|
*/
|
||||||
|
resolveAssetMetadata(softwareType, modelId) {
|
||||||
|
if (!softwareType || !modelId) return null;
|
||||||
|
const tree = this.resolve('menu', softwareType);
|
||||||
|
if (!tree || !Array.isArray(tree.suppliers)) return null;
|
||||||
|
const norm = String(modelId).toLowerCase();
|
||||||
|
for (const supplier of tree.suppliers) {
|
||||||
|
for (const type of supplier.types || []) {
|
||||||
|
for (const model of type.models || []) {
|
||||||
|
const candidate = String(model.id || model.name || '').toLowerCase();
|
||||||
|
if (candidate === norm) {
|
||||||
|
return {
|
||||||
|
supplier: supplier.name,
|
||||||
|
supplierId: supplier.id || supplier.name,
|
||||||
|
type: type.name,
|
||||||
|
typeId: type.id || type.name,
|
||||||
|
model: model.name,
|
||||||
|
modelId: model.id || model.name,
|
||||||
|
units: Array.isArray(model.units) ? [...model.units] : [],
|
||||||
|
raw: model,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = AssetResolver;
|
||||||
78
src/registry/README.md
Normal file
78
src/registry/README.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# registry — AssetResolver
|
||||||
|
|
||||||
|
Single entry point for all asset-side metadata: pump/valve curves, editor menu
|
||||||
|
trees, monster sample codes, unit families, and anything else we add later.
|
||||||
|
|
||||||
|
Replaces (will replace, phase-by-phase):
|
||||||
|
|
||||||
|
- `loadCurve(model)` → `assetResolver.resolve('curves', model)`
|
||||||
|
- `AssetCategoryManager` → `assetResolver.resolve('menu', softwareType)`
|
||||||
|
- ad-hoc loaders for `monsterSamples.json`, `unitData.json` → `assetResolver.resolve('monsterSamples'|'units', …)`
|
||||||
|
|
||||||
|
## Surface
|
||||||
|
|
||||||
|
```js
|
||||||
|
const { assetResolver } = require('generalFunctions');
|
||||||
|
|
||||||
|
const curve = assetResolver.resolve('curves', 'hidrostal-H05K-S03R');
|
||||||
|
const tree = assetResolver.resolve('menu', 'rotatingmachine');
|
||||||
|
const meta = assetResolver.resolveAssetMetadata('rotatingmachine', 'hidrostal-H05K-S03R');
|
||||||
|
// meta → { supplier, type, units, model, raw }
|
||||||
|
|
||||||
|
assetResolver.list('curves'); // ['hidrostal-H05K-S03R', 'ECDV', ...]
|
||||||
|
assetResolver.namespaces(); // ['curves', 'menu', 'monsterSamples', 'units']
|
||||||
|
await assetResolver.refresh(); // re-pull everything (FileBackend: re-reads disk; HttpBackend: future)
|
||||||
|
```
|
||||||
|
|
||||||
|
Resolution is synchronous. First call to `resolve(namespace, id)` warms that
|
||||||
|
namespace's cache; later calls are O(1) map lookups.
|
||||||
|
|
||||||
|
## Adding a namespace
|
||||||
|
|
||||||
|
Create `src/registry/namespaces/<name>.js`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const path = require('path');
|
||||||
|
const FileBackend = require('../backends/FileBackend');
|
||||||
|
|
||||||
|
const backend = new FileBackend({
|
||||||
|
baseDir: path.resolve(__dirname, '../../../datasets/...'),
|
||||||
|
layout: 'per-id', // or 'single-file'
|
||||||
|
caseInsensitive: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
name: 'newThing',
|
||||||
|
description: 'What this namespace is for',
|
||||||
|
loadAll: () => backend.loadAll(),
|
||||||
|
refresh: () => backend.refresh(),
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Register it in `namespaces/index.js`. Done.
|
||||||
|
|
||||||
|
## Backends
|
||||||
|
|
||||||
|
- **FileBackend** — reads JSON from disk. Two layouts: `per-id` (one file per
|
||||||
|
id, filename minus `.json` is the id) or `single-file` (one file with an
|
||||||
|
array; pick `arrayKey` and `indexField`).
|
||||||
|
- **HttpBackend** — stub. Disabled unless `EVOLV_ASSET_REMOTE=1`. Will hold
|
||||||
|
the future WBD product API client; currently throws if invoked. Exists so
|
||||||
|
the resolver contract is backend-agnostic from day one.
|
||||||
|
|
||||||
|
Backends are interchangeable per namespace: the namespace file is the
|
||||||
|
declarative join between "what this metadata is" and "where it comes from".
|
||||||
|
|
||||||
|
## Why sync at runtime
|
||||||
|
|
||||||
|
Node-RED node constructors aren't async-friendly. Every consumer that used
|
||||||
|
`loadCurve(model)` expects a synchronous return. The resolver preserves that
|
||||||
|
contract: cache is warmed lazily (first `resolve()` call pulls everything),
|
||||||
|
and lookups are O(1) map gets after that. Async `refresh()` exists for future
|
||||||
|
HttpBackend hydration on a background timer.
|
||||||
|
|
||||||
|
## Convention: namespace name is the cache key
|
||||||
|
|
||||||
|
`assetResolver.resolve(namespace, id)` lowercases `id` for the lookup. Old
|
||||||
|
case-mismatched configs (`Hidrostal-H05K-S03R` vs `hidrostal-H05K-S03R`) still
|
||||||
|
resolve correctly — same as `loadCurve` did historically.
|
||||||
96
src/registry/backends/FileBackend.js
Normal file
96
src/registry/backends/FileBackend.js
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FileBackend — reads JSON payloads from a directory on disk.
|
||||||
|
*
|
||||||
|
* Two layouts supported:
|
||||||
|
* - 'per-id' one file per id (filename minus .json is the id)
|
||||||
|
* - 'single-file' one file containing an array; index by a field name
|
||||||
|
*
|
||||||
|
* Returns Map<lowerCaseId, payload>. Case-insensitive lookups by default —
|
||||||
|
* matches how loadCurve worked historically.
|
||||||
|
*/
|
||||||
|
class FileBackend {
|
||||||
|
constructor(opts = {}) {
|
||||||
|
const {
|
||||||
|
baseDir,
|
||||||
|
layout = 'per-id',
|
||||||
|
filePath,
|
||||||
|
arrayKey,
|
||||||
|
indexField,
|
||||||
|
exclude = [],
|
||||||
|
caseInsensitive = true,
|
||||||
|
} = opts;
|
||||||
|
|
||||||
|
if (!baseDir) throw new TypeError('FileBackend: baseDir is required');
|
||||||
|
if (layout !== 'per-id' && layout !== 'single-file') {
|
||||||
|
throw new TypeError(`FileBackend: unsupported layout '${layout}'`);
|
||||||
|
}
|
||||||
|
if (layout === 'single-file' && !filePath) {
|
||||||
|
throw new TypeError('FileBackend: single-file layout requires filePath');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.baseDir = baseDir;
|
||||||
|
this.layout = layout;
|
||||||
|
this.filePath = filePath;
|
||||||
|
this.arrayKey = arrayKey;
|
||||||
|
this.indexField = indexField;
|
||||||
|
this.exclude = new Set(exclude);
|
||||||
|
this.caseInsensitive = caseInsensitive;
|
||||||
|
}
|
||||||
|
|
||||||
|
_norm(k) {
|
||||||
|
return this.caseInsensitive ? String(k).toLowerCase() : String(k);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadAll() {
|
||||||
|
if (this.layout === 'per-id') return this._loadPerId();
|
||||||
|
return this._loadSingleFile();
|
||||||
|
}
|
||||||
|
|
||||||
|
async refresh() {
|
||||||
|
// No actual I/O penalty on local disk; the async surface exists so
|
||||||
|
// callers can `await resolver.refresh()` symmetrically with future
|
||||||
|
// HttpBackend implementations.
|
||||||
|
return this.loadAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
_loadPerId() {
|
||||||
|
const map = new Map();
|
||||||
|
if (!fs.existsSync(this.baseDir)) return map;
|
||||||
|
const entries = fs.readdirSync(this.baseDir, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isFile()) continue;
|
||||||
|
if (!entry.name.endsWith('.json')) continue;
|
||||||
|
const id = path.basename(entry.name, '.json');
|
||||||
|
if (this.exclude.has(id)) continue;
|
||||||
|
const raw = fs.readFileSync(path.join(this.baseDir, entry.name), 'utf8');
|
||||||
|
const data = JSON.parse(raw);
|
||||||
|
map.set(this._norm(id), data);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
_loadSingleFile() {
|
||||||
|
const full = path.resolve(this.baseDir, this.filePath);
|
||||||
|
if (!fs.existsSync(full)) return new Map();
|
||||||
|
const data = JSON.parse(fs.readFileSync(full, 'utf8'));
|
||||||
|
const arr = this.arrayKey ? data[this.arrayKey] : data;
|
||||||
|
if (!Array.isArray(arr)) {
|
||||||
|
throw new Error(
|
||||||
|
`FileBackend(single-file): expected array at ${this.arrayKey || '<root>'} in ${full}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const map = new Map();
|
||||||
|
for (const entry of arr) {
|
||||||
|
const k = entry && this.indexField ? entry[this.indexField] : null;
|
||||||
|
if (k != null) map.set(this._norm(k), entry);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = FileBackend;
|
||||||
41
src/registry/backends/HttpBackend.js
Normal file
41
src/registry/backends/HttpBackend.js
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HttpBackend — stub. The shape that any future remote product/asset DB will
|
||||||
|
* implement so the resolver can swap backends without touching consumers.
|
||||||
|
*
|
||||||
|
* Disabled by default. Set EVOLV_ASSET_REMOTE=1 to opt in; even then this
|
||||||
|
* stub throws on use because the upstream API is not yet defined. See
|
||||||
|
* `assetApiConfig.js` for the URL/auth scaffolding that will eventually
|
||||||
|
* land here.
|
||||||
|
*/
|
||||||
|
class HttpBackend {
|
||||||
|
constructor({ url, headers = {}, namespace } = {}) {
|
||||||
|
this.url = url;
|
||||||
|
this.headers = headers;
|
||||||
|
this.namespace = namespace;
|
||||||
|
}
|
||||||
|
|
||||||
|
static get enabled() {
|
||||||
|
return process.env.EVOLV_ASSET_REMOTE === '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
loadAll() {
|
||||||
|
if (!HttpBackend.enabled) {
|
||||||
|
throw new Error(
|
||||||
|
'HttpBackend disabled (set EVOLV_ASSET_REMOTE=1 to enable); ' +
|
||||||
|
'no synchronous remote fetch is implemented yet.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
'HttpBackend.loadAll(): remote asset backend not yet implemented. ' +
|
||||||
|
'Use FileBackend or implement this method against the WBD product API.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async refresh() {
|
||||||
|
return this.loadAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = HttpBackend;
|
||||||
15
src/registry/index.js
Normal file
15
src/registry/index.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const AssetResolver = require('./AssetResolver');
|
||||||
|
const FileBackend = require('./backends/FileBackend');
|
||||||
|
const HttpBackend = require('./backends/HttpBackend');
|
||||||
|
const namespaces = require('./namespaces');
|
||||||
|
|
||||||
|
const assetResolver = new AssetResolver(namespaces);
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
AssetResolver,
|
||||||
|
FileBackend,
|
||||||
|
HttpBackend,
|
||||||
|
assetResolver,
|
||||||
|
};
|
||||||
17
src/registry/namespaces/curves.js
Normal file
17
src/registry/namespaces/curves.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
const FileBackend = require('../backends/FileBackend');
|
||||||
|
|
||||||
|
const backend = new FileBackend({
|
||||||
|
baseDir: path.resolve(__dirname, '../../../datasets/assetData/curves'),
|
||||||
|
layout: 'per-id',
|
||||||
|
caseInsensitive: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
name: 'curves',
|
||||||
|
description: 'Pump and valve performance curves keyed by model id',
|
||||||
|
loadAll: () => backend.loadAll(),
|
||||||
|
refresh: () => backend.refresh(),
|
||||||
|
};
|
||||||
9
src/registry/namespaces/index.js
Normal file
9
src/registry/namespaces/index.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
module.exports = [
|
||||||
|
require('./curves'),
|
||||||
|
require('./menu'),
|
||||||
|
require('./monsterSamples'),
|
||||||
|
require('./monsterSpecs'),
|
||||||
|
require('./units'),
|
||||||
|
];
|
||||||
47
src/registry/namespaces/menu.js
Normal file
47
src/registry/namespaces/menu.js
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const FileBackend = require('../backends/FileBackend');
|
||||||
|
|
||||||
|
const BASE_DIR = path.resolve(__dirname, '../../../datasets/assetData');
|
||||||
|
// Files in datasets/assetData that aren't editor menu trees.
|
||||||
|
const EXCLUDE = ['assetData', 'monsterSamples', 'unitData'];
|
||||||
|
|
||||||
|
// Plain per-id File backend, but the menu namespace also wants to key by the
|
||||||
|
// inner `softwareType` field (so '/menu/rotatingmachine' works even if the
|
||||||
|
// file is named machine.json). The FileBackend gives us filename-keyed maps;
|
||||||
|
// we rekey in a thin wrapper.
|
||||||
|
const backend = new FileBackend({
|
||||||
|
baseDir: BASE_DIR,
|
||||||
|
layout: 'per-id',
|
||||||
|
caseInsensitive: true,
|
||||||
|
exclude: EXCLUDE,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Menu trees are looked up by softwareType. We index by BOTH the inner
|
||||||
|
// `softwareType` field AND the filename (sans .json), because consumers come
|
||||||
|
// from two paths: editor endpoints pass the node type ('rotatingmachine'),
|
||||||
|
// while older code paths pass the filename slug ('machine'). Both should hit
|
||||||
|
// the same tree.
|
||||||
|
function _rekeyBySoftwareType(map) {
|
||||||
|
const out = new Map();
|
||||||
|
for (const [filenameId, data] of map.entries()) {
|
||||||
|
const stKey = String(data?.softwareType || '').toLowerCase();
|
||||||
|
const fnKey = String(filenameId).toLowerCase();
|
||||||
|
if (stKey) out.set(stKey, data);
|
||||||
|
if (fnKey && fnKey !== stKey) out.set(fnKey, data);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
name: 'menu',
|
||||||
|
description: 'Editor cascade trees (supplier→type→model→unit), keyed by softwareType',
|
||||||
|
loadAll: () => _rekeyBySoftwareType(backend.loadAll()),
|
||||||
|
refresh: async () => _rekeyBySoftwareType(await backend.refresh()),
|
||||||
|
// Exposed for inline tests / debugging.
|
||||||
|
_BASE_DIR: BASE_DIR,
|
||||||
|
_EXCLUDE: EXCLUDE,
|
||||||
|
_existsForFilename: (id) => fs.existsSync(path.join(BASE_DIR, `${id}.json`)),
|
||||||
|
};
|
||||||
20
src/registry/namespaces/monsterSamples.js
Normal file
20
src/registry/namespaces/monsterSamples.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
const FileBackend = require('../backends/FileBackend');
|
||||||
|
|
||||||
|
const backend = new FileBackend({
|
||||||
|
baseDir: path.resolve(__dirname, '../../../datasets/assetData'),
|
||||||
|
layout: 'single-file',
|
||||||
|
filePath: 'monsterSamples.json',
|
||||||
|
arrayKey: 'samples',
|
||||||
|
indexField: 'code',
|
||||||
|
caseInsensitive: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
name: 'monsterSamples',
|
||||||
|
description: 'Monster (Aquon) sample codes keyed by sample code',
|
||||||
|
loadAll: () => backend.loadAll(),
|
||||||
|
refresh: () => backend.refresh(),
|
||||||
|
};
|
||||||
30
src/registry/namespaces/monsterSpecs.js
Normal file
30
src/registry/namespaces/monsterSpecs.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
// monsterSpecs is a single-document namespace (one file, two top-level keys:
|
||||||
|
// defaults and bySample). It doesn't fit FileBackend's per-id or single-file
|
||||||
|
// array layouts cleanly — so we inline a tiny loader here instead of bending
|
||||||
|
// FileBackend to accommodate the shape.
|
||||||
|
//
|
||||||
|
// The whole document is exposed under id 'all'. Consumers (AquonSamplesMenu,
|
||||||
|
// monster specificClass) call assetResolver.resolve('monsterSpecs', 'all').
|
||||||
|
|
||||||
|
const FILE_PATH = path.resolve(__dirname, '../../../datasets/assetData/specs/monster/index.json');
|
||||||
|
|
||||||
|
function _load() {
|
||||||
|
if (!fs.existsSync(FILE_PATH)) return new Map();
|
||||||
|
const data = JSON.parse(fs.readFileSync(FILE_PATH, 'utf8'));
|
||||||
|
return new Map([['all', {
|
||||||
|
defaults: data.defaults || {},
|
||||||
|
bySample: data.bySample || {},
|
||||||
|
}]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
name: 'monsterSpecs',
|
||||||
|
description: 'Monster sampling specs (defaults + per-sample overrides) from specs/monster/index.json',
|
||||||
|
loadAll: _load,
|
||||||
|
refresh: () => _load(),
|
||||||
|
};
|
||||||
21
src/registry/namespaces/units.js
Normal file
21
src/registry/namespaces/units.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
const FileBackend = require('../backends/FileBackend');
|
||||||
|
|
||||||
|
// unitData.json lives at datasets/ (not datasets/assetData/).
|
||||||
|
const backend = new FileBackend({
|
||||||
|
baseDir: path.resolve(__dirname, '../../../datasets'),
|
||||||
|
layout: 'single-file',
|
||||||
|
filePath: 'unitData.json',
|
||||||
|
arrayKey: 'units',
|
||||||
|
indexField: 'category',
|
||||||
|
caseInsensitive: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
name: 'units',
|
||||||
|
description: 'Unit families keyed by measurement category (flow, pressure, …)',
|
||||||
|
loadAll: () => backend.loadAll(),
|
||||||
|
refresh: () => backend.refresh(),
|
||||||
|
};
|
||||||
@@ -23,6 +23,13 @@ class state{
|
|||||||
|
|
||||||
this.delayedMove = null;
|
this.delayedMove = null;
|
||||||
this.mode = this.config.mode.current;
|
this.mode = this.config.mode.current;
|
||||||
|
// Monotonic counter incremented on every EXTERNAL abort (i.e. one
|
||||||
|
// initiated outside the in-flight sequence — typically MGC reacting
|
||||||
|
// to a new demand). executeSequence captures the value at entry and
|
||||||
|
// breaks its for-loop if the counter advances mid-sequence, so a
|
||||||
|
// shutdown that was already past its ramp-down step doesn't barge
|
||||||
|
// through stopping → coolingdown when a re-engage arrives.
|
||||||
|
this.sequenceAbortToken = 0;
|
||||||
|
|
||||||
// Log initialization
|
// Log initialization
|
||||||
this.logger.info("State class initialized.");
|
this.logger.info("State class initialized.");
|
||||||
@@ -151,6 +158,14 @@ class state{
|
|||||||
if (this.abortController && !this.abortController.signal.aborted) {
|
if (this.abortController && !this.abortController.signal.aborted) {
|
||||||
this.logger.warn(`Aborting movement: ${reason}`);
|
this.logger.warn(`Aborting movement: ${reason}`);
|
||||||
this._returnToOperationalOnAbort = Boolean(options.returnToOperational);
|
this._returnToOperationalOnAbort = Boolean(options.returnToOperational);
|
||||||
|
// Only external aborts (returnToOperational=false) advance the
|
||||||
|
// sequence-abort token. Sequence-internal aborts (e.g. shutdown's
|
||||||
|
// own setpoint(0) being pre-empted by a fresher shutdown/estop)
|
||||||
|
// come from inside executeSequence and must not terminate their
|
||||||
|
// own loop.
|
||||||
|
if (!options.returnToOperational) {
|
||||||
|
this.sequenceAbortToken += 1;
|
||||||
|
}
|
||||||
this.abortController.abort();
|
this.abortController.abort();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,11 @@
|
|||||||
class stateManager {
|
class stateManager {
|
||||||
constructor(config, logger) {
|
constructor(config, logger) {
|
||||||
this.currentState = config.state.current;
|
this.currentState = config.state.current;
|
||||||
|
// Wall-clock entry timestamp into currentState. Used by
|
||||||
|
// getRemainingTransitionS() so callers (e.g. MGC movement planner)
|
||||||
|
// can compute exact remaining time for timed states without
|
||||||
|
// approximating from the full configured duration.
|
||||||
|
this.stateEnteredAt = Date.now();
|
||||||
this.availableStates = config.state.available;
|
this.availableStates = config.state.available;
|
||||||
this.descriptions = config.state.descriptions;
|
this.descriptions = config.state.descriptions;
|
||||||
this.logger = logger;
|
this.logger = logger;
|
||||||
@@ -64,6 +69,17 @@ class stateManager {
|
|||||||
return this.currentState;
|
return this.currentState;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Seconds remaining in the current timed state (warmingup, coolingdown,
|
||||||
|
// starting, stopping, …). Returns 0 for untimed states or once the
|
||||||
|
// configured duration has elapsed. The MGC movement planner uses this to
|
||||||
|
// compute exact rendezvous time for protected (non-interruptible) states.
|
||||||
|
getRemainingTransitionS() {
|
||||||
|
const d = this.transitionTimes?.[this.currentState] || 0;
|
||||||
|
if (d <= 0) return 0;
|
||||||
|
const elapsed = (Date.now() - this.stateEnteredAt) / 1000;
|
||||||
|
return Math.max(0, d - elapsed);
|
||||||
|
}
|
||||||
|
|
||||||
transitionTo(newState,signal) {
|
transitionTo(newState,signal) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (signal && signal.aborted) {
|
if (signal && signal.aborted) {
|
||||||
@@ -89,6 +105,7 @@ class stateManager {
|
|||||||
if (transitionDuration > 0) {
|
if (transitionDuration > 0) {
|
||||||
const timeoutId = setTimeout(() => {
|
const timeoutId = setTimeout(() => {
|
||||||
this.currentState = newState;
|
this.currentState = newState;
|
||||||
|
this.stateEnteredAt = Date.now();
|
||||||
resolve(`Transition from ${this.currentState} to ${newState} completed in ${transitionDuration}s.`);
|
resolve(`Transition from ${this.currentState} to ${newState} completed in ${transitionDuration}s.`);
|
||||||
}, transitionDuration * 1000);
|
}, transitionDuration * 1000);
|
||||||
if (signal) {
|
if (signal) {
|
||||||
@@ -99,6 +116,7 @@ class stateManager {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.currentState = newState;
|
this.currentState = newState;
|
||||||
|
this.stateEnteredAt = Date.now();
|
||||||
resolve(`Immediate transition to ${this.currentState} completed.`);
|
resolve(`Immediate transition to ${this.currentState} completed.`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
52
src/stats/index.js
Normal file
52
src/stats/index.js
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reducer-shape stats helpers shared across the platform.
|
||||||
|
*
|
||||||
|
* These were duplicated as static helpers on `Channel` and as instance
|
||||||
|
* methods on the older `measurement/specificClass.js`. Consolidated here so
|
||||||
|
* any consumer (outlier detection, monster summaries, future analytics)
|
||||||
|
* can import a single canonical implementation.
|
||||||
|
*
|
||||||
|
* Stream-shape filters (low/high/band-pass, kalman, savitzky-golay) stay
|
||||||
|
* on Channel as static helpers — they're pipeline state, not reducers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function mean(arr) {
|
||||||
|
if (!arr.length) return 0;
|
||||||
|
return arr.reduce((a, b) => a + b, 0) / arr.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sample std dev (n-1 denominator). A single sample has no variance to
|
||||||
|
// estimate, so we return 0 rather than NaN — callers (e.g. z-score) treat
|
||||||
|
// 0 as "no spread yet" and skip rejection.
|
||||||
|
function stdDev(arr) {
|
||||||
|
if (arr.length <= 1) return 0;
|
||||||
|
const m = mean(arr);
|
||||||
|
const variance = arr.reduce((s, v) => s + (v - m) ** 2, 0) / (arr.length - 1);
|
||||||
|
return Math.sqrt(variance);
|
||||||
|
}
|
||||||
|
|
||||||
|
function median(arr) {
|
||||||
|
if (!arr.length) return 0;
|
||||||
|
const sorted = [...arr].sort((a, b) => a - b);
|
||||||
|
const mid = Math.floor(sorted.length / 2);
|
||||||
|
return sorted.length % 2 !== 0
|
||||||
|
? sorted[mid]
|
||||||
|
: (sorted[mid - 1] + sorted[mid]) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mad(arr) {
|
||||||
|
if (!arr.length) return 0;
|
||||||
|
const med = median(arr);
|
||||||
|
return median(arr.map((v) => Math.abs(v - med)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Degenerate-range pass-through matches Channel._lerp: callers rely on it
|
||||||
|
// for early-warmup paths where input bounds haven't separated yet.
|
||||||
|
function lerp(value, iMin, iMax, oMin, oMax) {
|
||||||
|
if (iMin >= iMax) return value;
|
||||||
|
return oMin + ((value - iMin) * (oMax - oMin)) / (iMax - iMin);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { mean, stdDev, median, mad, lerp };
|
||||||
@@ -26,8 +26,11 @@ test('barrel exports expected public members', () => {
|
|||||||
'createCascadePidController',
|
'createCascadePidController',
|
||||||
'childRegistrationUtils',
|
'childRegistrationUtils',
|
||||||
'loadCurve',
|
'loadCurve',
|
||||||
'loadModel',
|
|
||||||
'gravity',
|
'gravity',
|
||||||
|
'AssetResolver',
|
||||||
|
'FileBackend',
|
||||||
|
'HttpBackend',
|
||||||
|
'assetResolver',
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const key of expected) {
|
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.createPidController, 'function');
|
||||||
assert.equal(typeof barrel.createCascadePidController, 'function');
|
assert.equal(typeof barrel.createCascadePidController, 'function');
|
||||||
assert.equal(typeof barrel.gravity.getStandardGravity, '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');
|
||||||
});
|
});
|
||||||
|
|||||||
195
test/basic/BaseDomain.basic.test.js
Normal file
195
test/basic/BaseDomain.basic.test.js
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { EventEmitter } = require('events');
|
||||||
|
|
||||||
|
const BaseDomain = require('../../src/domain/BaseDomain');
|
||||||
|
const UnitPolicy = require('../../src/domain/UnitPolicy');
|
||||||
|
|
||||||
|
// ── Subclasses ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Minimal subclass — relies on every base default. Uses 'measurement' so the
|
||||||
|
// configManager finds a real config schema in src/configs/measurement.json.
|
||||||
|
class PlainMeasurement extends BaseDomain {
|
||||||
|
static name = 'measurement';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subclass that records call ordering and exposes hooks.
|
||||||
|
class TrackingMeasurement extends BaseDomain {
|
||||||
|
static name = 'measurement';
|
||||||
|
|
||||||
|
configure() {
|
||||||
|
this.calls = this.calls || [];
|
||||||
|
// Pin the moment at which `configure` runs — these MUST be populated
|
||||||
|
// before the hook fires.
|
||||||
|
this.calls.push({
|
||||||
|
hook: 'configure',
|
||||||
|
hasConfig: !!this.config,
|
||||||
|
hasMeasurements: !!this.measurements,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_init() {
|
||||||
|
this.calls = this.calls || [];
|
||||||
|
this.calls.push({ hook: '_init' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subclass with a UnitPolicy — verify containerOptions reach MeasurementContainer.
|
||||||
|
class PolicyMeasurement extends BaseDomain {
|
||||||
|
static name = 'measurement';
|
||||||
|
static unitPolicy = UnitPolicy.declare({
|
||||||
|
canonical: { flow: 'm3/s', pressure: 'Pa' },
|
||||||
|
output: { flow: 'L/s', pressure: 'kPa' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subclass that declares a child getter in `configure`.
|
||||||
|
class ParentDomain extends BaseDomain {
|
||||||
|
static name = 'measurement';
|
||||||
|
|
||||||
|
configure() {
|
||||||
|
this.declareChildGetter('machines', 'machine');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function makeChild({ id = 'c1', name = id, softwareType = 'machine', category = 'centrifugal' } = {}) {
|
||||||
|
return {
|
||||||
|
config: {
|
||||||
|
general: { id, name },
|
||||||
|
functionality: { softwareType },
|
||||||
|
asset: { category, type: 'pump' },
|
||||||
|
},
|
||||||
|
measurements: {
|
||||||
|
emitter: new EventEmitter(),
|
||||||
|
setChildId() {}, setChildName() {}, setParentRef() {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('constructs successfully against a real config schema', () => {
|
||||||
|
const m = new PlainMeasurement({});
|
||||||
|
assert.ok(m.config?.general?.name);
|
||||||
|
assert.ok(m.measurements);
|
||||||
|
assert.ok(m.logger);
|
||||||
|
assert.ok(m.emitter);
|
||||||
|
assert.ok(m.childRegistrationUtils);
|
||||||
|
assert.ok(m.router);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('configure() runs after config + measurements are populated, exactly once', () => {
|
||||||
|
const m = new TrackingMeasurement({});
|
||||||
|
const configureCalls = m.calls.filter(c => c.hook === 'configure');
|
||||||
|
assert.equal(configureCalls.length, 1);
|
||||||
|
assert.equal(configureCalls[0].hasConfig, true);
|
||||||
|
assert.equal(configureCalls[0].hasMeasurements, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('_init() runs after configure()', () => {
|
||||||
|
const m = new TrackingMeasurement({});
|
||||||
|
const order = m.calls.map(c => c.hook);
|
||||||
|
assert.deepEqual(order, ['configure', '_init']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('static unitPolicy is honored — defaultUnits reflect output map', () => {
|
||||||
|
const m = new PolicyMeasurement({});
|
||||||
|
// PolicyMeasurement declares output.flow='L/s', output.pressure='kPa'
|
||||||
|
assert.equal(m.measurements.defaultUnits.flow, 'L/s');
|
||||||
|
assert.equal(m.measurements.defaultUnits.pressure, 'kPa');
|
||||||
|
// Canonical flow was declared as 'm3/s'
|
||||||
|
assert.equal(m.measurements.canonicalUnits.flow, 'm3/s');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('without unitPolicy, MeasurementContainer keeps its built-in defaults', () => {
|
||||||
|
const m = new PlainMeasurement({});
|
||||||
|
assert.equal(m.unitPolicy, null);
|
||||||
|
// Built-in defaults from MeasurementContainer.
|
||||||
|
assert.equal(m.measurements.defaultUnits.flow, 'm3/h');
|
||||||
|
assert.equal(m.measurements.defaultUnits.pressure, 'mbar');
|
||||||
|
assert.equal(m.measurements.autoConvert, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('declareChildGetter flattens registry slice across categories', () => {
|
||||||
|
const p = new ParentDomain({});
|
||||||
|
// Empty before any registration.
|
||||||
|
assert.deepEqual(p.machines, {});
|
||||||
|
|
||||||
|
// Mirror what childRegistrationUtils._storeChild does: child.machine.<cat>=[...]
|
||||||
|
const a = makeChild({ id: 'pumpA', category: 'centrifugal' });
|
||||||
|
const b = makeChild({ id: 'pumpB', category: 'positivedisplacement' });
|
||||||
|
p.child = { machine: { centrifugal: [a], positivedisplacement: [b] } };
|
||||||
|
|
||||||
|
const flat = p.machines;
|
||||||
|
assert.deepEqual(Object.keys(flat).sort(), ['pumpA', 'pumpB']);
|
||||||
|
assert.equal(flat.pumpA, a);
|
||||||
|
assert.equal(flat.pumpB, b);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('notifyOutputChanged fires "output-changed" on emitter', () => {
|
||||||
|
const m = new PlainMeasurement({});
|
||||||
|
let count = 0;
|
||||||
|
m.emitter.on('output-changed', () => count++);
|
||||||
|
m.notifyOutputChanged();
|
||||||
|
m.notifyOutputChanged();
|
||||||
|
assert.equal(count, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('context() returns a frozen object with the documented keys', () => {
|
||||||
|
const m = new PlainMeasurement({});
|
||||||
|
const ctx = m.context();
|
||||||
|
assert.ok(Object.isFrozen(ctx));
|
||||||
|
for (const k of ['config', 'logger', 'measurements', 'emitter', 'child', 'unitPolicy', 'router']) {
|
||||||
|
assert.ok(k in ctx, `context() missing key '${k}'`);
|
||||||
|
}
|
||||||
|
assert.equal(ctx.config, m.config);
|
||||||
|
assert.equal(ctx.measurements, m.measurements);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('close() removes emitter listeners and tears down router', () => {
|
||||||
|
const m = new PlainMeasurement({});
|
||||||
|
let teardownCount = 0;
|
||||||
|
const origTeardown = m.router.tearDown.bind(m.router);
|
||||||
|
m.router.tearDown = () => { teardownCount++; origTeardown(); };
|
||||||
|
|
||||||
|
m.emitter.on('output-changed', () => {});
|
||||||
|
assert.equal(m.emitter.listenerCount('output-changed'), 1);
|
||||||
|
|
||||||
|
m.close();
|
||||||
|
assert.equal(teardownCount, 1);
|
||||||
|
assert.equal(m.emitter.listenerCount('output-changed'), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('registerChild delegates to router.dispatchRegister', () => {
|
||||||
|
const m = new PlainMeasurement({});
|
||||||
|
const seen = [];
|
||||||
|
const origDispatch = m.router.dispatchRegister.bind(m.router);
|
||||||
|
m.router.dispatchRegister = (child, st) => {
|
||||||
|
seen.push({ id: child.config.general.id, st });
|
||||||
|
return origDispatch(child, st);
|
||||||
|
};
|
||||||
|
|
||||||
|
const child = makeChild({ id: 'kid1', softwareType: 'measurement' });
|
||||||
|
const result = m.registerChild(child, 'measurement');
|
||||||
|
assert.equal(result, true);
|
||||||
|
assert.deepEqual(seen, [{ id: 'kid1', st: 'measurement' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('childRegistrationUtils.registerChild flows through router (end-to-end handshake)', async () => {
|
||||||
|
const m = new PlainMeasurement({});
|
||||||
|
let routed = null;
|
||||||
|
m.router.onRegister('measurement', (child, st) => {
|
||||||
|
routed = { id: child.config.general.id, st };
|
||||||
|
});
|
||||||
|
|
||||||
|
const child = makeChild({ id: 'kid2', softwareType: 'measurement' });
|
||||||
|
await m.childRegistrationUtils.registerChild(child, 'upstream', 0);
|
||||||
|
|
||||||
|
assert.deepEqual(routed, { id: 'kid2', st: 'measurement' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('direct BaseDomain instantiation throws (abstract)', () => {
|
||||||
|
assert.throws(() => new BaseDomain({}), /abstract/);
|
||||||
|
});
|
||||||
457
test/basic/BaseNodeAdapter.basic.test.js
Normal file
457
test/basic/BaseNodeAdapter.basic.test.js
Normal file
@@ -0,0 +1,457 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const EventEmitter = require('events');
|
||||||
|
|
||||||
|
const BaseNodeAdapter = require('../../src/nodered/BaseNodeAdapter');
|
||||||
|
|
||||||
|
// ---- test doubles ---------------------------------------------------------
|
||||||
|
|
||||||
|
function makeLogger() {
|
||||||
|
const calls = { warn: [], error: [], info: [], debug: [] };
|
||||||
|
return {
|
||||||
|
warn: (...a) => calls.warn.push(a.join(' ')),
|
||||||
|
error: (...a) => calls.error.push(a.join(' ')),
|
||||||
|
info: (...a) => calls.info.push(a.join(' ')),
|
||||||
|
debug: (...a) => calls.debug.push(a.join(' ')),
|
||||||
|
_calls: calls,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeNode(id = 'node-1') {
|
||||||
|
const sends = [];
|
||||||
|
const statuses = [];
|
||||||
|
const handlers = {};
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
sends,
|
||||||
|
statuses,
|
||||||
|
handlers,
|
||||||
|
send(arr) { sends.push(arr); },
|
||||||
|
status(b) { statuses.push(b); },
|
||||||
|
on(ev, fn) { handlers[ev] = fn; },
|
||||||
|
warn() {},
|
||||||
|
error() {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRED() {
|
||||||
|
return { nodes: { getNode: () => null } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fake domain — surfaces just enough of the BaseDomain contract that
|
||||||
|
// BaseNodeAdapter touches (config, logger, emitter, getOutput, getStatusBadge,
|
||||||
|
// optionally tick + close). Avoids the JSON-config dependency BaseDomain has.
|
||||||
|
function makeDomain(opts = {}) {
|
||||||
|
const logger = opts.logger || makeLogger();
|
||||||
|
return class FakeDomain {
|
||||||
|
constructor(config) {
|
||||||
|
this.config = config;
|
||||||
|
this.logger = logger;
|
||||||
|
this.emitter = new EventEmitter();
|
||||||
|
this.tickCount = 0;
|
||||||
|
this.closed = false;
|
||||||
|
this._output = opts.output || { temperature: 21 };
|
||||||
|
this._badge = opts.badge || { fill: 'green', shape: 'dot', text: 'OK' };
|
||||||
|
}
|
||||||
|
tick() { this.tickCount += 1; }
|
||||||
|
getOutput() { return this._output; }
|
||||||
|
getStatusBadge() { return this._badge; }
|
||||||
|
close() { this.closed = true; }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// uiConfig field set used by configManager.buildConfig — measurement is
|
||||||
|
// chosen as the config-file name because measurement.json ships in
|
||||||
|
// generalFunctions/src/configs and getConfig() is called during construction.
|
||||||
|
function uiConfigFixture() {
|
||||||
|
return {
|
||||||
|
name: 'm1', unit: 'C', logLevel: 'warn',
|
||||||
|
positionVsParent: 'upstream', hasDistance: true, distance: 5,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 1. Construction with full subclass succeeds --------------------------
|
||||||
|
|
||||||
|
test('full subclass constructs and stores wiring on this', () => {
|
||||||
|
const Domain = makeDomain();
|
||||||
|
class Adapter extends BaseNodeAdapter {
|
||||||
|
static DomainClass = Domain;
|
||||||
|
static commands = [];
|
||||||
|
// Disable the real status interval — would hold the event loop open
|
||||||
|
// past the test and stall `node --test test/basic/` runs.
|
||||||
|
static statusInterval = 0;
|
||||||
|
buildDomainConfig() { return { extra: { foo: 1 } }; }
|
||||||
|
}
|
||||||
|
const node = makeNode();
|
||||||
|
const a = new Adapter(uiConfigFixture(), makeRED(), node, 'measurement');
|
||||||
|
assert.equal(a.name, 'measurement');
|
||||||
|
assert.equal(a.node, node);
|
||||||
|
assert.equal(node.source, a.source);
|
||||||
|
assert.equal(a.config.extra.foo, 1);
|
||||||
|
assert.equal(a.config.general.name, 'm1');
|
||||||
|
node.handlers.close(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- 2-4. Static-field validation -----------------------------------------
|
||||||
|
|
||||||
|
test('direct new BaseNodeAdapter() throws abstract error', () => {
|
||||||
|
assert.throws(
|
||||||
|
() => new BaseNodeAdapter({}, makeRED(), makeNode(), 'measurement'),
|
||||||
|
/abstract/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('subclass without static DomainClass throws clearly', () => {
|
||||||
|
class Bad extends BaseNodeAdapter { static commands = []; buildDomainConfig() { return {}; } }
|
||||||
|
assert.throws(
|
||||||
|
() => new Bad({}, makeRED(), makeNode(), 'measurement'),
|
||||||
|
/DomainClass is required/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('subclass without static commands throws clearly', () => {
|
||||||
|
class Bad extends BaseNodeAdapter {
|
||||||
|
static DomainClass = makeDomain();
|
||||||
|
buildDomainConfig() { return {}; }
|
||||||
|
}
|
||||||
|
assert.throws(
|
||||||
|
() => new Bad({}, makeRED(), makeNode(), 'measurement'),
|
||||||
|
/commands is required/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('static commands = [] is allowed (explicit no-op registry)', () => {
|
||||||
|
class Adapter extends BaseNodeAdapter {
|
||||||
|
static DomainClass = makeDomain();
|
||||||
|
static commands = [];
|
||||||
|
static statusInterval = 0; // see fix in test #1
|
||||||
|
buildDomainConfig() { return {}; }
|
||||||
|
}
|
||||||
|
const node = makeNode();
|
||||||
|
assert.doesNotThrow(
|
||||||
|
() => new Adapter(uiConfigFixture(), makeRED(), node, 'measurement'),
|
||||||
|
);
|
||||||
|
node.handlers.close(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- 5. Registration message after 100 ms ---------------------------------
|
||||||
|
|
||||||
|
test('registration message fires on Port 2 after 100 ms with child.register', (t) => {
|
||||||
|
t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] });
|
||||||
|
class Adapter extends BaseNodeAdapter {
|
||||||
|
static DomainClass = makeDomain();
|
||||||
|
static commands = [];
|
||||||
|
buildDomainConfig() { return {}; }
|
||||||
|
}
|
||||||
|
const node = makeNode('xyz');
|
||||||
|
new Adapter(uiConfigFixture(), makeRED(), node, 'measurement');
|
||||||
|
assert.equal(node.sends.length, 0);
|
||||||
|
t.mock.timers.tick(100);
|
||||||
|
assert.equal(node.sends.length, 1);
|
||||||
|
const [p0, p1, reg] = node.sends[0];
|
||||||
|
assert.equal(p0, null);
|
||||||
|
assert.equal(p1, null);
|
||||||
|
assert.equal(reg.topic, 'child.register');
|
||||||
|
assert.equal(reg.payload, 'xyz');
|
||||||
|
assert.equal(reg.positionVsParent, 'upstream');
|
||||||
|
assert.equal(reg.distance, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- 6. Tick mode ---------------------------------------------------------
|
||||||
|
|
||||||
|
test('static tickInterval > 0 calls source.tick() on schedule and emits outputs', (t) => {
|
||||||
|
t.mock.timers.enable({ apis: ['setInterval', 'setTimeout'] });
|
||||||
|
class Adapter extends BaseNodeAdapter {
|
||||||
|
static DomainClass = makeDomain();
|
||||||
|
static commands = [];
|
||||||
|
static tickInterval = 50;
|
||||||
|
static statusInterval = 0;
|
||||||
|
buildDomainConfig() { return {}; }
|
||||||
|
}
|
||||||
|
const node = makeNode();
|
||||||
|
const a = new Adapter(uiConfigFixture(), makeRED(), node, 'measurement');
|
||||||
|
assert.equal(a.source.tickCount, 0);
|
||||||
|
t.mock.timers.tick(50);
|
||||||
|
assert.equal(a.source.tickCount, 1);
|
||||||
|
t.mock.timers.tick(100);
|
||||||
|
assert.equal(a.source.tickCount, 3);
|
||||||
|
// Every tick triggers an output emission (the first carries the changed
|
||||||
|
// fields; subsequent ones may emit nulls because of delta compression —
|
||||||
|
// but node.send is called either way).
|
||||||
|
assert.ok(node.sends.length >= 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- 7. Event-driven default ----------------------------------------------
|
||||||
|
|
||||||
|
test('default (no tick) subscribes to "output-changed" on source.emitter', (t) => {
|
||||||
|
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||||
|
class Adapter extends BaseNodeAdapter {
|
||||||
|
static DomainClass = makeDomain();
|
||||||
|
static commands = [];
|
||||||
|
static statusInterval = 0;
|
||||||
|
buildDomainConfig() { return {}; }
|
||||||
|
}
|
||||||
|
const node = makeNode();
|
||||||
|
const a = new Adapter(uiConfigFixture(), makeRED(), node, 'measurement');
|
||||||
|
// Drain the registration tick so we can isolate output emissions.
|
||||||
|
t.mock.timers.tick(100);
|
||||||
|
const before = node.sends.length;
|
||||||
|
a.source.emitter.emit('output-changed');
|
||||||
|
assert.equal(node.sends.length, before + 1);
|
||||||
|
const last = node.sends[node.sends.length - 1];
|
||||||
|
assert.equal(last.length, 3);
|
||||||
|
assert.equal(last[2], null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- 8. _emitOutputs shape ------------------------------------------------
|
||||||
|
|
||||||
|
test('_emitOutputs sends [processMsg, influxMsg, null] with both formatters', () => {
|
||||||
|
class Adapter extends BaseNodeAdapter {
|
||||||
|
static DomainClass = makeDomain({ output: { v: 1 } });
|
||||||
|
static commands = [];
|
||||||
|
static statusInterval = 0;
|
||||||
|
buildDomainConfig() { return {}; }
|
||||||
|
}
|
||||||
|
const node = makeNode();
|
||||||
|
const a = new Adapter(uiConfigFixture(), makeRED(), node, 'measurement');
|
||||||
|
node.sends.length = 0;
|
||||||
|
a._emitOutputs();
|
||||||
|
assert.equal(node.sends.length, 1);
|
||||||
|
const [proc, influx, port2] = node.sends[0];
|
||||||
|
assert.ok(proc && typeof proc === 'object', 'process msg present');
|
||||||
|
assert.ok(influx && typeof influx === 'object', 'influxdb msg present');
|
||||||
|
assert.equal(port2, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- 9-10. Input dispatch -------------------------------------------------
|
||||||
|
|
||||||
|
test('input handler dispatches a known topic to the registered handler', async () => {
|
||||||
|
const seen = [];
|
||||||
|
class Adapter extends BaseNodeAdapter {
|
||||||
|
static DomainClass = makeDomain();
|
||||||
|
static commands = [{
|
||||||
|
topic: 'set.mode',
|
||||||
|
handler: (source, msg, ctx) => { seen.push({ source, msg, ctx }); },
|
||||||
|
}];
|
||||||
|
static statusInterval = 0;
|
||||||
|
buildDomainConfig() { return {}; }
|
||||||
|
}
|
||||||
|
const node = makeNode();
|
||||||
|
const a = new Adapter(uiConfigFixture(), makeRED(), node, 'measurement');
|
||||||
|
let donec = 0;
|
||||||
|
await node.handlers.input({ topic: 'set.mode', payload: 'auto' }, () => {}, () => { donec += 1; });
|
||||||
|
assert.equal(seen.length, 1);
|
||||||
|
assert.equal(seen[0].source, a.source);
|
||||||
|
assert.equal(seen[0].msg.payload, 'auto');
|
||||||
|
assert.equal(donec, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('input handler with unknown topic warns and does not crash', async () => {
|
||||||
|
const logger = makeLogger();
|
||||||
|
class Adapter extends BaseNodeAdapter {
|
||||||
|
static DomainClass = makeDomain({ logger });
|
||||||
|
static commands = [];
|
||||||
|
static statusInterval = 0;
|
||||||
|
buildDomainConfig() { return {}; }
|
||||||
|
}
|
||||||
|
const node = makeNode();
|
||||||
|
new Adapter(uiConfigFixture(), makeRED(), node, 'measurement');
|
||||||
|
let donec = 0;
|
||||||
|
await node.handlers.input({ topic: 'totally.unknown', payload: 1 }, () => {}, () => { donec += 1; });
|
||||||
|
assert.equal(donec, 1);
|
||||||
|
assert.ok(logger._calls.warn.some((m) => m.includes('totally.unknown')));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- 11. Status updater wiring --------------------------------------------
|
||||||
|
|
||||||
|
test('status updater receives static statusInterval', (t) => {
|
||||||
|
t.mock.timers.enable({ apis: ['setInterval', 'setTimeout'] });
|
||||||
|
class Adapter extends BaseNodeAdapter {
|
||||||
|
static DomainClass = makeDomain({ badge: { fill: 'red', shape: 'ring', text: 'X' } });
|
||||||
|
static commands = [];
|
||||||
|
static statusInterval = 250;
|
||||||
|
buildDomainConfig() { return {}; }
|
||||||
|
}
|
||||||
|
const node = makeNode();
|
||||||
|
new Adapter(uiConfigFixture(), makeRED(), node, 'measurement');
|
||||||
|
assert.equal(node.statuses.length, 0);
|
||||||
|
t.mock.timers.tick(250);
|
||||||
|
assert.equal(node.statuses.length, 1);
|
||||||
|
assert.deepEqual(node.statuses[0], { fill: 'red', shape: 'ring', text: 'X' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- 12. Close handler ----------------------------------------------------
|
||||||
|
|
||||||
|
test('close handler clears tick interval, stops status, clears badge, calls source.close', (t) => {
|
||||||
|
t.mock.timers.enable({ apis: ['setInterval', 'setTimeout'] });
|
||||||
|
class Adapter extends BaseNodeAdapter {
|
||||||
|
static DomainClass = makeDomain();
|
||||||
|
static commands = [];
|
||||||
|
static tickInterval = 100;
|
||||||
|
static statusInterval = 100;
|
||||||
|
buildDomainConfig() { return {}; }
|
||||||
|
}
|
||||||
|
const node = makeNode();
|
||||||
|
const a = new Adapter(uiConfigFixture(), makeRED(), node, 'measurement');
|
||||||
|
t.mock.timers.tick(200); // two ticks fire
|
||||||
|
const ticksAtClose = a.source.tickCount;
|
||||||
|
let donec = 0;
|
||||||
|
node.handlers.close(() => { donec += 1; });
|
||||||
|
assert.equal(donec, 1);
|
||||||
|
assert.equal(a.source.closed, true);
|
||||||
|
// Final node.status({}) appears in statuses.
|
||||||
|
assert.deepEqual(node.statuses[node.statuses.length - 1], {});
|
||||||
|
// No further ticks after close.
|
||||||
|
t.mock.timers.tick(1000);
|
||||||
|
assert.equal(a.source.tickCount, ticksAtClose);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- 13. Hook points fire when defined ------------------------------------
|
||||||
|
|
||||||
|
// ---- 14-16. Auto-wired query.units ---------------------------------------
|
||||||
|
|
||||||
|
test('implicit query.units returns measure+default+accepted for every units-declaring topic', async () => {
|
||||||
|
class Adapter extends BaseNodeAdapter {
|
||||||
|
static DomainClass = makeDomain();
|
||||||
|
static commands = [
|
||||||
|
{
|
||||||
|
topic: 'set.demand',
|
||||||
|
units: { measure: 'volumeFlowRate', default: 'm3/h' },
|
||||||
|
payloadSchema: { type: 'number' },
|
||||||
|
handler: () => {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
topic: 'cmd.calibrate.volume',
|
||||||
|
units: { measure: 'volume', default: 'm3' },
|
||||||
|
payloadSchema: { type: 'number' },
|
||||||
|
handler: () => {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
topic: 'set.mode',
|
||||||
|
payloadSchema: { type: 'string' },
|
||||||
|
handler: () => {},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
static statusInterval = 0;
|
||||||
|
buildDomainConfig() { return {}; }
|
||||||
|
}
|
||||||
|
const node = makeNode();
|
||||||
|
new Adapter(uiConfigFixture(), makeRED(), node, 'measurement');
|
||||||
|
const sent = [];
|
||||||
|
await node.handlers.input(
|
||||||
|
{ topic: 'query.units' },
|
||||||
|
(arr) => sent.push(arr),
|
||||||
|
() => {},
|
||||||
|
);
|
||||||
|
assert.equal(sent.length, 1);
|
||||||
|
const [p0, p1, p2] = sent[0];
|
||||||
|
assert.equal(p1, null);
|
||||||
|
assert.equal(p2, null);
|
||||||
|
assert.equal(p0.topic, 'query.units');
|
||||||
|
assert.equal(p0.payload.node, 'measurement');
|
||||||
|
const u = p0.payload.units;
|
||||||
|
assert.ok(u['set.demand'], 'set.demand entry present');
|
||||||
|
assert.equal(u['set.demand'].measure, 'volumeFlowRate');
|
||||||
|
assert.equal(u['set.demand'].default, 'm3/h');
|
||||||
|
assert.ok(Array.isArray(u['set.demand'].accepted), 'accepted is an array');
|
||||||
|
assert.ok(u['set.demand'].accepted.length > 0, 'accepted is non-empty');
|
||||||
|
assert.ok(u['cmd.calibrate.volume'], 'cmd.calibrate.volume entry present');
|
||||||
|
assert.equal(u['cmd.calibrate.volume'].measure, 'volume');
|
||||||
|
assert.equal(u['cmd.calibrate.volume'].default, 'm3');
|
||||||
|
// Topic without units does not show up.
|
||||||
|
assert.equal(u['set.mode'], undefined);
|
||||||
|
node.handlers.close(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('implicit query.units returns empty units object when no command declares units', async () => {
|
||||||
|
class Adapter extends BaseNodeAdapter {
|
||||||
|
static DomainClass = makeDomain();
|
||||||
|
static commands = [
|
||||||
|
{ topic: 'set.mode', payloadSchema: { type: 'string' }, handler: () => {} },
|
||||||
|
];
|
||||||
|
static statusInterval = 0;
|
||||||
|
buildDomainConfig() { return {}; }
|
||||||
|
}
|
||||||
|
const node = makeNode();
|
||||||
|
new Adapter(uiConfigFixture(), makeRED(), node, 'measurement');
|
||||||
|
const sent = [];
|
||||||
|
await node.handlers.input(
|
||||||
|
{ topic: 'query.units' },
|
||||||
|
(arr) => sent.push(arr),
|
||||||
|
() => {},
|
||||||
|
);
|
||||||
|
assert.equal(sent.length, 1);
|
||||||
|
const [p0] = sent[0];
|
||||||
|
assert.equal(p0.topic, 'query.units');
|
||||||
|
assert.deepEqual(p0.payload.units, {});
|
||||||
|
assert.equal(p0.payload.node, 'measurement');
|
||||||
|
node.handlers.close(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('explicit query.units descriptor wins over the implicit auto-wired handler', async () => {
|
||||||
|
let customRan = 0;
|
||||||
|
class Adapter extends BaseNodeAdapter {
|
||||||
|
static DomainClass = makeDomain();
|
||||||
|
static commands = [
|
||||||
|
{
|
||||||
|
topic: 'set.demand',
|
||||||
|
units: { measure: 'volumeFlowRate', default: 'm3/h' },
|
||||||
|
payloadSchema: { type: 'number' },
|
||||||
|
handler: () => {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
topic: 'query.units',
|
||||||
|
payloadSchema: { type: 'any' },
|
||||||
|
handler: (source, msg, ctx) => {
|
||||||
|
customRan += 1;
|
||||||
|
if (ctx && typeof ctx.send === 'function') {
|
||||||
|
ctx.send([{ topic: 'query.units', payload: 'CUSTOM' }, null, null]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
static statusInterval = 0;
|
||||||
|
buildDomainConfig() { return {}; }
|
||||||
|
}
|
||||||
|
const node = makeNode();
|
||||||
|
new Adapter(uiConfigFixture(), makeRED(), node, 'measurement');
|
||||||
|
const sent = [];
|
||||||
|
await node.handlers.input(
|
||||||
|
{ topic: 'query.units' },
|
||||||
|
(arr) => sent.push(arr),
|
||||||
|
() => {},
|
||||||
|
);
|
||||||
|
assert.equal(customRan, 1, 'custom handler must have been called once');
|
||||||
|
assert.equal(sent.length, 1);
|
||||||
|
assert.equal(sent[0][0].payload, 'CUSTOM',
|
||||||
|
'reply payload comes from the subclass-declared handler, not the implicit one');
|
||||||
|
node.handlers.close(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('extraSetup, extraInputDispatch, extraClose hooks fire when present', async (t) => {
|
||||||
|
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||||
|
const trace = [];
|
||||||
|
class Adapter extends BaseNodeAdapter {
|
||||||
|
static DomainClass = makeDomain();
|
||||||
|
static commands = [{ topic: 'set.x', handler: () => { trace.push('handler'); } }];
|
||||||
|
static statusInterval = 0;
|
||||||
|
buildDomainConfig() { return {}; }
|
||||||
|
extraSetup() { trace.push('extraSetup'); }
|
||||||
|
extraInputDispatch(msg) { trace.push(`extraInput:${msg.topic}`); }
|
||||||
|
extraClose() { trace.push('extraClose'); }
|
||||||
|
}
|
||||||
|
const node = makeNode();
|
||||||
|
new Adapter(uiConfigFixture(), makeRED(), node, 'measurement');
|
||||||
|
assert.ok(trace.includes('extraSetup'));
|
||||||
|
await node.handlers.input({ topic: 'set.x', payload: 1 }, () => {}, () => {});
|
||||||
|
assert.ok(trace.includes('handler'));
|
||||||
|
assert.ok(trace.includes('extraInput:set.x'));
|
||||||
|
// Unknown-topic path also runs extraInputDispatch — by design, it's the
|
||||||
|
// fallback the contract documents.
|
||||||
|
await node.handlers.input({ topic: 'unknown', payload: 1 }, () => {}, () => {});
|
||||||
|
assert.ok(trace.includes('extraInput:unknown'));
|
||||||
|
node.handlers.close(() => {});
|
||||||
|
assert.ok(trace.includes('extraClose'));
|
||||||
|
});
|
||||||
268
test/basic/ChildRouter.basic.test.js
Normal file
268
test/basic/ChildRouter.basic.test.js
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { EventEmitter } = require('events');
|
||||||
|
|
||||||
|
const ChildRouter = require('../../src/domain/ChildRouter');
|
||||||
|
|
||||||
|
// ── helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function makeDomain() {
|
||||||
|
const logs = [];
|
||||||
|
return {
|
||||||
|
logger: {
|
||||||
|
debug: (...a) => logs.push(['debug', ...a]),
|
||||||
|
info: (...a) => logs.push(['info', ...a]),
|
||||||
|
warn: (...a) => logs.push(['warn', ...a]),
|
||||||
|
error: (...a) => logs.push(['error', ...a]),
|
||||||
|
},
|
||||||
|
_logs: logs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeChild({ id = 'c1', name = id, softwareType = 'measurement' } = {}) {
|
||||||
|
return {
|
||||||
|
config: {
|
||||||
|
general: { id, name },
|
||||||
|
functionality: { softwareType },
|
||||||
|
asset: { type: 'pressure' },
|
||||||
|
},
|
||||||
|
measurements: { emitter: new EventEmitter() },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitMeasured(child, type, position, value, extra = {}) {
|
||||||
|
child.measurements.emitter.emit(`${type}.measured.${position}`, { value, ...extra });
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitPredicted(child, type, position, value, extra = {}) {
|
||||||
|
child.measurements.emitter.emit(`${type}.predicted.${position}`, { value, ...extra });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── tests ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('onRegister fires for the matching softwareType', () => {
|
||||||
|
const domain = makeDomain();
|
||||||
|
const router = new ChildRouter(domain);
|
||||||
|
const seen = [];
|
||||||
|
|
||||||
|
router.onRegister('measurement', (child, st) => seen.push({ id: child.config.general.id, st }));
|
||||||
|
|
||||||
|
const ch = makeChild({ id: 'm1' });
|
||||||
|
router.dispatchRegister(ch, 'measurement');
|
||||||
|
|
||||||
|
assert.equal(seen.length, 1);
|
||||||
|
assert.equal(seen[0].id, 'm1');
|
||||||
|
assert.equal(seen[0].st, 'measurement');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('onMeasurement with full filter only fires for matching events', () => {
|
||||||
|
const router = new ChildRouter(makeDomain());
|
||||||
|
const hits = [];
|
||||||
|
|
||||||
|
router.onMeasurement('measurement', { type: 'pressure', position: 'upstream' },
|
||||||
|
(data, child) => hits.push({ v: data.value, id: child.config.general.id }));
|
||||||
|
|
||||||
|
const ch = makeChild({ id: 'p-up' });
|
||||||
|
router.dispatchRegister(ch, 'measurement');
|
||||||
|
|
||||||
|
emitMeasured(ch, 'pressure', 'upstream', 100);
|
||||||
|
emitMeasured(ch, 'pressure', 'downstream', 200); // ignored: wrong position
|
||||||
|
emitMeasured(ch, 'flow', 'upstream', 5); // ignored: wrong type
|
||||||
|
emitPredicted(ch, 'pressure', 'upstream', 999); // ignored: wrong variant
|
||||||
|
|
||||||
|
assert.deepEqual(hits, [{ v: 100, id: 'p-up' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('onMeasurement without position filter fires for all positions of the type', () => {
|
||||||
|
const router = new ChildRouter(makeDomain());
|
||||||
|
const hits = [];
|
||||||
|
|
||||||
|
router.onMeasurement('measurement', { type: 'pressure' },
|
||||||
|
(data) => hits.push(data.value));
|
||||||
|
|
||||||
|
const ch = makeChild();
|
||||||
|
router.dispatchRegister(ch, 'measurement');
|
||||||
|
|
||||||
|
emitMeasured(ch, 'pressure', 'upstream', 1);
|
||||||
|
emitMeasured(ch, 'pressure', 'downstream', 2);
|
||||||
|
emitMeasured(ch, 'pressure', 'atequipment', 3);
|
||||||
|
emitMeasured(ch, 'flow', 'upstream', 99); // ignored: wrong type
|
||||||
|
emitPredicted(ch, 'pressure', 'upstream', 50); // ignored: wrong variant
|
||||||
|
|
||||||
|
assert.deepEqual(hits.sort(), [1, 2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('onPrediction works analogously to onMeasurement', () => {
|
||||||
|
const router = new ChildRouter(makeDomain());
|
||||||
|
const hits = [];
|
||||||
|
|
||||||
|
router.onPrediction('machinegroup', { type: 'flow', position: 'downstream' },
|
||||||
|
(data) => hits.push(data.value));
|
||||||
|
|
||||||
|
const ch = makeChild({ softwareType: 'machinegroupcontrol' });
|
||||||
|
router.dispatchRegister(ch, 'machinegroupcontrol');
|
||||||
|
|
||||||
|
emitPredicted(ch, 'flow', 'downstream', 42);
|
||||||
|
emitPredicted(ch, 'flow', 'upstream', 7); // ignored: wrong position
|
||||||
|
emitMeasured(ch, 'flow', 'downstream', 99); // ignored: wrong variant
|
||||||
|
|
||||||
|
assert.deepEqual(hits, [42]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('software-type alias resolution: onRegister("machine") matches softwareType="rotatingmachine"', () => {
|
||||||
|
const router = new ChildRouter(makeDomain());
|
||||||
|
const seen = [];
|
||||||
|
|
||||||
|
router.onRegister('machine', (child) => seen.push(child.config.general.id));
|
||||||
|
|
||||||
|
const rm = makeChild({ id: 'rm-1', softwareType: 'rotatingmachine' });
|
||||||
|
router.dispatchRegister(rm, 'rotatingmachine');
|
||||||
|
|
||||||
|
assert.deepEqual(seen, ['rm-1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('alias resolution also flows through measurement subscriptions', () => {
|
||||||
|
const router = new ChildRouter(makeDomain());
|
||||||
|
const hits = [];
|
||||||
|
|
||||||
|
// Declare with the canonical 'machine' alias.
|
||||||
|
router.onMeasurement('machine', { type: 'flow', position: 'downstream' },
|
||||||
|
(data) => hits.push(data.value));
|
||||||
|
|
||||||
|
// Child reports the raw, non-canonical softwareType.
|
||||||
|
const rm = makeChild({ id: 'rm-1', softwareType: 'rotatingmachine' });
|
||||||
|
router.dispatchRegister(rm, 'rotatingmachine');
|
||||||
|
|
||||||
|
emitMeasured(rm, 'flow', 'downstream', 17);
|
||||||
|
assert.deepEqual(hits, [17]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tearDown removes listeners — re-emitting after tearDown does not invoke handler', () => {
|
||||||
|
const router = new ChildRouter(makeDomain());
|
||||||
|
const hits = [];
|
||||||
|
|
||||||
|
router.onMeasurement('measurement', { type: 'pressure', position: 'upstream' },
|
||||||
|
(data) => hits.push(['concrete', data.value]));
|
||||||
|
router.onMeasurement('measurement', { type: 'pressure' }, // wildcard branch
|
||||||
|
(data) => hits.push(['wild', data.value]));
|
||||||
|
|
||||||
|
const ch = makeChild();
|
||||||
|
router.dispatchRegister(ch, 'measurement');
|
||||||
|
|
||||||
|
emitMeasured(ch, 'pressure', 'upstream', 1);
|
||||||
|
assert.equal(hits.length, 2);
|
||||||
|
|
||||||
|
router.tearDown();
|
||||||
|
|
||||||
|
emitMeasured(ch, 'pressure', 'upstream', 2);
|
||||||
|
emitMeasured(ch, 'pressure', 'downstream', 3);
|
||||||
|
assert.equal(hits.length, 2, 'no further hits after tearDown');
|
||||||
|
|
||||||
|
// Original emit should be restored after teardown — sanity-check it still works
|
||||||
|
// for unrelated listeners on the same emitter.
|
||||||
|
let other = 0;
|
||||||
|
ch.measurements.emitter.on('flow.measured.upstream', () => other++);
|
||||||
|
emitMeasured(ch, 'flow', 'upstream', 9);
|
||||||
|
assert.equal(other, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('multiple onMeasurement subscriptions for same softwareType all fire', () => {
|
||||||
|
const router = new ChildRouter(makeDomain());
|
||||||
|
const a = []; const b = []; const c = [];
|
||||||
|
|
||||||
|
router.onMeasurement('measurement', { type: 'pressure', position: 'upstream' },
|
||||||
|
(d) => a.push(d.value));
|
||||||
|
router.onMeasurement('measurement', { type: 'pressure', position: 'upstream' },
|
||||||
|
(d) => b.push(d.value)); // duplicate concrete sub
|
||||||
|
router.onMeasurement('measurement', { type: 'pressure' },
|
||||||
|
(d) => c.push(d.value)); // wildcard-position sub
|
||||||
|
|
||||||
|
const ch = makeChild();
|
||||||
|
router.dispatchRegister(ch, 'measurement');
|
||||||
|
|
||||||
|
emitMeasured(ch, 'pressure', 'upstream', 7);
|
||||||
|
|
||||||
|
assert.deepEqual(a, [7]);
|
||||||
|
assert.deepEqual(b, [7]);
|
||||||
|
assert.deepEqual(c, [7]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('chainable API returns the router instance', () => {
|
||||||
|
const router = new ChildRouter(makeDomain());
|
||||||
|
const r = router
|
||||||
|
.onRegister('measurement', () => {})
|
||||||
|
.onMeasurement('measurement', { type: 'flow' }, () => {})
|
||||||
|
.onPrediction('machine', { type: 'flow', position: 'downstream' }, () => {});
|
||||||
|
assert.equal(r, router);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('multi-parent: two routers on the same child both receive every event and tear down independently', () => {
|
||||||
|
// Regression for the pre-2026-05-11 emit-patching stack: two parents
|
||||||
|
// subscribing partial-filter wildcards on the same child must compose
|
||||||
|
// without stacking wrappers, and either teardown order must work.
|
||||||
|
const routerA = new ChildRouter(makeDomain());
|
||||||
|
const routerB = new ChildRouter(makeDomain());
|
||||||
|
const a = []; const b = [];
|
||||||
|
|
||||||
|
routerA.onMeasurement('measurement', { type: 'pressure' },
|
||||||
|
(data) => a.push(data.value));
|
||||||
|
routerB.onMeasurement('measurement', { type: 'pressure' },
|
||||||
|
(data) => b.push(data.value));
|
||||||
|
|
||||||
|
const ch = makeChild();
|
||||||
|
routerA.dispatchRegister(ch, 'measurement');
|
||||||
|
routerB.dispatchRegister(ch, 'measurement');
|
||||||
|
|
||||||
|
emitMeasured(ch, 'pressure', 'upstream', 11);
|
||||||
|
emitMeasured(ch, 'pressure', 'downstream', 22);
|
||||||
|
assert.deepEqual(a.sort(), [11, 22]);
|
||||||
|
assert.deepEqual(b.sort(), [11, 22]);
|
||||||
|
|
||||||
|
// Tear down B first — A must continue to fire on subsequent events.
|
||||||
|
routerB.tearDown();
|
||||||
|
emitMeasured(ch, 'pressure', 'upstream', 33);
|
||||||
|
assert.deepEqual(a.sort(), [11, 22, 33]);
|
||||||
|
assert.deepEqual(b.sort(), [11, 22], 'B receives nothing after its teardown');
|
||||||
|
|
||||||
|
// Now tear down A in the reverse order; neither should fire.
|
||||||
|
routerA.tearDown();
|
||||||
|
emitMeasured(ch, 'pressure', 'upstream', 44);
|
||||||
|
assert.deepEqual(a.sort(), [11, 22, 33], 'A receives nothing after its teardown');
|
||||||
|
assert.deepEqual(b.sort(), [11, 22]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('position-only filter fans out across every known type for that position', () => {
|
||||||
|
const router = new ChildRouter(makeDomain());
|
||||||
|
const hits = [];
|
||||||
|
|
||||||
|
router.onMeasurement('measurement', { position: 'upstream' },
|
||||||
|
(data) => hits.push(data.value));
|
||||||
|
|
||||||
|
const ch = makeChild();
|
||||||
|
router.dispatchRegister(ch, 'measurement');
|
||||||
|
|
||||||
|
emitMeasured(ch, 'pressure', 'upstream', 1);
|
||||||
|
emitMeasured(ch, 'flow', 'upstream', 2);
|
||||||
|
emitMeasured(ch, 'temperature', 'upstream', 3);
|
||||||
|
emitMeasured(ch, 'pressure', 'downstream', 99); // wrong position
|
||||||
|
emitPredicted(ch, 'pressure', 'upstream', 99); // wrong variant
|
||||||
|
|
||||||
|
assert.deepEqual(hits.sort(), [1, 2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty filter ({}) fires for every type/position combination', () => {
|
||||||
|
const router = new ChildRouter(makeDomain());
|
||||||
|
const hits = [];
|
||||||
|
|
||||||
|
router.onMeasurement('measurement', {}, (data) => hits.push(data.value));
|
||||||
|
|
||||||
|
const ch = makeChild();
|
||||||
|
router.dispatchRegister(ch, 'measurement');
|
||||||
|
|
||||||
|
emitMeasured(ch, 'pressure', 'upstream', 1);
|
||||||
|
emitMeasured(ch, 'flow', 'downstream', 2);
|
||||||
|
emitMeasured(ch, 'level', 'atequipment', 3);
|
||||||
|
emitPredicted(ch, 'flow', 'upstream', 99); // wrong variant
|
||||||
|
|
||||||
|
assert.deepEqual(hits.sort(), [1, 2, 3]);
|
||||||
|
});
|
||||||
103
test/basic/HealthStatus.basic.test.js
Normal file
103
test/basic/HealthStatus.basic.test.js
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
|
||||||
|
const HealthStatus = require('../../src/domain/HealthStatus');
|
||||||
|
|
||||||
|
test('ok() returns the canonical zero-level shape', () => {
|
||||||
|
const h = HealthStatus.ok();
|
||||||
|
assert.strictEqual(h.level, 0);
|
||||||
|
assert.deepStrictEqual(h.flags, []);
|
||||||
|
assert.strictEqual(h.message, 'nominal');
|
||||||
|
assert.strictEqual(h.source, null);
|
||||||
|
assert.ok(Object.isFrozen(h));
|
||||||
|
assert.ok(Object.isFrozen(h.flags));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ok(message, source) carries through optional args', () => {
|
||||||
|
const h = HealthStatus.ok('all good', 'aggregator');
|
||||||
|
assert.strictEqual(h.level, 0);
|
||||||
|
assert.strictEqual(h.message, 'all good');
|
||||||
|
assert.strictEqual(h.source, 'aggregator');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('degraded(2, [...], msg, src) returns the right frozen shape', () => {
|
||||||
|
const h = HealthStatus.degraded(2, ['x'], 'msg', 'src');
|
||||||
|
assert.strictEqual(h.level, 2);
|
||||||
|
assert.deepStrictEqual(h.flags, ['x']);
|
||||||
|
assert.strictEqual(h.message, 'msg');
|
||||||
|
assert.strictEqual(h.source, 'src');
|
||||||
|
assert.ok(Object.isFrozen(h));
|
||||||
|
assert.ok(Object.isFrozen(h.flags));
|
||||||
|
// Mutation attempts must not change the frozen flags array.
|
||||||
|
assert.throws(() => { h.flags.push('y'); }, TypeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('degraded clamps out-of-range levels (high)', () => {
|
||||||
|
const h = HealthStatus.degraded(7, ['hot'], 'too high');
|
||||||
|
assert.strictEqual(h.level, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('degraded clamps out-of-range levels (low / non-numeric)', () => {
|
||||||
|
const lo = HealthStatus.degraded(0, ['lo'], 'too low');
|
||||||
|
assert.strictEqual(lo.level, 1);
|
||||||
|
const nan = HealthStatus.degraded('nope', ['n'], 'bad input');
|
||||||
|
assert.strictEqual(nan.level, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('degraded falls back to label-derived message when message is empty', () => {
|
||||||
|
const h = HealthStatus.degraded(2, ['x']);
|
||||||
|
assert.strictEqual(h.message, 'major');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('compose([]) returns ok()', () => {
|
||||||
|
const h = HealthStatus.compose([]);
|
||||||
|
assert.strictEqual(h.level, 0);
|
||||||
|
assert.deepStrictEqual(h.flags, []);
|
||||||
|
assert.strictEqual(h.message, 'nominal');
|
||||||
|
assert.strictEqual(h.source, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('compose merges, picking worst level + that status\'s message/source', () => {
|
||||||
|
const h = HealthStatus.compose([
|
||||||
|
HealthStatus.ok(),
|
||||||
|
HealthStatus.degraded(1, ['a'], 'a-msg', 'a-src'),
|
||||||
|
HealthStatus.degraded(2, ['b'], 'b-msg', 'b-src'),
|
||||||
|
]);
|
||||||
|
assert.strictEqual(h.level, 2);
|
||||||
|
assert.deepStrictEqual(h.flags, ['a', 'b']);
|
||||||
|
assert.strictEqual(h.message, 'b-msg');
|
||||||
|
assert.strictEqual(h.source, 'b-src');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('compose ties: first worst-level status wins for message/source', () => {
|
||||||
|
const h = HealthStatus.compose([
|
||||||
|
HealthStatus.degraded(2, ['a'], 'first', 'first-src'),
|
||||||
|
HealthStatus.degraded(2, ['b'], 'second', 'second-src'),
|
||||||
|
]);
|
||||||
|
assert.strictEqual(h.level, 2);
|
||||||
|
assert.strictEqual(h.message, 'first');
|
||||||
|
assert.strictEqual(h.source, 'first-src');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('compose dedupes flags across statuses', () => {
|
||||||
|
const h = HealthStatus.compose([
|
||||||
|
HealthStatus.degraded(1, ['x', 'y'], 'one'),
|
||||||
|
HealthStatus.degraded(2, ['y', 'z', 'x'], 'two'),
|
||||||
|
]);
|
||||||
|
assert.deepStrictEqual(h.flags, ['x', 'y', 'z']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('label maps 0..3 → nominal/minor/major/critical', () => {
|
||||||
|
assert.strictEqual(HealthStatus.label(0), 'nominal');
|
||||||
|
assert.strictEqual(HealthStatus.label(1), 'minor');
|
||||||
|
assert.strictEqual(HealthStatus.label(2), 'major');
|
||||||
|
assert.strictEqual(HealthStatus.label(3), 'critical');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('label returns "unknown" for out-of-range levels', () => {
|
||||||
|
assert.strictEqual(HealthStatus.label(-1), 'unknown');
|
||||||
|
assert.strictEqual(HealthStatus.label(4), 'unknown');
|
||||||
|
assert.strictEqual(HealthStatus.label('x'), 'unknown');
|
||||||
|
});
|
||||||
240
test/basic/LatestWinsGate.basic.test.js
Normal file
240
test/basic/LatestWinsGate.basic.test.js
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const LatestWinsGate = require('../../src/domain/LatestWinsGate');
|
||||||
|
|
||||||
|
// Helper: a deferred promise so a test can pause a dispatch and inspect
|
||||||
|
// gate state before resolving. Avoids real timers entirely.
|
||||||
|
function deferred() {
|
||||||
|
let resolve;
|
||||||
|
let reject;
|
||||||
|
const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
|
||||||
|
return { promise, resolve, reject };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('single fire calls dispatch with the value', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const gate = new LatestWinsGate(async (v) => { calls.push(v); });
|
||||||
|
gate.fire('a');
|
||||||
|
await gate.drain();
|
||||||
|
assert.deepEqual(calls, ['a']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('two fires while in-flight: second value runs after first settles', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const gates = [deferred(), deferred()];
|
||||||
|
const started = [deferred(), deferred()];
|
||||||
|
let n = 0;
|
||||||
|
const gate = new LatestWinsGate(async (v) => {
|
||||||
|
const slot = n++;
|
||||||
|
calls.push(v);
|
||||||
|
started[slot].resolve();
|
||||||
|
await gates[slot].promise;
|
||||||
|
});
|
||||||
|
|
||||||
|
gate.fire('first');
|
||||||
|
gate.fire('second'); // parks while 'first' is in flight
|
||||||
|
await started[0].promise;
|
||||||
|
assert.deepEqual(calls, ['first']);
|
||||||
|
assert.equal(gate.size, 2);
|
||||||
|
|
||||||
|
gates[0].resolve();
|
||||||
|
await started[1].promise;
|
||||||
|
assert.deepEqual(calls, ['first', 'second']);
|
||||||
|
|
||||||
|
gates[1].resolve();
|
||||||
|
await gate.drain();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('three fires back-to-back: only the last runs after the first settles', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const first = deferred();
|
||||||
|
const firstStarted = deferred();
|
||||||
|
let count = 0;
|
||||||
|
const gate = new LatestWinsGate(async (v) => {
|
||||||
|
calls.push(v);
|
||||||
|
if (count++ === 0) {
|
||||||
|
firstStarted.resolve();
|
||||||
|
await first.promise;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
gate.fire(1);
|
||||||
|
gate.fire(2); // parked
|
||||||
|
gate.fire(3); // overwrites 2
|
||||||
|
|
||||||
|
await firstStarted.promise;
|
||||||
|
assert.deepEqual(calls, [1]);
|
||||||
|
first.resolve();
|
||||||
|
await gate.drain();
|
||||||
|
assert.deepEqual(calls, [1, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('drain() resolves only after all queued work has run', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const d = deferred();
|
||||||
|
let started = 0;
|
||||||
|
const gate = new LatestWinsGate(async (v) => {
|
||||||
|
calls.push(v);
|
||||||
|
if (started++ === 0) await d.promise;
|
||||||
|
});
|
||||||
|
|
||||||
|
gate.fire('x');
|
||||||
|
gate.fire('y');
|
||||||
|
|
||||||
|
let drained = false;
|
||||||
|
const p = gate.drain().then(() => { drained = true; });
|
||||||
|
|
||||||
|
// While first is paused, drain must not have resolved yet.
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
assert.equal(drained, false);
|
||||||
|
|
||||||
|
d.resolve();
|
||||||
|
await p;
|
||||||
|
assert.deepEqual(calls, ['x', 'y']);
|
||||||
|
assert.equal(drained, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('error in dispatch does not prevent subsequent fire from working', async () => {
|
||||||
|
const calls = [];
|
||||||
|
let throwNext = true;
|
||||||
|
const errors = [];
|
||||||
|
const logger = { error: (e) => errors.push(e) };
|
||||||
|
const gate = new LatestWinsGate(async (v) => {
|
||||||
|
calls.push(v);
|
||||||
|
if (throwNext) {
|
||||||
|
throwNext = false;
|
||||||
|
throw new Error('boom');
|
||||||
|
}
|
||||||
|
}, { logger });
|
||||||
|
|
||||||
|
gate.fire('a');
|
||||||
|
await gate.drain();
|
||||||
|
assert.equal(calls.length, 1);
|
||||||
|
assert.equal(errors.length, 1);
|
||||||
|
assert.match(errors[0].message, /boom/);
|
||||||
|
assert.ok(gate.lastError instanceof Error);
|
||||||
|
|
||||||
|
// Gate must still accept further work.
|
||||||
|
gate.fire('b');
|
||||||
|
await gate.drain();
|
||||||
|
assert.deepEqual(calls, ['a', 'b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('error is recorded on lastError when no logger is supplied', async () => {
|
||||||
|
const gate = new LatestWinsGate(async () => { throw new Error('silent'); });
|
||||||
|
gate.fire('only');
|
||||||
|
await gate.drain();
|
||||||
|
assert.ok(gate.lastError instanceof Error);
|
||||||
|
assert.match(gate.lastError.message, /silent/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('size reports 0 / 1 / 2 across the lifecycle', async () => {
|
||||||
|
const d1 = deferred();
|
||||||
|
const gate = new LatestWinsGate(async () => { await d1.promise; });
|
||||||
|
|
||||||
|
assert.equal(gate.size, 0);
|
||||||
|
|
||||||
|
gate.fire('one');
|
||||||
|
// fire is sync, but _dispatch starts on a microtask. Either way the
|
||||||
|
// gate is marked in-flight synchronously.
|
||||||
|
assert.equal(gate.size, 1);
|
||||||
|
|
||||||
|
gate.fire('two'); // parked
|
||||||
|
assert.equal(gate.size, 2);
|
||||||
|
|
||||||
|
d1.resolve();
|
||||||
|
await gate.drain();
|
||||||
|
assert.equal(gate.size, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fireAndWait resolves when the dispatch for that value settles', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const gate = new LatestWinsGate(async (v) => { calls.push(v); return `done:${v}`; });
|
||||||
|
const result = await gate.fireAndWait('a');
|
||||||
|
assert.deepEqual(calls, ['a']);
|
||||||
|
assert.equal(result, 'done:a');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fireAndWait while in-flight: caller awaits OWN settlement, not the first call', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const d = deferred();
|
||||||
|
let count = 0;
|
||||||
|
const gate = new LatestWinsGate(async (v) => {
|
||||||
|
calls.push(v);
|
||||||
|
if (count++ === 0) await d.promise;
|
||||||
|
return `r:${v}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const p1 = gate.fireAndWait('first');
|
||||||
|
// p1 in flight. Park second; second's promise should resolve only
|
||||||
|
// after second's OWN dispatch runs, not after first's.
|
||||||
|
const p2 = gate.fireAndWait('second');
|
||||||
|
|
||||||
|
let p2Settled = false;
|
||||||
|
p2.then(() => { p2Settled = true; });
|
||||||
|
await Promise.resolve(); await Promise.resolve();
|
||||||
|
assert.equal(p2Settled, false);
|
||||||
|
|
||||||
|
d.resolve();
|
||||||
|
const r1 = await p1;
|
||||||
|
assert.equal(r1, 'r:first');
|
||||||
|
const r2 = await p2;
|
||||||
|
assert.equal(r2, 'r:second');
|
||||||
|
assert.deepEqual(calls, ['first', 'second']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fireAndWait superseded by a later fireAndWait resolves with { superseded: true }', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const d = deferred();
|
||||||
|
let count = 0;
|
||||||
|
const gate = new LatestWinsGate(async (v) => {
|
||||||
|
calls.push(v);
|
||||||
|
if (count++ === 0) await d.promise;
|
||||||
|
});
|
||||||
|
|
||||||
|
const p1 = gate.fireAndWait('first'); // in flight
|
||||||
|
const pParked = gate.fireAndWait('parked'); // gets superseded
|
||||||
|
const pLatest = gate.fireAndWait('latest'); // wins
|
||||||
|
|
||||||
|
d.resolve();
|
||||||
|
const supersedeRes = await pParked;
|
||||||
|
assert.equal(supersedeRes.superseded, true);
|
||||||
|
|
||||||
|
await p1;
|
||||||
|
await pLatest;
|
||||||
|
assert.deepEqual(calls, ['first', 'latest']); // 'parked' dropped
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fireAndWait + fire intermix: a plain fire supersedes a pending fireAndWait', async () => {
|
||||||
|
const d = deferred();
|
||||||
|
let count = 0;
|
||||||
|
const calls = [];
|
||||||
|
const gate = new LatestWinsGate(async (v) => {
|
||||||
|
calls.push(v);
|
||||||
|
if (count++ === 0) await d.promise;
|
||||||
|
});
|
||||||
|
|
||||||
|
gate.fire('first'); // in flight, no settle
|
||||||
|
const pParked = gate.fireAndWait('parked');
|
||||||
|
gate.fire('latest'); // supersedes parked
|
||||||
|
|
||||||
|
d.resolve();
|
||||||
|
const res = await pParked;
|
||||||
|
assert.equal(res.superseded, true);
|
||||||
|
await gate.drain();
|
||||||
|
assert.deepEqual(calls, ['first', 'latest']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fireAndWait still resolves (with undefined) when the dispatch throws', async () => {
|
||||||
|
const errors = [];
|
||||||
|
const logger = { error: (e) => errors.push(e) };
|
||||||
|
const gate = new LatestWinsGate(async () => { throw new Error('kaboom'); }, { logger });
|
||||||
|
const r = await gate.fireAndWait('only');
|
||||||
|
assert.equal(r, undefined);
|
||||||
|
assert.equal(errors.length, 1);
|
||||||
|
assert.ok(gate.lastError instanceof Error);
|
||||||
|
});
|
||||||
192
test/basic/UnitPolicy.basic.test.js
Normal file
192
test/basic/UnitPolicy.basic.test.js
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const UnitPolicy = require('../../src/domain/UnitPolicy.js');
|
||||||
|
|
||||||
|
function makeFakeLogger() {
|
||||||
|
const calls = { warn: [], info: [], error: [], debug: [] };
|
||||||
|
return {
|
||||||
|
calls,
|
||||||
|
warn: (m) => calls.warn.push(m),
|
||||||
|
info: (m) => calls.info.push(m),
|
||||||
|
error: (m) => calls.error.push(m),
|
||||||
|
debug: (m) => calls.debug.push(m),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseSpec = {
|
||||||
|
canonical: { flow: 'm3/s', pressure: 'Pa', power: 'W', temperature: 'K' },
|
||||||
|
output: { flow: 'm3/h', pressure: 'mbar', power: 'kW', temperature: 'C' },
|
||||||
|
curve: { flow: 'm3/h', pressure: 'mbar', power: 'kW', control: '%' },
|
||||||
|
};
|
||||||
|
|
||||||
|
test('declare returns a policy whose canonical/output match the input', () => {
|
||||||
|
const policy = UnitPolicy.declare(baseSpec);
|
||||||
|
assert.equal(policy.canonical('flow'), 'm3/s');
|
||||||
|
assert.equal(policy.canonical('pressure'), 'Pa');
|
||||||
|
assert.equal(policy.canonical('power'), 'W');
|
||||||
|
assert.equal(policy.canonical('temperature'), 'K');
|
||||||
|
assert.equal(policy.output('flow'), 'm3/h');
|
||||||
|
assert.equal(policy.output('pressure'), 'mbar');
|
||||||
|
assert.equal(policy.output('power'), 'kW');
|
||||||
|
assert.equal(policy.output('temperature'), 'C');
|
||||||
|
assert.equal(policy.curve('flow'), 'm3/h');
|
||||||
|
assert.equal(policy.curve('control'), '%');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('canonical/output/curve are also frozen property bags (dot access)', () => {
|
||||||
|
const policy = UnitPolicy.declare(baseSpec);
|
||||||
|
// Property-access form — equivalent to the method-call form above.
|
||||||
|
assert.equal(policy.canonical.flow, 'm3/s');
|
||||||
|
assert.equal(policy.canonical.pressure, 'Pa');
|
||||||
|
assert.equal(policy.output.flow, 'm3/h');
|
||||||
|
assert.equal(policy.output.temperature, 'C');
|
||||||
|
assert.equal(policy.curve.flow, 'm3/h');
|
||||||
|
assert.equal(policy.curve.control, '%');
|
||||||
|
// Method-call form keeps working alongside it.
|
||||||
|
assert.equal(policy.canonical('flow'), 'm3/s');
|
||||||
|
assert.equal(policy.output('power'), 'kW');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('canonical/output/curve property bags are frozen — no assignment / delete / redefine', () => {
|
||||||
|
'use strict';
|
||||||
|
const policy = UnitPolicy.declare(baseSpec);
|
||||||
|
// Existing own-properties are non-writable.
|
||||||
|
assert.throws(() => { policy.canonical.flow = 'tampered'; }, TypeError);
|
||||||
|
// Existing own-properties are non-configurable: delete throws.
|
||||||
|
assert.throws(() => { delete policy.canonical.pressure; }, TypeError);
|
||||||
|
// Redefining an existing prop throws.
|
||||||
|
assert.throws(
|
||||||
|
() => Object.defineProperty(policy.canonical, 'flow', { value: 'tampered' }),
|
||||||
|
TypeError
|
||||||
|
);
|
||||||
|
// Object.isFrozen reports the accessor as frozen.
|
||||||
|
assert.equal(Object.isFrozen(policy.canonical), true);
|
||||||
|
assert.equal(Object.isFrozen(policy.output), true);
|
||||||
|
assert.equal(Object.isFrozen(policy.curve), true);
|
||||||
|
// Original values survive the failed attempts.
|
||||||
|
assert.equal(policy.canonical.flow, 'm3/s');
|
||||||
|
assert.equal(policy.canonical.pressure, 'Pa');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('curve property bag is present (empty) even when no curve was declared', () => {
|
||||||
|
const policy = UnitPolicy.declare({
|
||||||
|
canonical: baseSpec.canonical,
|
||||||
|
output: baseSpec.output,
|
||||||
|
});
|
||||||
|
// Method form returns null for unknown types.
|
||||||
|
assert.equal(policy.curve('flow'), null);
|
||||||
|
// Property form is an empty frozen function — accessing missing keys is undefined.
|
||||||
|
assert.equal(policy.curve.flow, undefined);
|
||||||
|
assert.equal(Object.isFrozen(policy.curve), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('declare throws when canonical or output is missing', () => {
|
||||||
|
assert.throws(() => UnitPolicy.declare({ output: {} }), /canonical/);
|
||||||
|
assert.throws(() => UnitPolicy.declare({ canonical: {} }), /output/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolve returns the candidate when it matches the expected measure', () => {
|
||||||
|
const logger = makeFakeLogger();
|
||||||
|
const policy = UnitPolicy.declare(baseSpec).setLogger(logger);
|
||||||
|
assert.equal(policy.resolve('m3/h', 'flow', 'm3/s', 'general.flow'), 'm3/h');
|
||||||
|
assert.equal(policy.resolve('bar', 'pressure', 'mbar', 'asset.pressure'), 'bar');
|
||||||
|
assert.equal(policy.resolve('kW', 'power', 'W', 'asset.power'), 'kW');
|
||||||
|
// No warnings on valid inputs.
|
||||||
|
assert.equal(logger.calls.warn.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolve falls back when given an invalid candidate, warns once', () => {
|
||||||
|
const logger = makeFakeLogger();
|
||||||
|
const policy = UnitPolicy.declare(baseSpec).setLogger(logger);
|
||||||
|
|
||||||
|
// Wrong measure family (mass unit declared as a flow unit).
|
||||||
|
assert.equal(policy.resolve('kg', 'flow', 'm3/s', 'general.flow'), 'm3/s');
|
||||||
|
// Same call again — the warn-once memo must suppress.
|
||||||
|
assert.equal(policy.resolve('kg', 'flow', 'm3/s', 'general.flow'), 'm3/s');
|
||||||
|
assert.equal(logger.calls.warn.length, 1);
|
||||||
|
assert.match(logger.calls.warn[0], /Invalid general\.flow unit 'kg'/);
|
||||||
|
|
||||||
|
// A different invalid candidate logs a separate warning.
|
||||||
|
assert.equal(policy.resolve('not-a-unit', 'pressure', 'Pa', 'asset.pressure'), 'Pa');
|
||||||
|
assert.equal(logger.calls.warn.length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolve falls back to the default when candidate is empty/whitespace', () => {
|
||||||
|
const policy = UnitPolicy.declare(baseSpec);
|
||||||
|
assert.equal(policy.resolve('', 'flow', 'm3/s'), 'm3/s');
|
||||||
|
assert.equal(policy.resolve(' ', 'flow', 'm3/s'), 'm3/s');
|
||||||
|
assert.equal(policy.resolve(undefined, 'flow', 'm3/s'), 'm3/s');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolve accepts type-name shorthand as well as convert-module measure', () => {
|
||||||
|
const policy = UnitPolicy.declare(baseSpec);
|
||||||
|
// 'flow' shorthand should map to volumeFlowRate, not be passed through raw.
|
||||||
|
assert.equal(policy.resolve('m3/h', 'flow', 'm3/s'), 'm3/h');
|
||||||
|
assert.equal(policy.resolve('m3/h', 'volumeFlowRate', 'm3/s'), 'm3/h');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('convert is a no-op when from === to (still coerces to Number)', () => {
|
||||||
|
const policy = UnitPolicy.declare(baseSpec);
|
||||||
|
assert.equal(policy.convert('5', 'm3/h', 'm3/h'), 5);
|
||||||
|
assert.equal(typeof policy.convert(5, 'm3/h', 'm3/h'), 'number');
|
||||||
|
// Missing units also no-op.
|
||||||
|
assert.equal(policy.convert(7, '', 'm3/h'), 7);
|
||||||
|
assert.equal(policy.convert(7, 'm3/h', null), 7);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('convert across compatible units returns the expected numeric', () => {
|
||||||
|
const policy = UnitPolicy.declare(baseSpec);
|
||||||
|
// 1 m3/s -> 3600 m3/h
|
||||||
|
assert.equal(policy.convert(1, 'm3/s', 'm3/h'), 3600);
|
||||||
|
// 1 bar -> 100000 Pa
|
||||||
|
assert.equal(policy.convert(1, 'bar', 'Pa'), 100000);
|
||||||
|
// 1 kW -> 1000 W
|
||||||
|
assert.equal(policy.convert(1, 'kW', 'W'), 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('convert throws when value is not finite', () => {
|
||||||
|
const policy = UnitPolicy.declare(baseSpec);
|
||||||
|
assert.throws(() => policy.convert('not-a-number', 'm3/h', 'm3/s'), /not finite/);
|
||||||
|
assert.throws(() => policy.convert(NaN, 'm3/h', 'm3/s'), /not finite/);
|
||||||
|
assert.throws(() => policy.convert(Infinity, 'm3/h', 'm3/s'), /not finite/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('containerOptions returns the exact shape consumed by MeasurementContainer', () => {
|
||||||
|
const policy = UnitPolicy.declare(baseSpec);
|
||||||
|
const opts = policy.containerOptions();
|
||||||
|
|
||||||
|
assert.deepEqual(opts.defaultUnits, baseSpec.output);
|
||||||
|
assert.deepEqual(opts.preferredUnits, baseSpec.output);
|
||||||
|
assert.deepEqual(opts.canonicalUnits, baseSpec.canonical);
|
||||||
|
assert.equal(opts.storeCanonical, true);
|
||||||
|
assert.equal(opts.strictUnitValidation, true);
|
||||||
|
assert.equal(opts.throwOnInvalidUnit, true);
|
||||||
|
assert.deepEqual(opts.requireUnitForTypes, ['flow', 'pressure', 'power', 'temperature']);
|
||||||
|
|
||||||
|
// Mutating the returned bag must not leak back into the policy.
|
||||||
|
opts.defaultUnits.flow = 'tampered';
|
||||||
|
opts.requireUnitForTypes.push('volume');
|
||||||
|
assert.equal(policy.output('flow'), 'm3/h');
|
||||||
|
assert.deepEqual(policy.containerOptions().requireUnitForTypes, ['flow', 'pressure', 'power', 'temperature']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('containerOptions honours custom requireUnitForTypes from declare', () => {
|
||||||
|
const policy = UnitPolicy.declare({
|
||||||
|
...baseSpec,
|
||||||
|
requireUnitForTypes: ['flow', 'pressure'],
|
||||||
|
});
|
||||||
|
assert.deepEqual(policy.containerOptions().requireUnitForTypes, ['flow', 'pressure']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('containerOptions output works with a real MeasurementContainer', () => {
|
||||||
|
const { MeasurementContainer } = require('../../src/measurements/index.js');
|
||||||
|
const policy = UnitPolicy.declare(baseSpec);
|
||||||
|
const mc = new MeasurementContainer(policy.containerOptions());
|
||||||
|
// No throw on construction — proves the option bag is a valid input shape.
|
||||||
|
assert.equal(mc.storeCanonical, true);
|
||||||
|
assert.equal(mc.strictUnitValidation, true);
|
||||||
|
assert.equal(mc.throwOnInvalidUnit, true);
|
||||||
|
assert.equal(mc.canonicalUnits.flow, 'm3/s');
|
||||||
|
assert.equal(mc.defaultUnits.flow, 'm3/h');
|
||||||
|
});
|
||||||
436
test/basic/commandRegistry.basic.test.js
Normal file
436
test/basic/commandRegistry.basic.test.js
Normal file
@@ -0,0 +1,436 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const { createRegistry, CommandRegistry } = require('../../src/nodered/commandRegistry');
|
||||||
|
|
||||||
|
function makeLogger() {
|
||||||
|
const calls = { warn: [], error: [], info: [], debug: [] };
|
||||||
|
return {
|
||||||
|
warn: (...a) => calls.warn.push(a.join(' ')),
|
||||||
|
error: (...a) => calls.error.push(a.join(' ')),
|
||||||
|
info: (...a) => calls.info.push(a.join(' ')),
|
||||||
|
debug: (...a) => calls.debug.push(a.join(' ')),
|
||||||
|
_calls: calls,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('canonical topic dispatch invokes the handler with (source, msg, ctx)', async () => {
|
||||||
|
const seen = [];
|
||||||
|
const reg = createRegistry([{
|
||||||
|
topic: 'set.mode',
|
||||||
|
handler: (source, msg, ctx) => { seen.push({ source, msg, ctx }); },
|
||||||
|
}]);
|
||||||
|
const source = { id: 'src' };
|
||||||
|
const ctx = { tag: 'ctx' };
|
||||||
|
const msg = { topic: 'set.mode', payload: 'auto' };
|
||||||
|
await reg.dispatch(msg, source, ctx);
|
||||||
|
assert.equal(seen.length, 1);
|
||||||
|
assert.equal(seen[0].source, source);
|
||||||
|
assert.equal(seen[0].msg, msg);
|
||||||
|
assert.equal(seen[0].ctx, ctx);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('alias dispatch invokes handler and logs deprecation warning once', async () => {
|
||||||
|
const logger = makeLogger();
|
||||||
|
let count = 0;
|
||||||
|
const reg = createRegistry([{
|
||||||
|
topic: 'set.mode',
|
||||||
|
aliases: ['setMode'],
|
||||||
|
handler: () => { count += 1; },
|
||||||
|
}], { logger });
|
||||||
|
|
||||||
|
await reg.dispatch({ topic: 'setMode', payload: 'auto' }, {}, {});
|
||||||
|
await reg.dispatch({ topic: 'setMode', payload: 'manual' }, {}, {});
|
||||||
|
|
||||||
|
assert.equal(count, 2);
|
||||||
|
const deprecationWarns = logger._calls.warn.filter((m) => m.includes('deprecated'));
|
||||||
|
assert.equal(deprecationWarns.length, 1);
|
||||||
|
assert.match(deprecationWarns[0], /setMode/);
|
||||||
|
assert.match(deprecationWarns[0], /set\.mode/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unknown topic logs warn and returns without throwing', async () => {
|
||||||
|
const logger = makeLogger();
|
||||||
|
const reg = createRegistry([{ topic: 'set.mode', handler: () => {} }], { logger });
|
||||||
|
await reg.dispatch({ topic: 'no.such.topic' }, {}, {});
|
||||||
|
assert.ok(logger._calls.warn.some((m) => m.includes('unknown topic')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('payloadSchema scalar rejects mismatched payload', async () => {
|
||||||
|
const logger = makeLogger();
|
||||||
|
let invoked = false;
|
||||||
|
const reg = createRegistry([{
|
||||||
|
topic: 'set.demand',
|
||||||
|
payloadSchema: { type: 'number' },
|
||||||
|
handler: () => { invoked = true; },
|
||||||
|
}], { logger });
|
||||||
|
|
||||||
|
await reg.dispatch({ topic: 'set.demand', payload: 'not-a-number' }, {}, {});
|
||||||
|
assert.equal(invoked, false);
|
||||||
|
assert.ok(logger._calls.warn.some((m) => m.includes('expected number')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('payloadSchema object properties enforce per-key typeof', async () => {
|
||||||
|
const logger = makeLogger();
|
||||||
|
const accepted = [];
|
||||||
|
const reg = createRegistry([{
|
||||||
|
topic: 'cmd.startup',
|
||||||
|
payloadSchema: { type: 'object', properties: { name: 'string' } },
|
||||||
|
handler: (_s, msg) => { accepted.push(msg.payload); },
|
||||||
|
}], { logger });
|
||||||
|
|
||||||
|
await reg.dispatch({ topic: 'cmd.startup', payload: { name: 'foo' } }, {}, {});
|
||||||
|
await reg.dispatch({ topic: 'cmd.startup', payload: { name: 42 } }, {}, {});
|
||||||
|
assert.deepEqual(accepted, [{ name: 'foo' }]);
|
||||||
|
assert.ok(logger._calls.warn.some((m) => m.includes('payload.name')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('payloadSchema type any accepts any payload', async () => {
|
||||||
|
const logger = makeLogger();
|
||||||
|
const seen = [];
|
||||||
|
const reg = createRegistry([{
|
||||||
|
topic: 'data.measurement',
|
||||||
|
payloadSchema: { type: 'any' },
|
||||||
|
handler: (_s, msg) => { seen.push(msg.payload); },
|
||||||
|
}], { logger });
|
||||||
|
|
||||||
|
await reg.dispatch({ topic: 'data.measurement', payload: 1 }, {}, {});
|
||||||
|
await reg.dispatch({ topic: 'data.measurement', payload: 'x' }, {}, {});
|
||||||
|
await reg.dispatch({ topic: 'data.measurement', payload: { a: 1 } }, {}, {});
|
||||||
|
await reg.dispatch({ topic: 'data.measurement', payload: null }, {}, {});
|
||||||
|
assert.equal(seen.length, 4);
|
||||||
|
assert.equal(logger._calls.warn.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('async handler returns a promise that resolves after the handler completes', async () => {
|
||||||
|
let done = false;
|
||||||
|
const reg = createRegistry([{
|
||||||
|
topic: 'cmd.calibrate',
|
||||||
|
handler: async () => {
|
||||||
|
await new Promise((r) => setImmediate(r));
|
||||||
|
done = true;
|
||||||
|
},
|
||||||
|
}]);
|
||||||
|
const p = reg.dispatch({ topic: 'cmd.calibrate' }, {}, {});
|
||||||
|
assert.equal(done, false);
|
||||||
|
await p;
|
||||||
|
assert.equal(done, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('duplicate canonical topic throws at construction', () => {
|
||||||
|
assert.throws(() => createRegistry([
|
||||||
|
{ topic: 'set.mode', handler: () => {} },
|
||||||
|
{ topic: 'set.mode', handler: () => {} },
|
||||||
|
]), /duplicate command topic/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('alias collides with another command canonical topic throws', () => {
|
||||||
|
assert.throws(() => createRegistry([
|
||||||
|
{ topic: 'set.mode', handler: () => {} },
|
||||||
|
{ topic: 'cmd.startup', aliases: ['set.mode'], handler: () => {} },
|
||||||
|
]), /collides/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('alias collides with another alias throws', () => {
|
||||||
|
assert.throws(() => createRegistry([
|
||||||
|
{ topic: 'set.mode', aliases: ['mode'], handler: () => {} },
|
||||||
|
{ topic: 'cmd.start', aliases: ['mode'], handler: () => {} },
|
||||||
|
]), /collides/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('list() returns descriptors without handler functions', () => {
|
||||||
|
const reg = createRegistry([
|
||||||
|
{ topic: 'set.mode', aliases: ['setMode'], payloadSchema: { type: 'string' }, handler: () => {} },
|
||||||
|
{ topic: 'cmd.startup', handler: () => {} },
|
||||||
|
]);
|
||||||
|
const list = reg.list();
|
||||||
|
assert.equal(list.length, 2);
|
||||||
|
assert.deepEqual(list[0], {
|
||||||
|
topic: 'set.mode',
|
||||||
|
aliases: ['setMode'],
|
||||||
|
payloadSchema: { type: 'string' },
|
||||||
|
description: null,
|
||||||
|
units: null,
|
||||||
|
});
|
||||||
|
assert.deepEqual(list[1], {
|
||||||
|
topic: 'cmd.startup',
|
||||||
|
aliases: [],
|
||||||
|
payloadSchema: null,
|
||||||
|
description: null,
|
||||||
|
units: null,
|
||||||
|
});
|
||||||
|
for (const d of list) assert.ok(!('handler' in d), 'handler must not be in descriptor');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("payloadSchema type 'none' invokes handler with no payload and no warning", async () => {
|
||||||
|
const logger = makeLogger();
|
||||||
|
let invoked = 0;
|
||||||
|
const reg = createRegistry([{
|
||||||
|
topic: 'cmd.calibrate',
|
||||||
|
payloadSchema: { type: 'none' },
|
||||||
|
handler: () => { invoked += 1; },
|
||||||
|
}], { logger });
|
||||||
|
|
||||||
|
await reg.dispatch({ topic: 'cmd.calibrate' }, {}, {});
|
||||||
|
await reg.dispatch({ topic: 'cmd.calibrate', payload: undefined }, {}, {});
|
||||||
|
await reg.dispatch({ topic: 'cmd.calibrate', payload: null }, {}, {});
|
||||||
|
assert.equal(invoked, 3);
|
||||||
|
assert.equal(logger._calls.warn.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("payloadSchema type 'none' invokes handler with non-empty payload but logs warn", async () => {
|
||||||
|
const logger = makeLogger();
|
||||||
|
let invoked = 0;
|
||||||
|
const reg = createRegistry([{
|
||||||
|
topic: 'cmd.calibrate',
|
||||||
|
payloadSchema: { type: 'none' },
|
||||||
|
handler: () => { invoked += 1; },
|
||||||
|
}], { logger });
|
||||||
|
|
||||||
|
await reg.dispatch({ topic: 'cmd.calibrate', payload: 'ignored' }, {}, {});
|
||||||
|
await reg.dispatch({ topic: 'cmd.calibrate', payload: { a: 1 } }, {}, {});
|
||||||
|
await reg.dispatch({ topic: 'cmd.calibrate', payload: 0 }, {}, {});
|
||||||
|
assert.equal(invoked, 3);
|
||||||
|
const warns = logger._calls.warn.filter((m) => m.includes('payload ignored'));
|
||||||
|
assert.equal(warns.length, 3);
|
||||||
|
assert.ok(warns[0].includes('cmd.calibrate'));
|
||||||
|
assert.ok(warns[0].includes('trigger-only'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('list() includes description field when present', () => {
|
||||||
|
const reg = createRegistry([
|
||||||
|
{ topic: 'cmd.calibrate', payloadSchema: { type: 'none' }, description: 'Trigger calibration.', handler: () => {} },
|
||||||
|
{ topic: 'set.mode', payloadSchema: { type: 'string' }, handler: () => {} },
|
||||||
|
]);
|
||||||
|
const list = reg.list();
|
||||||
|
assert.equal(list[0].description, 'Trigger calibration.');
|
||||||
|
assert.equal(list[1].description, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deprecationStats reflects alias hit counts', async () => {
|
||||||
|
const logger = makeLogger();
|
||||||
|
const reg = createRegistry([{
|
||||||
|
topic: 'set.mode',
|
||||||
|
aliases: ['setMode', 'changemode'],
|
||||||
|
handler: () => {},
|
||||||
|
}], { logger });
|
||||||
|
|
||||||
|
await reg.dispatch({ topic: 'setMode', payload: 'a' }, {}, {});
|
||||||
|
await reg.dispatch({ topic: 'setMode', payload: 'b' }, {}, {});
|
||||||
|
await reg.dispatch({ topic: 'changemode', payload: 'c' }, {}, {});
|
||||||
|
await reg.dispatch({ topic: 'set.mode', payload: 'd' }, {}, {});
|
||||||
|
|
||||||
|
assert.deepEqual(reg.deprecationStats(), { setMode: 2, changemode: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('canonical() resolves alias to canonical topic; passes through canonical', () => {
|
||||||
|
const reg = createRegistry([{ topic: 'set.mode', aliases: ['setMode'], handler: () => {} }]);
|
||||||
|
assert.equal(reg.canonical('setMode'), 'set.mode');
|
||||||
|
assert.equal(reg.canonical('set.mode'), 'set.mode');
|
||||||
|
assert.equal(reg.canonical('unknown'), 'unknown');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('has() reports membership for canonical and alias keys', () => {
|
||||||
|
const reg = createRegistry([{ topic: 'set.mode', aliases: ['setMode'], handler: () => {} }]);
|
||||||
|
assert.equal(reg.has('set.mode'), true);
|
||||||
|
assert.equal(reg.has('setMode'), true);
|
||||||
|
assert.equal(reg.has('nope'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CommandRegistry class is exported for advanced cases', () => {
|
||||||
|
const reg = new CommandRegistry([{ topic: 'set.mode', handler: () => {} }]);
|
||||||
|
assert.ok(reg instanceof CommandRegistry);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('msg without topic logs warn and does not throw', async () => {
|
||||||
|
const logger = makeLogger();
|
||||||
|
const reg = createRegistry([{ topic: 'set.mode', handler: () => {} }], { logger });
|
||||||
|
await reg.dispatch({ payload: 'x' }, {}, {});
|
||||||
|
assert.ok(logger._calls.warn.some((m) => m.includes('no topic')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ctx.logger overrides the constructor logger at dispatch time', async () => {
|
||||||
|
const ctorLogger = makeLogger();
|
||||||
|
const ctxLogger = makeLogger();
|
||||||
|
const reg = createRegistry([{ topic: 'set.mode', handler: () => {} }], { logger: ctorLogger });
|
||||||
|
await reg.dispatch({ topic: 'unknown' }, {}, { logger: ctxLogger });
|
||||||
|
assert.equal(ctorLogger._calls.warn.length, 0);
|
||||||
|
assert.ok(ctxLogger._calls.warn.some((m) => m.includes('unknown topic')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('object schema rejects null payload (typeof null === object guard)', async () => {
|
||||||
|
const logger = makeLogger();
|
||||||
|
let invoked = false;
|
||||||
|
const reg = createRegistry([{
|
||||||
|
topic: 'cmd.startup',
|
||||||
|
payloadSchema: { type: 'object' },
|
||||||
|
handler: () => { invoked = true; },
|
||||||
|
}], { logger });
|
||||||
|
await reg.dispatch({ topic: 'cmd.startup', payload: null }, {}, {});
|
||||||
|
assert.equal(invoked, false);
|
||||||
|
assert.ok(logger._calls.warn.some((m) => m.includes('expected object')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('constructor throws on missing topic / handler', () => {
|
||||||
|
assert.throws(() => createRegistry([{ handler: () => {} }]), /topic/);
|
||||||
|
assert.throws(() => createRegistry([{ topic: 'set.x' }]), /handler/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('constructor throws when input is not an array', () => {
|
||||||
|
assert.throws(() => createRegistry(null), /array/);
|
||||||
|
assert.throws(() => createRegistry({}), /array/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// descriptor.units — Phase 11 pre-dispatch normalisation pipeline
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test('units: valid unit + correct measure converts to default before handler', async () => {
|
||||||
|
const logger = makeLogger();
|
||||||
|
const seen = [];
|
||||||
|
const reg = createRegistry([{
|
||||||
|
topic: 'set.demand',
|
||||||
|
units: { measure: 'volumeFlowRate', default: 'm3/h' },
|
||||||
|
handler: (_s, msg) => { seen.push({ payload: msg.payload, unit: msg.unit }); },
|
||||||
|
}], { logger });
|
||||||
|
|
||||||
|
await reg.dispatch({ topic: 'set.demand', payload: 1, unit: 'm3/s' }, {}, {});
|
||||||
|
assert.equal(seen.length, 1);
|
||||||
|
assert.ok(Math.abs(seen[0].payload - 3600) < 1e-6, `expected 3600, got ${seen[0].payload}`);
|
||||||
|
assert.equal(seen[0].unit, 'm3/h');
|
||||||
|
assert.equal(logger._calls.warn.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('units: wrong measure warns + lists accepted + falls back to default unit', async () => {
|
||||||
|
const logger = makeLogger();
|
||||||
|
const seen = [];
|
||||||
|
const reg = createRegistry([{
|
||||||
|
topic: 'set.demand',
|
||||||
|
units: { measure: 'volumeFlowRate', default: 'm3/h' },
|
||||||
|
handler: (_s, msg) => { seen.push({ payload: msg.payload, unit: msg.unit }); },
|
||||||
|
}], { logger });
|
||||||
|
|
||||||
|
await reg.dispatch({ topic: 'set.demand', payload: 42, unit: 'mbar' }, {}, {});
|
||||||
|
assert.equal(seen.length, 1);
|
||||||
|
assert.equal(seen[0].payload, 42);
|
||||||
|
assert.equal(seen[0].unit, 'm3/h');
|
||||||
|
const warns = logger._calls.warn;
|
||||||
|
assert.equal(warns.length, 1);
|
||||||
|
assert.match(warns[0], /set\.demand/);
|
||||||
|
assert.match(warns[0], /'mbar'/);
|
||||||
|
assert.match(warns[0], /pressure/);
|
||||||
|
assert.match(warns[0], /volumeFlowRate/);
|
||||||
|
assert.match(warns[0], /m3\/h/); // accepted list contains the default
|
||||||
|
assert.match(warns[0], /Treating 42 as m3\/h/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('units: unknown unit warns + lists accepted + falls back to default', async () => {
|
||||||
|
const logger = makeLogger();
|
||||||
|
const seen = [];
|
||||||
|
const reg = createRegistry([{
|
||||||
|
topic: 'set.demand',
|
||||||
|
units: { measure: 'volumeFlowRate', default: 'm3/h' },
|
||||||
|
handler: (_s, msg) => { seen.push({ payload: msg.payload, unit: msg.unit }); },
|
||||||
|
}], { logger });
|
||||||
|
|
||||||
|
await reg.dispatch({ topic: 'set.demand', payload: 7, unit: 'flarbargs' }, {}, {});
|
||||||
|
assert.equal(seen.length, 1);
|
||||||
|
assert.equal(seen[0].payload, 7);
|
||||||
|
assert.equal(seen[0].unit, 'm3/h');
|
||||||
|
const warns = logger._calls.warn;
|
||||||
|
assert.equal(warns.length, 1);
|
||||||
|
assert.match(warns[0], /unknown unit 'flarbargs'/);
|
||||||
|
assert.match(warns[0], /m3\/h/);
|
||||||
|
assert.match(warns[0], /Treating 7 as m3\/h/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('units: no unit at all — handler gets raw value tagged with default unit, silent', async () => {
|
||||||
|
const logger = makeLogger();
|
||||||
|
const seen = [];
|
||||||
|
const reg = createRegistry([{
|
||||||
|
topic: 'set.demand',
|
||||||
|
units: { measure: 'volumeFlowRate', default: 'm3/h' },
|
||||||
|
handler: (_s, msg) => { seen.push({ payload: msg.payload, unit: msg.unit }); },
|
||||||
|
}], { logger });
|
||||||
|
|
||||||
|
await reg.dispatch({ topic: 'set.demand', payload: 12 }, {}, {});
|
||||||
|
assert.equal(seen.length, 1);
|
||||||
|
assert.equal(seen[0].payload, 12);
|
||||||
|
assert.equal(seen[0].unit, 'm3/h');
|
||||||
|
assert.equal(logger._calls.warn.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('units: object payload {value, unit} normalises the same as msg.payload+msg.unit', async () => {
|
||||||
|
const logger = makeLogger();
|
||||||
|
const seen = [];
|
||||||
|
const reg = createRegistry([{
|
||||||
|
topic: 'set.pressure',
|
||||||
|
units: { measure: 'pressure', default: 'Pa' },
|
||||||
|
handler: (_s, msg) => { seen.push({ payload: msg.payload, unit: msg.unit }); },
|
||||||
|
}], { logger });
|
||||||
|
|
||||||
|
await reg.dispatch({ topic: 'set.pressure', payload: { value: 5, unit: 'mbar' } }, {}, {});
|
||||||
|
assert.equal(seen.length, 1);
|
||||||
|
assert.ok(Math.abs(seen[0].payload - 500) < 1e-6, `expected 500, got ${seen[0].payload}`);
|
||||||
|
assert.equal(seen[0].unit, 'Pa');
|
||||||
|
assert.equal(logger._calls.warn.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('units: object payload {value} without unit falls back to default unit silently', async () => {
|
||||||
|
const logger = makeLogger();
|
||||||
|
const seen = [];
|
||||||
|
const reg = createRegistry([{
|
||||||
|
topic: 'set.pressure',
|
||||||
|
units: { measure: 'pressure', default: 'Pa' },
|
||||||
|
handler: (_s, msg) => { seen.push({ payload: msg.payload, unit: msg.unit }); },
|
||||||
|
}], { logger });
|
||||||
|
|
||||||
|
await reg.dispatch({ topic: 'set.pressure', payload: { value: 100 } }, {}, {});
|
||||||
|
assert.equal(seen.length, 1);
|
||||||
|
assert.equal(seen[0].payload, 100);
|
||||||
|
assert.equal(seen[0].unit, 'Pa');
|
||||||
|
assert.equal(logger._calls.warn.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('units: non-numeric payload (no normalisation applied) passes through to handler', async () => {
|
||||||
|
const logger = makeLogger();
|
||||||
|
const seen = [];
|
||||||
|
const reg = createRegistry([{
|
||||||
|
topic: 'set.demand',
|
||||||
|
units: { measure: 'volumeFlowRate', default: 'm3/h' },
|
||||||
|
handler: (_s, msg) => { seen.push(msg.payload); },
|
||||||
|
}], { logger });
|
||||||
|
|
||||||
|
// string payload — not normalisable. Should not crash; handler still fires.
|
||||||
|
await reg.dispatch({ topic: 'set.demand', payload: 'magic' }, {}, {});
|
||||||
|
assert.equal(seen.length, 1);
|
||||||
|
assert.equal(seen[0], 'magic');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('units: missing default field throws at construction', () => {
|
||||||
|
assert.throws(() => createRegistry([{
|
||||||
|
topic: 'set.demand',
|
||||||
|
units: { measure: 'volumeFlowRate' },
|
||||||
|
handler: () => {},
|
||||||
|
}]), /units requires/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('units: missing measure field throws at construction', () => {
|
||||||
|
assert.throws(() => createRegistry([{
|
||||||
|
topic: 'set.demand',
|
||||||
|
units: { default: 'm3/h' },
|
||||||
|
handler: () => {},
|
||||||
|
}]), /units requires/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('units: descriptor.units surfaces in list() output', () => {
|
||||||
|
const reg = createRegistry([
|
||||||
|
{ topic: 'set.demand', units: { measure: 'volumeFlowRate', default: 'm3/h' }, handler: () => {} },
|
||||||
|
{ topic: 'set.mode', handler: () => {} },
|
||||||
|
]);
|
||||||
|
const list = reg.list();
|
||||||
|
assert.deepEqual(list[0].units, { measure: 'volumeFlowRate', default: 'm3/h' });
|
||||||
|
assert.equal(list[1].units, null);
|
||||||
|
});
|
||||||
90
test/basic/convert.basic.test.js
Normal file
90
test/basic/convert.basic.test.js
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const convert = require('../../src/convert/index.js');
|
||||||
|
|
||||||
|
test('convert.possibilities — exported as a top-level function', () => {
|
||||||
|
assert.equal(typeof convert.possibilities, 'function');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('convert.possibilities(volumeFlowRate) returns common flow units', () => {
|
||||||
|
const units = convert.possibilities('volumeFlowRate');
|
||||||
|
assert.ok(Array.isArray(units));
|
||||||
|
assert.ok(units.length > 0);
|
||||||
|
for (const u of ['m3/s', 'm3/h', 'l/s', 'l/min', 'l/h']) {
|
||||||
|
assert.ok(units.includes(u), `expected '${u}' in volumeFlowRate possibilities`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('convert.possibilities(pressure) returns common pressure units', () => {
|
||||||
|
const units = convert.possibilities('pressure');
|
||||||
|
for (const u of ['Pa', 'kPa', 'bar', 'mbar', 'psi']) {
|
||||||
|
assert.ok(units.includes(u), `expected '${u}' in pressure possibilities`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('convert.possibilities(power) returns common power units', () => {
|
||||||
|
const units = convert.possibilities('power');
|
||||||
|
for (const u of ['W', 'kW', 'MW']) {
|
||||||
|
assert.ok(units.includes(u), `expected '${u}' in power possibilities`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('convert.possibilities(temperature) returns K, C, F', () => {
|
||||||
|
const units = convert.possibilities('temperature');
|
||||||
|
for (const u of ['K', 'C', 'F']) {
|
||||||
|
assert.ok(units.includes(u), `expected '${u}' in temperature possibilities`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('convert.possibilities for length / mass / volume return non-empty', () => {
|
||||||
|
assert.ok(convert.possibilities('length').includes('m'));
|
||||||
|
assert.ok(convert.possibilities('mass').includes('kg'));
|
||||||
|
assert.ok(convert.possibilities('volume').includes('l'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('convert.possibilities(unknown) returns []', () => {
|
||||||
|
assert.deepEqual(convert.possibilities('foo'), []);
|
||||||
|
assert.deepEqual(convert.possibilities('bogus-measure'), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('convert.possibilities handles invalid input safely', () => {
|
||||||
|
assert.deepEqual(convert.possibilities(), []);
|
||||||
|
assert.deepEqual(convert.possibilities(null), []);
|
||||||
|
assert.deepEqual(convert.possibilities(''), []);
|
||||||
|
assert.deepEqual(convert.possibilities(42), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('convert.possibilities is sorted and deduplicated', () => {
|
||||||
|
const units = convert.possibilities('pressure');
|
||||||
|
const sorted = [...units].sort();
|
||||||
|
assert.deepEqual(units, sorted, 'result should be alphabetically sorted');
|
||||||
|
const set = new Set(units);
|
||||||
|
assert.equal(set.size, units.length, 'result should have no duplicates');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('convert.possibilities returns stable / cached results across calls', () => {
|
||||||
|
const a = convert.possibilities('volumeFlowRate');
|
||||||
|
const b = convert.possibilities('volumeFlowRate');
|
||||||
|
assert.deepEqual(a, b, 'two calls must return equal arrays');
|
||||||
|
// Mutating the returned array must not poison the cache.
|
||||||
|
a.push('SHOULD_NOT_PERSIST');
|
||||||
|
const c = convert.possibilities('volumeFlowRate');
|
||||||
|
assert.ok(!c.includes('SHOULD_NOT_PERSIST'), 'cached array must be defensively copied');
|
||||||
|
assert.deepEqual(c, b);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('convert.measures lists known measure names', () => {
|
||||||
|
const m = convert.measures();
|
||||||
|
assert.ok(Array.isArray(m));
|
||||||
|
for (const name of ['length', 'mass', 'volume', 'pressure', 'power', 'temperature', 'volumeFlowRate']) {
|
||||||
|
assert.ok(m.includes(name), `expected measure '${name}'`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('convert factory still works (regression — no breakage of existing API)', () => {
|
||||||
|
const result = convert(1).from('m').to('cm');
|
||||||
|
assert.equal(result, 100);
|
||||||
|
});
|
||||||
77
test/basic/stateManagerRemaining.basic.test.js
Normal file
77
test/basic/stateManagerRemaining.basic.test.js
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const StateManager = require('../../src/state/stateManager');
|
||||||
|
|
||||||
|
// Minimal config that satisfies the stateManager constructor's expectations.
|
||||||
|
// Real configs come from configs/<node>.json; we hand-roll one here so the
|
||||||
|
// test doesn't drag the whole node-config plumbing in for a 30-line getter.
|
||||||
|
function makeConfig(initial = 'idle', times = { idle: 0, warmingup: 5 }) {
|
||||||
|
return {
|
||||||
|
state: {
|
||||||
|
current: initial,
|
||||||
|
available: ['idle', 'warmingup', 'operational'],
|
||||||
|
descriptions: { idle: 'off', warmingup: 'warming', operational: 'running' },
|
||||||
|
allowedTransitions: {
|
||||||
|
idle: new Set(['warmingup']),
|
||||||
|
warmingup: new Set(['operational']),
|
||||||
|
operational: new Set(['idle']),
|
||||||
|
},
|
||||||
|
activeStates: new Set(['operational']),
|
||||||
|
},
|
||||||
|
time: times,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const noopLogger = { debug() {}, info() {}, warn() {}, error() {} };
|
||||||
|
|
||||||
|
test('getRemainingTransitionS returns 0 for untimed initial state', () => {
|
||||||
|
const sm = new StateManager(makeConfig('idle'), noopLogger);
|
||||||
|
assert.equal(sm.getRemainingTransitionS(), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getRemainingTransitionS returns ≈full duration just after entering a timed state', async () => {
|
||||||
|
const sm = new StateManager(makeConfig('idle', { idle: 0, warmingup: 5 }), noopLogger);
|
||||||
|
// Force-enter "warmingup" via the constructor's state machinery: simulate
|
||||||
|
// by manually setting fields the way transitionTo would.
|
||||||
|
sm.currentState = 'warmingup';
|
||||||
|
sm.stateEnteredAt = Date.now();
|
||||||
|
const remaining = sm.getRemainingTransitionS();
|
||||||
|
assert.ok(remaining > 4.9 && remaining <= 5.0, `expected ~5s, got ${remaining}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getRemainingTransitionS decays with elapsed time', async () => {
|
||||||
|
const sm = new StateManager(makeConfig('idle', { idle: 0, warmingup: 5 }), noopLogger);
|
||||||
|
sm.currentState = 'warmingup';
|
||||||
|
sm.stateEnteredAt = Date.now() - 2000; // pretend we entered 2s ago
|
||||||
|
const remaining = sm.getRemainingTransitionS();
|
||||||
|
assert.ok(remaining > 2.9 && remaining <= 3.0, `expected ~3s, got ${remaining}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getRemainingTransitionS clamps to 0 once duration has elapsed', () => {
|
||||||
|
const sm = new StateManager(makeConfig('idle', { idle: 0, warmingup: 5 }), noopLogger);
|
||||||
|
sm.currentState = 'warmingup';
|
||||||
|
sm.stateEnteredAt = Date.now() - 60_000; // a minute ago, way past 5s
|
||||||
|
assert.equal(sm.getRemainingTransitionS(), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('transitionTo refreshes stateEnteredAt on the immediate branch', async () => {
|
||||||
|
const sm = new StateManager(makeConfig('idle', { idle: 0 }), noopLogger);
|
||||||
|
const before = sm.stateEnteredAt;
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
await sm.transitionTo('warmingup');
|
||||||
|
assert.ok(sm.stateEnteredAt > before, 'stateEnteredAt should advance on transition');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('transitionTo refreshes stateEnteredAt on the timed branch', async () => {
|
||||||
|
// Tiny duration so the test stays fast.
|
||||||
|
const sm = new StateManager(makeConfig('idle', { idle: 0.05, warmingup: 0 }), noopLogger);
|
||||||
|
const before = sm.stateEnteredAt;
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
await sm.transitionTo('warmingup');
|
||||||
|
assert.ok(sm.stateEnteredAt > before, 'stateEnteredAt should advance after timed transition');
|
||||||
|
// And remaining should now be 0 (we're in warmingup, but warmingup duration is 0).
|
||||||
|
assert.equal(sm.getRemainingTransitionS(), 0);
|
||||||
|
});
|
||||||
50
test/basic/stats.basic.test.js
Normal file
50
test/basic/stats.basic.test.js
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const { mean, stdDev, median, mad, lerp } = require('../../src/stats');
|
||||||
|
|
||||||
|
const EPS = 1e-9;
|
||||||
|
|
||||||
|
function near(a, b, eps = EPS) {
|
||||||
|
assert.ok(Math.abs(a - b) <= eps, `expected ${a} ≈ ${b} (eps ${eps})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('mean: basic and empty', () => {
|
||||||
|
assert.equal(mean([1, 2, 3, 4]), 2.5);
|
||||||
|
assert.equal(mean([]), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stdDev: zero-variance, classic sample, single-element, empty', () => {
|
||||||
|
assert.equal(stdDev([1, 1, 1, 1]), 0);
|
||||||
|
near(stdDev([1, 2, 3, 4, 5]), 1.5811388300841898);
|
||||||
|
assert.equal(stdDev([5]), 0);
|
||||||
|
assert.equal(stdDev([]), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('median: odd, even, empty', () => {
|
||||||
|
assert.equal(median([1, 2, 3, 4, 5]), 3);
|
||||||
|
assert.equal(median([1, 2, 3, 4]), 2.5);
|
||||||
|
assert.equal(median([]), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mad: hand-checked sample and constant array', () => {
|
||||||
|
// [1,1,2,2,4,6,9] -> median 2 -> |dev| [1,1,0,0,2,4,7] -> sorted
|
||||||
|
// [0,0,1,1,2,4,7] -> mad = 1.
|
||||||
|
assert.equal(mad([1, 1, 2, 2, 4, 6, 9]), 1);
|
||||||
|
assert.equal(mad([5, 5, 5]), 0);
|
||||||
|
assert.equal(mad([]), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lerp: in-range mapping and degenerate pass-through', () => {
|
||||||
|
assert.equal(lerp(2, 0, 4, 0, 100), 50);
|
||||||
|
assert.equal(lerp(2, 0, 0, 0, 100), 2);
|
||||||
|
// iMin > iMax also degenerate (defensive against swapped bounds).
|
||||||
|
assert.equal(lerp(2, 4, 0, 0, 100), 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lerp: float arithmetic stays within epsilon', () => {
|
||||||
|
near(lerp(0.1, 0, 1, 0, 10), 1);
|
||||||
|
near(lerp(1 / 3, 0, 1, 0, 30), 10);
|
||||||
|
});
|
||||||
70
test/basic/statusBadge.basic.test.js
Normal file
70
test/basic/statusBadge.basic.test.js
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const { statusBadge, MAX_TEXT } = require('../../src/nodered/statusBadge');
|
||||||
|
|
||||||
|
test('compose joins parts with " | " and uses default green/dot', () => {
|
||||||
|
const badge = statusBadge.compose(['A', 'B']);
|
||||||
|
assert.deepEqual(badge, { fill: 'green', shape: 'dot', text: 'A | B' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('compose drops null/undefined/empty parts', () => {
|
||||||
|
const badge = statusBadge.compose(['A', null, 'B', undefined, '']);
|
||||||
|
assert.equal(badge.text, 'A | B');
|
||||||
|
assert.equal(badge.fill, 'green');
|
||||||
|
assert.equal(badge.shape, 'dot');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('compose with empty parts and override fill returns empty text', () => {
|
||||||
|
const badge = statusBadge.compose([], { fill: 'yellow' });
|
||||||
|
assert.equal(badge.text, '');
|
||||||
|
assert.equal(badge.fill, 'yellow');
|
||||||
|
assert.equal(badge.shape, 'dot');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('error returns red ring with ⚠ prefix', () => {
|
||||||
|
const badge = statusBadge.error('boom');
|
||||||
|
assert.deepEqual(badge, { fill: 'red', shape: 'ring', text: '⚠ boom' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('idle returns blue dot with ⏸ prefix', () => {
|
||||||
|
const badge = statusBadge.idle('waiting');
|
||||||
|
assert.deepEqual(badge, { fill: 'blue', shape: 'dot', text: '⏸️ waiting' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('byState returns the matching template', () => {
|
||||||
|
const map = { off: { fill: 'red', shape: 'dot', text: 'OFF' } };
|
||||||
|
const badge = statusBadge.byState(map, 'off');
|
||||||
|
assert.deepEqual(badge, { fill: 'red', shape: 'dot', text: 'OFF' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('byState returns grey "unknown state" badge when key is missing', () => {
|
||||||
|
const badge = statusBadge.byState({}, 'unknown');
|
||||||
|
assert.equal(badge.fill, 'grey');
|
||||||
|
assert.equal(badge.shape, 'ring');
|
||||||
|
assert.match(badge.text, /unknown state/);
|
||||||
|
assert.match(badge.text, /unknown/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('byState composes extra parts into the template text', () => {
|
||||||
|
const map = { run: { fill: 'green', shape: 'dot', text: 'RUN' } };
|
||||||
|
const badge = statusBadge.byState(map, 'run', { compose: ['flow=12.0', 'P=3kW'] });
|
||||||
|
assert.equal(badge.text, 'RUN | flow=12.0 | P=3kW');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('text length is truncated to MAX_TEXT chars ending with …', () => {
|
||||||
|
const longInput = 'x'.repeat(200);
|
||||||
|
const badge = statusBadge.text(longInput);
|
||||||
|
assert.equal(badge.text.length, MAX_TEXT);
|
||||||
|
assert.equal(badge.text.endsWith('…'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('text helper defaults to green/dot and never returns null text', () => {
|
||||||
|
assert.equal(statusBadge.text(null).text, '');
|
||||||
|
assert.equal(statusBadge.text(undefined).text, '');
|
||||||
|
const badge = statusBadge.text('hi');
|
||||||
|
assert.equal(badge.fill, 'green');
|
||||||
|
assert.equal(badge.shape, 'dot');
|
||||||
|
});
|
||||||
189
test/basic/statusUpdater.basic.test.js
Normal file
189
test/basic/statusUpdater.basic.test.js
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const { StatusUpdater } = require('../../src/nodered/statusUpdater');
|
||||||
|
|
||||||
|
function makeNode() {
|
||||||
|
const calls = [];
|
||||||
|
return {
|
||||||
|
calls,
|
||||||
|
status(badge) { calls.push(badge); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeSource(initial) {
|
||||||
|
return {
|
||||||
|
badge: initial,
|
||||||
|
throwOnNext: false,
|
||||||
|
getStatusBadge() {
|
||||||
|
if (this.throwOnNext) {
|
||||||
|
this.throwOnNext = false;
|
||||||
|
throw new Error('boom');
|
||||||
|
}
|
||||||
|
return this.badge;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeLogger() {
|
||||||
|
const errors = [];
|
||||||
|
return {
|
||||||
|
errors,
|
||||||
|
error(msg) { errors.push(msg); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('start() schedules a tick that applies the source badge', (t) => {
|
||||||
|
t.mock.timers.enable({ apis: ['setInterval'] });
|
||||||
|
const node = makeNode();
|
||||||
|
const source = makeSource({ fill: 'green', shape: 'dot', text: 'OK' });
|
||||||
|
const u = new StatusUpdater({ node, source, intervalMs: 1000 });
|
||||||
|
u.start();
|
||||||
|
assert.equal(node.calls.length, 0);
|
||||||
|
t.mock.timers.tick(1000);
|
||||||
|
assert.equal(node.calls.length, 1);
|
||||||
|
assert.deepEqual(node.calls[0], { fill: 'green', shape: 'dot', text: 'OK' });
|
||||||
|
u.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('multiple ticks reflect the latest badge from the source', (t) => {
|
||||||
|
t.mock.timers.enable({ apis: ['setInterval'] });
|
||||||
|
const node = makeNode();
|
||||||
|
const source = makeSource({ fill: 'green', shape: 'dot', text: 'A' });
|
||||||
|
const u = new StatusUpdater({ node, source, intervalMs: 500 });
|
||||||
|
u.start();
|
||||||
|
t.mock.timers.tick(500);
|
||||||
|
source.badge = { fill: 'yellow', shape: 'dot', text: 'B' };
|
||||||
|
t.mock.timers.tick(500);
|
||||||
|
source.badge = { fill: 'red', shape: 'ring', text: 'C' };
|
||||||
|
t.mock.timers.tick(500);
|
||||||
|
assert.equal(node.calls.length, 3);
|
||||||
|
assert.equal(node.calls[0].text, 'A');
|
||||||
|
assert.equal(node.calls[1].text, 'B');
|
||||||
|
assert.equal(node.calls[2].text, 'C');
|
||||||
|
u.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('source returns null → node.status({}) is called', (t) => {
|
||||||
|
t.mock.timers.enable({ apis: ['setInterval'] });
|
||||||
|
const node = makeNode();
|
||||||
|
const source = makeSource(null);
|
||||||
|
const u = new StatusUpdater({ node, source, intervalMs: 100 });
|
||||||
|
u.start();
|
||||||
|
t.mock.timers.tick(100);
|
||||||
|
assert.equal(node.calls.length, 1);
|
||||||
|
assert.deepEqual(node.calls[0], {});
|
||||||
|
u.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('source throw → error logged, error badge applied, next tick still runs', (t) => {
|
||||||
|
t.mock.timers.enable({ apis: ['setInterval'] });
|
||||||
|
const node = makeNode();
|
||||||
|
const logger = makeLogger();
|
||||||
|
const source = makeSource({ fill: 'green', shape: 'dot', text: 'OK' });
|
||||||
|
source.throwOnNext = true;
|
||||||
|
const u = new StatusUpdater({ node, source, intervalMs: 1000, logger });
|
||||||
|
u.start();
|
||||||
|
t.mock.timers.tick(1000);
|
||||||
|
assert.equal(logger.errors.length, 1, 'error logged once');
|
||||||
|
assert.match(logger.errors[0], /boom/);
|
||||||
|
assert.deepEqual(node.calls[0], { fill: 'red', shape: 'ring', text: '⚠ boom' });
|
||||||
|
// Subsequent tick: source recovers, normal badge resumes.
|
||||||
|
t.mock.timers.tick(1000);
|
||||||
|
assert.equal(node.calls.length, 2);
|
||||||
|
assert.deepEqual(node.calls[1], { fill: 'green', shape: 'dot', text: 'OK' });
|
||||||
|
u.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stop() halts the interval AND clears the badge', (t) => {
|
||||||
|
t.mock.timers.enable({ apis: ['setInterval'] });
|
||||||
|
const node = makeNode();
|
||||||
|
const source = makeSource({ fill: 'green', shape: 'dot', text: 'OK' });
|
||||||
|
const u = new StatusUpdater({ node, source, intervalMs: 500 });
|
||||||
|
u.start();
|
||||||
|
t.mock.timers.tick(500);
|
||||||
|
assert.equal(node.calls.length, 1);
|
||||||
|
u.stop();
|
||||||
|
assert.equal(u.isRunning, false);
|
||||||
|
// stop() pushes a clear-badge call.
|
||||||
|
assert.equal(node.calls.length, 2);
|
||||||
|
assert.deepEqual(node.calls[1], {});
|
||||||
|
// No further ticks after stop.
|
||||||
|
t.mock.timers.tick(5000);
|
||||||
|
assert.equal(node.calls.length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('start() called twice does not schedule two intervals', (t) => {
|
||||||
|
t.mock.timers.enable({ apis: ['setInterval'] });
|
||||||
|
const node = makeNode();
|
||||||
|
const source = makeSource({ fill: 'green', shape: 'dot', text: 'OK' });
|
||||||
|
const u = new StatusUpdater({ node, source, intervalMs: 1000 });
|
||||||
|
u.start();
|
||||||
|
u.start();
|
||||||
|
u.start();
|
||||||
|
t.mock.timers.tick(1000);
|
||||||
|
assert.equal(node.calls.length, 1, 'one tick per interval period');
|
||||||
|
t.mock.timers.tick(1000);
|
||||||
|
assert.equal(node.calls.length, 2);
|
||||||
|
u.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('intervalMs: 0 makes start() a no-op', (t) => {
|
||||||
|
t.mock.timers.enable({ apis: ['setInterval'] });
|
||||||
|
const node = makeNode();
|
||||||
|
const source = makeSource({ fill: 'green', shape: 'dot', text: 'OK' });
|
||||||
|
const u = new StatusUpdater({ node, source, intervalMs: 0 });
|
||||||
|
u.start();
|
||||||
|
assert.equal(u.isRunning, false);
|
||||||
|
t.mock.timers.tick(10000);
|
||||||
|
assert.equal(node.calls.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('intervalMs omitted is also treated as a no-op', (t) => {
|
||||||
|
t.mock.timers.enable({ apis: ['setInterval'] });
|
||||||
|
const node = makeNode();
|
||||||
|
const source = makeSource({ fill: 'green', shape: 'dot', text: 'OK' });
|
||||||
|
const u = new StatusUpdater({ node, source });
|
||||||
|
u.start();
|
||||||
|
assert.equal(u.isRunning, false);
|
||||||
|
t.mock.timers.tick(10000);
|
||||||
|
assert.equal(node.calls.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('constructor throws if node.status is missing', () => {
|
||||||
|
const source = makeSource(null);
|
||||||
|
assert.throws(
|
||||||
|
() => new StatusUpdater({ node: {}, source, intervalMs: 1000 }),
|
||||||
|
/node must expose a \.status/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => new StatusUpdater({ node: null, source, intervalMs: 1000 }),
|
||||||
|
/node must expose a \.status/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('constructor throws if source.getStatusBadge is missing', () => {
|
||||||
|
const node = makeNode();
|
||||||
|
assert.throws(
|
||||||
|
() => new StatusUpdater({ node, source: {}, intervalMs: 1000 }),
|
||||||
|
/source must expose a \.getStatusBadge/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => new StatusUpdater({ node, source: null, intervalMs: 1000 }),
|
||||||
|
/source must expose a \.getStatusBadge/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isRunning getter reflects timer lifecycle', (t) => {
|
||||||
|
t.mock.timers.enable({ apis: ['setInterval'] });
|
||||||
|
const node = makeNode();
|
||||||
|
const source = makeSource(null);
|
||||||
|
const u = new StatusUpdater({ node, source, intervalMs: 1000 });
|
||||||
|
assert.equal(u.isRunning, false);
|
||||||
|
u.start();
|
||||||
|
assert.equal(u.isRunning, true);
|
||||||
|
u.stop();
|
||||||
|
assert.equal(u.isRunning, false);
|
||||||
|
});
|
||||||
@@ -1,360 +0,0 @@
|
|||||||
const ChildRegistrationUtils = require('../src/helper/childRegistrationUtils');
|
|
||||||
const { POSITIONS } = require('../src/constants/positions');
|
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Create a minimal mock parent (mainClass) that ChildRegistrationUtils expects. */
|
|
||||||
function createMockParent(opts = {}) {
|
|
||||||
return {
|
|
||||||
child: {},
|
|
||||||
logger: {
|
|
||||||
debug: jest.fn(),
|
|
||||||
info: jest.fn(),
|
|
||||||
warn: jest.fn(),
|
|
||||||
error: jest.fn(),
|
|
||||||
},
|
|
||||||
// optionally provide a registerChild callback so the utils can delegate
|
|
||||||
registerChild: opts.registerChild || undefined,
|
|
||||||
...opts,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Create a minimal mock child node with the given overrides. */
|
|
||||||
function createMockChild(overrides = {}) {
|
|
||||||
const defaults = {
|
|
||||||
config: {
|
|
||||||
general: {
|
|
||||||
id: overrides.id || 'child-1',
|
|
||||||
name: overrides.name || 'TestChild',
|
|
||||||
},
|
|
||||||
functionality: {
|
|
||||||
softwareType: overrides.softwareType !== undefined ? overrides.softwareType : 'measurement',
|
|
||||||
positionVsParent: overrides.position || POSITIONS.UPSTREAM,
|
|
||||||
},
|
|
||||||
asset: {
|
|
||||||
category: overrides.category || 'sensor',
|
|
||||||
type: overrides.assetType || 'pressure',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
measurements: overrides.measurements || null,
|
|
||||||
};
|
|
||||||
// allow caller to add extra top-level props
|
|
||||||
return { ...defaults, ...(overrides.extra || {}) };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('ChildRegistrationUtils', () => {
|
|
||||||
let parent;
|
|
||||||
let utils;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
parent = createMockParent();
|
|
||||||
utils = new ChildRegistrationUtils(parent);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Construction ─────────────────────────────────────────────────────────
|
|
||||||
describe('constructor', () => {
|
|
||||||
it('should store a reference to the mainClass', () => {
|
|
||||||
expect(utils.mainClass).toBe(parent);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should initialise with an empty registeredChildren map', () => {
|
|
||||||
expect(utils.registeredChildren.size).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should use the parent logger', () => {
|
|
||||||
expect(utils.logger).toBe(parent.logger);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── registerChild ────────────────────────────────────────────────────────
|
|
||||||
describe('registerChild()', () => {
|
|
||||||
it('should register a child and store it in the internal map', async () => {
|
|
||||||
const child = createMockChild();
|
|
||||||
await utils.registerChild(child, POSITIONS.UPSTREAM);
|
|
||||||
|
|
||||||
expect(utils.registeredChildren.size).toBe(1);
|
|
||||||
expect(utils.registeredChildren.has('child-1')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should store softwareType, position and timestamp in the registry entry', async () => {
|
|
||||||
const child = createMockChild({ softwareType: 'machine' });
|
|
||||||
const before = Date.now();
|
|
||||||
await utils.registerChild(child, POSITIONS.DOWNSTREAM);
|
|
||||||
const after = Date.now();
|
|
||||||
|
|
||||||
const entry = utils.registeredChildren.get('child-1');
|
|
||||||
expect(entry.softwareType).toBe('machine');
|
|
||||||
expect(entry.position).toBe(POSITIONS.DOWNSTREAM);
|
|
||||||
expect(entry.registeredAt).toBeGreaterThanOrEqual(before);
|
|
||||||
expect(entry.registeredAt).toBeLessThanOrEqual(after);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should store the child in mainClass.child[softwareType][category]', async () => {
|
|
||||||
const child = createMockChild({ softwareType: 'measurement', category: 'sensor' });
|
|
||||||
await utils.registerChild(child, POSITIONS.UPSTREAM);
|
|
||||||
|
|
||||||
expect(parent.child.measurement).toBeDefined();
|
|
||||||
expect(parent.child.measurement.sensor).toBeInstanceOf(Array);
|
|
||||||
expect(parent.child.measurement.sensor).toContain(child);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should set the parent reference on the child', async () => {
|
|
||||||
const child = createMockChild();
|
|
||||||
await utils.registerChild(child, POSITIONS.UPSTREAM);
|
|
||||||
|
|
||||||
expect(child.parent).toEqual([parent]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should set positionVsParent on the child', async () => {
|
|
||||||
const child = createMockChild();
|
|
||||||
await utils.registerChild(child, POSITIONS.DOWNSTREAM);
|
|
||||||
|
|
||||||
expect(child.positionVsParent).toBe(POSITIONS.DOWNSTREAM);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should lowercase the softwareType before storing', async () => {
|
|
||||||
const child = createMockChild({ softwareType: 'Measurement' });
|
|
||||||
await utils.registerChild(child, POSITIONS.UPSTREAM);
|
|
||||||
|
|
||||||
const entry = utils.registeredChildren.get('child-1');
|
|
||||||
expect(entry.softwareType).toBe('measurement');
|
|
||||||
expect(parent.child.measurement).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should delegate to mainClass.registerChild when it is a function', async () => {
|
|
||||||
const registerSpy = jest.fn();
|
|
||||||
parent.registerChild = registerSpy;
|
|
||||||
const child = createMockChild({ softwareType: 'measurement' });
|
|
||||||
|
|
||||||
await utils.registerChild(child, POSITIONS.UPSTREAM);
|
|
||||||
|
|
||||||
expect(registerSpy).toHaveBeenCalledWith(child, 'measurement');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should NOT throw when mainClass has no registerChild method', async () => {
|
|
||||||
delete parent.registerChild;
|
|
||||||
const child = createMockChild();
|
|
||||||
|
|
||||||
await expect(utils.registerChild(child, POSITIONS.UPSTREAM)).resolves.not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should log a debug message on registration', async () => {
|
|
||||||
const child = createMockChild({ name: 'Pump1', id: 'p1' });
|
|
||||||
await utils.registerChild(child, POSITIONS.UPSTREAM);
|
|
||||||
|
|
||||||
expect(parent.logger.debug).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining('Registering child: Pump1')
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle empty softwareType gracefully', async () => {
|
|
||||||
const child = createMockChild({ softwareType: '' });
|
|
||||||
await utils.registerChild(child, POSITIONS.UPSTREAM);
|
|
||||||
|
|
||||||
const entry = utils.registeredChildren.get('child-1');
|
|
||||||
expect(entry.softwareType).toBe('');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Multiple children ────────────────────────────────────────────────────
|
|
||||||
describe('multiple children registration', () => {
|
|
||||||
it('should register multiple children of the same softwareType', async () => {
|
|
||||||
const c1 = createMockChild({ id: 'c1', name: 'Sensor1', softwareType: 'measurement' });
|
|
||||||
const c2 = createMockChild({ id: 'c2', name: 'Sensor2', softwareType: 'measurement' });
|
|
||||||
|
|
||||||
await utils.registerChild(c1, POSITIONS.UPSTREAM);
|
|
||||||
await utils.registerChild(c2, POSITIONS.DOWNSTREAM);
|
|
||||||
|
|
||||||
expect(utils.registeredChildren.size).toBe(2);
|
|
||||||
expect(parent.child.measurement.sensor).toHaveLength(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should register children of different softwareTypes', async () => {
|
|
||||||
const sensor = createMockChild({ id: 's1', softwareType: 'measurement' });
|
|
||||||
const machine = createMockChild({ id: 'm1', softwareType: 'machine', category: 'pump' });
|
|
||||||
|
|
||||||
await utils.registerChild(sensor, POSITIONS.UPSTREAM);
|
|
||||||
await utils.registerChild(machine, POSITIONS.AT_EQUIPMENT);
|
|
||||||
|
|
||||||
expect(parent.child.measurement).toBeDefined();
|
|
||||||
expect(parent.child.machine).toBeDefined();
|
|
||||||
expect(parent.child.machine.pump).toContain(machine);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should register children of different categories under the same softwareType', async () => {
|
|
||||||
const sensor = createMockChild({ id: 's1', softwareType: 'measurement', category: 'sensor' });
|
|
||||||
const analyser = createMockChild({ id: 'a1', softwareType: 'measurement', category: 'analyser' });
|
|
||||||
|
|
||||||
await utils.registerChild(sensor, POSITIONS.UPSTREAM);
|
|
||||||
await utils.registerChild(analyser, POSITIONS.DOWNSTREAM);
|
|
||||||
|
|
||||||
expect(parent.child.measurement.sensor).toHaveLength(1);
|
|
||||||
expect(parent.child.measurement.analyser).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should support multiple parents on a child (array append)', async () => {
|
|
||||||
const parent2 = createMockParent();
|
|
||||||
const utils2 = new ChildRegistrationUtils(parent2);
|
|
||||||
const child = createMockChild();
|
|
||||||
|
|
||||||
await utils.registerChild(child, POSITIONS.UPSTREAM);
|
|
||||||
await utils2.registerChild(child, POSITIONS.DOWNSTREAM);
|
|
||||||
|
|
||||||
expect(child.parent).toEqual([parent, parent2]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Duplicate registration ───────────────────────────────────────────────
|
|
||||||
describe('duplicate registration', () => {
|
|
||||||
it('should overwrite the registry entry when the same child id is registered twice', async () => {
|
|
||||||
const child = createMockChild({ id: 'dup-1' });
|
|
||||||
|
|
||||||
await utils.registerChild(child, POSITIONS.UPSTREAM);
|
|
||||||
await utils.registerChild(child, POSITIONS.DOWNSTREAM);
|
|
||||||
|
|
||||||
// Map.set overwrites, so still size 1
|
|
||||||
expect(utils.registeredChildren.size).toBe(1);
|
|
||||||
const entry = utils.registeredChildren.get('dup-1');
|
|
||||||
expect(entry.position).toBe(POSITIONS.DOWNSTREAM);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should push the child into the category array again on duplicate registration', async () => {
|
|
||||||
const child = createMockChild({ id: 'dup-1' });
|
|
||||||
|
|
||||||
await utils.registerChild(child, POSITIONS.UPSTREAM);
|
|
||||||
await utils.registerChild(child, POSITIONS.UPSTREAM);
|
|
||||||
|
|
||||||
// _storeChild does a push each time
|
|
||||||
expect(parent.child.measurement.sensor).toHaveLength(2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Measurement context setup ────────────────────────────────────────────
|
|
||||||
describe('measurement context on child', () => {
|
|
||||||
it('should call setChildId, setChildName, setParentRef when child has measurements', async () => {
|
|
||||||
const measurements = {
|
|
||||||
setChildId: jest.fn(),
|
|
||||||
setChildName: jest.fn(),
|
|
||||||
setParentRef: jest.fn(),
|
|
||||||
};
|
|
||||||
const child = createMockChild({ id: 'mc-1', name: 'Sensor1', measurements });
|
|
||||||
|
|
||||||
await utils.registerChild(child, POSITIONS.UPSTREAM);
|
|
||||||
|
|
||||||
expect(measurements.setChildId).toHaveBeenCalledWith('mc-1');
|
|
||||||
expect(measurements.setChildName).toHaveBeenCalledWith('Sensor1');
|
|
||||||
expect(measurements.setParentRef).toHaveBeenCalledWith(parent);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should skip measurement setup when child has no measurements object', async () => {
|
|
||||||
const child = createMockChild({ measurements: null });
|
|
||||||
|
|
||||||
// Should not throw
|
|
||||||
await expect(utils.registerChild(child, POSITIONS.UPSTREAM)).resolves.not.toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── getChildrenOfType ────────────────────────────────────────────────────
|
|
||||||
describe('getChildrenOfType()', () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
const s1 = createMockChild({ id: 's1', softwareType: 'measurement', category: 'sensor' });
|
|
||||||
const s2 = createMockChild({ id: 's2', softwareType: 'measurement', category: 'sensor' });
|
|
||||||
const a1 = createMockChild({ id: 'a1', softwareType: 'measurement', category: 'analyser' });
|
|
||||||
const m1 = createMockChild({ id: 'm1', softwareType: 'machine', category: 'pump' });
|
|
||||||
|
|
||||||
await utils.registerChild(s1, POSITIONS.UPSTREAM);
|
|
||||||
await utils.registerChild(s2, POSITIONS.DOWNSTREAM);
|
|
||||||
await utils.registerChild(a1, POSITIONS.UPSTREAM);
|
|
||||||
await utils.registerChild(m1, POSITIONS.AT_EQUIPMENT);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return all children of a given softwareType', () => {
|
|
||||||
const measurements = utils.getChildrenOfType('measurement');
|
|
||||||
expect(measurements).toHaveLength(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return children filtered by category', () => {
|
|
||||||
const sensors = utils.getChildrenOfType('measurement', 'sensor');
|
|
||||||
expect(sensors).toHaveLength(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return empty array for unknown softwareType', () => {
|
|
||||||
expect(utils.getChildrenOfType('nonexistent')).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return empty array for unknown category', () => {
|
|
||||||
expect(utils.getChildrenOfType('measurement', 'nonexistent')).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── getChildById ─────────────────────────────────────────────────────────
|
|
||||||
describe('getChildById()', () => {
|
|
||||||
it('should return the child by its id', async () => {
|
|
||||||
const child = createMockChild({ id: 'find-me' });
|
|
||||||
await utils.registerChild(child, POSITIONS.UPSTREAM);
|
|
||||||
|
|
||||||
expect(utils.getChildById('find-me')).toBe(child);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return null for unknown id', () => {
|
|
||||||
expect(utils.getChildById('does-not-exist')).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── getAllChildren ───────────────────────────────────────────────────────
|
|
||||||
describe('getAllChildren()', () => {
|
|
||||||
it('should return an empty array when no children registered', () => {
|
|
||||||
expect(utils.getAllChildren()).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return all registered child objects', async () => {
|
|
||||||
const c1 = createMockChild({ id: 'c1' });
|
|
||||||
const c2 = createMockChild({ id: 'c2' });
|
|
||||||
await utils.registerChild(c1, POSITIONS.UPSTREAM);
|
|
||||||
await utils.registerChild(c2, POSITIONS.DOWNSTREAM);
|
|
||||||
|
|
||||||
const all = utils.getAllChildren();
|
|
||||||
expect(all).toHaveLength(2);
|
|
||||||
expect(all).toContain(c1);
|
|
||||||
expect(all).toContain(c2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── logChildStructure ───────────────────────────────────────────────────
|
|
||||||
describe('logChildStructure()', () => {
|
|
||||||
it('should log the child structure via debug', async () => {
|
|
||||||
const child = createMockChild({ id: 'log-1', name: 'LogChild' });
|
|
||||||
await utils.registerChild(child, POSITIONS.UPSTREAM);
|
|
||||||
|
|
||||||
utils.logChildStructure();
|
|
||||||
|
|
||||||
expect(parent.logger.debug).toHaveBeenCalledWith(
|
|
||||||
'Current child structure:',
|
|
||||||
expect.any(String)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── _storeChild (internal) ──────────────────────────────────────────────
|
|
||||||
describe('_storeChild() internal behaviour', () => {
|
|
||||||
it('should create the child object on parent if it does not exist', async () => {
|
|
||||||
delete parent.child;
|
|
||||||
const child = createMockChild();
|
|
||||||
await utils.registerChild(child, POSITIONS.UPSTREAM);
|
|
||||||
|
|
||||||
expect(parent.child).toBeDefined();
|
|
||||||
expect(parent.child.measurement.sensor).toContain(child);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should use "sensor" as default category when asset.category is absent', async () => {
|
|
||||||
const child = createMockChild();
|
|
||||||
// remove asset.category to trigger default
|
|
||||||
delete child.config.asset.category;
|
|
||||||
await utils.registerChild(child, POSITIONS.UPSTREAM);
|
|
||||||
|
|
||||||
expect(parent.child.measurement.sensor).toContain(child);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -3,6 +3,48 @@ const assert = require('node:assert/strict');
|
|||||||
|
|
||||||
const ConfigManager = require('../src/configs/index.js');
|
const ConfigManager = require('../src/configs/index.js');
|
||||||
|
|
||||||
|
test('buildConfig deep-merges domainConfig so general.id and asset.model survive', () => {
|
||||||
|
// Regression: previously this used Object.assign (shallow), so a
|
||||||
|
// buildDomainConfig that returned {general:{unit:'m3/h'}, asset:{curveUnits:...}}
|
||||||
|
// wiped general.id (→ null via schema default) and asset.model (→ "Unknown"),
|
||||||
|
// which collapsed MGC child registration (id collision) and broke curve loading.
|
||||||
|
const manager = new ConfigManager('.');
|
||||||
|
const uiConfig = {
|
||||||
|
name: 'PumpA',
|
||||||
|
model: 'hidrostal-H05K-S03R',
|
||||||
|
supplier: 'Hidrostal',
|
||||||
|
category: 'pump',
|
||||||
|
assetType: 'Centrifugal',
|
||||||
|
unit: 'm3/h',
|
||||||
|
};
|
||||||
|
const domainConfig = {
|
||||||
|
general: { unit: 'm3/h' },
|
||||||
|
asset: { curveUnits: { pressure: 'mbar', flow: 'm3/h', power: 'kW', control: '%' } },
|
||||||
|
};
|
||||||
|
const cfg = manager.buildConfig('rotatingMachine', uiConfig, 'node-abc-123', domainConfig);
|
||||||
|
|
||||||
|
assert.equal(cfg.general.id, 'node-abc-123', 'general.id must come from nodeId');
|
||||||
|
assert.equal(cfg.general.name, 'PumpA', 'general.name must come from uiConfig.name');
|
||||||
|
assert.equal(cfg.general.unit, 'm3/h', 'general.unit must come from domainConfig');
|
||||||
|
assert.equal(cfg.asset.model, 'hidrostal-H05K-S03R', 'asset.model must come from uiConfig.model');
|
||||||
|
assert.equal(cfg.asset.supplier, 'Hidrostal', 'asset.supplier must survive merge');
|
||||||
|
assert.deepEqual(cfg.asset.curveUnits, domainConfig.asset.curveUnits,
|
||||||
|
'asset.curveUnits must come from domainConfig');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildConfig: two nodes with same uiConfig keep distinct general.id', () => {
|
||||||
|
// Regression: MGC.onRegister('machine') keys by config.general.id; if two
|
||||||
|
// machines collapse to the same id (e.g. null), the second is rejected.
|
||||||
|
const manager = new ConfigManager('.');
|
||||||
|
const ui = { model: 'hidrostal-H05K-S03R', supplier: 'Hidrostal', unit: 'm3/h' };
|
||||||
|
const dom = { general: { unit: 'm3/h' }, asset: { curveUnits: { pressure: 'mbar' } } };
|
||||||
|
const a = manager.buildConfig('rotatingMachine', ui, 'red-node-A', dom);
|
||||||
|
const b = manager.buildConfig('rotatingMachine', ui, 'red-node-B', dom);
|
||||||
|
assert.notEqual(a.general.id, b.general.id);
|
||||||
|
assert.equal(a.general.id, 'red-node-A');
|
||||||
|
assert.equal(b.general.id, 'red-node-B');
|
||||||
|
});
|
||||||
|
|
||||||
test('can read known config and report existence', () => {
|
test('can read known config and report existence', () => {
|
||||||
const manager = new ConfigManager('.');
|
const manager = new ConfigManager('.');
|
||||||
assert.equal(manager.hasConfig('measurement'), true);
|
assert.equal(manager.hasConfig('measurement'), true);
|
||||||
|
|||||||
@@ -1,217 +0,0 @@
|
|||||||
const path = require('path');
|
|
||||||
const ConfigManager = require('../src/configs/index');
|
|
||||||
|
|
||||||
describe('ConfigManager', () => {
|
|
||||||
const configDir = path.resolve(__dirname, '../src/configs');
|
|
||||||
let cm;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
cm = new ConfigManager(configDir);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── getConfig() ──────────────────────────────────────────────────────
|
|
||||||
describe('getConfig()', () => {
|
|
||||||
it('should load and parse a known JSON config file', () => {
|
|
||||||
const config = cm.getConfig('baseConfig');
|
|
||||||
expect(config).toBeDefined();
|
|
||||||
expect(typeof config).toBe('object');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return the same content on successive calls', () => {
|
|
||||||
const a = cm.getConfig('baseConfig');
|
|
||||||
const b = cm.getConfig('baseConfig');
|
|
||||||
expect(a).toEqual(b);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw when the config file does not exist', () => {
|
|
||||||
expect(() => cm.getConfig('nonExistentConfig_xyz'))
|
|
||||||
.toThrow(/Failed to load config/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw a descriptive message including the config name', () => {
|
|
||||||
expect(() => cm.getConfig('missing'))
|
|
||||||
.toThrow("Failed to load config 'missing'");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── hasConfig() ──────────────────────────────────────────────────────
|
|
||||||
describe('hasConfig()', () => {
|
|
||||||
it('should return true for a config that exists', () => {
|
|
||||||
expect(cm.hasConfig('baseConfig')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false for a config that does not exist', () => {
|
|
||||||
expect(cm.hasConfig('doesNotExist_abc')).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── getAvailableConfigs() ────────────────────────────────────────────
|
|
||||||
describe('getAvailableConfigs()', () => {
|
|
||||||
it('should return an array of strings', () => {
|
|
||||||
const configs = cm.getAvailableConfigs();
|
|
||||||
expect(Array.isArray(configs)).toBe(true);
|
|
||||||
configs.forEach(name => expect(typeof name).toBe('string'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should include known config names without .json extension', () => {
|
|
||||||
const configs = cm.getAvailableConfigs();
|
|
||||||
expect(configs).toContain('baseConfig');
|
|
||||||
expect(configs).toContain('diffuser');
|
|
||||||
expect(configs).toContain('measurement');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not include .json extension in returned names', () => {
|
|
||||||
const configs = cm.getAvailableConfigs();
|
|
||||||
configs.forEach(name => {
|
|
||||||
expect(name).not.toMatch(/\.json$/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw when pointed at a non-existent directory', () => {
|
|
||||||
const bad = new ConfigManager('/tmp/nonexistent_dir_xyz_123');
|
|
||||||
expect(() => bad.getAvailableConfigs()).toThrow(/Failed to read config directory/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── buildConfig() ────────────────────────────────────────────────────
|
|
||||||
describe('buildConfig()', () => {
|
|
||||||
it('should return an object with general and functionality sections', () => {
|
|
||||||
const uiConfig = { name: 'TestNode', unit: 'bar', enableLog: true, logLevel: 'debug' };
|
|
||||||
const result = cm.buildConfig('measurement', uiConfig, 'node-id-1');
|
|
||||||
expect(result).toHaveProperty('general');
|
|
||||||
expect(result).toHaveProperty('functionality');
|
|
||||||
expect(result).toHaveProperty('output');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should populate general.name from uiConfig.name', () => {
|
|
||||||
const uiConfig = { name: 'MySensor' };
|
|
||||||
const result = cm.buildConfig('measurement', uiConfig, 'id-1');
|
|
||||||
expect(result.general.name).toBe('MySensor');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should default general.name to nodeName when uiConfig.name is empty', () => {
|
|
||||||
const result = cm.buildConfig('measurement', {}, 'id-1');
|
|
||||||
expect(result.general.name).toBe('measurement');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should set general.id from the nodeId argument', () => {
|
|
||||||
const result = cm.buildConfig('valve', {}, 'node-42');
|
|
||||||
expect(result.general.id).toBe('node-42');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should default unit to unitless', () => {
|
|
||||||
const result = cm.buildConfig('valve', {}, 'id-1');
|
|
||||||
expect(result.general.unit).toBe('unitless');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should default logging.enabled to true when enableLog is undefined', () => {
|
|
||||||
const result = cm.buildConfig('valve', {}, 'id-1');
|
|
||||||
expect(result.general.logging.enabled).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should respect enableLog = false', () => {
|
|
||||||
const result = cm.buildConfig('valve', { enableLog: false }, 'id-1');
|
|
||||||
expect(result.general.logging.enabled).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should default logLevel to info', () => {
|
|
||||||
const result = cm.buildConfig('valve', {}, 'id-1');
|
|
||||||
expect(result.general.logging.logLevel).toBe('info');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should set functionality.softwareType to lowercase nodeName', () => {
|
|
||||||
const result = cm.buildConfig('Valve', {}, 'id-1');
|
|
||||||
expect(result.functionality.softwareType).toBe('valve');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should default positionVsParent to atEquipment', () => {
|
|
||||||
const result = cm.buildConfig('valve', {}, 'id-1');
|
|
||||||
expect(result.functionality.positionVsParent).toBe('atEquipment');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should set distance when hasDistance is true', () => {
|
|
||||||
const result = cm.buildConfig('valve', { hasDistance: true, distance: 5.5 }, 'id-1');
|
|
||||||
expect(result.functionality.distance).toBe(5.5);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should set distance to undefined when hasDistance is false', () => {
|
|
||||||
const result = cm.buildConfig('valve', { hasDistance: false, distance: 5.5 }, 'id-1');
|
|
||||||
expect(result.functionality.distance).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── asset section ──────────────────────────────────────────────────
|
|
||||||
it('should not include asset section when no asset fields provided', () => {
|
|
||||||
const result = cm.buildConfig('valve', {}, 'id-1');
|
|
||||||
expect(result.asset).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should include asset section when supplier is provided', () => {
|
|
||||||
const result = cm.buildConfig('valve', { supplier: 'Siemens' }, 'id-1');
|
|
||||||
expect(result.asset).toBeDefined();
|
|
||||||
expect(result.asset.supplier).toBe('Siemens');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should populate asset defaults for missing optional fields', () => {
|
|
||||||
const result = cm.buildConfig('valve', { supplier: 'ABB' }, 'id-1');
|
|
||||||
expect(result.asset.category).toBe('sensor');
|
|
||||||
expect(result.asset.type).toBe('Unknown');
|
|
||||||
expect(result.asset.model).toBe('Unknown');
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── domainConfig merge ─────────────────────────────────────────────
|
|
||||||
it('should merge domainConfig sections into the result', () => {
|
|
||||||
const domain = { scaling: { enabled: true, factor: 2 } };
|
|
||||||
const result = cm.buildConfig('measurement', {}, 'id-1', domain);
|
|
||||||
expect(result.scaling).toEqual({ enabled: true, factor: 2 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle empty domainConfig gracefully', () => {
|
|
||||||
const result = cm.buildConfig('measurement', {}, 'id-1', {});
|
|
||||||
expect(result).toHaveProperty('general');
|
|
||||||
expect(result).toHaveProperty('functionality');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should default output formats to process and influxdb', () => {
|
|
||||||
const result = cm.buildConfig('measurement', {}, 'id-1');
|
|
||||||
expect(result.output).toEqual({
|
|
||||||
process: 'process',
|
|
||||||
dbase: 'influxdb',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should allow output format overrides from ui config', () => {
|
|
||||||
const result = cm.buildConfig('measurement', {
|
|
||||||
processOutputFormat: 'json',
|
|
||||||
dbaseOutputFormat: 'csv',
|
|
||||||
}, 'id-1');
|
|
||||||
expect(result.output).toEqual({
|
|
||||||
process: 'json',
|
|
||||||
dbase: 'csv',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── createEndpoint() ─────────────────────────────────────────────────
|
|
||||||
describe('createEndpoint()', () => {
|
|
||||||
it('should return a JavaScript string containing the node name', () => {
|
|
||||||
const script = cm.createEndpoint('baseConfig');
|
|
||||||
expect(typeof script).toBe('string');
|
|
||||||
expect(script).toContain('baseConfig');
|
|
||||||
expect(script).toContain('window.EVOLV');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw for a non-existent config', () => {
|
|
||||||
expect(() => cm.createEndpoint('doesNotExist_xyz'))
|
|
||||||
.toThrow(/Failed to create endpoint/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── getBaseConfig() ──────────────────────────────────────────────────
|
|
||||||
describe('getBaseConfig()', () => {
|
|
||||||
it('should load the baseConfig.json file', () => {
|
|
||||||
const base = cm.getBaseConfig();
|
|
||||||
expect(base).toBeDefined();
|
|
||||||
expect(typeof base).toBe('object');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,336 +0,0 @@
|
|||||||
const MeasurementContainer = require('../src/measurements/MeasurementContainer');
|
|
||||||
|
|
||||||
describe('MeasurementContainer', () => {
|
|
||||||
let mc;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
mc = new MeasurementContainer({ windowSize: 5, autoConvert: false });
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Construction ─────────────────────────────────────────────────────
|
|
||||||
describe('constructor', () => {
|
|
||||||
it('should initialise with default windowSize when none provided', () => {
|
|
||||||
const m = new MeasurementContainer();
|
|
||||||
expect(m.windowSize).toBe(10);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should accept a custom windowSize', () => {
|
|
||||||
expect(mc.windowSize).toBe(5);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should start with an empty measurements map', () => {
|
|
||||||
expect(mc.measurements).toEqual({});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should populate default units', () => {
|
|
||||||
expect(mc.defaultUnits.pressure).toBe('mbar');
|
|
||||||
expect(mc.defaultUnits.flow).toBe('m3/h');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should allow overriding default units', () => {
|
|
||||||
const m = new MeasurementContainer({ defaultUnits: { pressure: 'Pa' } });
|
|
||||||
expect(m.defaultUnits.pressure).toBe('Pa');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Chainable setters ───────────────────────────────────────────────
|
|
||||||
describe('chaining API — type / variant / position', () => {
|
|
||||||
it('should set type and return this for chaining', () => {
|
|
||||||
const ret = mc.type('pressure');
|
|
||||||
expect(ret).toBe(mc);
|
|
||||||
expect(mc._currentType).toBe('pressure');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reset variant and position when type is called', () => {
|
|
||||||
mc.type('pressure').variant('measured').position('upstream');
|
|
||||||
mc.type('flow');
|
|
||||||
expect(mc._currentVariant).toBeNull();
|
|
||||||
expect(mc._currentPosition).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should set variant and return this', () => {
|
|
||||||
mc.type('pressure');
|
|
||||||
const ret = mc.variant('measured');
|
|
||||||
expect(ret).toBe(mc);
|
|
||||||
expect(mc._currentVariant).toBe('measured');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw if variant is called without type', () => {
|
|
||||||
expect(() => mc.variant('measured')).toThrow(/Type must be specified/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should set position (lowercased) and return this', () => {
|
|
||||||
mc.type('pressure').variant('measured');
|
|
||||||
const ret = mc.position('Upstream');
|
|
||||||
expect(ret).toBe(mc);
|
|
||||||
expect(mc._currentPosition).toBe('upstream');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw if position is called without variant', () => {
|
|
||||||
mc.type('pressure');
|
|
||||||
expect(() => mc.position('upstream')).toThrow(/Variant must be specified/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Storing and retrieving values ───────────────────────────────────
|
|
||||||
describe('value() and retrieval methods', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mc.type('pressure').variant('measured').position('upstream');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should store a value and retrieve it with getCurrentValue()', () => {
|
|
||||||
mc.value(42, 1000);
|
|
||||||
expect(mc.getCurrentValue()).toBe(42);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return this for chaining from value()', () => {
|
|
||||||
const ret = mc.value(1, 1000);
|
|
||||||
expect(ret).toBe(mc);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should store multiple values and keep the latest', () => {
|
|
||||||
mc.value(10, 1).value(20, 2).value(30, 3);
|
|
||||||
expect(mc.getCurrentValue()).toBe(30);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should respect the windowSize (rolling window)', () => {
|
|
||||||
for (let i = 1; i <= 8; i++) {
|
|
||||||
mc.value(i, i);
|
|
||||||
}
|
|
||||||
const all = mc.getAllValues();
|
|
||||||
// windowSize is 5, so only the last 5 values should remain
|
|
||||||
expect(all.values.length).toBe(5);
|
|
||||||
expect(all.values).toEqual([4, 5, 6, 7, 8]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should compute getAverage() correctly', () => {
|
|
||||||
mc.value(10, 1).value(20, 2).value(30, 3);
|
|
||||||
expect(mc.getAverage()).toBe(20);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should compute getMin()', () => {
|
|
||||||
mc.value(10, 1).value(5, 2).value(20, 3);
|
|
||||||
expect(mc.getMin()).toBe(5);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should compute getMax()', () => {
|
|
||||||
mc.value(10, 1).value(5, 2).value(20, 3);
|
|
||||||
expect(mc.getMax()).toBe(20);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return null for getCurrentValue() when no values exist', () => {
|
|
||||||
expect(mc.getCurrentValue()).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return null for getAverage() when no values exist', () => {
|
|
||||||
expect(mc.getAverage()).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return null for getMin() when no values exist', () => {
|
|
||||||
expect(mc.getMin()).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return null for getMax() when no values exist', () => {
|
|
||||||
expect(mc.getMax()).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── getAllValues() ──────────────────────────────────────────────────
|
|
||||||
describe('getAllValues()', () => {
|
|
||||||
it('should return values, timestamps, and unit', () => {
|
|
||||||
mc.type('pressure').variant('measured').position('upstream');
|
|
||||||
mc.unit('bar');
|
|
||||||
mc.value(10, 100).value(20, 200);
|
|
||||||
const all = mc.getAllValues();
|
|
||||||
expect(all.values).toEqual([10, 20]);
|
|
||||||
expect(all.timestamps).toEqual([100, 200]);
|
|
||||||
expect(all.unit).toBe('bar');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return null when chain is incomplete', () => {
|
|
||||||
mc.type('pressure');
|
|
||||||
expect(mc.getAllValues()).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── unit() ──────────────────────────────────────────────────────────
|
|
||||||
describe('unit()', () => {
|
|
||||||
it('should set unit on the underlying measurement', () => {
|
|
||||||
mc.type('pressure').variant('measured').position('upstream');
|
|
||||||
mc.unit('bar');
|
|
||||||
const measurement = mc.get();
|
|
||||||
expect(measurement.unit).toBe('bar');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── get() ───────────────────────────────────────────────────────────
|
|
||||||
describe('get()', () => {
|
|
||||||
it('should return the Measurement instance for a complete chain', () => {
|
|
||||||
mc.type('pressure').variant('measured').position('upstream');
|
|
||||||
mc.value(1, 1);
|
|
||||||
const m = mc.get();
|
|
||||||
expect(m).toBeDefined();
|
|
||||||
expect(m.type).toBe('pressure');
|
|
||||||
expect(m.variant).toBe('measured');
|
|
||||||
expect(m.position).toBe('upstream');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return null when chain is incomplete', () => {
|
|
||||||
mc.type('pressure');
|
|
||||||
expect(mc.get()).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── exists() ────────────────────────────────────────────────────────
|
|
||||||
describe('exists()', () => {
|
|
||||||
it('should return false for a non-existent measurement', () => {
|
|
||||||
mc.type('pressure').variant('measured').position('upstream');
|
|
||||||
expect(mc.exists()).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return true after a value has been stored', () => {
|
|
||||||
mc.type('pressure').variant('measured').position('upstream').value(1, 1);
|
|
||||||
expect(mc.exists()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should support requireValues option', () => {
|
|
||||||
mc.type('pressure').variant('measured').position('upstream');
|
|
||||||
// Force creation of measurement without values
|
|
||||||
mc.get();
|
|
||||||
expect(mc.exists({ requireValues: false })).toBe(true);
|
|
||||||
expect(mc.exists({ requireValues: true })).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should support explicit type/variant/position overrides', () => {
|
|
||||||
mc.type('pressure').variant('measured').position('upstream').value(1, 1);
|
|
||||||
// Reset chain, then query by explicit keys
|
|
||||||
mc.type('flow');
|
|
||||||
expect(mc.exists({ type: 'pressure', variant: 'measured', position: 'upstream' })).toBe(true);
|
|
||||||
expect(mc.exists({ type: 'flow', variant: 'measured', position: 'upstream' })).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false when type is not set and not provided', () => {
|
|
||||||
const fresh = new MeasurementContainer({ autoConvert: false });
|
|
||||||
expect(fresh.exists()).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── getLaggedValue() / getLaggedSample() ─────────────────────────────
|
|
||||||
describe('getLaggedValue() and getLaggedSample()', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mc.type('pressure').variant('measured').position('upstream');
|
|
||||||
mc.value(10, 100).value(20, 200).value(30, 300);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return the value at lag=1 (previous value)', () => {
|
|
||||||
expect(mc.getLaggedValue(1)).toBe(20);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return null when lag exceeds stored values', () => {
|
|
||||||
expect(mc.getLaggedValue(10)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return a sample object from getLaggedSample()', () => {
|
|
||||||
const sample = mc.getLaggedSample(0);
|
|
||||||
expect(sample).toHaveProperty('value', 30);
|
|
||||||
expect(sample).toHaveProperty('timestamp', 300);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return null from getLaggedSample when not enough values', () => {
|
|
||||||
expect(mc.getLaggedSample(10)).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Listing helpers ─────────────────────────────────────────────────
|
|
||||||
describe('getTypes() / getVariants() / getPositions()', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mc.type('pressure').variant('measured').position('upstream').value(1, 1);
|
|
||||||
mc.type('flow').variant('predicted').position('downstream').value(2, 2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should list all stored types', () => {
|
|
||||||
const types = mc.getTypes();
|
|
||||||
expect(types).toContain('pressure');
|
|
||||||
expect(types).toContain('flow');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should list variants for a given type', () => {
|
|
||||||
mc.type('pressure');
|
|
||||||
expect(mc.getVariants()).toContain('measured');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return empty array for type with no variants', () => {
|
|
||||||
mc.type('temperature');
|
|
||||||
expect(mc.getVariants()).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw if getVariants() called without type', () => {
|
|
||||||
const fresh = new MeasurementContainer({ autoConvert: false });
|
|
||||||
expect(() => fresh.getVariants()).toThrow(/Type must be specified/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should list positions for type+variant', () => {
|
|
||||||
mc.type('pressure').variant('measured');
|
|
||||||
expect(mc.getPositions()).toContain('upstream');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw if getPositions() called without type and variant', () => {
|
|
||||||
const fresh = new MeasurementContainer({ autoConvert: false });
|
|
||||||
expect(() => fresh.getPositions()).toThrow(/Type and variant must be specified/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── clear() ─────────────────────────────────────────────────────────
|
|
||||||
describe('clear()', () => {
|
|
||||||
it('should reset all measurements and chain state', () => {
|
|
||||||
mc.type('pressure').variant('measured').position('upstream').value(1, 1);
|
|
||||||
mc.clear();
|
|
||||||
expect(mc.measurements).toEqual({});
|
|
||||||
expect(mc._currentType).toBeNull();
|
|
||||||
expect(mc._currentVariant).toBeNull();
|
|
||||||
expect(mc._currentPosition).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Child context setters ───────────────────────────────────────────
|
|
||||||
describe('child context', () => {
|
|
||||||
it('should set childId and return this', () => {
|
|
||||||
expect(mc.setChildId('c1')).toBe(mc);
|
|
||||||
expect(mc.childId).toBe('c1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should set childName and return this', () => {
|
|
||||||
expect(mc.setChildName('pump1')).toBe(mc);
|
|
||||||
expect(mc.childName).toBe('pump1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should set parentRef and return this', () => {
|
|
||||||
const parent = { id: 'p1' };
|
|
||||||
expect(mc.setParentRef(parent)).toBe(mc);
|
|
||||||
expect(mc.parentRef).toBe(parent);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Event emission ──────────────────────────────────────────────────
|
|
||||||
describe('event emission', () => {
|
|
||||||
it('should emit an event when a value is set', (done) => {
|
|
||||||
mc.emitter.on('pressure.measured.upstream', (data) => {
|
|
||||||
expect(data.value).toBe(42);
|
|
||||||
expect(data.type).toBe('pressure');
|
|
||||||
expect(data.variant).toBe('measured');
|
|
||||||
expect(data.position).toBe('upstream');
|
|
||||||
done();
|
|
||||||
});
|
|
||||||
mc.type('pressure').variant('measured').position('upstream').value(42, 1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── setPreferredUnit ────────────────────────────────────────────────
|
|
||||||
describe('setPreferredUnit()', () => {
|
|
||||||
it('should store preferred unit and return this', () => {
|
|
||||||
const ret = mc.setPreferredUnit('pressure', 'Pa');
|
|
||||||
expect(ret).toBe(mc);
|
|
||||||
expect(mc.preferredUnits.pressure).toBe('Pa');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
const OutputUtils = require('../src/helper/outputUtils');
|
|
||||||
|
|
||||||
describe('OutputUtils', () => {
|
|
||||||
let outputUtils;
|
|
||||||
let config;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
outputUtils = new OutputUtils();
|
|
||||||
config = {
|
|
||||||
general: {
|
|
||||||
name: 'Pump-1',
|
|
||||||
id: 'node-1',
|
|
||||||
unit: 'm3/h',
|
|
||||||
},
|
|
||||||
functionality: {
|
|
||||||
softwareType: 'pump',
|
|
||||||
role: 'test-role',
|
|
||||||
},
|
|
||||||
asset: {
|
|
||||||
supplier: 'EVOLV',
|
|
||||||
type: 'sensor',
|
|
||||||
},
|
|
||||||
output: {
|
|
||||||
process: 'process',
|
|
||||||
dbase: 'influxdb',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps legacy process output by default', () => {
|
|
||||||
const msg = outputUtils.formatMsg({ flow: 12.5 }, config, 'process');
|
|
||||||
expect(msg).toEqual({
|
|
||||||
topic: 'Pump-1',
|
|
||||||
payload: { flow: 12.5 },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps legacy influxdb output by default', () => {
|
|
||||||
const msg = outputUtils.formatMsg({ flow: 12.5 }, config, 'influxdb');
|
|
||||||
expect(msg.topic).toBe('Pump-1');
|
|
||||||
expect(msg.payload).toEqual(expect.objectContaining({
|
|
||||||
measurement: 'Pump-1',
|
|
||||||
fields: { flow: 12.5 },
|
|
||||||
tags: expect.objectContaining({
|
|
||||||
id: 'node-1',
|
|
||||||
name: 'Pump-1',
|
|
||||||
softwareType: 'pump',
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('supports config-driven json formatting on the process channel', () => {
|
|
||||||
config.output.process = 'json';
|
|
||||||
const msg = outputUtils.formatMsg({ flow: 12.5 }, config, 'process');
|
|
||||||
expect(msg.topic).toBe('Pump-1');
|
|
||||||
expect(typeof msg.payload).toBe('string');
|
|
||||||
expect(msg.payload).toContain('"measurement":"Pump-1"');
|
|
||||||
expect(msg.payload).toContain('"flow":12.5');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('supports config-driven csv formatting on the database channel', () => {
|
|
||||||
config.output.dbase = 'csv';
|
|
||||||
const msg = outputUtils.formatMsg({ flow: 12.5 }, config, 'influxdb');
|
|
||||||
expect(msg.topic).toBe('Pump-1');
|
|
||||||
expect(typeof msg.payload).toBe('string');
|
|
||||||
expect(msg.payload).toContain('Pump-1');
|
|
||||||
expect(msg.payload).toContain('flow=12.5');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
112
test/registry/AssetResolver.test.js
Normal file
112
test/registry/AssetResolver.test.js
Normal file
@@ -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);
|
||||||
|
});
|
||||||
98
test/registry/FileBackend.test.js
Normal file
98
test/registry/FileBackend.test.js
Normal file
@@ -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/);
|
||||||
|
});
|
||||||
30
test/registry/HttpBackend.test.js
Normal file
30
test/registry/HttpBackend.test.js
Normal file
@@ -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;
|
||||||
|
});
|
||||||
99
test/registry/namespaces.test.js
Normal file
99
test/registry/namespaces.test.js
Normal file
@@ -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);
|
||||||
|
});
|
||||||
@@ -1,554 +0,0 @@
|
|||||||
const ValidationUtils = require('../src/helper/validationUtils');
|
|
||||||
const { validateNumber, validateInteger, validateBoolean, validateString, validateEnum } = require('../src/helper/validators/typeValidators');
|
|
||||||
const { validateArray, validateSet, validateObject } = require('../src/helper/validators/collectionValidators');
|
|
||||||
const { validateCurve, validateMachineCurve, isSorted, isUnique, areNumbers } = require('../src/helper/validators/curveValidator');
|
|
||||||
|
|
||||||
// Shared mock logger used across tests
|
|
||||||
function mockLogger() {
|
|
||||||
return { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
|
||||||
// Type validators
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
|
||||||
describe('typeValidators', () => {
|
|
||||||
let logger;
|
|
||||||
beforeEach(() => { logger = mockLogger(); });
|
|
||||||
|
|
||||||
// ── validateNumber ──────────────────────────────────────────────────
|
|
||||||
describe('validateNumber()', () => {
|
|
||||||
it('should accept a valid number', () => {
|
|
||||||
expect(validateNumber(42, {}, { default: 0 }, 'n', 'k', logger)).toBe(42);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should parse a string to a number', () => {
|
|
||||||
expect(validateNumber('3.14', {}, { default: 0 }, 'n', 'k', logger)).toBe(3.14);
|
|
||||||
expect(logger.warn).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return default when below min', () => {
|
|
||||||
expect(validateNumber(1, { min: 5 }, { default: 5 }, 'n', 'k', logger)).toBe(5);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return default when above max', () => {
|
|
||||||
expect(validateNumber(100, { max: 50 }, { default: 50 }, 'n', 'k', logger)).toBe(50);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should accept boundary value equal to min', () => {
|
|
||||||
expect(validateNumber(5, { min: 5 }, { default: 0 }, 'n', 'k', logger)).toBe(5);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should accept boundary value equal to max', () => {
|
|
||||||
expect(validateNumber(50, { max: 50 }, { default: 0 }, 'n', 'k', logger)).toBe(50);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── validateInteger ─────────────────────────────────────────────────
|
|
||||||
describe('validateInteger()', () => {
|
|
||||||
it('should accept a valid integer', () => {
|
|
||||||
expect(validateInteger(7, {}, { default: 0 }, 'n', 'k', logger)).toBe(7);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should parse a string to an integer', () => {
|
|
||||||
expect(validateInteger('10', {}, { default: 0 }, 'n', 'k', logger)).toBe(10);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return default for a non-parseable value', () => {
|
|
||||||
expect(validateInteger('abc', {}, { default: -1 }, 'n', 'k', logger)).toBe(-1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return default when below min', () => {
|
|
||||||
expect(validateInteger(2, { min: 5 }, { default: 5 }, 'n', 'k', logger)).toBe(5);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return default when above max', () => {
|
|
||||||
expect(validateInteger(100, { max: 50 }, { default: 50 }, 'n', 'k', logger)).toBe(50);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should parse a float string and truncate to integer', () => {
|
|
||||||
expect(validateInteger('7.9', {}, { default: 0 }, 'n', 'k', logger)).toBe(7);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── validateBoolean ─────────────────────────────────────────────────
|
|
||||||
describe('validateBoolean()', () => {
|
|
||||||
it('should pass through a true boolean', () => {
|
|
||||||
expect(validateBoolean(true, 'n', 'k', logger)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should pass through a false boolean', () => {
|
|
||||||
expect(validateBoolean(false, 'n', 'k', logger)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should parse string "true" to boolean true', () => {
|
|
||||||
expect(validateBoolean('true', 'n', 'k', logger)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should parse string "false" to boolean false', () => {
|
|
||||||
expect(validateBoolean('false', 'n', 'k', logger)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should pass through non-boolean non-string values unchanged', () => {
|
|
||||||
expect(validateBoolean(42, 'n', 'k', logger)).toBe(42);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── validateString ──────────────────────────────────────────────────
|
|
||||||
describe('validateString()', () => {
|
|
||||||
it('should accept a lowercase string', () => {
|
|
||||||
expect(validateString('hello', {}, { default: '' }, 'n', 'k', logger)).toBe('hello');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should convert uppercase to lowercase', () => {
|
|
||||||
expect(validateString('Hello', {}, { default: '' }, 'n', 'k', logger)).toBe('hello');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should convert a number to a string', () => {
|
|
||||||
expect(validateString(42, {}, { default: '' }, 'n', 'k', logger)).toBe('42');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return null when nullable and value is null', () => {
|
|
||||||
expect(validateString(null, { nullable: true }, { default: '' }, 'n', 'k', logger)).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── validateEnum ────────────────────────────────────────────────────
|
|
||||||
describe('validateEnum()', () => {
|
|
||||||
const rules = { values: [{ value: 'open' }, { value: 'closed' }, { value: 'partial' }] };
|
|
||||||
|
|
||||||
it('should accept a valid enum value', () => {
|
|
||||||
expect(validateEnum('open', rules, { default: 'closed' }, 'n', 'k', logger)).toBe('open');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should be case-insensitive', () => {
|
|
||||||
expect(validateEnum('OPEN', rules, { default: 'closed' }, 'n', 'k', logger)).toBe('open');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return default for an invalid value', () => {
|
|
||||||
expect(validateEnum('invalid', rules, { default: 'closed' }, 'n', 'k', logger)).toBe('closed');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return default when value is null', () => {
|
|
||||||
expect(validateEnum(null, rules, { default: 'closed' }, 'n', 'k', logger)).toBe('closed');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return default when rules.values is not an array', () => {
|
|
||||||
expect(validateEnum('open', {}, { default: 'closed' }, 'n', 'k', logger)).toBe('closed');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
|
||||||
// Collection validators
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
|
||||||
describe('collectionValidators', () => {
|
|
||||||
let logger;
|
|
||||||
beforeEach(() => { logger = mockLogger(); });
|
|
||||||
|
|
||||||
// ── validateArray ───────────────────────────────────────────────────
|
|
||||||
describe('validateArray()', () => {
|
|
||||||
it('should return default when value is not an array', () => {
|
|
||||||
expect(validateArray('not-array', { itemType: 'number' }, { default: [1] }, 'n', 'k', logger))
|
|
||||||
.toEqual([1]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should filter items by itemType', () => {
|
|
||||||
const result = validateArray([1, 'a', 2], { itemType: 'number', minLength: 1 }, { default: [] }, 'n', 'k', logger);
|
|
||||||
expect(result).toEqual([1, 2]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should respect maxLength', () => {
|
|
||||||
const result = validateArray([1, 2, 3, 4, 5], { itemType: 'number', maxLength: 3, minLength: 1 }, { default: [] }, 'n', 'k', logger);
|
|
||||||
expect(result).toEqual([1, 2, 3]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return default when fewer items than minLength after filtering', () => {
|
|
||||||
const result = validateArray(['a'], { itemType: 'number', minLength: 1 }, { default: [0] }, 'n', 'k', logger);
|
|
||||||
expect(result).toEqual([0]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should pass all items through when itemType is null', () => {
|
|
||||||
const result = validateArray([1, 'a', true], { itemType: 'null', minLength: 1 }, { default: [] }, 'n', 'k', logger);
|
|
||||||
expect(result).toEqual([1, 'a', true]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── validateSet ─────────────────────────────────────────────────────
|
|
||||||
describe('validateSet()', () => {
|
|
||||||
it('should convert default to Set when value is not a Set', () => {
|
|
||||||
const result = validateSet('not-a-set', { itemType: 'number' }, { default: [1, 2] }, 'n', 'k', logger);
|
|
||||||
expect(result).toBeInstanceOf(Set);
|
|
||||||
expect([...result]).toEqual([1, 2]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should filter Set items by type', () => {
|
|
||||||
const input = new Set([1, 'a', 2]);
|
|
||||||
const result = validateSet(input, { itemType: 'number', minLength: 1 }, { default: [] }, 'n', 'k', logger);
|
|
||||||
expect([...result]).toEqual([1, 2]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return default Set when too few items remain', () => {
|
|
||||||
const input = new Set(['a']);
|
|
||||||
const result = validateSet(input, { itemType: 'number', minLength: 1 }, { default: [0] }, 'n', 'k', logger);
|
|
||||||
expect([...result]).toEqual([0]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── validateObject ──────────────────────────────────────────────────
|
|
||||||
describe('validateObject()', () => {
|
|
||||||
it('should return default when value is not an object', () => {
|
|
||||||
expect(validateObject('str', {}, { default: { a: 1 } }, 'n', 'k', jest.fn(), logger))
|
|
||||||
.toEqual({ a: 1 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return default when value is an array', () => {
|
|
||||||
expect(validateObject([1, 2], {}, { default: {} }, 'n', 'k', jest.fn(), logger))
|
|
||||||
.toEqual({});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return default when no schema is provided', () => {
|
|
||||||
expect(validateObject({ a: 1 }, {}, { default: { b: 2 } }, 'n', 'k', jest.fn(), logger))
|
|
||||||
.toEqual({ b: 2 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should call validateSchemaFn when schema is provided', () => {
|
|
||||||
const mockFn = jest.fn().mockReturnValue({ validated: true });
|
|
||||||
const rules = { schema: { x: { default: 1 } } };
|
|
||||||
const result = validateObject({ x: 2 }, rules, {}, 'n', 'k', mockFn, logger);
|
|
||||||
expect(mockFn).toHaveBeenCalledWith({ x: 2 }, rules.schema, 'n.k');
|
|
||||||
expect(result).toEqual({ validated: true });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
|
||||||
// Curve validators
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
|
||||||
describe('curveValidator', () => {
|
|
||||||
let logger;
|
|
||||||
beforeEach(() => { logger = mockLogger(); });
|
|
||||||
|
|
||||||
// ── Helper utilities ────────────────────────────────────────────────
|
|
||||||
describe('isSorted()', () => {
|
|
||||||
it('should return true for a sorted array', () => {
|
|
||||||
expect(isSorted([1, 2, 3, 4])).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false for an unsorted array', () => {
|
|
||||||
expect(isSorted([3, 1, 2])).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return true for an empty array', () => {
|
|
||||||
expect(isSorted([])).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return true for equal adjacent values', () => {
|
|
||||||
expect(isSorted([1, 1, 2])).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('isUnique()', () => {
|
|
||||||
it('should return true when all values are unique', () => {
|
|
||||||
expect(isUnique([1, 2, 3])).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false when duplicates exist', () => {
|
|
||||||
expect(isUnique([1, 2, 2])).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('areNumbers()', () => {
|
|
||||||
it('should return true for all numbers', () => {
|
|
||||||
expect(areNumbers([1, 2.5, -3])).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false when a non-number is present', () => {
|
|
||||||
expect(areNumbers([1, 'a', 3])).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── validateCurve ───────────────────────────────────────────────────
|
|
||||||
describe('validateCurve()', () => {
|
|
||||||
const defaultCurve = { line1: { x: [0, 1], y: [0, 1] } };
|
|
||||||
|
|
||||||
it('should return default when input is null', () => {
|
|
||||||
expect(validateCurve(null, defaultCurve, logger)).toEqual(defaultCurve);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return default for an empty object', () => {
|
|
||||||
expect(validateCurve({}, defaultCurve, logger)).toEqual(defaultCurve);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should validate a correct curve', () => {
|
|
||||||
const curve = { line1: { x: [1, 2, 3], y: [10, 20, 30] } };
|
|
||||||
const result = validateCurve(curve, defaultCurve, logger);
|
|
||||||
expect(result.line1.x).toEqual([1, 2, 3]);
|
|
||||||
expect(result.line1.y).toEqual([10, 20, 30]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should sort unsorted x values and reorder y accordingly', () => {
|
|
||||||
const curve = { line1: { x: [3, 1, 2], y: [30, 10, 20] } };
|
|
||||||
const result = validateCurve(curve, defaultCurve, logger);
|
|
||||||
expect(result.line1.x).toEqual([1, 2, 3]);
|
|
||||||
expect(result.line1.y).toEqual([10, 20, 30]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should remove duplicate x values', () => {
|
|
||||||
const curve = { line1: { x: [1, 1, 2], y: [10, 11, 20] } };
|
|
||||||
const result = validateCurve(curve, defaultCurve, logger);
|
|
||||||
expect(result.line1.x).toEqual([1, 2]);
|
|
||||||
expect(result.line1.y.length).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return default when y contains non-numbers', () => {
|
|
||||||
const curve = { line1: { x: [1, 2], y: ['a', 'b'] } };
|
|
||||||
expect(validateCurve(curve, defaultCurve, logger)).toEqual(defaultCurve);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── validateMachineCurve ────────────────────────────────────────────
|
|
||||||
describe('validateMachineCurve()', () => {
|
|
||||||
const defaultMC = {
|
|
||||||
nq: { line1: { x: [0, 1], y: [0, 1] } },
|
|
||||||
np: { line1: { x: [0, 1], y: [0, 1] } },
|
|
||||||
};
|
|
||||||
|
|
||||||
it('should return default when input is null', () => {
|
|
||||||
expect(validateMachineCurve(null, defaultMC, logger)).toEqual(defaultMC);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return default when nq or np is missing', () => {
|
|
||||||
expect(validateMachineCurve({ nq: {} }, defaultMC, logger)).toEqual(defaultMC);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should validate a correct machine curve', () => {
|
|
||||||
const input = {
|
|
||||||
nq: { line1: { x: [1, 2], y: [10, 20] } },
|
|
||||||
np: { line1: { x: [1, 2], y: [5, 10] } },
|
|
||||||
};
|
|
||||||
const result = validateMachineCurve(input, defaultMC, logger);
|
|
||||||
expect(result.nq.line1.x).toEqual([1, 2]);
|
|
||||||
expect(result.np.line1.y).toEqual([5, 10]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
|
||||||
// ValidationUtils class
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
|
||||||
describe('ValidationUtils', () => {
|
|
||||||
let vu;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vu = new ValidationUtils(true, 'error'); // suppress most logging noise
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── constrain() ─────────────────────────────────────────────────────
|
|
||||||
describe('constrain()', () => {
|
|
||||||
it('should return value when within range', () => {
|
|
||||||
expect(vu.constrain(5, 0, 10)).toBe(5);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should clamp to min when value is below range', () => {
|
|
||||||
expect(vu.constrain(-5, 0, 10)).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should clamp to max when value is above range', () => {
|
|
||||||
expect(vu.constrain(15, 0, 10)).toBe(10);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return min for boundary value equal to min', () => {
|
|
||||||
expect(vu.constrain(0, 0, 10)).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return max for boundary value equal to max', () => {
|
|
||||||
expect(vu.constrain(10, 0, 10)).toBe(10);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return min when value is not a number', () => {
|
|
||||||
expect(vu.constrain('abc', 0, 10)).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return min when value is null', () => {
|
|
||||||
expect(vu.constrain(null, 0, 10)).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return min when value is undefined', () => {
|
|
||||||
expect(vu.constrain(undefined, 0, 10)).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── validateSchema() ────────────────────────────────────────────────
|
|
||||||
describe('validateSchema()', () => {
|
|
||||||
it('should use default value when config key is missing', () => {
|
|
||||||
const schema = {
|
|
||||||
speed: { default: 100, rules: { type: 'number' } },
|
|
||||||
};
|
|
||||||
const result = vu.validateSchema({}, schema, 'test');
|
|
||||||
expect(result.speed).toBe(100);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should use provided value over default', () => {
|
|
||||||
const schema = {
|
|
||||||
speed: { default: 100, rules: { type: 'number' } },
|
|
||||||
};
|
|
||||||
const result = vu.validateSchema({ speed: 200 }, schema, 'test');
|
|
||||||
expect(result.speed).toBe(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should strip unknown keys from config', () => {
|
|
||||||
const schema = {
|
|
||||||
speed: { default: 100, rules: { type: 'number' } },
|
|
||||||
};
|
|
||||||
const config = { speed: 50, unknownKey: 'bad' };
|
|
||||||
const result = vu.validateSchema(config, schema, 'test');
|
|
||||||
expect(result.unknownKey).toBeUndefined();
|
|
||||||
expect(result.speed).toBe(50);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should validate number type with min/max', () => {
|
|
||||||
const schema = {
|
|
||||||
speed: { default: 10, rules: { type: 'number', min: 0, max: 100 } },
|
|
||||||
};
|
|
||||||
// within range
|
|
||||||
expect(vu.validateSchema({ speed: 50 }, schema, 'test').speed).toBe(50);
|
|
||||||
// below min -> default
|
|
||||||
expect(vu.validateSchema({ speed: -1 }, schema, 'test').speed).toBe(10);
|
|
||||||
// above max -> default
|
|
||||||
expect(vu.validateSchema({ speed: 101 }, schema, 'test').speed).toBe(10);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should validate boolean type', () => {
|
|
||||||
const schema = {
|
|
||||||
enabled: { default: true, rules: { type: 'boolean' } },
|
|
||||||
};
|
|
||||||
expect(vu.validateSchema({ enabled: false }, schema, 'test').enabled).toBe(false);
|
|
||||||
expect(vu.validateSchema({ enabled: 'true' }, schema, 'test').enabled).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should validate string type (lowercased)', () => {
|
|
||||||
const schema = {
|
|
||||||
mode: { default: 'auto', rules: { type: 'string' } },
|
|
||||||
};
|
|
||||||
expect(vu.validateSchema({ mode: 'Manual' }, schema, 'test').mode).toBe('manual');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should validate enum type', () => {
|
|
||||||
const schema = {
|
|
||||||
state: {
|
|
||||||
default: 'open',
|
|
||||||
rules: { type: 'enum', values: [{ value: 'open' }, { value: 'closed' }] },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
expect(vu.validateSchema({ state: 'closed' }, schema, 'test').state).toBe('closed');
|
|
||||||
expect(vu.validateSchema({ state: 'invalid' }, schema, 'test').state).toBe('open');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should validate integer type', () => {
|
|
||||||
const schema = {
|
|
||||||
count: { default: 5, rules: { type: 'integer', min: 1, max: 100 } },
|
|
||||||
};
|
|
||||||
expect(vu.validateSchema({ count: 10 }, schema, 'test').count).toBe(10);
|
|
||||||
expect(vu.validateSchema({ count: '42' }, schema, 'test').count).toBe(42);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should validate array type', () => {
|
|
||||||
const schema = {
|
|
||||||
items: { default: [1, 2], rules: { type: 'array', itemType: 'number', minLength: 1 } },
|
|
||||||
};
|
|
||||||
expect(vu.validateSchema({ items: [3, 4, 5] }, schema, 'test').items).toEqual([3, 4, 5]);
|
|
||||||
expect(vu.validateSchema({ items: 'not-array' }, schema, 'test').items).toEqual([1, 2]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle nested object with schema recursively', () => {
|
|
||||||
const schema = {
|
|
||||||
logging: {
|
|
||||||
rules: { type: 'object', schema: {
|
|
||||||
enabled: { default: true, rules: { type: 'boolean' } },
|
|
||||||
level: { default: 'info', rules: { type: 'string' } },
|
|
||||||
}},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const result = vu.validateSchema(
|
|
||||||
{ logging: { enabled: false, level: 'Debug' } },
|
|
||||||
schema,
|
|
||||||
'test'
|
|
||||||
);
|
|
||||||
expect(result.logging.enabled).toBe(false);
|
|
||||||
expect(result.logging.level).toBe('debug');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should skip reserved keys (rules, description, schema)', () => {
|
|
||||||
const schema = {
|
|
||||||
rules: 'should be skipped',
|
|
||||||
description: 'should be skipped',
|
|
||||||
schema: 'should be skipped',
|
|
||||||
speed: { default: 10, rules: { type: 'number' } },
|
|
||||||
};
|
|
||||||
const result = vu.validateSchema({}, schema, 'test');
|
|
||||||
expect(result).not.toHaveProperty('rules');
|
|
||||||
expect(result).not.toHaveProperty('description');
|
|
||||||
expect(result).not.toHaveProperty('schema');
|
|
||||||
expect(result.speed).toBe(10);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should use default for unknown validation type', () => {
|
|
||||||
const schema = {
|
|
||||||
weird: { default: 'fallback', rules: { type: 'unknownType' } },
|
|
||||||
};
|
|
||||||
const result = vu.validateSchema({ weird: 'value' }, schema, 'test');
|
|
||||||
expect(result.weird).toBe('fallback');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle curve type', () => {
|
|
||||||
const schema = {
|
|
||||||
curve: {
|
|
||||||
default: { line1: { x: [0, 1], y: [0, 1] } },
|
|
||||||
rules: { type: 'curve' },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const validCurve = { line1: { x: [1, 2], y: [10, 20] } };
|
|
||||||
const result = vu.validateSchema({ curve: validCurve }, schema, 'test');
|
|
||||||
expect(result.curve.line1.x).toEqual([1, 2]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── removeUnwantedKeys() ────────────────────────────────────────────
|
|
||||||
describe('removeUnwantedKeys()', () => {
|
|
||||||
it('should remove rules and description keys', () => {
|
|
||||||
const input = {
|
|
||||||
speed: { default: 10, rules: { type: 'number' }, description: 'Speed setting' },
|
|
||||||
};
|
|
||||||
const result = vu.removeUnwantedKeys(input);
|
|
||||||
expect(result.speed).toBe(10);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should recurse into nested objects', () => {
|
|
||||||
const input = {
|
|
||||||
logging: {
|
|
||||||
enabled: { default: true, rules: {} },
|
|
||||||
level: { default: 'info', description: 'Log level' },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const result = vu.removeUnwantedKeys(input);
|
|
||||||
expect(result.logging.enabled).toBe(true);
|
|
||||||
expect(result.logging.level).toBe('info');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle arrays', () => {
|
|
||||||
const input = [
|
|
||||||
{ a: { default: 1, rules: {} } },
|
|
||||||
{ b: { default: 2, description: 'x' } },
|
|
||||||
];
|
|
||||||
const result = vu.removeUnwantedKeys(input);
|
|
||||||
expect(result[0].a).toBe(1);
|
|
||||||
expect(result[1].b).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return primitives as-is', () => {
|
|
||||||
expect(vu.removeUnwantedKeys(42)).toBe(42);
|
|
||||||
expect(vu.removeUnwantedKeys('hello')).toBe('hello');
|
|
||||||
expect(vu.removeUnwantedKeys(null)).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
452
wiki/Home.md
Normal file
452
wiki/Home.md
Normal file
@@ -0,0 +1,452 @@
|
|||||||
|
# generalFunctions
|
||||||
|
|
||||||
|
> **Reflects code as of `f21e2aa` · regenerated `2026-05-11` (hand-written)**
|
||||||
|
> No `npm run wiki:all` script exists for this library. The API surface block (section 5) is hand-maintained between the AUTOGEN markers. If the banner is stale, treat this page as informative, not authoritative.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. What this library is
|
||||||
|
|
||||||
|
**generalFunctions** is the shared infrastructure every EVOLV node depends on. It provides the base classes all nodes extend (`BaseDomain`, `BaseNodeAdapter`), the command dispatch engine, the measurement store, unit-policy system, child-registration machinery, InfluxDB output formatting, and a set of domain utilities (PID, curve interpolation, prediction, statistics, coolprop). Nodes hold zero duplicated scaffolding — they only write the logic that differs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Position in the platform
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
gf["generalFunctions\n(shared library)"]:::lib
|
||||||
|
|
||||||
|
rm["rotatingMachine\nEquipment"]:::equip
|
||||||
|
mgc["machineGroupControl\nUnit"]:::unit
|
||||||
|
ps["pumpingStation\nProcess Cell"]:::proc
|
||||||
|
meas["measurement\nControl Module"]:::ctrl
|
||||||
|
valve["valve\nEquipment"]:::equip
|
||||||
|
vgc["valveGroupControl\nUnit"]:::unit
|
||||||
|
reactor["reactor\nUnit"]:::unit
|
||||||
|
settler["settler\nUnit"]:::unit
|
||||||
|
monster["monster\nUnit"]:::unit
|
||||||
|
diffuser["diffuser\nEquipment"]:::equip
|
||||||
|
dashAPI["dashboardAPI\nutility"]:::util
|
||||||
|
|
||||||
|
gf --> rm
|
||||||
|
gf --> mgc
|
||||||
|
gf --> ps
|
||||||
|
gf --> meas
|
||||||
|
gf --> valve
|
||||||
|
gf --> vgc
|
||||||
|
gf --> reactor
|
||||||
|
gf --> settler
|
||||||
|
gf --> monster
|
||||||
|
gf --> diffuser
|
||||||
|
gf --> dashAPI
|
||||||
|
|
||||||
|
classDef lib fill:#222,color:#fff,stroke:#444
|
||||||
|
classDef proc fill:#0c99d9,color:#fff
|
||||||
|
classDef unit fill:#50a8d9,color:#000
|
||||||
|
classDef equip fill:#86bbdd,color:#000
|
||||||
|
classDef ctrl fill:#a9daee,color:#000
|
||||||
|
classDef util fill:#dddddd,color:#000
|
||||||
|
```
|
||||||
|
|
||||||
|
Every EVOLV node declares `generalFunctions` as a `dependencies` entry and imports from the package root only (`require('generalFunctions')`). Cross-node coupling happens exclusively through this library's API surface and Node-RED messages — never through direct imports between node packages.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Capability matrix
|
||||||
|
|
||||||
|
| Capability | Status | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Base domain scaffolding (`BaseDomain`) | ✅ | Constructor, emitter, logger, measurements, child registry wired automatically |
|
||||||
|
| Base Node-RED adapter (`BaseNodeAdapter`) | ✅ | Tick/event loop, status badge, input dispatch, Port 0/1/2 output |
|
||||||
|
| Declarative command dispatch (`CommandRegistry`) | ✅ | Alias deprecation warnings, unit normalisation, `query.units` auto-topic |
|
||||||
|
| Declarative child-registration routing (`ChildRouter`) | ✅ | Replaces per-node `registerChild` switch blocks |
|
||||||
|
| Unit policy + conversion (`UnitPolicy`, `convert`) | ✅ | Canonical ↔ output ↔ curve unit sets; dual method/property access |
|
||||||
|
| Measurement store (`MeasurementContainer`) | ✅ | Chainable, windowed, auto-convert, 4-segment key output |
|
||||||
|
| InfluxDB + process output formatting (`outputUtils`) | ✅ | Delta-compressed; consumers must cache and merge |
|
||||||
|
| Status badge helpers (`statusBadge`, `StatusUpdater`) | ✅ | Converged look-and-feel across all nodes |
|
||||||
|
| Latest-wins async gate (`LatestWinsGate`) | ✅ | Extracted from MGC; shared by PS, VGC, MGC |
|
||||||
|
| Prediction quality / drift tracking (`HealthStatus`) | ✅ | Frozen plain-object shape; composable |
|
||||||
|
| Config schema registry (`configManager`) | ✅ | One JSON schema per node in `src/configs/` |
|
||||||
|
| PID control (`PIDController`, `CascadePIDController`) | ✅ | Full-featured discrete PID with bumpless transfer |
|
||||||
|
| Curve interpolation (`interpolation`, `predict`) | ✅ | Multidimensional characteristic-curve predictor |
|
||||||
|
| Statistical helpers (`stats`, `nrmse`, `outliers`) | ✅ | Mean, stddev, median, NRMSE, dynamic-cluster outlier detection |
|
||||||
|
| Thermodynamic properties (`coolprop`) | ✅ | CoolProp bindings for fluid/gas property lookup |
|
||||||
|
| FSM for valve/machine states (`state`) | ✅ | StateManager + MovementManager |
|
||||||
|
| Gravity calculations (`gravity`) | ✅ | WGS-84 model |
|
||||||
|
| Physical constants (`Fysics`) | ✅ | Air density, viscosity, etc. |
|
||||||
|
| Browser-side editor dropdowns (`MenuManager`, `menuUtils`) | ✅ | Node-RED editor form population |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Module map
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
subgraph domain["src/domain/ — base classes"]
|
||||||
|
BD["BaseDomain.js"]
|
||||||
|
CR["ChildRouter.js"]
|
||||||
|
UP["UnitPolicy.js"]
|
||||||
|
LWG["LatestWinsGate.js"]
|
||||||
|
HS["HealthStatus.js"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph nodered["src/nodered/ — Node-RED adapter layer"]
|
||||||
|
BNA["BaseNodeAdapter.js"]
|
||||||
|
CMR["commandRegistry.js"]
|
||||||
|
SB["statusBadge.js"]
|
||||||
|
SU["statusUpdater.js"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph measurements["src/measurements/ — measurement store"]
|
||||||
|
MC["MeasurementContainer.js"]
|
||||||
|
MB["MeasurementBuilder.js"]
|
||||||
|
Meas["Measurement.js"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph helper["src/helper/ — shared utilities"]
|
||||||
|
LOG["logger.js"]
|
||||||
|
OU["outputUtils.js"]
|
||||||
|
CRU["childRegistrationUtils.js"]
|
||||||
|
CFG["configUtils.js"]
|
||||||
|
VAL["validationUtils.js"]
|
||||||
|
MU["menuUtils.js"]
|
||||||
|
GR["gravity.js"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph predict_grp["src/predict/ — curve prediction"]
|
||||||
|
PRED["predict_class.js"]
|
||||||
|
INTERP["interpolation.js"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph configs["src/configs/ — schema registry"]
|
||||||
|
CFGM["index.js (ConfigManager)"]
|
||||||
|
JSON["*.json — per-node schemas"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph math["numeric & domain utilities"]
|
||||||
|
PID["src/pid/ — PIDController"]
|
||||||
|
NRMSE["src/nrmse/ — ErrorMetrics"]
|
||||||
|
STATS["src/stats/ — mean/stddev/median"]
|
||||||
|
OUT["src/outliers/ — DynamicClusterDeviation"]
|
||||||
|
STATE["src/state/ — state FSM"]
|
||||||
|
CONV["src/convert/ — unit conversion"]
|
||||||
|
COOL["src/coolprop-node/ — thermodynamics"]
|
||||||
|
FYS["src/convert/fysics.js — physical constants"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph menu_grp["src/menu/ — editor menus"]
|
||||||
|
MM["MenuManager"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph constants["src/constants/"]
|
||||||
|
POS["positions.js"]
|
||||||
|
end
|
||||||
|
|
||||||
|
BD --> CR
|
||||||
|
BD --> UP
|
||||||
|
BD --> MC
|
||||||
|
BD --> CRU
|
||||||
|
BD --> LOG
|
||||||
|
BNA --> BD
|
||||||
|
BNA --> CMR
|
||||||
|
BNA --> OU
|
||||||
|
BNA --> SU
|
||||||
|
```
|
||||||
|
|
||||||
|
| Directory | Primary export | Read first if you're changing… |
|
||||||
|
|---|---|---|
|
||||||
|
| `src/domain/` | `BaseDomain`, `ChildRouter`, `UnitPolicy`, `LatestWinsGate`, `HealthStatus` | Base class contracts, child routing, unit system |
|
||||||
|
| `src/nodered/` | `BaseNodeAdapter`, `CommandRegistry`, `statusBadge`, `StatusUpdater` | Input dispatch, output loops, editor status |
|
||||||
|
| `src/measurements/` | `MeasurementContainer` | Measurement storage, statistics, 4-segment key output |
|
||||||
|
| `src/helper/` | `logger`, `outputUtils`, `childRegistrationUtils`, `configUtils`, `validationUtils`, `menuUtils`, `gravity` | Logging, InfluxDB formatting, child registration |
|
||||||
|
| `src/configs/` | `ConfigManager` + per-node JSON schemas | Schema loading, config validation, default values |
|
||||||
|
| `src/predict/` | `predict`, `interpolation` | Characteristic curve fitting and flow/power prediction |
|
||||||
|
| `src/pid/` | `PIDController`, `CascadePIDController` | Closed-loop control |
|
||||||
|
| `src/nrmse/` | `ErrorMetrics` (NRMSE) | Prediction quality scoring |
|
||||||
|
| `src/stats/` | `stats` (mean, stddev, median) | Statistical reducers |
|
||||||
|
| `src/outliers/` | `DynamicClusterDeviation` | Online outlier detection |
|
||||||
|
| `src/state/` | `state`, `StateManager`, `MovementManager` | FSM for valve/machine state machines |
|
||||||
|
| `src/convert/` | `convert`, `Fysics` | Unit conversion, physical constants |
|
||||||
|
| `src/coolprop-node/` | `coolprop` | Thermodynamic property lookup |
|
||||||
|
| `src/menu/` | `MenuManager` | Editor-form dropdown population |
|
||||||
|
| `src/constants/` | `POSITIONS`, `POSITION_VALUES`, `isValidPosition` | Canonical spatial position constants |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. API surface
|
||||||
|
|
||||||
|
<!-- BEGIN AUTOGEN: api-surface -->
|
||||||
|
|
||||||
|
All imports use the package root: `const { X } = require('generalFunctions');`
|
||||||
|
|
||||||
|
| Export | Import name | Source file | Contract |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `BaseDomain` | `BaseDomain` | `src/domain/BaseDomain.js` | Abstract base class for every `specificClass.js`. Provides `emitter`, `config`, `logger`, `measurements`, `childRegistrationUtils`, `router`. Subclass must declare `static name` (maps to schema JSON) and implement `configure()`. See CONTRACTS.md §3. |
|
||||||
|
| `BaseNodeAdapter` | `BaseNodeAdapter` | `src/nodered/BaseNodeAdapter.js` | Abstract base for every `nodeClass.js`. Wires config build → domain instantiation → registration delay → output strategy → status loop → input dispatch → close handler. Subclass declares `static DomainClass`, `static commands`, `static tickInterval`, `static statusInterval`, and overrides `buildDomainConfig(uiConfig, nodeId)`. See CONTRACTS.md §2. |
|
||||||
|
| `ChildRouter` | `ChildRouter` | `src/domain/ChildRouter.js` | Declarative parent-side child registration. Replaces per-node `registerChild` switch. Chain `.onRegister(softwareType, cb)`, `.onMeasurement(softwareType, filter, cb)`, `.onPrediction(softwareType, filter, cb)`. See CONTRACTS.md §5. |
|
||||||
|
| `CommandRegistry` | `CommandRegistry` | `src/nodered/commandRegistry.js` | Class form of the command registry. Accepts array of descriptors (topic, aliases, payloadSchema, units, description, handler). Dispatches by O(1) lookup, normalises units before handler runs, warns on alias use. |
|
||||||
|
| `createRegistry` | `createRegistry` | `src/nodered/commandRegistry.js` | Factory: `createRegistry(descriptors, options)` → `CommandRegistry`. Used by `BaseNodeAdapter`; rarely needed directly. |
|
||||||
|
| `UnitPolicy` | `UnitPolicy` | `src/domain/UnitPolicy.js` | Declare unit sets: `UnitPolicy.declare({ canonical, output, curve?, requireUnitForTypes? })`. Returns policy with dual method/property access (`policy.canonical('flow')` and `policy.canonical.flow`). Methods: `canonical`, `output`, `curve`, `resolve`, `convert`, `containerOptions`, `setLogger`. See CONTRACTS.md §6. |
|
||||||
|
| `LatestWinsGate` | `LatestWinsGate` | `src/domain/LatestWinsGate.js` | Serialises async dispatches so only the latest value wins. `fire(value)` — non-blocking. `fireAndWait(value)` → `Promise` that resolves with dispatch result or `LatestWinsGate.SUPERSEDED`. `drain()` — await idle. See CONTRACTS.md §8. |
|
||||||
|
| `HealthStatus` | `HealthStatus` | `src/domain/HealthStatus.js` | Factory functions for frozen health objects: `HealthStatus.ok(msg, src)`, `HealthStatus.degraded(level, flags, msg, src)`, `HealthStatus.compose(statuses)`. Shape: `{ level: 0–3, flags: string[], message, source }`. See CONTRACTS.md §9. |
|
||||||
|
| `MeasurementContainer` | `MeasurementContainer` | `src/measurements/MeasurementContainer.js` | Chainable measurement store: `.type().variant().position().value(v, ts, srcUnit)`. Query: `getCurrentValue(unit)`, `getAverage(unit)`, `difference({ from, to, unit })`. Introspect: `getFlattenedOutput()` returns 4-segment keyed object (`type.variant.position.childId`). |
|
||||||
|
| `outputUtils` | `outputUtils` | `src/helper/outputUtils.js` | Singleton-per-node delta-compression engine. `formatMsg(output, config, format)` returns `msg` only when fields changed, or `undefined`. `format` is `'process'` or `'influxdb'`. Consumers must cache and merge. |
|
||||||
|
| `logger` | `logger` | `src/helper/logger.js` | `new logger(enabled, logLevel, moduleName)`. Methods: `debug`, `info`, `warn`, `error`, `setLogLevel`, `toggleLogging`. Never use `console.log` directly. |
|
||||||
|
| `configManager` | `configManager` | `src/configs/index.js` | `new configManager()`. Methods: `getConfig(name)`, `buildConfig(name, uiConfig, nodeId, domainSlice?)`, `getAvailableConfigs()`, `hasConfig(name)`. Config files live in `src/configs/*.json`. |
|
||||||
|
| `configUtils` | `configUtils` | `src/helper/configUtils.js` | `new configUtils(defaultConfig)`. `initConfig(userConfig)` validates and merges user values over defaults via `validationUtils`. |
|
||||||
|
| `validation` | `validation` | `src/helper/validationUtils.js` | `new validation(logEnabled, logLevel)`. `validateSchema(config, schema, name)` walks schema, clamps numbers, coerces types, strips unknown keys. |
|
||||||
|
| `childRegistrationUtils` | `childRegistrationUtils` | `src/helper/childRegistrationUtils.js` | `new childRegistrationUtils(parentDomain)`. `registerChild(child, positionVsParent, distance?)` stores child by softwareType/category with alias normalisation. `getChildrenOfType(softwareType, category?)`, `getChildById(id)`, `getAllChildren()`. Normally used via `ChildRouter` — direct use is for advanced cases. |
|
||||||
|
| `statusBadge` | `statusBadge` | `src/nodered/statusBadge.js` | Pure-function badge builder. `statusBadge.compose(parts, opts?)` → `{ fill, shape, text }`. `statusBadge.error(msg)`, `statusBadge.idle(label)`. Text clipped to 60 chars. See CONTRACTS.md §7. |
|
||||||
|
| `StatusUpdater` | `StatusUpdater` | `src/nodered/statusUpdater.js` | `new StatusUpdater({ node, source, intervalMs, logger })`. `start()`, `stop()`. Calls `source.getStatusBadge()` on interval; catches errors and shows a red badge. Owned by `BaseNodeAdapter` — rarely needed directly. |
|
||||||
|
| `convert` | `convert` | `src/convert/index.js` | unit-converter factory. `convert(value).from(unit).to(unit)`. `convert.possibilities(measure)` lists accepted units. Measures: `volumeFlowRate`, `pressure`, `power`, `temperature`, `volume`, `length`, `mass`, `energy`, `reactivePower`, `apparentPower`, `reactiveEnergy`, and more. |
|
||||||
|
| `Fysics` | `Fysics` | `src/convert/fysics.js` | `new Fysics()`. Physical constants: `air_density`, `g0`; methods for gravity and viscosity calculations. |
|
||||||
|
| `gravity` | `gravity` | `src/helper/gravity.js` | Singleton-style `Gravity` instance. `getStandardGravity()` → 9.80665 m/s². WGS-84 latitude/altitude corrections available. |
|
||||||
|
| `predict` | `predict` | `src/predict/predict_class.js` | `new predict(config, logger)`. Multidimensional characteristic-curve predictor; emits results via internal EventEmitter. |
|
||||||
|
| `interpolation` | `interpolation` | `src/predict/interpolation.js` | Class for 1-D and 2-D curve interpolation (linear, cubic-spline). Used internally by `predict`. |
|
||||||
|
| `PIDController` | `PIDController` | `src/pid/PIDController.js` | Discrete PID with bumpless auto/manual transfer, anti-windup, derivative filtering, rate limiting, gain scheduling, feedforward. |
|
||||||
|
| `CascadePIDController` | `CascadePIDController` | `src/pid/PIDController.js` | Outer-inner PID cascade built on `PIDController`. |
|
||||||
|
| `createPidController` | `createPidController` | `src/pid/index.js` | Factory shorthand: `createPidController(options)` → `PIDController`. |
|
||||||
|
| `createCascadePidController` | `createCascadePidController` | `src/pid/index.js` | Factory shorthand for cascade PID. |
|
||||||
|
| `nrmse` | `nrmse` | `src/nrmse/index.js` | `ErrorMetrics` class for normalised-root-mean-squared-error tracking. Multi-metric via `registerMetric(id)`, `update(id, predicted, measured)`. |
|
||||||
|
| `stats` | `stats` | `src/stats/index.js` | Pure functions: `mean(arr)`, `stdDev(arr)`, `median(arr)`. No state; safe to call on any numeric array. |
|
||||||
|
| `state` | `state` | `src/state/index.js` | `new state(config, logger)`. FSM for valve/machine: StateManager (transitions) + MovementManager (timed moves). Emits state-change events. |
|
||||||
|
| `MenuManager` | `MenuManager` | `src/menu/index.js` | `new MenuManager()`. Manages editor dropdown menus (asset, logger, position, aquon). `registerMenu(type, factory)`. Used in node entry files to power Node-RED editor forms. |
|
||||||
|
| `menuUtils` / `MenuUtils` | via `menuUtils` in helper | `src/helper/menuUtils.js` | Browser-side editor helper. Toggles, data fetching, URL construction, dropdown population, HTML generation. Served to browser via `endpointUtils`. |
|
||||||
|
| `POSITIONS` | `POSITIONS` | `src/constants/positions.js` | Frozen enum: `{ UPSTREAM, DOWNSTREAM, AT_EQUIPMENT, DELTA }`. |
|
||||||
|
| `POSITION_VALUES` | `POSITION_VALUES` | `src/constants/positions.js` | `string[]` of all four position strings. |
|
||||||
|
| `isValidPosition` | `isValidPosition` | `src/constants/positions.js` | `(pos: string) => boolean`. |
|
||||||
|
| `coolprop` | `coolprop` | `src/coolprop-node/src/index.js` | CoolProp fluid/gas thermodynamic property lookup. Used by nodes that model heat transfer or gas compression. |
|
||||||
|
| `loadModel` | `loadModel` | `datasets/assetData/modelData/index.js` | Load a JSON model-data asset by dataset type and asset ID (with LRU cache). Preferred over deprecated `loadCurve`. |
|
||||||
|
| `loadCurve` | `loadCurve` | `datasets/assetData/curves/index.js` | **Deprecated** — load a pump-curve JSON. Replaced by `loadModel`. |
|
||||||
|
|
||||||
|
<!-- END AUTOGEN: api-surface -->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Config schema registry
|
||||||
|
|
||||||
|
One JSON file per node in `src/configs/`. `ConfigManager.buildConfig` merges the schema defaults with the Node-RED editor values before the domain sees them.
|
||||||
|
|
||||||
|
| File | Node | What it defines |
|
||||||
|
|---|---|---|
|
||||||
|
| `baseConfig.json` | all nodes | Shared `general`, `asset`, `functionality`, `logging` sections |
|
||||||
|
| `rotatingMachine.json` | rotatingMachine | Curve selection, startup/shutdown ramps, safety thresholds, unit config |
|
||||||
|
| `machineGroupControl.json` | machineGroupControl | Demand targets, strategy selection, dispatcher settings |
|
||||||
|
| `pumpingStation.json` | pumpingStation | Basin geometry, hydraulics, control strategies, safety levels |
|
||||||
|
| `measurement.json` | measurement | Scaling, smoothing, stability threshold, digital/MQTT mode |
|
||||||
|
| `valve.json` | valve | Actuator travel time, position limits, FSM config |
|
||||||
|
| `valveGroupControl.json` | valveGroupControl | Group strategy, demand distribution |
|
||||||
|
| `reactor.json` | reactor | ASM kinetics, reactor type (CSTR/PFR), volume, influent |
|
||||||
|
| `settler.json` | settler | Sludge settling parameters, effluent quality |
|
||||||
|
| `monster.json` | monster | Multi-parameter monitoring, flow bounds, sample intervals |
|
||||||
|
| `diffuser.json` | diffuser | Aeration model, oxygen transfer parameters |
|
||||||
|
|
||||||
|
To add a new node: create `src/configs/<nodeName>.json` extending `baseConfig.json`, declare `static name = '<nodeName>'` in the domain class. `configManager.buildConfig` finds it automatically.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Lifecycle — how a node tick or event reaches the output port
|
||||||
|
|
||||||
|
The sequence below uses `rotatingMachine` as the example. Every stateful EVOLV node follows the same path. See the [rotatingMachine wiki](../rotatingMachine/Home.md) for node-specific detail.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant RED as Node-RED runtime
|
||||||
|
participant BNA as BaseNodeAdapter
|
||||||
|
participant CMD as CommandRegistry
|
||||||
|
participant DOM as Domain (specificClass)
|
||||||
|
participant CR as ChildRouter
|
||||||
|
participant MC as MeasurementContainer
|
||||||
|
participant OU as outputUtils
|
||||||
|
participant PORT as Port 0 / 1 / 2
|
||||||
|
|
||||||
|
RED->>BNA: constructor(uiConfig, RED, node, name)
|
||||||
|
BNA->>BNA: configManager.buildConfig()
|
||||||
|
BNA->>DOM: new DomainClass(config)
|
||||||
|
DOM->>MC: new MeasurementContainer(unitPolicy.containerOptions())
|
||||||
|
DOM->>DOM: configure() — wire ChildRouter, concern modules
|
||||||
|
BNA-->>PORT: Port 2 registration msg (after 100 ms delay)
|
||||||
|
BNA->>BNA: start status loop (1000 ms)
|
||||||
|
|
||||||
|
Note over RED,PORT: Event-driven path (default)
|
||||||
|
|
||||||
|
RED->>BNA: input msg {topic: 'data.pressure', payload: 3.4}
|
||||||
|
BNA->>CMD: dispatch(msg)
|
||||||
|
CMD->>CMD: unit normalisation (Pa → mbar)
|
||||||
|
CMD->>DOM: handler(source, msg, ctx)
|
||||||
|
DOM->>MC: .type('pressure').variant('measured').position('upstream').value(3.4)
|
||||||
|
DOM->>DOM: emitter.emit('output-changed')
|
||||||
|
BNA->>DOM: getOutput()
|
||||||
|
DOM-->>BNA: flat snapshot object
|
||||||
|
BNA->>OU: formatMsg(snapshot, config, 'process')
|
||||||
|
OU-->>BNA: delta msg (only changed fields)
|
||||||
|
BNA-->>PORT: Port 0 process msg, Port 1 influx msg
|
||||||
|
|
||||||
|
Note over RED,PORT: Tick-driven path (opt-in — tickInterval set)
|
||||||
|
|
||||||
|
RED->>BNA: timer fires every tickInterval ms
|
||||||
|
BNA->>DOM: tick()
|
||||||
|
DOM->>DOM: time-based math; emitter.emit('output-changed')
|
||||||
|
BNA->>DOM: getOutput()
|
||||||
|
BNA->>OU: formatMsg(...)
|
||||||
|
BNA-->>PORT: Port 0 / 1 msgs (delta only)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Stability + versioning
|
||||||
|
|
||||||
|
Source of truth: `.claude/rules/general-functions.md`.
|
||||||
|
|
||||||
|
| Category | Rule |
|
||||||
|
|---|---|
|
||||||
|
| **Safe to add** | New named exports. New optional methods on existing classes. New config keys with defaults in the schema. |
|
||||||
|
| **Requires decision-gate interview** | Removing or renaming any export. Changing a method signature. Changing the output key format of `MeasurementContainer.getFlattenedOutput()`. Changing the `formatMsg` delta-compression behaviour. |
|
||||||
|
| **Forbidden without migration** | Breaking the 4-segment key shape (`type.variant.position.childId`). Changing Port 0/1/2 payload envelope. Changing the CONTRACTS.md §1–§9 shapes. |
|
||||||
|
|
||||||
|
`generalFunctions` is a git submodule shared by all 12 node repos. A breaking change here requires updating every consumer in a single coordinated commit. Before modifying any module, run `grep -r "require('generalFunctions')" nodes/*/` to identify all call sites.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. No editor form — consumers' config forms map to config slices
|
||||||
|
|
||||||
|
`generalFunctions` has no Node-RED editor form of its own. The library is never placed directly in a flow.
|
||||||
|
|
||||||
|
Consumer nodes expose their own editor forms. Each form field writes into a config key that `configManager.buildConfig` validates against the node's schema (in `src/configs/<nodeName>.json`). The resulting merged config is passed to the domain constructor.
|
||||||
|
|
||||||
|
For the form-to-config mapping of a specific node, see section 9 of that node's wiki page.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Examples — usage snippets from a real node
|
||||||
|
|
||||||
|
### 10.1 Extending `BaseDomain` (from `pumpingStation/specificClass.js` pattern)
|
||||||
|
|
||||||
|
```js
|
||||||
|
const { BaseDomain, UnitPolicy, ChildRouter } = require('generalFunctions');
|
||||||
|
|
||||||
|
class PumpingStation extends BaseDomain {
|
||||||
|
static name = 'pumpingStation';
|
||||||
|
|
||||||
|
static unitPolicy = UnitPolicy.declare({
|
||||||
|
canonical: { flow: 'm3/s', pressure: 'Pa', power: 'W', temperature: 'K' },
|
||||||
|
output: { flow: 'm3/h', pressure: 'mbar', power: 'kW', temperature: 'C' },
|
||||||
|
});
|
||||||
|
|
||||||
|
configure() {
|
||||||
|
// Declare named child getters — readable in code, registry is source of truth
|
||||||
|
this.declareChildGetter('machines', 'machine');
|
||||||
|
this.declareChildGetter('machineGroups', 'machinegroup');
|
||||||
|
|
||||||
|
// Declarative child routing — no per-node registerChild switch
|
||||||
|
this.router
|
||||||
|
.onRegister('machinegroup', (child) => this._onMachineGroupRegistered(child))
|
||||||
|
.onMeasurement('measurement', { type: 'level' }, (data, child) => {
|
||||||
|
this._onLevel(data.value, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getOutput() {
|
||||||
|
return {
|
||||||
|
...this.measurements.getFlattenedOutput(),
|
||||||
|
...this.basin.snapshot(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
getStatusBadge() {
|
||||||
|
const { statusBadge } = require('generalFunctions');
|
||||||
|
return statusBadge.compose(['filling', 'V=12.4/50.0 m³']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
module.exports = PumpingStation;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.2 Extending `BaseNodeAdapter` (from `pumpingStation/nodeClass.js` pattern)
|
||||||
|
|
||||||
|
```js
|
||||||
|
const { BaseNodeAdapter } = require('generalFunctions');
|
||||||
|
const Domain = require('./specificClass');
|
||||||
|
const commands = require('./commands');
|
||||||
|
|
||||||
|
class nodeClass extends BaseNodeAdapter {
|
||||||
|
static DomainClass = Domain;
|
||||||
|
static commands = commands;
|
||||||
|
static tickInterval = 1000; // ms — only for time-driven math
|
||||||
|
static statusInterval = 1000;
|
||||||
|
|
||||||
|
buildDomainConfig(uiConfig, nodeId) {
|
||||||
|
return {
|
||||||
|
basin: {
|
||||||
|
volume: Number(uiConfig.basinVolume),
|
||||||
|
height: Number(uiConfig.basinHeight),
|
||||||
|
surfaceArea: Number(uiConfig.basinSurface),
|
||||||
|
},
|
||||||
|
hydraulics: {
|
||||||
|
inflowPipeArea: Number(uiConfig.inflowArea),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
module.exports = nodeClass;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.3 Command descriptor with unit normalisation
|
||||||
|
|
||||||
|
```js
|
||||||
|
// src/commands/index.js
|
||||||
|
module.exports = [
|
||||||
|
{
|
||||||
|
topic: 'set.demand',
|
||||||
|
aliases: ['Qd'], // legacy name — logs one-time deprecation
|
||||||
|
units: { measure: 'volumeFlowRate', default: 'm3/h' },
|
||||||
|
payloadSchema: { type: 'number' },
|
||||||
|
description: 'Operator demand setpoint. Unit-normalised before handler runs.',
|
||||||
|
handler: (source, msg) => { source.setDemand(msg.payload); },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
topic: 'cmd.startup',
|
||||||
|
payloadSchema: { type: 'none' },
|
||||||
|
description: 'Trigger startup sequence.',
|
||||||
|
handler: (source, msg) => { source.startup(msg.payload?.source); },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Debug recipes
|
||||||
|
|
||||||
|
| Symptom | First check | Where to look |
|
||||||
|
|---|---|---|
|
||||||
|
| Child never registers (no `registerChild` log) | Is the child's `softwareType` in the `SOFTWARE_TYPE_ALIASES` map? | `src/helper/childRegistrationUtils.js` line 1–12 and `src/domain/ChildRouter.js` |
|
||||||
|
| Port 0 sends nothing after an input | `outputUtils` only emits on changes. Is the field actually different from the last call? | Add a debug tap after `formatMsg`; check `outputUtils._output[format]` state |
|
||||||
|
| Unit mismatch — handler receives wrong value | Did the command descriptor declare `units: { measure, default }`? Is `msg.unit` set by the sender? | `commandRegistry.js` → `_normaliseUnit()`; check the warn log |
|
||||||
|
| `query.units` returns empty object | The commands array has no descriptors with a `units` field. | `BaseNodeAdapter._buildImplicitUnitsCommand()` |
|
||||||
|
| `MeasurementContainer.getFlattenedOutput()` returns unexpected key shape | Key is `type.variant.position.childId` — position is always lowercase. Check `setChildId()` was called. | `src/measurements/MeasurementContainer.js` → `getFlattenedOutput()` |
|
||||||
|
| `LatestWinsGate` promise never resolves | A superseded fire resolves with `{ superseded: true }`, not `undefined`. Branch on `r && r.superseded`. | `src/domain/LatestWinsGate.js` |
|
||||||
|
| Status badge stuck at grey | `getStatusBadge()` threw and `StatusUpdater` caught it. Look for `statusBadge.error(...)` in the container log. | `src/nodered/statusUpdater.js` |
|
||||||
|
|
||||||
|
> Never ship `enableLog: 'debug'` in a demo or production config — it fills the container log within seconds and obscures real errors.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. When NOT to depend on this library
|
||||||
|
|
||||||
|
- **Passive HTTP gateway nodes** (e.g. `dashboardAPI`) may skip `BaseDomain` and `BaseNodeAdapter` entirely if they hold no domain state. A plain Node-RED node with HTTP endpoints needs only `logger`, `outputUtils`, and `configManager`. See the `dashboardAPI` wiki for the rationale.
|
||||||
|
- **External scripts or standalone tools** that need only unit conversion can import just `const { convert } = require('generalFunctions')` without pulling in the full domain stack.
|
||||||
|
- **Nodes at a different S88 level** that inherit from a third-party base class must not import from `src/domain/` or `src/nodered/` internal paths — they may only use root-level exports.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Known limitations
|
||||||
|
|
||||||
|
| # | Issue | Tracked in |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | `loadCurve` is deprecated; replacement `loadModel` exists but not all nodes have migrated | `OPEN_QUESTIONS.md` — Phase 8.5 cleanup |
|
||||||
|
| 2 | `outlierDetection` (`DynamicClusterDeviation`) prints to `console.log` internally — not routed through `logger` | Code review backlog |
|
||||||
|
| 3 | `configUtils.initConfig` strips unknown keys silently; schema must include every key the domain reads or defaults are lost | `OPEN_QUESTIONS.md` — e.g. monster schema fix 2026-05-11 |
|
||||||
|
| 4 | `state` (FSM) and `predict` are not yet integrated with `BaseDomain` lifecycle — nodes wire them manually in `configure()` | Architecture backlog |
|
||||||
|
| 5 | `menuUtils` / `MenuManager` are served as browser JavaScript and bypass the normal Node.js import path — deep changes require testing in both environments | `endpointUtils.js` |
|
||||||
|
| 6 | `CascadePIDController` has no dedicated test suite | Test backlog |
|
||||||
|
| 7 | `substrate_score` / wiki autogen script (`wiki:all`) not yet wired for this library; API surface block is hand-maintained | Phase 9 follow-up |
|
||||||
Reference in New Issue
Block a user