Compare commits
39 Commits
fix/valida
...
8252a5f898
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8252a5f898 | ||
|
|
4f715e8ad6 | ||
|
|
8b28f8969e | ||
|
|
48fa54363d | ||
|
|
ab481357d2 | ||
|
|
49c77f262f | ||
|
|
34a4ef0610 | ||
|
|
af02d36b07 | ||
|
|
f8f71a4f1c | ||
|
|
c59da5ca98 | ||
|
|
0a4b52f517 | ||
|
|
84a4430266 | ||
|
|
1b6b43349f | ||
|
|
c7e561e593 | ||
|
|
f21e2aa8bb | ||
|
|
5ea968eabc | ||
|
|
f11754635b | ||
|
|
ff9aec8702 | ||
|
|
30c5dc8508 | ||
|
|
95c5e684e4 | ||
|
|
8ebf31dd39 | ||
|
|
92eb8d2f15 | ||
|
|
7372d12088 | ||
|
|
62f389a51f | ||
|
|
57b77f905a | ||
|
|
47faf94048 | ||
|
|
9a998191cd | ||
|
|
94bcc90b4b | ||
|
|
a516c2b2b6 | ||
|
|
4b6250cc42 | ||
|
|
35f648f64e | ||
|
|
4252292ae1 | ||
|
|
693517cc8f | ||
|
|
086e5fe751 | ||
|
|
29b78a3f9b | ||
|
|
43f69066af | ||
|
|
e50be2ee66 | ||
|
|
75d16c620a | ||
|
|
024db5533a |
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Local stub generated by `npm install` in the submodule directory.
|
||||||
|
# generalFunctions has no production deps of its own.
|
||||||
|
package-lock.json
|
||||||
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]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -153,7 +153,7 @@
|
|||||||
100
|
100
|
||||||
],
|
],
|
||||||
"y": [
|
"y": [
|
||||||
52.14679487594751,
|
11.142207365162072,
|
||||||
20.746724065725342,
|
20.746724065725342,
|
||||||
31.960270693111905,
|
31.960270693111905,
|
||||||
45.6989826531509,
|
45.6989826531509,
|
||||||
@@ -411,7 +411,7 @@
|
|||||||
"y": [
|
"y": [
|
||||||
8.219999984177646,
|
8.219999984177646,
|
||||||
13.426327986363882,
|
13.426327986363882,
|
||||||
57.998168647814666,
|
25.971821741448165,
|
||||||
42.997354839160536,
|
42.997354839160536,
|
||||||
64.33911122026377
|
64.33911122026377
|
||||||
]
|
]
|
||||||
@@ -427,7 +427,7 @@
|
|||||||
"y": [
|
"y": [
|
||||||
8.219999984177646,
|
8.219999984177646,
|
||||||
13.426327986363882,
|
13.426327986363882,
|
||||||
53.35067019159144,
|
25.288156424842576,
|
||||||
42.48429874246399,
|
42.48429874246399,
|
||||||
64.03769740244357
|
64.03769740244357
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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)."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ class ConfigManager {
|
|||||||
functionality: {
|
functionality: {
|
||||||
softwareType: nodeName.toLowerCase(),
|
softwareType: nodeName.toLowerCase(),
|
||||||
positionVsParent: uiConfig.positionVsParent || 'atEquipment',
|
positionVsParent: uiConfig.positionVsParent || 'atEquipment',
|
||||||
distance: uiConfig.hasDistance ? uiConfig.distance : undefined
|
distance: uiConfig.hasDistance ? uiConfig.distance : null
|
||||||
},
|
},
|
||||||
output: {
|
output: {
|
||||||
process: uiConfig.processOutputFormat || 'process',
|
process: uiConfig.processOutputFormat || 'process',
|
||||||
@@ -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,
|
||||||
@@ -411,6 +438,44 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"mode": {
|
||||||
|
"current": {
|
||||||
|
"default": "analog",
|
||||||
|
"rules": {
|
||||||
|
"type": "enum",
|
||||||
|
"values": [
|
||||||
|
{
|
||||||
|
"value": "analog",
|
||||||
|
"description": "Single-scalar input mode (classic 4-20mA / PLC style). msg.payload is a number; the node runs one offset/scaling/smoothing/outlier pipeline and emits one MeasurementContainer slot."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "digital",
|
||||||
|
"description": "Multi-channel input mode (MQTT / IoT JSON style). msg.payload is an object keyed by channel names declared under config.channels; the node routes each key through its own pipeline and emits N slots from one input message."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Selects how incoming msg.payload is interpreted."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"channels": {
|
||||||
|
"default": [],
|
||||||
|
"rules": {
|
||||||
|
"type": "array",
|
||||||
|
"itemType": "object",
|
||||||
|
"minLength": 0,
|
||||||
|
"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."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,14 @@
|
|||||||
"description": "The default flow unit used for reporting station throughput."
|
"description": "The default flow unit used for reporting station throughput."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"flowThreshold": {
|
||||||
|
"default": 0.0001,
|
||||||
|
"rules": {
|
||||||
|
"type": "number",
|
||||||
|
"min": 0,
|
||||||
|
"description": "Flow dead-band in m3/s below which the station treats net flow as steady."
|
||||||
|
}
|
||||||
|
},
|
||||||
"logging": {
|
"logging": {
|
||||||
"logLevel": {
|
"logLevel": {
|
||||||
"default": "info",
|
"default": "info",
|
||||||
@@ -127,6 +135,50 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"output": {
|
||||||
|
"process": {
|
||||||
|
"default": "process",
|
||||||
|
"rules": {
|
||||||
|
"type": "enum",
|
||||||
|
"values": [
|
||||||
|
{
|
||||||
|
"value": "process",
|
||||||
|
"description": "Delta-compressed process message."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "json",
|
||||||
|
"description": "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 telemetry payload."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "json",
|
||||||
|
"description": "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,
|
||||||
@@ -215,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"
|
||||||
@@ -235,24 +287,24 @@
|
|||||||
"description": "Unit used for level related setpoints and thresholds."
|
"description": "Unit used for level related setpoints and thresholds."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"heightInlet": {
|
"inflowLevel": {
|
||||||
"default": 2,
|
"default": 1.5,
|
||||||
"rules": {
|
"rules": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
"min": 0,
|
"min": 0,
|
||||||
"description": "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]."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"heightOutlet": {
|
"outflowLevel": {
|
||||||
"default": 0.2,
|
"default": 0.2,
|
||||||
"rules": {
|
"rules": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
"min": 0,
|
"min": 0,
|
||||||
"description": "Height of the outlet pipe measured from the basin floor (m)."
|
"description": "Top height of the outlet or pump-suction pipe measured from the basin floor (m)."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"heightOverflow": {
|
"overflowLevel": {
|
||||||
"default": 2.5,
|
"default": 3.8,
|
||||||
"rules": {
|
"rules": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
"min": 0,
|
"min": 0,
|
||||||
@@ -433,36 +485,86 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"levelbased": {
|
"levelbased": {
|
||||||
|
"minLevel": {
|
||||||
|
"default": 0.3,
|
||||||
|
"rules": {
|
||||||
|
"type": "number",
|
||||||
|
"min": 0,
|
||||||
|
"description": "Below this level the MGC shuts down all pumps (unconditional stop). Between minLevel and the active ramp start, demand is held at 0 %."
|
||||||
|
}
|
||||||
|
},
|
||||||
"startLevel": {
|
"startLevel": {
|
||||||
"default": 1,
|
"default": 1,
|
||||||
"rules": {
|
"rules": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
"min": 0,
|
"min": 0,
|
||||||
"description": "start of pump / group when level reaches this in meters starting from bottom."
|
"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": {
|
||||||
"default": 1,
|
"default": null,
|
||||||
"rules": {
|
"rules": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
|
"nullable": true,
|
||||||
"min": 0,
|
"min": 0,
|
||||||
"description": "stop of pump / group when level reaches this in meters starting from bottom"
|
"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."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"minFlowLevel": {
|
"maxLevel": {
|
||||||
"default": 1,
|
"default": 3.8,
|
||||||
"rules": {
|
"rules": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
"min": 0,
|
"min": 0,
|
||||||
"description": "min level to scale the flow lineair"
|
"description": "Level at which the pump demand saturates at 100 %. Above this, demand stays clamped."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"maxFlowLevel": {
|
"curveType": {
|
||||||
"default": 4,
|
"default": "linear",
|
||||||
|
"rules": {
|
||||||
|
"type": "enum",
|
||||||
|
"values": [
|
||||||
|
{
|
||||||
|
"value": "linear",
|
||||||
|
"description": "Linear demand scaling between the active lower ramp level and maxLevel."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "log",
|
||||||
|
"description": "Logarithmic demand scaling with fast response early in the ramp."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Demand curve used by levelbased control."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"logCurveFactor": {
|
||||||
|
"default": 9,
|
||||||
|
"rules": {
|
||||||
|
"type": "number",
|
||||||
|
"min": 0.001,
|
||||||
|
"description": "Shape factor for the levelbased log curve; higher values increase early response."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enableShiftedRamp": {
|
||||||
|
"default": false,
|
||||||
|
"rules": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "When true, arm a hysteresis shift: once level rises past shiftLevel the ramp foot moves left from inflowLevel to startLevel until level falls back below startLevel."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"shiftLevel": {
|
||||||
|
"default": 0,
|
||||||
"rules": {
|
"rules": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
"min": 0,
|
"min": 0,
|
||||||
"description": "max level to scale the flow lineair"
|
"description": "Level (m) at which the held output starts ramping down during draining. Must be > startLevel and ≤ maxLevel. Ignored when enableShiftedRamp is false."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"shiftArmPercent": {
|
||||||
|
"default": 95,
|
||||||
|
"rules": {
|
||||||
|
"type": "number",
|
||||||
|
"min": 0,
|
||||||
|
"max": 100,
|
||||||
|
"description": "Output % threshold that arms the shift on the way up. Once armed, the output value at the moment direction flips to draining becomes the held value, and stays held until level drops to shiftLevel. Disarms when level reaches startLevel."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -638,19 +740,18 @@
|
|||||||
"description": "Volume percentage below which dry run protection activates."
|
"description": "Volume percentage below which dry run protection activates."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dryRunDebounceSeconds": {
|
|
||||||
"default": 30,
|
|
||||||
"rules": {
|
|
||||||
"type": "number",
|
|
||||||
"min": 0,
|
|
||||||
"description": "Time the low-volume condition must persist before dry-run protection engages (seconds)."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"enableOverfillProtection": {
|
"enableOverfillProtection": {
|
||||||
"default": true,
|
"default": true,
|
||||||
"rules": {
|
"rules": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"description": "If true, high level alarms and shutdowns will be enforced to prevent overfilling."
|
"description": "Deprecated alias for enableHighVolumeSafety. If true, high level alarms and shutdowns will be enforced to preserve overflow margin."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enableHighVolumeSafety": {
|
||||||
|
"default": true,
|
||||||
|
"rules": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "If true, high-volume safety actions run before the basin reaches physical overflow."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"overfillThresholdPercent": {
|
"overfillThresholdPercent": {
|
||||||
@@ -659,15 +760,16 @@
|
|||||||
"type": "number",
|
"type": "number",
|
||||||
"min": 0,
|
"min": 0,
|
||||||
"max": 100,
|
"max": 100,
|
||||||
"description": "Volume percentage above which overfill protection activates."
|
"description": "Deprecated alias for highVolumeSafetyThresholdPercent."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"overfillDebounceSeconds": {
|
"highVolumeSafetyThresholdPercent": {
|
||||||
"default": 30,
|
"default": 98,
|
||||||
"rules": {
|
"rules": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
"min": 0,
|
"min": 0,
|
||||||
"description": "Time the high-volume condition must persist before overfill protection engages (seconds)."
|
"max": 100,
|
||||||
|
"description": "Percentage of maxVolAtOverflow where high-volume safety activates before actual overflow."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"timeleftToFullOrEmptyThresholdSeconds": {
|
"timeleftToFullOrEmptyThresholdSeconds": {
|
||||||
|
|||||||
@@ -136,12 +136,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"timeStep": {
|
"timeStep": {
|
||||||
"default": 0.001,
|
"default": 1,
|
||||||
"rules": {
|
"rules": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
"min": 0.0001,
|
"min": 0.001,
|
||||||
"unit": "h",
|
"unit": "s",
|
||||||
"description": "Integration time step for the reactor model."
|
"description": "Integration time step in seconds. The kinetics engine converts to days internally (timeStep / 86400) before each ASM Euler step; the HTML editor labels this field [s] and tests assume seconds. Do not change the unit without updating baseEngine.js line 40 in the reactor submodule."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -91,6 +91,54 @@
|
|||||||
],
|
],
|
||||||
"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 (e.g. 'cable length from control panel to motor')."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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": {
|
||||||
@@ -148,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": {
|
||||||
@@ -234,42 +263,8 @@
|
|||||||
},
|
},
|
||||||
"machineCurve": {
|
"machineCurve": {
|
||||||
"default": {
|
"default": {
|
||||||
"nq": {
|
"nq": {},
|
||||||
"1": {
|
"np": {}
|
||||||
"x": [
|
|
||||||
1,
|
|
||||||
2,
|
|
||||||
3,
|
|
||||||
4,
|
|
||||||
5
|
|
||||||
],
|
|
||||||
"y": [
|
|
||||||
10,
|
|
||||||
20,
|
|
||||||
30,
|
|
||||||
40,
|
|
||||||
50
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"np": {
|
|
||||||
"1": {
|
|
||||||
"x": [
|
|
||||||
1,
|
|
||||||
2,
|
|
||||||
3,
|
|
||||||
4,
|
|
||||||
5
|
|
||||||
],
|
|
||||||
"y": [
|
|
||||||
10,
|
|
||||||
20,
|
|
||||||
30,
|
|
||||||
40,
|
|
||||||
50
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"rules": {
|
"rules": {
|
||||||
"type": "machineCurve",
|
"type": "machineCurve",
|
||||||
@@ -464,27 +459,6 @@
|
|||||||
"description": "Predefined sequences of states for the machine."
|
"description": "Predefined sequences of states for the machine."
|
||||||
|
|
||||||
},
|
},
|
||||||
"calculationMode": {
|
|
||||||
"default": "medium",
|
|
||||||
"rules": {
|
|
||||||
"type": "enum",
|
|
||||||
"values": [
|
|
||||||
{
|
|
||||||
"value": "low",
|
|
||||||
"description": "Calculations run at fixed intervals (time-based)."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"value": "medium",
|
|
||||||
"description": "Calculations run when new setpoints arrive or measured changes occur (event-driven)."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"value": "high",
|
|
||||||
"description": "Calculations run on all event-driven info, including every movement."
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "The frequency at which calculations are performed."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"flowNumber": {
|
"flowNumber": {
|
||||||
"default": 1,
|
"default": 1,
|
||||||
"rules": {
|
"rules": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
@@ -224,47 +205,6 @@
|
|||||||
"description": "The operational mode of the machine."
|
"description": "The operational mode of the machine."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"allowedActions":{
|
|
||||||
"default":{},
|
|
||||||
"rules": {
|
|
||||||
"type": "object",
|
|
||||||
"schema":{
|
|
||||||
"auto": {
|
|
||||||
"default": ["statusCheck", "execMovement", "execSequence", "emergencyStop"],
|
|
||||||
"rules": {
|
|
||||||
"type": "set",
|
|
||||||
"itemType": "string",
|
|
||||||
"description": "Actions allowed in auto mode."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"virtualControl": {
|
|
||||||
"default": ["statusCheck", "execMovement", "execSequence", "emergencyStop"],
|
|
||||||
"rules": {
|
|
||||||
"type": "set",
|
|
||||||
"itemType": "string",
|
|
||||||
"description": "Actions allowed in virtualControl mode."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"fysicalControl": {
|
|
||||||
"default": ["statusCheck", "emergencyStop"],
|
|
||||||
"rules": {
|
|
||||||
"type": "set",
|
|
||||||
"itemType": "string",
|
|
||||||
"description": "Actions allowed in fysicalControl mode."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"maintenance": {
|
|
||||||
"default": ["statusCheck"],
|
|
||||||
"rules": {
|
|
||||||
"type": "set",
|
|
||||||
"itemType": "string",
|
|
||||||
"description": "Actions allowed in maintenance mode."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"description": "Information about valid command sources recognized by the machine."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"allowedSources":{
|
"allowedSources":{
|
||||||
"default": {},
|
"default": {},
|
||||||
"rules": {
|
"rules": {
|
||||||
@@ -361,27 +301,6 @@
|
|||||||
},
|
},
|
||||||
"description": "Predefined sequences of states for the machine."
|
"description": "Predefined sequences of states for the machine."
|
||||||
|
|
||||||
},
|
|
||||||
"calculationMode": {
|
|
||||||
"default": "medium",
|
|
||||||
"rules": {
|
|
||||||
"type": "enum",
|
|
||||||
"values": [
|
|
||||||
{
|
|
||||||
"value": "low",
|
|
||||||
"description": "Calculations run at fixed intervals (time-based)."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"value": "medium",
|
|
||||||
"description": "Calculations run when new setpoints arrive or measured changes occur (event-driven)."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"value": "high",
|
|
||||||
"description": "Calculations run on all event-driven info, including every movement."
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "The frequency at which calculations are performed."
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,47 +176,6 @@
|
|||||||
"description": "The operational mode of the valveGroupControl."
|
"description": "The operational mode of the valveGroupControl."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"allowedActions":{
|
|
||||||
"default":{},
|
|
||||||
"rules": {
|
|
||||||
"type": "object",
|
|
||||||
"schema":{
|
|
||||||
"auto": {
|
|
||||||
"default": ["statusCheck", "execSequence", "emergencyStop", "valvePositionChange", "totalFlowChange", "valveDeltaPchange"],
|
|
||||||
"rules": {
|
|
||||||
"type": "set",
|
|
||||||
"itemType": "string",
|
|
||||||
"description": "Actions allowed in auto mode."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"virtualControl": {
|
|
||||||
"default": ["statusCheck", "execSequence", "emergencyStop", "valvePositionChange", "totalFlowChange", "valveDeltaPchange"],
|
|
||||||
"rules": {
|
|
||||||
"type": "set",
|
|
||||||
"itemType": "string",
|
|
||||||
"description": "Actions allowed in virtualControl mode."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"fysicalControl": {
|
|
||||||
"default": ["statusCheck", "emergencyStop"],
|
|
||||||
"rules": {
|
|
||||||
"type": "set",
|
|
||||||
"itemType": "string",
|
|
||||||
"description": "Actions allowed in fysicalControl mode."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"maintenance": {
|
|
||||||
"default": ["statusCheck"],
|
|
||||||
"rules": {
|
|
||||||
"type": "set",
|
|
||||||
"itemType": "string",
|
|
||||||
"description": "Actions allowed in maintenance mode."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"description": "Information about valid command sources recognized by the valve."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"allowedSources":{
|
"allowedSources":{
|
||||||
"default": {},
|
"default": {},
|
||||||
"rules": {
|
"rules": {
|
||||||
@@ -346,26 +305,5 @@
|
|||||||
},
|
},
|
||||||
"description": "Predefined sequences of states for the valveGroupControl."
|
"description": "Predefined sequences of states for the valveGroupControl."
|
||||||
|
|
||||||
},
|
|
||||||
"calculationMode": {
|
|
||||||
"default": "medium",
|
|
||||||
"rules": {
|
|
||||||
"type": "enum",
|
|
||||||
"values": [
|
|
||||||
{
|
|
||||||
"value": "low",
|
|
||||||
"description": "Calculations run at fixed intervals (time-based)."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"value": "medium",
|
|
||||||
"description": "Calculations run when new setpoints arrive or measured changes occur (event-driven)."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"value": "high",
|
|
||||||
"description": "Calculations run on all event-driven info, including every movement."
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "The frequency at which calculations are performed."
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -1,3 +1,17 @@
|
|||||||
|
// Map a child's raw softwareType (the lowercased node name from
|
||||||
|
// buildConfig) to the "role" key that parent registerChild() handlers
|
||||||
|
// dispatch on. Without this, MGC/pumpingStation register-handlers (which
|
||||||
|
// branch on 'machine' / 'machinegroup' / 'pumpingstation' / 'measurement')
|
||||||
|
// silently miss every real production child because rotatingMachine
|
||||||
|
// reports softwareType='rotatingmachine' and machineGroupControl reports
|
||||||
|
// 'machinegroupcontrol'. Existing tests that pass already-aliased keys
|
||||||
|
// ('machine', 'machinegroup') stay green because those aren't in the
|
||||||
|
// alias map and pass through unchanged.
|
||||||
|
const SOFTWARE_TYPE_ALIASES = {
|
||||||
|
rotatingmachine: 'machine',
|
||||||
|
machinegroupcontrol: 'machinegroup',
|
||||||
|
};
|
||||||
|
|
||||||
class ChildRegistrationUtils {
|
class ChildRegistrationUtils {
|
||||||
constructor(mainClass) {
|
constructor(mainClass) {
|
||||||
this.mainClass = mainClass;
|
this.mainClass = mainClass;
|
||||||
@@ -15,7 +29,8 @@ class ChildRegistrationUtils {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const softwareType = (child.config.functionality.softwareType || '').toLowerCase();
|
const rawSoftwareType = (child.config.functionality.softwareType || '').toLowerCase();
|
||||||
|
const softwareType = SOFTWARE_TYPE_ALIASES[rawSoftwareType] || rawSoftwareType;
|
||||||
const name = child.config.general.name || child.config.general.id || 'unknown';
|
const name = child.config.general.name || child.config.general.id || 'unknown';
|
||||||
const id = child.config.general.id || name;
|
const id = child.config.general.id || name;
|
||||||
|
|
||||||
|
|||||||
@@ -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.`
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,51 @@ const EventEmitter = require('events');
|
|||||||
const convertModule = require('../convert/index');
|
const convertModule = require('../convert/index');
|
||||||
const { POSITIONS } = require('../constants/positions');
|
const { POSITIONS } = require('../constants/positions');
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
* MeasurementContainer — measurement storage with chainable type/variant/
|
||||||
|
* position/child addressing.
|
||||||
|
*
|
||||||
|
* INTERNAL STORAGE SHAPE
|
||||||
|
* measurements[type][variant][position][childId] = Measurement instance
|
||||||
|
*
|
||||||
|
* The childId layer is ALWAYS present, even when the caller doesn't specify
|
||||||
|
* one. _getOrCreateMeasurement defaults childId to 'default' when no
|
||||||
|
* .child(...) is in the chain. So writing
|
||||||
|
*
|
||||||
|
* mc.type('level').variant('measured').position('atequipment')
|
||||||
|
* .value(2.5, ts, 'm');
|
||||||
|
*
|
||||||
|
* stores the value at measurements.level.measured.atequipment.default.
|
||||||
|
*
|
||||||
|
* READING — the chainable getters resolve the default child transparently,
|
||||||
|
* so consumers usually don't see it:
|
||||||
|
*
|
||||||
|
* mc.type('level').variant('measured').position('atequipment')
|
||||||
|
* .getCurrentValue('m'); // returns 2.5
|
||||||
|
*
|
||||||
|
* FLATTENED OUTPUT — getFlattenedOutput() emits ONE key per child, including
|
||||||
|
* the implicit 'default' bucket:
|
||||||
|
*
|
||||||
|
* {
|
||||||
|
* 'level.measured.atequipment.default': 2.5, // implicit child
|
||||||
|
* 'flow.predicted.in.manual-qin': 0.05, // explicit .child('manual-qin')
|
||||||
|
* 'flow.predicted.in.from-pump-A': 0.03,
|
||||||
|
* …
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* ⚠ DASHBOARDS / DOWNSTREAM PARSERS MUST INCLUDE THE CHILD KEY
|
||||||
|
* The flat key format is `${type}.${variant}.${position}.${childId}`.
|
||||||
|
* When you have not used .child(), the childId is the literal string
|
||||||
|
* 'default'. Use 'level.measured.atequipment.default', NOT
|
||||||
|
* 'level.measured.atequipment'. This trips up new consumers — see the
|
||||||
|
* pumpingStation basic-dashboard parser for an example that gets it right.
|
||||||
|
*
|
||||||
|
* AGGREGATION — sum() folds all children of a position into one number:
|
||||||
|
*
|
||||||
|
* mc.sum('flow', 'predicted', ['in'], 'm3/s');
|
||||||
|
* // = manual-qin + from-pump-A + … + (default if any)
|
||||||
|
* ============================================================================
|
||||||
|
*/
|
||||||
class MeasurementContainer {
|
class MeasurementContainer {
|
||||||
constructor(options = {},logger) {
|
constructor(options = {},logger) {
|
||||||
this.logger = logger || null;
|
this.logger = logger || null;
|
||||||
@@ -141,11 +186,17 @@ class MeasurementContainer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
isUnitCompatible(measurementType, unit) {
|
isUnitCompatible(measurementType, unit) {
|
||||||
const desc = this._describeUnit(unit);
|
// Unknown type (not in measureMap): accept any unit. This lets user-
|
||||||
if (!desc) return false;
|
// defined measurement types (e.g. 'humidity', 'co2', arbitrary IoT
|
||||||
|
// channels in digital mode) pass through without being rejected just
|
||||||
|
// because their unit string ('%', 'ppm', …) is not a known physical
|
||||||
|
// unit to the convert module. Known types are still validated strictly.
|
||||||
const normalizedType = this._normalizeType(measurementType);
|
const normalizedType = this._normalizeType(measurementType);
|
||||||
const expectedMeasure = this.measureMap[normalizedType];
|
const expectedMeasure = this.measureMap[normalizedType];
|
||||||
if (!expectedMeasure) return true;
|
if (!expectedMeasure) return true;
|
||||||
|
|
||||||
|
const desc = this._describeUnit(unit);
|
||||||
|
if (!desc) return false;
|
||||||
return desc.measure === expectedMeasure;
|
return desc.measure === expectedMeasure;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,16 +425,34 @@ class MeasurementContainer {
|
|||||||
// Legacy single measurement
|
// Legacy single measurement
|
||||||
if (posBucket?.getCurrentValue) return posBucket;
|
if (posBucket?.getCurrentValue) return posBucket;
|
||||||
|
|
||||||
// Child-aware: pick requested child, otherwise fall back to default, otherwise first available
|
// Child-aware lookup. Two separate sources of "child-id" on the
|
||||||
|
// container, with DIFFERENT strictness:
|
||||||
|
//
|
||||||
|
// _currentChildId : transient, set by .child(name) inside a chain.
|
||||||
|
// Explicit per-call. STRICT — if the named child
|
||||||
|
// does not exist, return null. Silent fall-through
|
||||||
|
// to a sibling would mask a missing-stream read
|
||||||
|
// as a wrong-stream read (see pumpingStation
|
||||||
|
// spillPrev bug, 2026-05-06).
|
||||||
|
//
|
||||||
|
// this.childId : persistent, set by setChildId(id). HINT only —
|
||||||
|
// try it first, then fall back to 'default' then
|
||||||
|
// first available. Containers registered with a
|
||||||
|
// persistent id (rotatingMachine, etc.) write
|
||||||
|
// under composed child ids (e.g. 'up-<id>') that
|
||||||
|
// don't equal the persistent id, and reads must
|
||||||
|
// still resolve to those writes.
|
||||||
if (posBucket && typeof posBucket === 'object') {
|
if (posBucket && typeof posBucket === 'object') {
|
||||||
const requestedKey = this._currentChildId || this.childId;
|
|
||||||
const keys = Object.keys(posBucket);
|
const keys = Object.keys(posBucket);
|
||||||
if (!keys.length) return null;
|
if (!keys.length) return null;
|
||||||
const measurement =
|
|
||||||
(requestedKey && posBucket[requestedKey]) ||
|
if (this._currentChildId) {
|
||||||
|
return posBucket[this._currentChildId] || null;
|
||||||
|
}
|
||||||
|
return (this.childId && posBucket[this.childId]) ||
|
||||||
posBucket.default ||
|
posBucket.default ||
|
||||||
posBucket[keys[0]];
|
posBucket[keys[0]] ||
|
||||||
return measurement || null;
|
null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -529,18 +598,43 @@ class MeasurementContainer {
|
|||||||
.reduce((acc, v) => acc + v, 0);
|
.reduce((acc, v) => acc + v, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flatten the entire container to a key→value map, suitable for
|
||||||
|
* dashboards / InfluxDB / debug dumps.
|
||||||
|
*
|
||||||
|
* KEY FORMAT — child-bucketed series (the common case):
|
||||||
|
* `${type}.${variant}.${position}.${childId}`
|
||||||
|
*
|
||||||
|
* Even measurements written without an explicit `.child(...)` end up
|
||||||
|
* here under `childId === 'default'` (see _getOrCreateMeasurement).
|
||||||
|
* Examples:
|
||||||
|
* level.measured.atequipment.default // implicit child
|
||||||
|
* flow.predicted.in.manual-qin // explicit child
|
||||||
|
* flow.predicted.in.from-pump-A // explicit child
|
||||||
|
*
|
||||||
|
* Consumers (Node-RED dashboards, parsers) MUST include the trailing
|
||||||
|
* `.default` when reading default-bucket measurements. Stripping it
|
||||||
|
* silently misses the value. This is the #1 footgun for new code that
|
||||||
|
* uses MeasurementContainer.
|
||||||
|
*
|
||||||
|
* The "Legacy single series" branch below catches a pre-v2 storage
|
||||||
|
* shape where a position held a Measurement directly (no child layer);
|
||||||
|
* new code never produces that shape but old serialized state may.
|
||||||
|
*/
|
||||||
getFlattenedOutput(options = {}) {
|
getFlattenedOutput(options = {}) {
|
||||||
const requestedUnits = options.requestedUnits || (options.usePreferredUnits ? this.preferredUnits : null);
|
const requestedUnits = options.requestedUnits || (options.usePreferredUnits ? this.preferredUnits : null);
|
||||||
const out = {};
|
const out = {};
|
||||||
Object.entries(this.measurements).forEach(([type, variants]) => {
|
Object.entries(this.measurements).forEach(([type, variants]) => {
|
||||||
Object.entries(variants).forEach(([variant, positions]) => {
|
Object.entries(variants).forEach(([variant, positions]) => {
|
||||||
Object.entries(positions).forEach(([position, entry]) => {
|
Object.entries(positions).forEach(([position, entry]) => {
|
||||||
// Legacy single series
|
// Legacy single series (no childId layer)
|
||||||
if (entry?.getCurrentValue) {
|
if (entry?.getCurrentValue) {
|
||||||
out[`${type}.${variant}.${position}`] = this._resolveOutputValue(type, entry, requestedUnits);
|
out[`${type}.${variant}.${position}`] = this._resolveOutputValue(type, entry, requestedUnits);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Child-bucketed series
|
// Child-bucketed series — ALWAYS the case for new writes,
|
||||||
|
// including the implicit 'default' bucket when no .child() is
|
||||||
|
// used. The flat key carries the childId.
|
||||||
if (entry && typeof entry === 'object') {
|
if (entry && typeof entry === 'object') {
|
||||||
Object.entries(entry).forEach(([childId, m]) => {
|
Object.entries(entry).forEach(([childId, m]) => {
|
||||||
if (m?.getCurrentValue) {
|
if (m?.getCurrentValue) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -193,8 +563,13 @@ class AssetMenu {
|
|||||||
return normalizeApiCategory(key, node.softwareType || key, payload.data);
|
return normalizeApiCategory(key, node.softwareType || key, payload.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Non-dispatching populate (matches the wireEvents version). The
|
||||||
|
// load path below explicitly walks supplier -> type -> model ->
|
||||||
|
// unit in order using saved node.* values, so auto-dispatched
|
||||||
|
// change events (which previously cascaded through wireEvents'
|
||||||
|
// listeners and double-populated everything) are no longer needed.
|
||||||
function populate(selectEl, items = [], selectedValue, mapFn, placeholderText = 'Select...') {
|
function populate(selectEl, items = [], selectedValue, mapFn, placeholderText = 'Select...') {
|
||||||
const previous = selectEl.value;
|
if (!selectEl) return;
|
||||||
const mapper = typeof mapFn === 'function'
|
const mapper = typeof mapFn === 'function'
|
||||||
? mapFn
|
? mapFn
|
||||||
: (value) => ({ value, label: value });
|
: (value) => ({ value, label: value });
|
||||||
@@ -227,9 +602,6 @@ class AssetMenu {
|
|||||||
} else {
|
} else {
|
||||||
selectEl.value = '';
|
selectEl.value = '';
|
||||||
}
|
}
|
||||||
if (selectEl.value !== previous) {
|
|
||||||
selectEl.dispatchEvent(new Event('change'));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const categoryKey = resolveCategoryKey();
|
const categoryKey = resolveCategoryKey();
|
||||||
@@ -251,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,
|
||||||
@@ -305,6 +697,28 @@ class AssetMenu {
|
|||||||
getEventInjectionCode(nodeName) {
|
getEventInjectionCode(nodeName) {
|
||||||
return `
|
return `
|
||||||
// Asset event wiring for ${nodeName}
|
// Asset event wiring for ${nodeName}
|
||||||
|
//
|
||||||
|
// The supplier -> type -> model -> unit chain is a strict downward
|
||||||
|
// cascade: each select rebuilds the next based on the currently
|
||||||
|
// selected value above it. Two earlier bugs in this code:
|
||||||
|
//
|
||||||
|
// 1. populate() auto-dispatched a synthetic 'change' event whenever
|
||||||
|
// the value of the rebuilt select differed from before the
|
||||||
|
// rebuild. That triggered the *child* select's listener mid-way
|
||||||
|
// through the *parent* listener, which then continued and
|
||||||
|
// blindly overwrote the child select with empty content. Net
|
||||||
|
// effect: model dropdown showed 'Awaiting Type Selection' even
|
||||||
|
// though a type was clearly selected.
|
||||||
|
//
|
||||||
|
// 2. Each downstream wipe ran unconditionally inside the parent
|
||||||
|
// handler, instead of being driven by the actual current value
|
||||||
|
// of the child select.
|
||||||
|
//
|
||||||
|
// Fix: populate() no longer dispatches change. Cascade is explicit
|
||||||
|
// via cascadeFromSupplier() / cascadeFromType() / cascadeFromModel()
|
||||||
|
// which are called from each handler. The same helpers run on
|
||||||
|
// initial load so behaviour is identical whether the user picked the
|
||||||
|
// value or it came from a saved node.
|
||||||
window.EVOLV.nodes.${nodeName}.assetMenu.wireEvents = function(node) {
|
window.EVOLV.nodes.${nodeName}.assetMenu.wireEvents = function(node) {
|
||||||
const menuAsset = window.EVOLV.nodes.${nodeName}.menuData.asset || {};
|
const menuAsset = window.EVOLV.nodes.${nodeName}.menuData.asset || {};
|
||||||
const categories = menuAsset.categories || {};
|
const categories = menuAsset.categories || {};
|
||||||
@@ -316,11 +730,17 @@ class AssetMenu {
|
|||||||
unit: document.getElementById('node-input-unit')
|
unit: document.getElementById('node-input-unit')
|
||||||
};
|
};
|
||||||
|
|
||||||
function populate(selectEl, items = [], selectedValue, mapFn, placeholderText = 'Select...') {
|
// populate(): rebuild a <select> with a placeholder + items.
|
||||||
const previous = selectEl.value;
|
// No change-event dispatch — cascading is done explicitly by the
|
||||||
|
// caller via cascadeFrom*() so the order of operations is
|
||||||
|
// predictable.
|
||||||
|
function populate(selectEl, items, selectedValue, mapFn, placeholderText) {
|
||||||
|
if (!selectEl) return;
|
||||||
|
if (!Array.isArray(items)) items = [];
|
||||||
|
if (!placeholderText) placeholderText = 'Select...';
|
||||||
const mapper = typeof mapFn === 'function'
|
const mapper = typeof mapFn === 'function'
|
||||||
? mapFn
|
? mapFn
|
||||||
: (value) => ({ value, label: value });
|
: (value) => ({ value: value, label: value });
|
||||||
|
|
||||||
selectEl.innerHTML = '';
|
selectEl.innerHTML = '';
|
||||||
|
|
||||||
@@ -331,11 +751,9 @@ class AssetMenu {
|
|||||||
placeholder.selected = true;
|
placeholder.selected = true;
|
||||||
selectEl.appendChild(placeholder);
|
selectEl.appendChild(placeholder);
|
||||||
|
|
||||||
items.forEach((item) => {
|
items.forEach(function (item) {
|
||||||
const option = mapper(item);
|
const option = mapper(item);
|
||||||
if (!option || typeof option.value === 'undefined') {
|
if (!option || typeof option.value === 'undefined') return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
const opt = document.createElement('option');
|
const opt = document.createElement('option');
|
||||||
opt.value = option.value;
|
opt.value = option.value;
|
||||||
opt.textContent = option.label;
|
opt.textContent = option.label;
|
||||||
@@ -344,111 +762,112 @@ class AssetMenu {
|
|||||||
|
|
||||||
if (selectedValue) {
|
if (selectedValue) {
|
||||||
selectEl.value = selectedValue;
|
selectEl.value = selectedValue;
|
||||||
if (!selectEl.value) {
|
if (!selectEl.value) selectEl.value = '';
|
||||||
selectEl.value = '';
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
selectEl.value = '';
|
selectEl.value = '';
|
||||||
}
|
}
|
||||||
if (selectEl.value !== previous) {
|
|
||||||
selectEl.dispatchEvent(new Event('change'));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolveCategoryKey = () => {
|
function resolveCategoryKey() {
|
||||||
if (node.softwareType && categories[node.softwareType]) {
|
if (node.softwareType && categories[node.softwareType]) return node.softwareType;
|
||||||
return node.softwareType;
|
if (node.category && categories[node.category]) return node.category;
|
||||||
}
|
|
||||||
if (node.category && categories[node.category]) {
|
|
||||||
return node.category;
|
|
||||||
}
|
|
||||||
return defaultCategory;
|
return defaultCategory;
|
||||||
};
|
}
|
||||||
|
function getActiveCategory() {
|
||||||
const getActiveCategory = () => {
|
|
||||||
const key = resolveCategoryKey();
|
const key = resolveCategoryKey();
|
||||||
return key ? categories[key] : null;
|
return key ? categories[key] : null;
|
||||||
};
|
}
|
||||||
|
|
||||||
node.category = resolveCategoryKey();
|
node.category = resolveCategoryKey();
|
||||||
|
|
||||||
elems.supplier.addEventListener('change', () => {
|
// Lookup helpers — read from the *currently selected* values in the
|
||||||
const category = getActiveCategory();
|
// DOM, not from node.* (which may not yet be in sync).
|
||||||
const supplier = category
|
function findSupplier() {
|
||||||
? category.suppliers.find(
|
const cat = getActiveCategory();
|
||||||
(item) => String(item.id || item.name) === String(elems.supplier.value)
|
if (!cat || !Array.isArray(cat.suppliers)) return null;
|
||||||
)
|
const id = String(elems.supplier.value);
|
||||||
: null;
|
return cat.suppliers.find(function (s) {
|
||||||
|
return String(s.id || s.name) === id;
|
||||||
|
}) || null;
|
||||||
|
}
|
||||||
|
function findType(supplier) {
|
||||||
|
if (!supplier || !Array.isArray(supplier.types)) return null;
|
||||||
|
const id = String(elems.type.value);
|
||||||
|
return supplier.types.find(function (t) {
|
||||||
|
return String(t.id || t.name) === id;
|
||||||
|
}) || null;
|
||||||
|
}
|
||||||
|
function findModel(type) {
|
||||||
|
if (!type || !Array.isArray(type.models)) return null;
|
||||||
|
const id = String(elems.model.value);
|
||||||
|
return type.models.find(function (m) {
|
||||||
|
return String(m.id || m.name) === id;
|
||||||
|
}) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Cascade rebuild functions ==========================
|
||||||
|
// Each one rebuilds the dropdown for the *level it owns* plus all
|
||||||
|
// levels below it, using the current values in the DOM. Called by
|
||||||
|
// the corresponding change handler AND by initial load so both
|
||||||
|
// paths produce identical state.
|
||||||
|
|
||||||
|
function cascadeFromSupplier() {
|
||||||
|
const supplier = findSupplier();
|
||||||
const types = supplier ? supplier.types : [];
|
const types = supplier ? supplier.types : [];
|
||||||
populate(
|
populate(
|
||||||
elems.type,
|
elems.type,
|
||||||
types,
|
types,
|
||||||
node.assetType,
|
node.assetType,
|
||||||
(type) => ({ value: type.id || type.name, label: type.name }),
|
function (t) { return { value: t.id || t.name, label: t.name }; },
|
||||||
supplier ? 'Select...' : 'Awaiting Supplier Selection'
|
supplier ? 'Select...' : 'Awaiting Supplier Selection'
|
||||||
);
|
);
|
||||||
node.modelMetadata = null;
|
// After repopulating type, propagate down. cascadeFromType()
|
||||||
populate(elems.model, [], '', undefined, 'Awaiting Type Selection');
|
// will read the new elems.type.value (which was set by populate
|
||||||
populate(elems.unit, [], '', undefined, 'Awaiting Type Selection');
|
// to either the saved node.assetType or '') and rebuild model.
|
||||||
});
|
cascadeFromType();
|
||||||
|
}
|
||||||
|
|
||||||
elems.type.addEventListener('change', () => {
|
function cascadeFromType() {
|
||||||
const category = getActiveCategory();
|
const supplier = findSupplier();
|
||||||
const supplier = category
|
const type = findType(supplier);
|
||||||
? category.suppliers.find(
|
|
||||||
(item) => String(item.id || item.name) === String(elems.supplier.value)
|
|
||||||
)
|
|
||||||
: null;
|
|
||||||
const type = supplier
|
|
||||||
? supplier.types.find(
|
|
||||||
(item) => String(item.id || item.name) === String(elems.type.value)
|
|
||||||
)
|
|
||||||
: null;
|
|
||||||
const models = type ? type.models : [];
|
const models = type ? type.models : [];
|
||||||
populate(
|
populate(
|
||||||
elems.model,
|
elems.model,
|
||||||
models,
|
models,
|
||||||
node.model,
|
node.model,
|
||||||
(model) => ({ value: model.id || model.name, label: model.name }),
|
function (m) { return { value: m.id || m.name, label: m.name }; },
|
||||||
type ? 'Select...' : 'Awaiting Type Selection'
|
type ? 'Select...' : 'Awaiting Type Selection'
|
||||||
);
|
);
|
||||||
node.modelMetadata = null;
|
node.modelMetadata = null;
|
||||||
populate(
|
cascadeFromModel();
|
||||||
elems.unit,
|
}
|
||||||
[],
|
|
||||||
'',
|
|
||||||
undefined,
|
|
||||||
type ? 'Awaiting Model Selection' : 'Awaiting Type Selection'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
elems.model.addEventListener('change', () => {
|
function cascadeFromModel() {
|
||||||
const category = getActiveCategory();
|
const supplier = findSupplier();
|
||||||
const supplier = category
|
const type = findType(supplier);
|
||||||
? category.suppliers.find(
|
const model = findModel(type);
|
||||||
(item) => String(item.id || item.name) === String(elems.supplier.value)
|
|
||||||
)
|
|
||||||
: null;
|
|
||||||
const type = supplier
|
|
||||||
? supplier.types.find(
|
|
||||||
(item) => String(item.id || item.name) === String(elems.type.value)
|
|
||||||
)
|
|
||||||
: null;
|
|
||||||
const model = type
|
|
||||||
? type.models.find(
|
|
||||||
(item) => String(item.id || item.name) === String(elems.model.value)
|
|
||||||
)
|
|
||||||
: null;
|
|
||||||
node.modelMetadata = model;
|
node.modelMetadata = model;
|
||||||
node.modelName = model ? model.name : '';
|
node.modelName = model ? model.name : '';
|
||||||
populate(
|
populate(
|
||||||
elems.unit,
|
elems.unit,
|
||||||
model ? model.units || [] : [],
|
model ? (model.units || []) : [],
|
||||||
node.unit,
|
node.unit,
|
||||||
(unit) => ({ value: unit, label: unit }),
|
function (u) { return { value: u, label: u }; },
|
||||||
model ? 'Select...' : type ? 'Awaiting Model Selection' : 'Awaiting Type Selection'
|
model ? 'Select...' : (type ? 'Awaiting Model Selection' : 'Awaiting Type Selection')
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
|
|
||||||
|
elems.supplier.addEventListener('change', cascadeFromSupplier);
|
||||||
|
elems.type.addEventListener('change', cascadeFromType);
|
||||||
|
elems.model.addEventListener('change', cascadeFromModel);
|
||||||
|
|
||||||
|
// Expose the cascades so loadData() (or future code) can re-run
|
||||||
|
// them after async data arrives without duplicating logic.
|
||||||
|
window.EVOLV.nodes.${nodeName}.assetMenu._cascade = {
|
||||||
|
fromSupplier: cascadeFromSupplier,
|
||||||
|
fromType: cascadeFromType,
|
||||||
|
fromModel: cascadeFromModel,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -548,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, '\\`')
|
||||||
@@ -595,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 };
|
||||||
@@ -68,10 +68,25 @@ const Interpolation = require('./interpolation');
|
|||||||
class Predict {
|
class Predict {
|
||||||
constructor(config = {}) {
|
constructor(config = {}) {
|
||||||
|
|
||||||
|
// Capture share-source BEFORE config validation strips it (ConfigUtils
|
||||||
|
// mutates the input config to drop unknown keys, which would remove
|
||||||
|
// 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)
|
||||||
|
? config.shareInputsFrom
|
||||||
|
: 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);
|
||||||
@@ -107,8 +122,29 @@ class Predict {
|
|||||||
this.calculationPoints = this.config.normalization.parameters.curvePoints;
|
this.calculationPoints = this.config.normalization.parameters.curvePoints;
|
||||||
this.interpolationType = this.config.interpolation.type;
|
this.interpolationType = this.config.interpolation.type;
|
||||||
|
|
||||||
// Load curve if provided
|
// Load curve if provided.
|
||||||
if (config.curve) {
|
// shareInputsFrom: an existing Predict instance whose pre-built input
|
||||||
|
// curves and splines we adopt by reference. Used to create a parallel
|
||||||
|
// "view" of the same source curves (e.g. an MGC group-scope predict
|
||||||
|
// that mirrors a pump's individual predict). Per-instance state —
|
||||||
|
// currentF / currentX / currentFxyCurve / currentFxySplines /
|
||||||
|
// currentFxyY/X Min/Max / outputY — stays freshly initialised so the
|
||||||
|
// two views have independent operating points. Curve mutations on the
|
||||||
|
// source via updateCurve() are propagated through the source's
|
||||||
|
// "curveUpdated" emitter (see updateCurve below).
|
||||||
|
if (_sharedSource) {
|
||||||
|
this._adoptInputsFrom(_sharedSource);
|
||||||
|
this._sharedInputsSource = _sharedSource;
|
||||||
|
this._sharedInputsHandler = (newCurve) => {
|
||||||
|
this._adoptInputsFrom(this._sharedInputsSource);
|
||||||
|
// Keep our currentF in range; constrain re-uses the new fValues.
|
||||||
|
this.fDimension = this.constrain(this.currentF, this.fValues.min, this.fValues.max);
|
||||||
|
};
|
||||||
|
this._sharedInputsSource.emitter.on('curveUpdated', this._sharedInputsHandler);
|
||||||
|
// Initialise our own operating point to the source's min, same as
|
||||||
|
// the standard buildAllFxyCurves flow does at end of curve load.
|
||||||
|
this.fDimension = this.fValues.min;
|
||||||
|
} else if (config.curve) {
|
||||||
this.inputCurveData = config.curve;
|
this.inputCurveData = config.curve;
|
||||||
} else {
|
} else {
|
||||||
this.logger.warn("No curve data provided. Please set curve data using setCurveData method. Using default");
|
this.logger.warn("No curve data provided. Please set curve data using setCurveData method. Using default");
|
||||||
@@ -117,6 +153,31 @@ class Predict {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Adopt another Predict's input curves and splines by reference. Used by
|
||||||
|
// the shareInputsFrom constructor option and by the curveUpdated emitter
|
||||||
|
// handler to re-sync after the source's curves change. Does NOT touch
|
||||||
|
// per-instance state (currentF, currentX, currentFxy* etc.).
|
||||||
|
//
|
||||||
|
// Also copies the scalar parameters (calculationPoints, normMin/Max,
|
||||||
|
// interpolationType) so the clone uses the SAME pointsCount the source
|
||||||
|
// built fSplines with — otherwise buildSingleFxyCurve can iterate past
|
||||||
|
// the end of the shared fSplines.
|
||||||
|
_adoptInputsFrom(source) {
|
||||||
|
this.inputCurve = source.inputCurve;
|
||||||
|
this.normalizedCurve = source.normalizedCurve;
|
||||||
|
this.calculatedCurve = source.calculatedCurve;
|
||||||
|
this.fCurve = source.fCurve;
|
||||||
|
this.fSplines = source.fSplines;
|
||||||
|
this.normalizedSplines = source.normalizedSplines;
|
||||||
|
this.xValues = source.xValues;
|
||||||
|
this.fValues = source.fValues;
|
||||||
|
this.yValues = source.yValues;
|
||||||
|
this.calculationPoints = source.calculationPoints;
|
||||||
|
this.normMin = source.normMin;
|
||||||
|
this.normMax = source.normMax;
|
||||||
|
this.interpolationType = source.interpolationType;
|
||||||
|
}
|
||||||
|
|
||||||
// Improved function to get a local peak in an array by starting in the middle.
|
// Improved function to get a local peak in an array by starting in the middle.
|
||||||
// It also handles the case of a tie by preferring the left side (arbitrary choice)
|
// It also handles the case of a tie by preferring the left side (arbitrary choice)
|
||||||
// when array[start] == leftValue or array[start] == rightValue.
|
// when array[start] == leftValue or array[start] == rightValue.
|
||||||
@@ -348,6 +409,9 @@ class Predict {
|
|||||||
|
|
||||||
this.buildAllFxyCurves(validatedCurve);
|
this.buildAllFxyCurves(validatedCurve);
|
||||||
|
|
||||||
|
// Notify shared-input clones (see shareInputsFrom in the constructor).
|
||||||
|
// They re-adopt our inputs and clamp their own operating point.
|
||||||
|
this.emitter.emit('curveUpdated', validatedCurve);
|
||||||
}
|
}
|
||||||
|
|
||||||
constrain(value,min,max) {
|
constrain(value,min,max) {
|
||||||
|
|||||||
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.");
|
||||||
@@ -66,6 +73,32 @@ class state{
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (this.stateManager.getCurrentState() !== "operational") {
|
if (this.stateManager.getCurrentState() !== "operational") {
|
||||||
|
// 'accelerating' / 'decelerating' here is post-abort residue —
|
||||||
|
// the previous moveTo was aborted (e.g. MGC's per-tick
|
||||||
|
// abortActiveMovements) and the catch block intentionally
|
||||||
|
// doesn't auto-return to operational (avoids a bounce loop).
|
||||||
|
// BUT a new setpoint just arrived, so there's nothing for the
|
||||||
|
// anti-bounce policy to protect: the caller IS asking for a
|
||||||
|
// move. Fall through to operational and execute it. Without
|
||||||
|
// this the FSM gets parked, all subsequent setpoints land in
|
||||||
|
// delayedMove which never fires, and currentPosition freezes —
|
||||||
|
// see test/integration/abort-deadlock.integration.test.js for
|
||||||
|
// the exact deadlock scenario.
|
||||||
|
const movementResidueStates = ['accelerating', 'decelerating'];
|
||||||
|
if (movementResidueStates.includes(this.stateManager.getCurrentState())) {
|
||||||
|
this.logger.debug(`moveTo(${targetPosition}) arrived while parked in '${this.stateManager.getCurrentState()}' (post-abort). Returning to operational to service the new setpoint.`);
|
||||||
|
try {
|
||||||
|
await this.transitionToState("operational");
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.warn(`Could not transition out of '${this.stateManager.getCurrentState()}': ${e?.message || e}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Fall through — state is now operational, proceed with new move.
|
||||||
|
} else {
|
||||||
|
// Genuine non-operational state (starting, warmingup, stopping,
|
||||||
|
// coolingdown, idle, off, emergencystop, maintenance) — these
|
||||||
|
// are sequence steps the caller can't legitimately interrupt
|
||||||
|
// with a setpoint. Save for later, exactly as before.
|
||||||
if (this.config.mode.current === "auto") {
|
if (this.config.mode.current === "auto") {
|
||||||
this.delayedMove = targetPosition;
|
this.delayedMove = targetPosition;
|
||||||
this.logger.warn(`Saving setpoint=${targetPosition} to execute once back in 'operational' state.`);
|
this.logger.warn(`Saving setpoint=${targetPosition} to execute once back in 'operational' state.`);
|
||||||
@@ -73,9 +106,9 @@ class state{
|
|||||||
else{
|
else{
|
||||||
this.logger.warn(`Not able to accept setpoint=${targetPosition} while not in ${this.stateManager.getCurrentState()} state`);
|
this.logger.warn(`Not able to accept setpoint=${targetPosition} while not in ${this.stateManager.getCurrentState()} state`);
|
||||||
}
|
}
|
||||||
//return early
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
this.abortController = new AbortController();
|
this.abortController = new AbortController();
|
||||||
const { signal } = this.abortController;
|
const { signal } = this.abortController;
|
||||||
try {
|
try {
|
||||||
@@ -85,15 +118,54 @@ class state{
|
|||||||
this.emitter.emit("movementComplete", { position: targetPosition });
|
this.emitter.emit("movementComplete", { position: targetPosition });
|
||||||
await this.transitionToState("operational");
|
await this.transitionToState("operational");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// Abort path: only return to 'operational' when explicitly requested
|
||||||
|
// (shutdown/emergency-stop needs it to unblock the FSM). Routine MGC
|
||||||
|
// demand-update aborts must NOT auto-transition — doing so causes a
|
||||||
|
// bounce loop where every tick aborts → operational → new move →
|
||||||
|
// abort → operational → ... and the pump never reaches its setpoint.
|
||||||
|
const msg = typeof error === 'string' ? error : error?.message;
|
||||||
|
if (msg === 'Transition aborted' || msg === 'Movement aborted') {
|
||||||
|
if (this._returnToOperationalOnAbort) {
|
||||||
|
this.logger.debug(`Movement aborted; returning to 'operational' (requested by caller).`);
|
||||||
|
try {
|
||||||
|
await this.transitionToState("operational");
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.debug(`Post-abort transition to operational failed: ${e?.message || e}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.logger.debug(`Movement aborted; staying in current state (routine abort).`);
|
||||||
|
}
|
||||||
|
this._returnToOperationalOnAbort = false;
|
||||||
|
this.emitter.emit("movementAborted", { position: targetPosition });
|
||||||
|
} else {
|
||||||
this.logger.error(error);
|
this.logger.error(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// -------- State Transition Methods -------- //
|
// -------- State Transition Methods -------- //
|
||||||
|
|
||||||
abortCurrentMovement(reason = "group override") {
|
/**
|
||||||
|
* @param {string} reason - human-readable abort reason
|
||||||
|
* @param {object} [options]
|
||||||
|
* @param {boolean} [options.returnToOperational=false] - when true the FSM
|
||||||
|
* transitions back to 'operational' after the abort so a subsequent
|
||||||
|
* shutdown/emergency-stop sequence can proceed. Set to false (default)
|
||||||
|
* for routine demand updates where the caller will send a new movement
|
||||||
|
* immediately — auto-transitioning would cause a bounce loop.
|
||||||
|
*/
|
||||||
|
abortCurrentMovement(reason = "group override", options = {}) {
|
||||||
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);
|
||||||
|
// 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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
266
wiki/Home.md
Normal file
266
wiki/Home.md
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
# generalFunctions
|
||||||
|
|
||||||
|
  
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## At a glance
|
||||||
|
|
||||||
|
| Thing | Value |
|
||||||
|
|:---|:---|
|
||||||
|
| What it is | The shared library — not a Node-RED node, never placed in a flow |
|
||||||
|
| Kind | Shared library (`require('generalFunctions')`) |
|
||||||
|
| Consumed by | All 12 EVOLV nodes (rotatingMachine, MGC, pumpingStation, valve, VGC, reactor, settler, monster, measurement, diffuser, dashboardAPI) |
|
||||||
|
| Import style | Package root only — `const { BaseDomain, UnitPolicy } = require('generalFunctions');` |
|
||||||
|
| Side effects on a flow | None — the library has no editor form, no node registration |
|
||||||
|
| Cross-node coupling | Through this library's API surface + Node-RED messages only — never direct imports between node packages |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How it fits
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
gf["generalFunctions<br/>(shared library)"]:::lib
|
||||||
|
|
||||||
|
rm["rotatingMachine<br/>Equipment"]:::equip
|
||||||
|
mgc["machineGroupControl<br/>Unit"]:::unit
|
||||||
|
ps["pumpingStation<br/>Process Cell"]:::proc
|
||||||
|
meas["measurement<br/>Control Module"]:::ctrl
|
||||||
|
valve["valve<br/>Equipment"]:::equip
|
||||||
|
vgc["valveGroupControl<br/>Unit"]:::unit
|
||||||
|
reactor["reactor<br/>Unit"]:::unit
|
||||||
|
settler["settler<br/>Unit"]:::unit
|
||||||
|
monster["monster<br/>Unit"]:::unit
|
||||||
|
diffuser["diffuser<br/>Equipment"]:::equip
|
||||||
|
dashAPI["dashboardAPI<br/>utility"]:::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. The library has no S88 level of its own — it is the substrate the S88-classified nodes are built on.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How to import
|
||||||
|
|
||||||
|
Single root import, destructure what you need:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const {
|
||||||
|
// Platform base classes
|
||||||
|
BaseDomain, BaseNodeAdapter, ChildRouter, UnitPolicy, HealthStatus, LatestWinsGate,
|
||||||
|
// Node-RED bridge
|
||||||
|
createRegistry, CommandRegistry, statusBadge, StatusUpdater,
|
||||||
|
// Measurement + config
|
||||||
|
MeasurementContainer, configManager, configUtils, validation,
|
||||||
|
// Output formatting + logging
|
||||||
|
outputUtils, logger,
|
||||||
|
// Child registration
|
||||||
|
childRegistrationUtils,
|
||||||
|
// Unit conversion + physics
|
||||||
|
convert, Fysics, gravity, coolprop,
|
||||||
|
// Control + prediction
|
||||||
|
PIDController, CascadePIDController, createPidController, createCascadePidController,
|
||||||
|
predict, interpolation, nrmse, stats, state,
|
||||||
|
// Editor menus
|
||||||
|
MenuManager,
|
||||||
|
// Asset registry
|
||||||
|
assetResolver, AssetResolver, FileBackend, HttpBackend,
|
||||||
|
// Constants
|
||||||
|
POSITIONS, POSITION_VALUES, isValidPosition,
|
||||||
|
} = require('generalFunctions');
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> Never import internal paths (`require('generalFunctions/src/domain/UnitPolicy')`). Only the package root is contractual; internal layout may move.
|
||||||
|
|
||||||
|
For the full export list with signatures and stability tags, see [Reference — Contracts](Reference-Contracts).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Module map — what lives where
|
||||||
|
|
||||||
|
```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/"]
|
||||||
|
NRMSE["src/nrmse/"]
|
||||||
|
STATS["src/stats/"]
|
||||||
|
OUT["src/outliers/"]
|
||||||
|
STATE["src/state/"]
|
||||||
|
CONV["src/convert/"]
|
||||||
|
COOL["src/coolprop-node/"]
|
||||||
|
FYS["src/convert/fysics.js"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph menu_grp["src/menu/"]
|
||||||
|
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 + 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/registry/` | `assetResolver`, `AssetResolver`, `FileBackend`, `HttpBackend` | Asset metadata lookup (replaces ad-hoc JSON readers) |
|
||||||
|
| `src/constants/` | `POSITIONS`, `POSITION_VALUES`, `isValidPosition` | Canonical spatial position constants |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What you'll send (the platform contract)
|
||||||
|
|
||||||
|
This library doesn't accept `msg.topic` directly — nodes do. But every node's `nodeClass.js` and `specificClass.js` route through the same primitives:
|
||||||
|
|
||||||
|
| Primitive | Role |
|
||||||
|
|:---|:---|
|
||||||
|
| `BaseNodeAdapter.input(msg)` | Routes incoming Node-RED messages through the node's `CommandRegistry`, applies unit normalisation, then dispatches to the handler. |
|
||||||
|
| `CommandRegistry` | Topic + alias map. Handlers are pure functions; `units: {measure, default}` triggers automatic `convert` normalisation. |
|
||||||
|
| `ChildRouter` | Declarative parent-side routing. `.onRegister(type, cb)`, `.onMeasurement(type, filter, cb)`, `.onPrediction(type, filter, cb)`. |
|
||||||
|
| `MeasurementContainer.type().variant().position().value()` | Chainable write. Flattened output emits 4-segment keys `<type>.<variant>.<position>.<childId>`. |
|
||||||
|
| `UnitPolicy.declare({canonical, output, curve?})` | The per-node unit triple. Used by `MeasurementContainer` (auto-convert on write) and by the output formatter (render in `output` units). |
|
||||||
|
| `outputUtils.formatMsg(snapshot, config, mode)` | Delta-compresses successive snapshots. Returns `undefined` when nothing changed. |
|
||||||
|
| `HealthStatus.ok / degraded / compose` | Frozen plain-object factory for prediction-quality state. |
|
||||||
|
| `LatestWinsGate.fire(value)` | Serialises async dispatches; the latest call wins, intermediates are marked `SUPERSEDED`. |
|
||||||
|
|
||||||
|
For full signatures and stability tags see [Reference — Contracts](Reference-Contracts).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What you'll see come out
|
||||||
|
|
||||||
|
A node that imports `BaseNodeAdapter` automatically gets the three EVOLV ports:
|
||||||
|
|
||||||
|
| Port | Carries | Built by |
|
||||||
|
|:---|:---|:---|
|
||||||
|
| 0 (process) | Delta-compressed state snapshot (the `getOutput()` return) | `outputUtils.formatMsg(snapshot, config, 'process')` |
|
||||||
|
| 1 (telemetry) | InfluxDB line-protocol payload (same fields) | `outputUtils.formatMsg(snapshot, config, 'influxdb')` |
|
||||||
|
| 2 (register / control) | Parent-child handshake messages | `childRegistrationUtils` via `BaseNodeAdapter` |
|
||||||
|
|
||||||
|
The 4-segment key shape **`<type>.<variant>.<position>.<childId>`** is the contractual output of `MeasurementContainer.getFlattenedOutput()`. Position labels are normalised to lowercase. Changing this shape is a forbidden breaking change — see [Reference — Limitations](Reference-Limitations#stability--versioning).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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 |
|
||||||
|
| Asset metadata registry (`assetResolver`) | ✅ | Replaces `loadCurve`, `AssetCategoryManager`, ad-hoc JSON readers |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Need more?
|
||||||
|
|
||||||
|
| Page | What you'll find |
|
||||||
|
|:---|:---|
|
||||||
|
| [Reference — Contracts](Reference-Contracts) | Full public API surface table — one row per export, with source file, stability tag, and signature |
|
||||||
|
| [Reference — Architecture](Reference-Architecture) | Three-tier rules, `src/` directory tree, how 12 nodes consume the library, additive-only export discipline |
|
||||||
|
| [Reference — Examples](Reference-Examples) | Usage patterns: extending `BaseDomain` and `BaseNodeAdapter`, registering commands, declaring child routes, `MeasurementContainer` chaining |
|
||||||
|
| [Reference — Limitations](Reference-Limitations) | Known issues (deprecated `loadCurve`, `outlierDetection` logs to console, `configUtils` silent strip, …) and stability/versioning rules |
|
||||||
|
|
||||||
|
[EVOLV master wiki](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Home) · [Platform CONTRACTS.md](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/.claude/refactor/CONTRACTS.md) · [Topic Conventions](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topic-Conventions)
|
||||||
286
wiki/Reference-Architecture.md
Normal file
286
wiki/Reference-Architecture.md
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
# Reference — Architecture
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> The shape of the library: the three-tier rule it enforces on consumer nodes, the `src/` directory layout, how 12 EVOLV nodes consume each module, and the additive-only export discipline. For an intuitive overview, return to [Home](Home).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Three-tier rule the library enforces
|
||||||
|
|
||||||
|
Every consumer node follows the same three-tier sandwich. `generalFunctions` provides the base classes for tiers 2 and 3; the entry file is per-node.
|
||||||
|
|
||||||
|
```
|
||||||
|
nodes/<nodeName>/
|
||||||
|
|
|
||||||
|
+-- <nodeName>.js entry: RED.nodes.registerType(...)
|
||||||
|
|
|
||||||
|
+-- src/
|
||||||
|
nodeClass.js extends BaseNodeAdapter <-- generalFunctions
|
||||||
|
specificClass.js extends BaseDomain <-- generalFunctions
|
||||||
|
commands/index.js CommandRegistry descriptors <-- generalFunctions
|
||||||
|
```
|
||||||
|
|
||||||
|
| Tier | Owns | May call `RED.*` | Provided by |
|
||||||
|
|:---|:---|:---:|:---|
|
||||||
|
| entry | Type registration, admin endpoints | Yes | per-node `<nodeName>.js` |
|
||||||
|
| nodeClass | Input routing, output ports, tick / status loops, registration delay | Yes | `BaseNodeAdapter` (this library) |
|
||||||
|
| specificClass | Domain logic, FSM, predictions, drift — no `RED.*` | No | `BaseDomain` (this library) |
|
||||||
|
|
||||||
|
Authoritative platform spec: [`.claude/refactor/CONTRACTS.md`](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/.claude/refactor/CONTRACTS.md) sections 2 (nodeClass), 3 (specificClass), 4 (commandRegistry), 5 (ChildRouter), 6 (UnitPolicy), 7 (statusBadge), 9 (HealthStatus).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `src/` directory tree
|
||||||
|
|
||||||
|
```
|
||||||
|
generalFunctions/
|
||||||
|
|
|
||||||
|
+-- index.js barrel — the only contractual import path
|
||||||
|
+-- CONTRACT.md per-export stability tags + cross-refs
|
||||||
|
|
|
||||||
|
+-- src/
|
||||||
|
| +-- domain/ base classes for specificClass.js
|
||||||
|
| | BaseDomain.js
|
||||||
|
| | ChildRouter.js
|
||||||
|
| | UnitPolicy.js
|
||||||
|
| | LatestWinsGate.js
|
||||||
|
| | HealthStatus.js
|
||||||
|
| |
|
||||||
|
| +-- nodered/ base classes for nodeClass.js
|
||||||
|
| | BaseNodeAdapter.js
|
||||||
|
| | commandRegistry.js
|
||||||
|
| | statusBadge.js
|
||||||
|
| | statusUpdater.js
|
||||||
|
| |
|
||||||
|
| +-- measurements/ measurement store
|
||||||
|
| | MeasurementContainer.js
|
||||||
|
| | MeasurementBuilder.js
|
||||||
|
| | Measurement.js
|
||||||
|
| |
|
||||||
|
| +-- helper/ shared utilities
|
||||||
|
| | logger.js
|
||||||
|
| | outputUtils.js
|
||||||
|
| | childRegistrationUtils.js
|
||||||
|
| | configUtils.js
|
||||||
|
| | validationUtils.js
|
||||||
|
| | menuUtils.js
|
||||||
|
| | gravity.js
|
||||||
|
| |
|
||||||
|
| +-- configs/ schema registry
|
||||||
|
| | index.js ConfigManager
|
||||||
|
| | baseConfig.json
|
||||||
|
| | <nodeName>.json one schema per consumer node
|
||||||
|
| | assetApiConfig.js
|
||||||
|
| |
|
||||||
|
| +-- convert/ unit conversion + physics
|
||||||
|
| | index.js convert
|
||||||
|
| | fysics.js Fysics class
|
||||||
|
| |
|
||||||
|
| +-- predict/ curve prediction
|
||||||
|
| | predict_class.js
|
||||||
|
| | interpolation.js
|
||||||
|
| |
|
||||||
|
| +-- pid/ closed-loop control
|
||||||
|
| | PIDController.js
|
||||||
|
| | index.js createPidController / createCascadePidController
|
||||||
|
| |
|
||||||
|
| +-- state/ FSM scaffold (StateManager + MovementManager)
|
||||||
|
| +-- nrmse/ prediction-quality NRMSE
|
||||||
|
| +-- stats/ pure-function statistical reducers
|
||||||
|
| +-- outliers/ DynamicClusterDeviation
|
||||||
|
| +-- coolprop-node/ CoolProp thermodynamic bindings
|
||||||
|
| +-- menu/ MenuManager (editor dropdowns)
|
||||||
|
| +-- registry/ AssetResolver + FileBackend / HttpBackend
|
||||||
|
| +-- constants/ POSITIONS, POSITION_VALUES, isValidPosition
|
||||||
|
|
|
||||||
|
+-- datasets/ asset metadata (curves, model data)
|
||||||
|
| +-- assetData/
|
||||||
|
| +-- curves/ pump / blower / compressor curves
|
||||||
|
| +-- modelData/ multi-parameter model assets
|
||||||
|
|
|
||||||
|
+-- test/ unit + integration tests
|
||||||
|
+-- scripts/ maintenance scripts
|
||||||
|
+-- settings/ shared Node-RED-side settings
|
||||||
|
```
|
||||||
|
|
||||||
|
`index.js` is the only contractual import path. Anything not re-exported there is internal; consumers must not reach into `src/...` paths.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How nodes consume the library
|
||||||
|
|
||||||
|
| Layer | Consumer responsibility | Library responsibility |
|
||||||
|
|:---|:---|:---|
|
||||||
|
| nodeClass | Declare `static DomainClass`, `static commands`, `static tickInterval`, `static statusInterval`. Override `buildDomainConfig(uiConfig, nodeId)` to translate editor values into the domain's config slice. | `BaseNodeAdapter` wires config build → domain instantiation → registration delay → output strategy → status loop → input dispatch → close handler. |
|
||||||
|
| specificClass | Declare `static name` (matches the schema file). Implement `configure()`: wire `ChildRouter` routes, instantiate concern modules, attach measurement listeners. Implement `getOutput()` and `getStatusBadge()`. | `BaseDomain` provides `this.emitter`, `this.config`, `this.logger`, `this.measurements`, `this.childRegistrationUtils`, `this.router`. |
|
||||||
|
| commands/index.js | Export an array of descriptors: `{topic, aliases?, units?, payloadSchema?, description, handler}`. Handler is `(source, msg, ctx)`. | `CommandRegistry` builds an `O(1)` lookup, normalises units via `convert`, warns once on alias use, generates the auto-`query.units` topic. |
|
||||||
|
| measurements | Write via the chain: `this.measurements.type(t).variant(v).position(p, childId).value(x, ts, srcUnit)`. Read via `getCurrentValue(unit)`, `getAverage(unit)`, `getFlattenedOutput()`. | `MeasurementContainer` auto-converts inputs to canonical units (per `UnitPolicy`), maintains windows, emits change events. |
|
||||||
|
| output | Implement `getOutput()` returning a flat snapshot object. Implement `getStatusBadge()` returning `statusBadge.compose(parts, opts)`. | `outputUtils.formatMsg` delta-compresses the snapshot for Port 0 + Port 1; `StatusUpdater` polls `getStatusBadge()` on `statusInterval`. |
|
||||||
|
|
||||||
|
All 12 nodes follow this pattern. Variations are in how richly they fill `configure()` — `dashboardAPI` has the lightest (HTTP gateway, no FSM); `rotatingMachine` and `machineGroupControl` have the densest (full curve loading, drift assessor, multi-source pressure routing).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lifecycle — one tick or event reaches the output port
|
||||||
|
|
||||||
|
```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)
|
||||||
|
```
|
||||||
|
|
||||||
|
The event path is the default. The tick path is opt-in via `static tickInterval = 1000;` — only nodes with genuinely time-based math (integrators, ramps, runtime counters) enable it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Config schema registry
|
||||||
|
|
||||||
|
Each consumer node has one JSON schema 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 — no registration step.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stability — additive-only export discipline
|
||||||
|
|
||||||
|
Source of truth: [`.claude/rules/general-functions.md`](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/.claude/rules/general-functions.md) in the superproject.
|
||||||
|
|
||||||
|
| 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:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -r "require('generalFunctions')" nodes/*/
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the test suites of every affected consumer, not just this library's own tests.
|
||||||
|
|
||||||
|
### Canonical units
|
||||||
|
|
||||||
|
`MeasurementContainer` and all internal processing assume canonical units:
|
||||||
|
|
||||||
|
| Quantity | Canonical |
|
||||||
|
|:---|:---|
|
||||||
|
| Pressure | `Pa` |
|
||||||
|
| Flow | `m3/s` |
|
||||||
|
| Power | `W` |
|
||||||
|
| Temperature | `K` |
|
||||||
|
|
||||||
|
Unit conversion happens at system boundaries (input via `CommandRegistry.units` normalisation, output via `UnitPolicy.output` rendering) — never in core logic.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Adding a new export — the dance
|
||||||
|
|
||||||
|
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 in [`CONTRACT.md`](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/nodes/generalFunctions/CONTRACT.md) 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`](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/.claude/refactor/CONTRACTS.md) in the superproject.
|
||||||
|
5. Add a test under `test/`.
|
||||||
|
|
||||||
|
## Removing an export
|
||||||
|
|
||||||
|
1. Mark it **deprecated** in `CONTRACT.md` (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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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`.
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Where to start reading
|
||||||
|
|
||||||
|
| If you're changing... | Read first |
|
||||||
|
|:---|:---|
|
||||||
|
| Base class for a new domain | `src/domain/BaseDomain.js` + `.claude/refactor/CONTRACTS.md §3` |
|
||||||
|
| Node-RED adapter behaviour | `src/nodered/BaseNodeAdapter.js` + `.claude/refactor/CONTRACTS.md §2` |
|
||||||
|
| Topic dispatch, alias warnings, unit normalisation | `src/nodered/commandRegistry.js` + `.claude/refactor/CONTRACTS.md §4` |
|
||||||
|
| Declarative child registration | `src/domain/ChildRouter.js` + `.claude/refactor/CONTRACTS.md §5` |
|
||||||
|
| Canonical / output / curve units | `src/domain/UnitPolicy.js` + `.claude/refactor/CONTRACTS.md §6` |
|
||||||
|
| Measurement chain + flattened output | `src/measurements/MeasurementContainer.js` |
|
||||||
|
| Delta-compressed output formatting | `src/helper/outputUtils.js` |
|
||||||
|
| Editor status badge | `src/nodered/statusBadge.js`, `statusUpdater.js`, `.claude/refactor/CONTRACTS.md §7` |
|
||||||
|
| Async dispatch serialisation | `src/domain/LatestWinsGate.js` + `.claude/refactor/CONTRACTS.md §8` |
|
||||||
|
| Prediction quality / drift state | `src/domain/HealthStatus.js` + `.claude/refactor/CONTRACTS.md §9` |
|
||||||
|
| Curve fitting + flow/power prediction | `src/predict/predict_class.js`, `interpolation.js` |
|
||||||
|
| PID control | `src/pid/PIDController.js` |
|
||||||
|
| FSM (valve / machine states) | `src/state/` |
|
||||||
|
| Per-node JSON schema loading | `src/configs/index.js` |
|
||||||
|
| Asset metadata lookup | `src/registry/AssetResolver.js`, `FileBackend.js`, `HttpBackend.js` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related pages
|
||||||
|
|
||||||
|
| Page | Why |
|
||||||
|
|:---|:---|
|
||||||
|
| [Home](Home) | Intuitive overview |
|
||||||
|
| [Reference — Contracts](Reference-Contracts) | Full public API surface, per-export stability tags |
|
||||||
|
| [Reference — Examples](Reference-Examples) | Usage patterns from real consumer nodes |
|
||||||
|
| [Reference — Limitations](Reference-Limitations) | Known issues, stability rules, deprecations |
|
||||||
|
| [Platform CONTRACTS.md](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/.claude/refactor/CONTRACTS.md) | The authoritative base-class + protocol spec |
|
||||||
|
| [EVOLV — Architecture](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Architecture) | Platform-wide three-tier pattern |
|
||||||
180
wiki/Reference-Contracts.md
Normal file
180
wiki/Reference-Contracts.md
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
# Reference — Contracts
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> The full public API surface — one row per export from `require('generalFunctions')`, with source file, stability tag, and contract summary. Source of truth: `index.js` (the barrel). For an intuitive overview, return to [Home](Home).
|
||||||
|
>
|
||||||
|
> **Stability tags:**
|
||||||
|
>
|
||||||
|
> - `stable` — API change requires a deprecation cycle and a CONTRACT update.
|
||||||
|
> - `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
|
||||||
|
|
||||||
|
| Export | Stability | Source | Contract |
|
||||||
|
|:---|:---|:---|:---|
|
||||||
|
| `BaseDomain` | stable | `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 the schema JSON file in `src/configs/`) and implement `configure()`. See [CONTRACTS.md §3](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/.claude/refactor/CONTRACTS.md). |
|
||||||
|
| `BaseNodeAdapter` | stable | `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](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/.claude/refactor/CONTRACTS.md). |
|
||||||
|
| `ChildRouter` | stable | `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](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/.claude/refactor/CONTRACTS.md). |
|
||||||
|
| `CommandRegistry` | stable | `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` | stable | `src/nodered/commandRegistry.js` | Factory: `createRegistry(descriptors, options) → CommandRegistry`. Used by `BaseNodeAdapter`; rarely needed directly. |
|
||||||
|
| `UnitPolicy` | stable | `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](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/.claude/refactor/CONTRACTS.md). |
|
||||||
|
| `LatestWinsGate` | stable | `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](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/.claude/refactor/CONTRACTS.md). |
|
||||||
|
| `HealthStatus` | stable | `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](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/.claude/refactor/CONTRACTS.md). |
|
||||||
|
| `statusBadge` | stable | `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](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/.claude/refactor/CONTRACTS.md). |
|
||||||
|
| `StatusUpdater` | stable | `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. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Measurements
|
||||||
|
|
||||||
|
| Export | Stability | Source | Contract |
|
||||||
|
|:---|:---|:---|:---|
|
||||||
|
| `MeasurementContainer` | stable | `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`). Auto-converts on write to canonical units per the supplied `UnitPolicy`. |
|
||||||
|
| `POSITIONS` | stable | `src/constants/positions.js` | Frozen enum: `{ UPSTREAM, DOWNSTREAM, AT_EQUIPMENT, DELTA }`. |
|
||||||
|
| `POSITION_VALUES` | stable | `src/constants/positions.js` | `string[]` of all position strings. |
|
||||||
|
| `isValidPosition` | stable | `src/constants/positions.js` | `(pos: string) => boolean`. |
|
||||||
|
|
||||||
|
### 4-segment output key
|
||||||
|
|
||||||
|
The contractual output of `MeasurementContainer.getFlattenedOutput()` is:
|
||||||
|
|
||||||
|
```
|
||||||
|
<type>.<variant>.<position>.<childId>
|
||||||
|
```
|
||||||
|
|
||||||
|
| Segment | Examples | Notes |
|
||||||
|
|:---|:---|:---|
|
||||||
|
| `type` | `flow`, `pressure`, `power`, `temperature`, `level`, `efficiency` | Lowercase. |
|
||||||
|
| `variant` | `predicted`, `measured`, `setpoint`, `max`, `min` | Lowercase. |
|
||||||
|
| `position` | `upstream`, `downstream`, `atequipment`, `delta` | Always lowercase — e.g. `atequipment`, not `atEquipment`. |
|
||||||
|
| `childId` | `default`, `<child.general.id>`, `dashboard-sim-upstream`, … | `default` for the node's own predictions; otherwise the registering child's id. |
|
||||||
|
|
||||||
|
Changing this shape is a forbidden breaking change — see [Reference — Limitations](Reference-Limitations#stability--versioning).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Output formatting
|
||||||
|
|
||||||
|
| Export | Stability | Source | Contract |
|
||||||
|
|:---|:---|:---|:---|
|
||||||
|
| `outputUtils` | stable | `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` | stable | `src/helper/logger.js` | `new logger(enabled, logLevel, moduleName)`. Methods: `debug`, `info`, `warn`, `error`, `setLogLevel`, `toggleLogging`. Use this instead of `console.log`. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Export | Stability | Source | Contract |
|
||||||
|
|:---|:---|:---|:---|
|
||||||
|
| `configManager` | stable | `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` | stable | `src/helper/configUtils.js` | `new configUtils(defaultConfig)`. `initConfig(userConfig)` validates and merges user values over defaults via `validationUtils`. |
|
||||||
|
| `validation` | stable | `src/helper/validationUtils.js` | `new validation(logEnabled, logLevel)`. `validateSchema(config, schema, name)` walks schema, clamps numbers, coerces types, strips unknown keys. |
|
||||||
|
| `assertions` | stable | `src/helper/` | Runtime validation primitives. |
|
||||||
|
| `assetApiConfig` | stable | `src/configs/assetApiConfig.js` | Asset-registry HTTP backend config. |
|
||||||
|
| `MenuManager` | stable | `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. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Child registration
|
||||||
|
|
||||||
|
| Export | Stability | Source | Contract |
|
||||||
|
|:---|:---|:---|:---|
|
||||||
|
| `childRegistrationUtils` | stable | `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. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Unit conversion + physics
|
||||||
|
|
||||||
|
| Export | Stability | Source | Contract |
|
||||||
|
|:---|:---|:---|:---|
|
||||||
|
| `convert` | stable | `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` | stable | `src/convert/fysics.js` | `new Fysics()`. Physical constants: `air_density`, `g0`; methods for gravity and viscosity calculations. |
|
||||||
|
| `gravity` | stable | `src/helper/gravity.js` | Singleton-style `Gravity` instance. `getStandardGravity() → 9.80665 m/s²`. WGS-84 latitude / altitude corrections available. |
|
||||||
|
| `coolprop` | stable | `src/coolprop-node/src/index.js` | CoolProp fluid/gas thermodynamic property lookup. Used by nodes that model heat transfer or gas compression. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Control & prediction
|
||||||
|
|
||||||
|
| Export | Stability | Source | Contract |
|
||||||
|
|:---|:---|:---|:---|
|
||||||
|
| `PIDController` | stable | `src/pid/PIDController.js` | Discrete PID with bumpless auto/manual transfer, anti-windup, derivative filtering, rate limiting, gain scheduling, feedforward. |
|
||||||
|
| `CascadePIDController` | stable | `src/pid/PIDController.js` | Outer-inner PID cascade built on `PIDController`. |
|
||||||
|
| `createPidController` | stable | `src/pid/index.js` | Factory shorthand: `createPidController(options) → PIDController`. |
|
||||||
|
| `createCascadePidController` | stable | `src/pid/index.js` | Factory shorthand for cascade PID. |
|
||||||
|
| `predict` | stable | `src/predict/predict_class.js` | `new predict(config, logger)`. Multidimensional characteristic-curve predictor; emits results via internal `EventEmitter`. |
|
||||||
|
| `interpolation` | stable | `src/predict/interpolation.js` | Class for 1-D and 2-D curve interpolation (linear, cubic-spline). Used internally by `predict`. |
|
||||||
|
| `nrmse` | stable | `src/nrmse/index.js` | `ErrorMetrics` class for normalised-root-mean-squared-error tracking. Multi-metric via `registerMetric(id)`, `update(id, predicted, measured)`. |
|
||||||
|
| `stats` | stable | `src/stats/index.js` | Pure functions: `mean(arr)`, `stdDev(arr)`, `median(arr)`. No state; safe to call on any numeric array. |
|
||||||
|
| `state` | stable | `src/state/index.js` | `new state(config, logger)`. FSM for valve / machine: `StateManager` (transitions) + `MovementManager` (timed moves). Emits state-change events. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Asset registry
|
||||||
|
|
||||||
|
| Export | Stability | Source | Contract |
|
||||||
|
|:---|:---|:---|:---|
|
||||||
|
| `assetResolver` | stable | `src/registry/index.js` | Singleton. `.resolve(category, modelId)` — sync, case-insensitive, returns `null` on miss. |
|
||||||
|
| `AssetResolver` | stable | `src/registry/index.js` | Resolver class (for testing / alternate backends). |
|
||||||
|
| `FileBackend` | stable | `src/registry/` | File-system asset backend. |
|
||||||
|
| `HttpBackend` | stable | `src/registry/` | HTTP asset backend. |
|
||||||
|
| `loadCurve` | **deprecated** | `index.js` (shim) | Thin shim over `assetResolver.resolve('curves', modelId)`. New code uses the resolver directly. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Canonical units (the platform-wide contract)
|
||||||
|
|
||||||
|
`MeasurementContainer` and all internal processing assume canonical units. Unit conversion happens at system boundaries (input via `CommandRegistry.units` normalisation, output via `UnitPolicy.output` rendering) — never in core logic.
|
||||||
|
|
||||||
|
| Quantity | Canonical (internal) | Typical output | Typical curve |
|
||||||
|
|:---|:---|:---|:---|
|
||||||
|
| Pressure | `Pa` | `mbar` | `mbar` |
|
||||||
|
| Atmospheric pressure | `Pa` | `Pa` | — |
|
||||||
|
| Flow | `m3/s` | `m3/h` | `m3/h` |
|
||||||
|
| Power | `W` | `kW` | `kW` |
|
||||||
|
| Temperature | `K` | `°C` | — |
|
||||||
|
| Control | — | — | `%` |
|
||||||
|
|
||||||
|
Each node declares its own `UnitPolicy` (typically as `static unitPolicy = UnitPolicy.declare({...})` on the domain class). The policy is passed to `MeasurementContainer` via `unitPolicy.containerOptions()`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Output ports (provided by `BaseNodeAdapter`)
|
||||||
|
|
||||||
|
Every node that extends `BaseNodeAdapter` automatically gets three ports:
|
||||||
|
|
||||||
|
| Port | Carries | Built by | Notes |
|
||||||
|
|:---|:---|:---|:---|
|
||||||
|
| 0 (process) | Delta-compressed state snapshot — the `getOutput()` return | `outputUtils.formatMsg(snapshot, config, 'process')` | Emits only when fields change. Consumers must cache and merge. |
|
||||||
|
| 1 (telemetry) | InfluxDB line-protocol payload (same fields as Port 0) | `outputUtils.formatMsg(snapshot, config, 'influxdb')` | Tags + fields per the schema. |
|
||||||
|
| 2 (register / control) | Parent-child handshake messages | `childRegistrationUtils` via `BaseNodeAdapter` | `child.register` at startup; subsequent `child.measurement` / `child.prediction` events. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Adding a new export — the dance
|
||||||
|
|
||||||
|
See [Reference — Architecture](Reference-Architecture#adding-a-new-export--the-dance) for the full step-by-step. Summary:
|
||||||
|
|
||||||
|
1. Implement under `src/<concern>/`.
|
||||||
|
2. Re-export from `index.js` (alphabetical within concern block).
|
||||||
|
3. Add a row to the appropriate table in [`CONTRACT.md`](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/nodes/generalFunctions/CONTRACT.md) with stability tag.
|
||||||
|
4. If it's a new platform shape, also update [`.claude/refactor/CONTRACTS.md`](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/.claude/refactor/CONTRACTS.md).
|
||||||
|
5. Add a test under `test/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related pages
|
||||||
|
|
||||||
|
| Page | Why |
|
||||||
|
|:---|:---|
|
||||||
|
| [Home](Home) | Intuitive overview |
|
||||||
|
| [Reference — Architecture](Reference-Architecture) | Three-tier rule, `src/` layout, consumer responsibilities |
|
||||||
|
| [Reference — Examples](Reference-Examples) | Usage patterns: extending base classes, registering commands, declaring child routes |
|
||||||
|
| [Reference — Limitations](Reference-Limitations) | Known issues, deprecations, stability rules |
|
||||||
|
| [Platform CONTRACTS.md](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/.claude/refactor/CONTRACTS.md) | The authoritative platform base-class + protocol spec |
|
||||||
|
| [Library CONTRACT.md](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/nodes/generalFunctions/CONTRACT.md) | Per-export source-of-truth with stability tags |
|
||||||
361
wiki/Reference-Examples.md
Normal file
361
wiki/Reference-Examples.md
Normal file
@@ -0,0 +1,361 @@
|
|||||||
|
# Reference — Examples
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Usage patterns: how a consumer node imports and extends the library's base classes, how to register topic commands, how to declare child routes, and how to chain `MeasurementContainer` writes. Snippets are pulled from real consumer nodes (`rotatingMachine`, `pumpingStation`, `machineGroupControl`). For an intuitive overview, return to [Home](Home).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Single root import — the contract
|
||||||
|
|
||||||
|
```js
|
||||||
|
const {
|
||||||
|
BaseDomain, BaseNodeAdapter, UnitPolicy, ChildRouter, HealthStatus, LatestWinsGate,
|
||||||
|
MeasurementContainer, outputUtils, logger, statusBadge,
|
||||||
|
convert, PIDController,
|
||||||
|
} = require('generalFunctions');
|
||||||
|
```
|
||||||
|
|
||||||
|
The package root (`require('generalFunctions')`) is the only contractual import path. Internal subpaths (`require('generalFunctions/src/domain/UnitPolicy')`) are NOT contractual and may move at any time.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Extending `BaseDomain` — pattern from `pumpingStation/specificClass.js`
|
||||||
|
|
||||||
|
```js
|
||||||
|
const { BaseDomain, UnitPolicy } = require('generalFunctions');
|
||||||
|
|
||||||
|
class PumpingStation extends BaseDomain {
|
||||||
|
// static name must match src/configs/<nodeName>.json on the library side.
|
||||||
|
static name = 'pumpingStation';
|
||||||
|
|
||||||
|
// Declarative unit triple. canonical = internal storage. output = render units.
|
||||||
|
// curve = supplier curve units (only if the node consumes a characteristic curve).
|
||||||
|
static unitPolicy = UnitPolicy.declare({
|
||||||
|
canonical: { flow: 'm3/s', pressure: 'Pa', power: 'W', temperature: 'K' },
|
||||||
|
output: { flow: 'm3/h', pressure: 'mbar', power: 'kW', temperature: 'C' },
|
||||||
|
requireUnitForTypes: ['pressure', 'flow', 'power', 'temperature'],
|
||||||
|
});
|
||||||
|
|
||||||
|
configure() {
|
||||||
|
// Named child getters — readable in code, but the registry remains source of truth.
|
||||||
|
this.declareChildGetter('machines', 'machine');
|
||||||
|
this.declareChildGetter('machineGroups', 'machinegroup');
|
||||||
|
|
||||||
|
// Declarative child routing — no per-node registerChild switch needed.
|
||||||
|
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() {
|
||||||
|
return statusBadge.compose(['filling', 'V=12.4/50.0 m³']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = PumpingStation;
|
||||||
|
```
|
||||||
|
|
||||||
|
Key points:
|
||||||
|
|
||||||
|
- `static name = '...'` — tells `configManager.buildConfig()` which `src/configs/<n>.json` file to merge defaults from.
|
||||||
|
- `static unitPolicy` — pre-built `UnitPolicy` instance; `BaseDomain` passes `unitPolicy.containerOptions()` to the `MeasurementContainer` so it auto-converts on write.
|
||||||
|
- `configure()` is where you wire `ChildRouter` routes and instantiate concern modules. The constructor is owned by `BaseDomain`.
|
||||||
|
- `getOutput()` and `getStatusBadge()` are the only two methods `BaseNodeAdapter` calls on the domain to produce ports + status — everything else is event-driven.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Extending `BaseNodeAdapter` — pattern from `pumpingStation/nodeClass.js`
|
||||||
|
|
||||||
|
```js
|
||||||
|
const { BaseNodeAdapter } = require('generalFunctions');
|
||||||
|
const Domain = require('./specificClass');
|
||||||
|
const commands = require('./commands');
|
||||||
|
|
||||||
|
class nodeClass extends BaseNodeAdapter {
|
||||||
|
static DomainClass = Domain; // The specificClass to instantiate.
|
||||||
|
static commands = commands; // Array of command descriptors.
|
||||||
|
static tickInterval = 1000; // ms — only for time-driven math. Omit for event-driven nodes.
|
||||||
|
static statusInterval = 1000; // ms — how often to re-render the status badge.
|
||||||
|
|
||||||
|
// Translate Node-RED editor field values into the domain's config slice.
|
||||||
|
// The base class already merges schema defaults from src/configs/<nodeName>.json;
|
||||||
|
// this hook lets the adapter shape per-node values before the domain sees them.
|
||||||
|
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;
|
||||||
|
```
|
||||||
|
|
||||||
|
`BaseNodeAdapter` wires the full lifecycle: schema merge → domain instantiation → Port 2 registration after a 100 ms delay → status loop start → input dispatch via the registry → close handler that drains everything. The subclass only declares the static config and overrides `buildDomainConfig`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Command descriptors with unit normalisation
|
||||||
|
|
||||||
|
```js
|
||||||
|
// src/commands/index.js
|
||||||
|
module.exports = [
|
||||||
|
{
|
||||||
|
topic: 'set.demand',
|
||||||
|
aliases: ['Qd'], // Legacy name — first use logs a 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); },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
topic: 'set.flow-setpoint',
|
||||||
|
aliases: ['flowMovement'],
|
||||||
|
units: { measure: 'volumeFlowRate', default: 'm3/h' },
|
||||||
|
payloadSchema: { type: 'object', properties: { setpoint: { type: 'number' } } },
|
||||||
|
description: 'Set a flow-unit setpoint. Auto-converted to canonical m³/s.',
|
||||||
|
handler: (source, msg) => { source.setFlowSetpoint(msg.payload.setpoint); },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
When `units` is declared, `CommandRegistry` reads `msg.unit` from the incoming message (falling back to `default`) and converts via the `convert` library to the canonical unit before invoking the handler. The handler always sees a canonical value — it never has to do its own unit conversion.
|
||||||
|
|
||||||
|
A free side-effect: every command descriptor with a `units` field contributes a row to the auto-generated `query.units` reply, which dashboards can use to introspect a node's unit contract at runtime.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Declarative child routing — `ChildRouter`
|
||||||
|
|
||||||
|
```js
|
||||||
|
configure() {
|
||||||
|
this.router
|
||||||
|
// Trigger a callback the first time a machine-group child registers.
|
||||||
|
.onRegister('machinegroup', (child) => {
|
||||||
|
this.logger.info(`MachineGroup ${child.general.id} attached`);
|
||||||
|
this._mgcChild = child;
|
||||||
|
})
|
||||||
|
|
||||||
|
// Filter on a measurement child's asset.type.
|
||||||
|
.onMeasurement('measurement', { type: 'pressure', position: 'upstream' }, (data, child) => {
|
||||||
|
this._onUpstreamPressure(data.value, data);
|
||||||
|
})
|
||||||
|
|
||||||
|
.onMeasurement('measurement', { type: 'pressure', position: 'downstream' }, (data, child) => {
|
||||||
|
this._onDownstreamPressure(data.value, data);
|
||||||
|
})
|
||||||
|
|
||||||
|
.onMeasurement('measurement', { type: 'flow' }, (data, child) => {
|
||||||
|
// No position filter → matches any position.
|
||||||
|
this._onFlow(data.value, data, child);
|
||||||
|
})
|
||||||
|
|
||||||
|
// React to a child's own predictions (e.g. a downstream MGC publishing predicted group flow).
|
||||||
|
.onPrediction('machinegroup', { type: 'flow' }, (data, child) => {
|
||||||
|
this._onChildPrediction(data, child);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Pre-refactor, the same code lived as a `registerChild(child)` method on every node with a 30-line `switch (child.softwareType)` block. `ChildRouter` makes the wiring declarative; the underlying `childRegistrationUtils` calls are unchanged.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. `MeasurementContainer` chaining
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Write: chainable, auto-converts from srcUnit to canonical per UnitPolicy.
|
||||||
|
this.measurements
|
||||||
|
.type('pressure')
|
||||||
|
.variant('measured')
|
||||||
|
.position('upstream', child.general.id) // childId narrows the storage slot.
|
||||||
|
.value(3.4, Date.now(), 'mbar'); // value, timestamp, srcUnit.
|
||||||
|
|
||||||
|
// Read: latest value in canonical or arbitrary unit.
|
||||||
|
const p_Pa = this.measurements.type('pressure').variant('measured').position('upstream').getCurrentValue();
|
||||||
|
const p_mbar = this.measurements.type('pressure').variant('measured').position('upstream').getCurrentValue('mbar');
|
||||||
|
|
||||||
|
// Read: windowed average.
|
||||||
|
const avg = this.measurements.type('flow').variant('measured').position('atequipment').getAverage('m3/h');
|
||||||
|
|
||||||
|
// Read: difference over a time window (e.g. for integrators).
|
||||||
|
const dV = this.measurements
|
||||||
|
.type('level').variant('measured').position('atequipment')
|
||||||
|
.difference({ from: Date.now() - 60_000, to: Date.now(), unit: 'm' });
|
||||||
|
|
||||||
|
// Introspect: the 4-segment flat output (used by getOutput()).
|
||||||
|
const flat = this.measurements.getFlattenedOutput();
|
||||||
|
// → {
|
||||||
|
// 'pressure.measured.upstream.dashboard-sim-upstream': 0,
|
||||||
|
// 'pressure.measured.downstream.dashboard-sim-downstream': 1100,
|
||||||
|
// 'flow.predicted.downstream.default': 12.4,
|
||||||
|
// 'power.predicted.atequipment.default': 18.2,
|
||||||
|
// }
|
||||||
|
```
|
||||||
|
|
||||||
|
Key shape: `<type>.<variant>.<position>.<childId>`. Position labels are always lowercase in keys (`atequipment`, not `atEquipment`). The `childId` is `default` for the node's own predictions; otherwise the registering child's `general.id`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. `HealthStatus` — prediction quality / drift state
|
||||||
|
|
||||||
|
```js
|
||||||
|
const { HealthStatus } = require('generalFunctions');
|
||||||
|
|
||||||
|
// Ok state.
|
||||||
|
const ok = HealthStatus.ok('Pressure source healthy', 'real-child');
|
||||||
|
|
||||||
|
// Degraded with reason flags.
|
||||||
|
const warm = HealthStatus.degraded(1, ['pressure_init_warming'], 'Pressure not yet initialised', 'dashboard-sim');
|
||||||
|
|
||||||
|
// Compose multiple sub-statuses into the worst case.
|
||||||
|
const overall = HealthStatus.compose([ok, warm, flowDrift, powerDrift]);
|
||||||
|
// → frozen { level: max(level_i), flags: union(flags_i), message, source }
|
||||||
|
```
|
||||||
|
|
||||||
|
Levels: `0 = good`, `1 = warming`, `2 = degraded`, `3 = invalid`. The shape is frozen; you cannot mutate a `HealthStatus` instance, only compose new ones.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. `LatestWinsGate` — latest-write-wins async dispatch
|
||||||
|
|
||||||
|
```js
|
||||||
|
const { LatestWinsGate } = require('generalFunctions');
|
||||||
|
|
||||||
|
// Construct.
|
||||||
|
this._dispatchGate = new LatestWinsGate({
|
||||||
|
dispatch: async (value) => { await this._reallySetDemand(value); },
|
||||||
|
logger: this.logger,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fire (non-blocking; intermediate calls are superseded).
|
||||||
|
this._dispatchGate.fire(newDemand);
|
||||||
|
|
||||||
|
// Fire and await result.
|
||||||
|
const result = await this._dispatchGate.fireAndWait(newDemand);
|
||||||
|
if (result === LatestWinsGate.SUPERSEDED) {
|
||||||
|
// A newer fire pre-empted this one; nothing to do.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait until idle (useful in tests and clean shutdown).
|
||||||
|
await this._dispatchGate.drain();
|
||||||
|
```
|
||||||
|
|
||||||
|
Originally extracted from `machineGroupControl` to coordinate fast successive demand changes against a slow dispatcher. Now shared by `pumpingStation`, `valveGroupControl`, `machineGroupControl`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. PID controller
|
||||||
|
|
||||||
|
```js
|
||||||
|
const { createPidController } = require('generalFunctions');
|
||||||
|
|
||||||
|
const pid = createPidController({
|
||||||
|
kp: 1.2, ki: 0.4, kd: 0.05,
|
||||||
|
outputLimits: { min: 0, max: 100 },
|
||||||
|
rateLimitPerSec: 5, // %/s ramp cap
|
||||||
|
derivativeFilterTau: 0.2, // first-order LPF on the D term
|
||||||
|
antiWindup: 'clamping',
|
||||||
|
setpoint: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
pid.setSetpoint(60); // bumpless on the next compute call
|
||||||
|
const output = pid.compute(processValue); // discrete tick
|
||||||
|
```
|
||||||
|
|
||||||
|
For cascaded loops (outer = level → inner = flow), use `createCascadePidController({ outer: {...}, inner: {...} })`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Status badge composition
|
||||||
|
|
||||||
|
```js
|
||||||
|
const { statusBadge } = require('generalFunctions');
|
||||||
|
|
||||||
|
getStatusBadge() {
|
||||||
|
const state = this.state.getCurrentState();
|
||||||
|
const flowFmt = `${(this._predictedFlow * 3600).toFixed(1)} m³/h`;
|
||||||
|
const powerFmt = `${(this._predictedPower / 1000).toFixed(1)} kW`;
|
||||||
|
|
||||||
|
if (state === 'emergencystop') {
|
||||||
|
return statusBadge.error('E-stop active');
|
||||||
|
}
|
||||||
|
if (state === 'idle') {
|
||||||
|
return statusBadge.idle('idle');
|
||||||
|
}
|
||||||
|
return statusBadge.compose([state, flowFmt, powerFmt]);
|
||||||
|
// → { fill: 'green', shape: 'dot', text: 'operational | 12.4 m³/h | 18.2 kW' }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`StatusUpdater` polls `getStatusBadge()` every `statusInterval` ms and calls `node.status(...)`. Text clipped to 60 chars to fit the Node-RED editor.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Unit conversion (when you really do need it directly)
|
||||||
|
|
||||||
|
```js
|
||||||
|
const { convert } = require('generalFunctions');
|
||||||
|
|
||||||
|
const m3s = convert(80).from('m3/h').to('m3/s'); // 0.0222...
|
||||||
|
|
||||||
|
// What units can a measure take?
|
||||||
|
const units = convert.possibilities('volumeFlowRate');
|
||||||
|
// → ['m3/s', 'm3/h', 'l/s', 'l/min', 'gpm', ...]
|
||||||
|
```
|
||||||
|
|
||||||
|
In domain code, you should usually be relying on the `UnitPolicy` + `MeasurementContainer` pipeline to convert at the boundary — calling `convert` directly is a smell unless you're processing a one-off ad-hoc payload.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Loading a per-node JSON schema
|
||||||
|
|
||||||
|
```js
|
||||||
|
const { configManager } = require('generalFunctions');
|
||||||
|
const cm = new configManager();
|
||||||
|
|
||||||
|
// What schemas are registered?
|
||||||
|
const names = cm.getAvailableConfigs();
|
||||||
|
// → ['baseConfig', 'rotatingMachine', 'pumpingStation', 'measurement', ...]
|
||||||
|
|
||||||
|
// Merge editor values over schema defaults.
|
||||||
|
const merged = cm.buildConfig('pumpingStation', uiConfig, nodeId, domainSlice);
|
||||||
|
```
|
||||||
|
|
||||||
|
`BaseNodeAdapter` does this for you in the constructor. Direct use is for tests and migration tooling.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related pages
|
||||||
|
|
||||||
|
| Page | Why |
|
||||||
|
|:---|:---|
|
||||||
|
| [Home](Home) | Intuitive overview |
|
||||||
|
| [Reference — Contracts](Reference-Contracts) | Full public API surface, per-export stability tags |
|
||||||
|
| [Reference — Architecture](Reference-Architecture) | Three-tier rule, `src/` layout, consumer responsibilities |
|
||||||
|
| [Reference — Limitations](Reference-Limitations) | Known issues, deprecations, stability rules |
|
||||||
|
| [Platform CONTRACTS.md](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/.claude/refactor/CONTRACTS.md) | The authoritative base-class spec |
|
||||||
|
| [rotatingMachine wiki](https://gitea.wbd-rd.nl/RnD/rotatingMachine/wiki/Home) | A consumer node that uses every primitive |
|
||||||
217
wiki/Reference-Limitations.md
Normal file
217
wiki/Reference-Limitations.md
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
# Reference — Limitations
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> What `generalFunctions` does not do, current rough edges, stability/versioning rules, and open questions. For an intuitive overview, return to [Home](Home).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Known limitations
|
||||||
|
|
||||||
|
### 1. `loadCurve` is deprecated
|
||||||
|
|
||||||
|
`loadCurve(modelId)` is kept as a thin shim over `assetResolver.resolve('curves', modelId)` so legacy consumers don't have to change in one go. New code should use `assetResolver` directly. Replacement `loadModel` exists but not every node has migrated.
|
||||||
|
|
||||||
|
- **Tracked in**: `OPEN_QUESTIONS.md` — Phase 8.5 cleanup.
|
||||||
|
|
||||||
|
### 2. `outlierDetection` (`DynamicClusterDeviation`) prints to `console.log`
|
||||||
|
|
||||||
|
The dynamic-cluster outlier detector emits diagnostic lines via `console.log` directly, bypassing the structured `logger`. This means its output cannot be silenced per-node and doesn't honour `logLevel`. Fix is routing through `logger` like the rest of the library.
|
||||||
|
|
||||||
|
- **Tracked in**: Code review backlog.
|
||||||
|
|
||||||
|
### 3. `configUtils.initConfig` silently strips unknown keys
|
||||||
|
|
||||||
|
When the user config carries a key that isn't in the schema, `configUtils.initConfig` (via `validationUtils.validateSchema`) silently drops it. This means a typo in an editor field name or a missed schema entry results in the default value being used — with no error, no warning, no log line.
|
||||||
|
|
||||||
|
Workaround: the schema must include every key the domain reads, with a sensible default. The 2026-05-11 monster schema fix was a direct consequence of this gotcha.
|
||||||
|
|
||||||
|
- **Tracked in**: `OPEN_QUESTIONS.md` — e.g. monster schema fix.
|
||||||
|
|
||||||
|
### 4. `state` (FSM) and `predict` are not yet integrated with `BaseDomain` lifecycle
|
||||||
|
|
||||||
|
The state machine and the prediction class are exported but not lifecycle-managed by `BaseDomain`. Consumer nodes wire them manually in `configure()` — constructor, event subscriptions, teardown. A second wave of refactor work will move them under the `BaseDomain` umbrella so subclasses get them for free.
|
||||||
|
|
||||||
|
- **Tracked in**: Architecture backlog.
|
||||||
|
|
||||||
|
### 5. `menuUtils` / `MenuManager` bypass the Node.js import path
|
||||||
|
|
||||||
|
These are served as browser JavaScript via the admin `endpointUtils` and run in the Node-RED editor's iframe. Deep changes require testing in both environments (Node-side schema validation, browser-side editor form rendering). There is no automated test harness for the browser side.
|
||||||
|
|
||||||
|
- **Tracked in**: `endpointUtils.js` comments.
|
||||||
|
|
||||||
|
### 6. `CascadePIDController` has no dedicated test suite
|
||||||
|
|
||||||
|
`PIDController` is unit-tested; the cascade variant is not. Adding tests is on the backlog.
|
||||||
|
|
||||||
|
- **Tracked in**: Test backlog.
|
||||||
|
|
||||||
|
### 7. Wiki autogen is hand-maintained
|
||||||
|
|
||||||
|
The API surface section is hand-maintained between the `<!-- BEGIN/END AUTOGEN: api-surface -->` markers in `CONTRACT.md`. There is no `npm run wiki:all` script (yet); when an export is added or changed, the table must be edited by hand. Mitigation: the source-of-truth is the barrel (`index.js`); when in doubt, trust the barrel.
|
||||||
|
|
||||||
|
- **Tracked in**: Phase 9 follow-up.
|
||||||
|
|
||||||
|
### 8. Single-side pressure handling lives in consumers
|
||||||
|
|
||||||
|
Consumer-node concerns like single-side pressure degradation, residue handling, and sequence-abort semantics are NOT centralised in this library — each consumer (`rotatingMachine`, `valveGroupControl`, …) implements its own variant. Cross-node consistency is by convention, not by enforcement. A future `BaseDomain` extension could pull common pressure-routing patterns up.
|
||||||
|
|
||||||
|
- **Tracked in**: Internal architecture notes.
|
||||||
|
|
||||||
|
### 9. Asset registry backends are not fully symmetric
|
||||||
|
|
||||||
|
`FileBackend` is the production default (sync, in-process JSON). `HttpBackend` is provided for remote-resolver scenarios but has fewer call sites and less test coverage. If you switch to `HttpBackend` in production, expect to find edge-case differences.
|
||||||
|
|
||||||
|
- **Tracked in**: Internal — not yet ticketed.
|
||||||
|
|
||||||
|
### 10. No editor form
|
||||||
|
|
||||||
|
`generalFunctions` is never placed in a flow. It has no Node-RED type registration, no `.html`, no admin endpoint of its own. 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`. This is a deliberate design choice, not a limitation — documented here for visitors searching for "where's the editor form".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stability + versioning
|
||||||
|
|
||||||
|
Source of truth: [`.claude/rules/general-functions.md`](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/.claude/rules/general-functions.md) in the superproject.
|
||||||
|
|
||||||
|
| 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](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/.claude/refactor/CONTRACTS.md) §1–§9 shapes. |
|
||||||
|
|
||||||
|
### Cross-node impact
|
||||||
|
|
||||||
|
`generalFunctions` is a git submodule shared by all 12 node repos. **Any change here can break any node.** Before modifying any module:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Identify all consumers of the symbol you're touching.
|
||||||
|
grep -r "require('generalFunctions')" nodes/*/
|
||||||
|
|
||||||
|
# Or for a specific export:
|
||||||
|
grep -rn "BaseDomain\|UnitPolicy\|MeasurementContainer" nodes/*/src/
|
||||||
|
```
|
||||||
|
|
||||||
|
After changes, run the test suites of every affected consumer node, not just `generalFunctions/test/`.
|
||||||
|
|
||||||
|
### Canonical units
|
||||||
|
|
||||||
|
`MeasurementContainer` and all internal processing assume canonical units:
|
||||||
|
|
||||||
|
| Quantity | Canonical |
|
||||||
|
|:---|:---|
|
||||||
|
| Pressure | `Pa` |
|
||||||
|
| Flow | `m3/s` |
|
||||||
|
| Power | `W` |
|
||||||
|
| Temperature | `K` |
|
||||||
|
|
||||||
|
Unit conversion happens at system boundaries (input via `CommandRegistry.units` normalisation, output via `UnitPolicy.output` rendering) — never in core logic. Code that assumes anything else is a bug.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deprecations
|
||||||
|
|
||||||
|
| Symbol | Status | Replacement | Plan |
|
||||||
|
|:---|:---|:---|:---|
|
||||||
|
| `loadCurve(modelId)` | deprecated | `assetResolver.resolve('curves', modelId)` | Remove after every consumer migrates. Tracked in Phase 8.5. |
|
||||||
|
|
||||||
|
When a symbol is marked deprecated:
|
||||||
|
|
||||||
|
1. The row in `CONTRACT.md` flips to `deprecated` and gains a "removed-in" line.
|
||||||
|
2. Consumers in `nodes/*` are updated to the replacement.
|
||||||
|
3. Each touched node's submodule pin is bumped in the superproject.
|
||||||
|
4. After one release on `development` with no consumers, the export and its row are removed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open questions (tracked)
|
||||||
|
|
||||||
|
| Question | Where it lives |
|
||||||
|
|:---|:---|
|
||||||
|
| Phase 8.5: complete `loadCurve` → `assetResolver` migration | Internal |
|
||||||
|
| Route `DynamicClusterDeviation` log lines through `logger` | Code review backlog |
|
||||||
|
| Surface a warning when `configUtils.initConfig` strips a key not in schema | `OPEN_QUESTIONS.md` |
|
||||||
|
| Move `state` (FSM) and `predict` under `BaseDomain` lifecycle | Architecture backlog |
|
||||||
|
| Browser-side test harness for `menuUtils` | `endpointUtils.js` |
|
||||||
|
| Test suite for `CascadePIDController` | Test backlog |
|
||||||
|
| Wiki autogen script (`npm run wiki:all`) for the API surface section | Phase 9 follow-up |
|
||||||
|
| `HttpBackend` test coverage parity with `FileBackend` | Internal |
|
||||||
|
| Centralised single-side-pressure handling pattern in `BaseDomain` | Internal architecture notes |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration notes
|
||||||
|
|
||||||
|
### Pre-refactor: per-node `registerChild` switch
|
||||||
|
|
||||||
|
The `ChildRouter` replaces hand-written `registerChild(child)` methods. The mechanical migration:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Before:
|
||||||
|
registerChild(child) {
|
||||||
|
switch (child.softwareType) {
|
||||||
|
case 'measurement':
|
||||||
|
if (child.config.asset.type === 'pressure' && child.positionVsParent === 'upstream') {
|
||||||
|
this._onUpstream(child);
|
||||||
|
} else if (child.config.asset.type === 'flow') {
|
||||||
|
this._onFlow(child);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'machinegroup':
|
||||||
|
this._onMgcChild(child);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// After (in configure()):
|
||||||
|
this.router
|
||||||
|
.onMeasurement('measurement', { type: 'pressure', position: 'upstream' }, (data, child) => this._onUpstream(child))
|
||||||
|
.onMeasurement('measurement', { type: 'flow' }, (data, child) => this._onFlow(child))
|
||||||
|
.onRegister('machinegroup', (child) => this._onMgcChild(child));
|
||||||
|
```
|
||||||
|
|
||||||
|
Behaviour is identical (the underlying `childRegistrationUtils` calls are unchanged); the wiring is just declarative.
|
||||||
|
|
||||||
|
### Pre-refactor: per-node `getStatusBadge` duplication
|
||||||
|
|
||||||
|
The `statusBadge` pure-function helpers replaced 12 copies of slightly different status-text formatters. New domains should use `statusBadge.compose(parts, opts)`, `statusBadge.error(msg)`, `statusBadge.idle(label)` instead of building `{fill, shape, text}` by hand. Text is clipped to 60 chars to fit the Node-RED editor.
|
||||||
|
|
||||||
|
### Pre-AssetResolver: `loadCurve` shim
|
||||||
|
|
||||||
|
Old code:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const { loadCurve } = require('generalFunctions');
|
||||||
|
const curve = loadCurve('SomeModel');
|
||||||
|
```
|
||||||
|
|
||||||
|
New code (preferred):
|
||||||
|
|
||||||
|
```js
|
||||||
|
const { assetResolver } = require('generalFunctions');
|
||||||
|
const curve = assetResolver.resolve('curves', 'SomeModel');
|
||||||
|
```
|
||||||
|
|
||||||
|
The shim still works, but the next API-surface review may remove it. Migrate when next touching the file.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related pages
|
||||||
|
|
||||||
|
| Page | Why |
|
||||||
|
|:---|:---|
|
||||||
|
| [Home](Home) | Intuitive overview |
|
||||||
|
| [Reference — Contracts](Reference-Contracts) | Full public API surface, per-export stability tags |
|
||||||
|
| [Reference — Architecture](Reference-Architecture) | Three-tier rule, `src/` layout, consumer responsibilities |
|
||||||
|
| [Reference — Examples](Reference-Examples) | Usage patterns: extending base classes, registering commands, declaring child routes |
|
||||||
|
| [Platform CONTRACTS.md](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/.claude/refactor/CONTRACTS.md) | The authoritative platform base-class + protocol spec |
|
||||||
|
| [`.claude/rules/general-functions.md`](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/.claude/rules/general-functions.md) | Stability + change-impact rules |
|
||||||
22
wiki/_Sidebar.md
Normal file
22
wiki/_Sidebar.md
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
### generalFunctions (Library)
|
||||||
|
|
||||||
|
- [Home](Home)
|
||||||
|
|
||||||
|
**Reference**
|
||||||
|
|
||||||
|
- [Contracts](Reference-Contracts)
|
||||||
|
- [Architecture](Reference-Architecture)
|
||||||
|
- [Examples](Reference-Examples)
|
||||||
|
- [Limitations](Reference-Limitations)
|
||||||
|
|
||||||
|
**Related**
|
||||||
|
|
||||||
|
- [EVOLV master wiki](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Home)
|
||||||
|
- [Platform CONTRACTS.md](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/.claude/refactor/CONTRACTS.md)
|
||||||
|
- [Library CONTRACT.md](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/nodes/generalFunctions/CONTRACT.md)
|
||||||
|
- [rotatingMachine wiki](https://gitea.wbd-rd.nl/RnD/rotatingMachine/wiki/Home)
|
||||||
|
- [machineGroupControl wiki](https://gitea.wbd-rd.nl/RnD/machineGroupControl/wiki/Home)
|
||||||
|
- [pumpingStation wiki](https://gitea.wbd-rd.nl/RnD/pumpingStation/wiki/Home)
|
||||||
|
- [Topology Patterns](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topology-Patterns)
|
||||||
|
- [Topic Conventions](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topic-Conventions)
|
||||||
|
- [Telemetry](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Telemetry)
|
||||||
Reference in New Issue
Block a user