Compare commits

..

43 Commits

Author SHA1 Message Date
Rene De Ren
12fce6c549 Add diffuser config schema 2026-03-12 16:39:25 +01:00
Rene De Ren
814ee3d763 Support config-driven output formatting 2026-03-12 16:13:39 +01:00
Rene De Ren
31928fd124 fix: add missing migrateConfig method, config versioning, and formatters module
ConfigManager.migrateConfig() was called but never defined — would crash at runtime.
Added config version checking, migration support, and fixed createEndpoint indentation.
New formatters module (csv, influxdb, json) for pluggable output formatting.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 09:33:22 +01:00
Rene De Ren
7e40ea0797 test: add child registration integration tests
32 tests covering registerChild, getChildren, deregistration, edge cases.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 16:31:58 +01:00
Rene De Ren
dec5f63b21 refactor: adopt POSITIONS constants, fix ESLint warnings, break menuUtils into modules
- Replace hardcoded position strings with POSITIONS.* constants
- Prefix unused variables with _ to resolve no-unused-vars warnings
- Fix no-prototype-builtins with Object.prototype.hasOwnProperty.call()
- Extract menuUtils.js (543 lines) into 6 focused modules under menu/
- menuUtils.js now 35 lines, delegates via prototype mixin pattern
- Add 158 unit tests for ConfigManager, MeasurementContainer, ValidationUtils

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 15:36:52 +01:00
Rene De Ren
fe2631f29b refactor: extract validators from validationUtils.js into strategy pattern modules
Break the 548-line monolith into focused modules:
- validators/typeValidators.js (number, integer, boolean, string, enum)
- validators/collectionValidators.js (array, set, object)
- validators/curveValidator.js (curve, machineCurve, dimensionStructure)

validationUtils.js now uses a VALIDATORS registry map and delegates to
extracted modules. Reduced from 548 to 217 lines.

Closes #2

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 15:15:01 +01:00
Rene De Ren
bf39b9df42 Fix sorting bug in validationUtils and add cache cap to AssetLoader
- validationUtils: unsorted x values now actually sorted (was returning false)
- validationUtils: duplicate x values now actually removed (was returning false)
- validationUtils: areNumbers check no longer skipped after sort/dedup
- AssetLoader: add maxCacheSize (default 100) with LRU-style eviction

Closes #21, closes #24

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 15:07:16 +01:00
Rene De Ren
f95ef43f05 Standardize softwareType to lowercase everywhere
- ConfigManager.buildConfig() now lowercases softwareType
- Updated config JSON defaults to lowercase
- childRegistrationUtils lowercases softwareType on extraction
- Closes #8

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 15:06:41 +01:00
Rene De Ren
89aec9a7eb Reset chaining context in MeasurementContainer.clear()
Closes #25

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 15:06:17 +01:00
Rene De Ren
135dfc31d3 Add base config schema and ConfigManager.buildConfig()
- New baseConfig.json: shared schema for general/logging/functionality/asset sections
- ConfigManager.buildConfig(): builds runtime config from UI inputs + domain overrides
- Eliminates the need for each nodeClass to manually construct base config sections
- All nodes can now use: cfgMgr.buildConfig(name, uiConfig, nodeId, domainConfig)

Closes #1

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 14:59:26 +01:00
Rene De Ren
96fdf2a27a Fix orphaned debug log and array bounds check
- Remove console.log('hello:') from Measurement.js (#22)
- Add bounds check for peakIndex in predict_class.js (#23)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 14:56:42 +01:00
Rene De Ren
c698e5a1bc Remove deprecated menuUtils and childRegistrationUtils files
Closes #6

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 14:56:28 +01:00
Rene De Ren
089f4c5129 Add position constants, reactor/settler config schemas
- New src/constants/positions.js: POSITIONS enum (upstream/downstream/atEquipment/delta)
- New src/configs/reactor.json: Full schema for CSTR/PFR reactor parameters and ASM3 initial state
- New src/configs/settler.json: Schema for settler node
- Export POSITIONS, POSITION_VALUES, isValidPosition from index.js

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 13:43:24 +01:00
Rene De Ren
82094d8d09 Fix ESLint errors, bugs, and add gravity export
- Fix missing return in childRegistrationUtils.registerChild()
- Fix assertionUtils: assertNoNaN uses this.assertNoNaN
- Fix logger: nameModule uses this.nameModule
- Fix assetUtils: convert ESM to CommonJS
- Fix childRegistrationUtils_DEPRECATED: desc -> softwareType
- Add gravity export to index.js for rotatingMachine
- Fix ESLint errors across 18 files (no-undef, no-case-declarations,
  no-mixed-spaces-and-tabs, parsing errors)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 13:39:40 +01:00
pimmoerman
858189d6da Update get_all_assets.php vanaf tagcode.app 2025-11-21 03:00:01 +00:00
pimmoerman
ec42ebcb25 Update get_all_assets.php vanaf tagcode.app 2025-11-20 12:15:55 +00:00
pimmoerman
f4629e5fcc Update get_all_assets.php vanaf extern endpoint 2025-11-17 15:57:29 +00:00
pimmoerman
dafe4c5336 Delete datasets/tagcodeapp_product_models.json 2025-11-17 15:57:15 +00:00
pimmoerman
5439d5111a Delete datasets/tagcodeapp_assets.json 2025-11-17 15:57:08 +00:00
pimmoerman
1e5ef47a4d Delete datasets/get_all_assets.php 2025-11-17 15:57:02 +00:00
pimmoerman
2b87c67876 Update get_all_assets.php vanaf extern endpoint 2025-11-17 15:00:01 +00:00
pimmoerman
0db90c0e4b Delete data/get_all_assets.php 2025-11-17 14:58:33 +00:00
pimmoerman
1e07093101 Update get_all_assets.php vanaf extern endpoint 2025-11-17 14:58:01 +00:00
p.vanderwilt
ce25ee930a Add ammonium and NOx quantity sensors to assetData 2025-11-12 10:47:41 +01:00
p.vanderwilt
a293e0286a Merge pull request 'Add addtional and updated configs' (#12) from dev-Pieter into main
Reviewed-on: https://gitea.centraal.wbd-rd.nl/RnD/generalFunctions/pulls/12
2025-11-06 14:01:32 +00:00
012b8a7ff6 Merge pull request 'Merging to latest updates' (#10) from dev-Rene into main
Reviewed-on: https://gitea.centraal.wbd-rd.nl/RnD/generalFunctions/pulls/10
2025-11-03 14:24:58 +00:00
znetsixe
9610e7138d Added extra pump data
lagged sample in measurement
2025-11-03 15:22:51 +01:00
p.vanderwilt
d5d078413c Add flowNumber configuration to define effluent flow handling 2025-10-31 14:03:54 +01:00
p.vanderwilt
17662ef7cb Add total suspended solids sensor to assetData 2025-10-31 13:53:35 +01:00
p.vanderwilt
9d8da15d0e Merge pull request 'Register multiple parents during child registration' (#9) from dev-Pieter into main
Reviewed-on: https://gitea.centraal.wbd-rd.nl/RnD/generalFunctions/pulls/9
2025-10-31 10:36:28 +00:00
d503cf5dc9 Merge pull request 'Added does measurement exist in measurement' (#8) from dev-Rene into main
Reviewed-on: https://gitea.centraal.wbd-rd.nl/RnD/generalFunctions/pulls/8
2025-10-24 19:20:31 +00:00
Rene De ren
48a227d519 Merge branch 'main' into dev-Rene 2025-10-24 15:22:08 +02:00
p.vanderwilt
f653a1e98c Refactor child setup to support multiple parents consistently 2025-10-24 13:37:26 +02:00
znetsixe
1725c5b0e9 bug fixes for measurement container lagged retrieval-> unit conversion and sample output 2025-10-23 09:51:27 +02:00
znetsixe
d7cb8e1072 latest version 2025-10-21 12:45:06 +02:00
9b7a8ae2c8 Merge pull request 'dev-Rene added features' (#5) from dev-Rene into main
Reviewed-on: https://gitea.centraal.wbd-rd.nl/RnD/generalFunctions/pulls/5
2025-10-16 13:20:04 +00:00
znetsixe
dc50432ee8 accepted conflict 2025-10-16 15:19:17 +02:00
znetsixe
c99d24e4c6 added lagged value functionality for retrieving values further down in memory; Converted position always to lower case strings to avoid problems with caps sensitivity names; added examples for use in examples.js 2025-10-16 14:37:42 +02:00
znetsixe
f9d1348fd0 added pumpingStation config, expanded functionality for difference in measurement container 2025-10-15 14:09:37 +02:00
znetsixe
428c611ec6 added pumping station and commented out console stuf 2025-10-14 13:51:57 +02:00
znetsixe
cffbd51d92 added coolprop 2025-10-07 18:10:04 +02:00
p.vanderwilt
3886277616 Fix bug in parent registration code block 2025-09-29 17:13:34 +02:00
p.vanderwilt
83018fabe0 Allow for multiple parents 2025-09-29 16:06:06 +02:00
82 changed files with 7354 additions and 2143 deletions

View File

@@ -66,6 +66,33 @@
"units": ["g/m³", "mol/m³"] "units": ["g/m³", "mol/m³"]
} }
] ]
},
{
"name": "Quantity (Ammonium)",
"models": [
{
"name": "VegaAmmoniaSense 10",
"units": ["g/m³", "mol/m³"]
}
]
},
{
"name": "Quantity (NOx)",
"models": [
{
"name": "VegaNOxSense 10",
"units": ["g/m³", "mol/m³"]
}
]
},
{
"name": "Quantity (TSS)",
"models": [
{
"name": "VegaSolidsProbe",
"units": ["g/m³"]
}
]
} }
] ]
} }
@@ -83,7 +110,12 @@
{ {
"id": "hidrostal-pump-001", "id": "hidrostal-pump-001",
"name": "hidrostal-H05K-S03R", "name": "hidrostal-H05K-S03R",
"units": ["m³/h", "gpm", "l/min"] "units": ["l/s"]
},
{
"id": "hidrostal-pump-002",
"name": "hidrostal-C5-D03R-SHN1",
"units": ["l/s"]
} }
] ]
} }

View File

@@ -0,0 +1,838 @@
{
"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
]
}
}
}

View File

@@ -2,10 +2,11 @@ const fs = require('fs');
const path = require('path'); const path = require('path');
class AssetLoader { class AssetLoader {
constructor() { constructor(maxCacheSize = 100) {
this.relPath = './' this.relPath = './'
this.baseDir = path.resolve(__dirname, this.relPath); this.baseDir = path.resolve(__dirname, this.relPath);
this.cache = new Map(); // Cache loaded JSON files for better performance this.cache = new Map();
this.maxCacheSize = maxCacheSize;
} }
/** /**
@@ -46,7 +47,11 @@ class AssetLoader {
const rawData = fs.readFileSync(filePath, 'utf8'); const rawData = fs.readFileSync(filePath, 'utf8');
const assetData = JSON.parse(rawData); const assetData = JSON.parse(rawData);
// Cache the result // 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); this.cache.set(cacheKey, assetData);
return assetData; return assetData;

View File

@@ -0,0 +1 @@
Database connection failed: SQLSTATE[28000] [1045] Access denied for user 'pimmoe1q_rdlab'@'localhost' (using password: YES)

File diff suppressed because one or more lines are too long

View File

@@ -1,229 +0,0 @@
{
"success": true,
"message": "Product modellen succesvol opgehaald.",
"data": [
{
"id": "1",
"name": "Macbook Air 12",
"product_model_subtype_id": "1",
"product_model_description": null,
"vendor_id": "1",
"product_model_status": null,
"vendor_name": "Apple",
"product_subtype_name": "Laptop",
"product_model_meta": []
},
{
"id": "2",
"name": "Macbook Air 13",
"product_model_subtype_id": "1",
"product_model_description": null,
"vendor_id": "1",
"product_model_status": null,
"vendor_name": "Apple",
"product_subtype_name": "Laptop",
"product_model_meta": []
},
{
"id": "3",
"name": "AirMac 1 128 GB White",
"product_model_subtype_id": "2",
"product_model_description": null,
"vendor_id": "1",
"product_model_status": null,
"vendor_name": "Apple",
"product_subtype_name": "Desktop",
"product_model_meta": []
},
{
"id": "4",
"name": "AirMac 2 256 GB Black",
"product_model_subtype_id": "2",
"product_model_description": null,
"vendor_id": "1",
"product_model_status": null,
"vendor_name": "Apple",
"product_subtype_name": "Desktop",
"product_model_meta": []
},
{
"id": "5",
"name": "AirMac 2 256 GB White",
"product_model_subtype_id": "2",
"product_model_description": null,
"vendor_id": "1",
"product_model_status": null,
"vendor_name": "Apple",
"product_subtype_name": "Desktop",
"product_model_meta": []
},
{
"id": "6",
"name": "Vegabar 14",
"product_model_subtype_id": "3",
"product_model_description": "vegabar 14",
"vendor_id": "4",
"product_model_status": "Actief",
"vendor_name": "vega",
"product_subtype_name": "pressure",
"product_model_meta": {
"machineCurve": {
"np": {
"700": {
"x": [
0,
24.59,
49.18,
73.77,
100
],
"y": [
12.962460720759278,
20.65443723573673,
31.029351002816465,
44.58926412111886,
62.87460150792057
]
},
"800": {
"x": [
0,
24.59,
49.18,
73.77,
100
],
"y": [
13.035157335397209,
20.74906989186132,
31.029351002816465,
44.58926412111886,
62.87460150792057
]
},
"900": {
"x": [
0,
24.59,
49.18,
73.77,
100
],
"y": [
13.064663380158798,
20.927197054134297,
31.107126521989933,
44.58926412111886,
62.87460150792057
]
},
"1000": {
"x": [
0,
24.59,
49.18,
73.77,
100
],
"y": [
13.039271391128953,
21.08680188366637,
31.30899920405947,
44.58926412111886,
62.87460150792057
]
},
"1100": {
"x": [
0,
24.59,
49.18,
73.77,
100
],
"y": [
12.940075520572446,
21.220547481589954,
31.51468295656385,
44.621326083982,
62.87460150792057
]
}
},
"nq": {
"700": {
"x": [
0,
24.59,
49.18,
73.77,
100
],
"y": [
119.13938764447377,
150.12178608265387,
178.82698019104356,
202.3699313222398,
227.06382297856618
]
},
"800": {
"x": [
0,
24.59,
49.18,
73.77,
100
],
"y": [
112.59072109293984,
148.15847460389205,
178.82698019104356,
202.3699313222398,
227.06382297856618
]
},
"900": {
"x": [
0,
24.59,
49.18,
73.77,
100
],
"y": [
105.6217241180404,
144.00502117747064,
177.15212647335034,
202.3699313222398,
227.06382297856618
]
}
}
}
}
},
{
"id": "7",
"name": "Vegabar 10",
"product_model_subtype_id": "3",
"product_model_description": null,
"vendor_id": "4",
"product_model_status": "Actief",
"vendor_name": "vega",
"product_subtype_name": "pressure",
"product_model_meta": []
},
{
"id": "8",
"name": "VegaFlow 10",
"product_model_subtype_id": "4",
"product_model_description": null,
"vendor_id": "4",
"product_model_status": "Actief",
"vendor_name": "vega",
"product_subtype_name": "flow",
"product_model_meta": []
}
]
}

View File

@@ -13,6 +13,7 @@ const logger = require('./src/helper/logger.js');
const validation = require('./src/helper/validationUtils.js'); const validation = require('./src/helper/validationUtils.js');
const configUtils = require('./src/helper/configUtils.js'); const configUtils = require('./src/helper/configUtils.js');
const assertions = require('./src/helper/assertionUtils.js') const assertions = require('./src/helper/assertionUtils.js')
const coolprop = require('./src/coolprop-node/src/index.js');
// Domain-specific modules // Domain-specific modules
const { MeasurementContainer } = require('./src/measurements/index.js'); const { MeasurementContainer } = require('./src/measurements/index.js');
@@ -25,6 +26,14 @@ const predict = require('./src/predict/predict_class.js');
const interpolation = require('./src/predict/interpolation.js'); const interpolation = require('./src/predict/interpolation.js');
const childRegistrationUtils = require('./src/helper/childRegistrationUtils.js'); const childRegistrationUtils = require('./src/helper/childRegistrationUtils.js');
const { loadCurve } = require('./datasets/assetData/curves/index.js'); const { loadCurve } = require('./datasets/assetData/curves/index.js');
const { POSITIONS, POSITION_VALUES, isValidPosition } = require('./src/constants/positions.js');
const Fysics = require('./src/convert/fysics.js');
// Gravity helper (used by rotatingMachine for efficiency calculations)
const gravity = {
getStandardGravity: () => 9.80665,
fysics: new Fysics()
};
// Export everything // Export everything
module.exports = { module.exports = {
@@ -39,8 +48,13 @@ module.exports = {
MeasurementContainer, MeasurementContainer,
nrmse, nrmse,
state, state,
coolprop,
convert, convert,
MenuManager, MenuManager,
childRegistrationUtils, childRegistrationUtils,
loadCurve loadCurve,
gravity,
POSITIONS,
POSITION_VALUES,
isValidPosition
}; };

View File

@@ -0,0 +1,85 @@
{
"general": {
"name": {
"default": "Unnamed Node",
"rules": { "type": "string", "description": "Human-readable name for this node." }
},
"id": {
"default": null,
"rules": { "type": "string", "nullable": true, "description": "Unique node identifier (set at runtime)." }
},
"unit": {
"default": "unitless",
"rules": { "type": "string", "description": "Default measurement unit." }
},
"logging": {
"logLevel": {
"default": "info",
"rules": {
"type": "enum",
"values": [
{ "value": "debug", "description": "Verbose diagnostic messages." },
{ "value": "info", "description": "General informational messages." },
{ "value": "warn", "description": "Warning messages." },
{ "value": "error", "description": "Error level messages only." }
]
}
},
"enabled": {
"default": true,
"rules": { "type": "boolean", "description": "Enable or disable logging." }
}
}
},
"functionality": {
"softwareType": {
"default": "unknown",
"rules": { "type": "string", "description": "Software type identifier for parent-child registration." }
},
"role": {
"default": "Generic EVOLV node",
"rules": { "type": "string", "description": "Describes the functional role of this node." }
},
"positionVsParent": {
"default": "atEquipment",
"rules": {
"type": "enum",
"values": [
{ "value": "upstream", "description": "Upstream of parent equipment." },
{ "value": "atEquipment", "description": "At equipment level." },
{ "value": "downstream", "description": "Downstream of parent equipment." }
]
}
}
},
"asset": {
"uuid": {
"default": null,
"rules": { "type": "string", "nullable": true, "description": "Asset UUID from asset management system." }
},
"tagCode": {
"default": null,
"rules": { "type": "string", "nullable": true, "description": "Asset tag code." }
},
"supplier": {
"default": "Unknown",
"rules": { "type": "string", "description": "Equipment supplier." }
},
"category": {
"default": "sensor",
"rules": { "type": "string", "description": "Asset category." }
},
"type": {
"default": "Unknown",
"rules": { "type": "string", "description": "Asset type." }
},
"model": {
"default": "Unknown",
"rules": { "type": "string", "description": "Equipment model." }
},
"unit": {
"default": "unitless",
"rules": { "type": "string", "description": "Asset measurement unit." }
}
}
}

111
src/configs/diffuser.json Normal file
View File

@@ -0,0 +1,111 @@
{
"general": {
"name": {
"default": "Diffuser",
"rules": {
"type": "string",
"description": "A human-readable name for this diffuser zone."
}
},
"id": {
"default": null,
"rules": {
"type": "string",
"nullable": true,
"description": "Unique identifier for this diffuser node."
}
},
"unit": {
"default": "Nm3/h",
"rules": {
"type": "string",
"description": "Default airflow unit for this diffuser."
}
},
"logging": {
"logLevel": {
"default": "info",
"rules": {
"type": "enum",
"values": [
{ "value": "debug", "description": "Verbose diagnostic messages." },
{ "value": "info", "description": "General informational messages." },
{ "value": "warn", "description": "Warning messages." },
{ "value": "error", "description": "Error level messages only." }
]
}
},
"enabled": {
"default": true,
"rules": {
"type": "boolean",
"description": "Enable or disable logging."
}
}
}
},
"functionality": {
"softwareType": {
"default": "diffuser",
"rules": {
"type": "string",
"description": "Software type identifier for parent-child registration."
}
},
"role": {
"default": "Aeration diffuser",
"rules": {
"type": "string",
"description": "Describes the functional role of this node."
}
},
"positionVsParent": {
"default": "atEquipment",
"rules": {
"type": "enum",
"values": [
{ "value": "upstream", "description": "Upstream of parent equipment." },
{ "value": "atEquipment", "description": "At equipment level." },
{ "value": "downstream", "description": "Downstream of parent equipment." }
]
}
}
},
"diffuser": {
"number": {
"default": 1,
"rules": {
"type": "number",
"description": "Sequential diffuser zone number."
}
},
"elements": {
"default": 1,
"rules": {
"type": "number",
"description": "Number of diffuser elements in the zone."
}
},
"density": {
"default": 2.4,
"rules": {
"type": "number",
"description": "Installed diffuser density per square meter."
}
},
"waterHeight": {
"default": 0,
"rules": {
"type": "number",
"description": "Water column height above the diffuser."
}
},
"alfaFactor": {
"default": 0.7,
"rules": {
"type": "number",
"description": "Alpha factor used for oxygen transfer correction."
}
}
}
}

View File

@@ -1,22 +1,52 @@
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
/**
* Current config version. All config JSONs should declare this version.
* Bump this when the config schema changes.
*/
const CURRENT_CONFIG_VERSION = '1.0.0';
class ConfigManager { class ConfigManager {
constructor(relPath = '.') { constructor(relPath = '.') {
this.configDir = path.resolve(__dirname, relPath); this.configDir = path.resolve(__dirname, relPath);
/**
* Migration functions keyed by "fromVersion->toVersion".
* Each function receives a config object and returns the migrated config.
*
* Example:
* this.migrations['1.0.0->1.1.0'] = (config) => {
* config.newSection = { enabled: false };
* return config;
* };
*/
this.migrations = {};
} }
/** /**
* Load a configuration file by name * Load a configuration file by name.
* Automatically checks the config version and migrates if needed.
* @param {string} configName - Name of the config file (without .json extension) * @param {string} configName - Name of the config file (without .json extension)
* @returns {Object} Parsed configuration object * @returns {Object} Parsed configuration object (migrated to current version if necessary)
*/ */
getConfig(configName) { getConfig(configName) {
try { try {
const configPath = path.resolve(this.configDir, `${configName}.json`); const configPath = path.resolve(this.configDir, `${configName}.json`);
const configData = fs.readFileSync(configPath, 'utf8'); const configData = fs.readFileSync(configPath, 'utf8');
return JSON.parse(configData); let config = JSON.parse(configData);
// Auto-migrate if version is behind current
const configVersion = config.version || '0.0.0';
if (configVersion !== CURRENT_CONFIG_VERSION) {
config = this.migrateConfig(config, configVersion, CURRENT_CONFIG_VERSION);
}
return config;
} catch (error) { } catch (error) {
if (error.message && error.message.startsWith('Failed to load config')) {
throw error;
}
throw new Error(`Failed to load config '${configName}': ${error.message}`); throw new Error(`Failed to load config '${configName}': ${error.message}`);
} }
} }
@@ -47,24 +77,104 @@ class ConfigManager {
return fs.existsSync(configPath); return fs.existsSync(configPath);
} }
/**
* Build a runtime config by merging base schema + node schema + UI overrides.
* Eliminates the need for each nodeClass to manually construct general/asset/functionality sections.
*
* @param {string} nodeName - Node type name (e.g., 'valve', 'measurement')
* @param {object} uiConfig - Raw config from Node-RED UI
* @param {string} nodeId - Node-RED node ID (from node.id)
* @param {object} [domainConfig={}] - Domain-specific config sections (e.g., { scaling: {...}, smoothing: {...} })
* @returns {object} Merged runtime config
*
* @example
* const cfgMgr = new ConfigManager();
* const config = cfgMgr.buildConfig('measurement', uiConfig, node.id, {
* scaling: { enabled: uiConfig.scaling, inputMin: uiConfig.i_min, ... },
* smoothing: { smoothWindow: uiConfig.count, ... }
* });
*/
buildConfig(nodeName, uiConfig, nodeId, domainConfig = {}) {
// Build base sections from UI config (common to ALL nodes)
const config = {
general: {
name: uiConfig.name || nodeName,
id: nodeId,
unit: uiConfig.unit || 'unitless',
logging: {
enabled: uiConfig.enableLog !== undefined ? uiConfig.enableLog : true,
logLevel: uiConfig.logLevel || 'info'
}
},
functionality: {
softwareType: nodeName.toLowerCase(),
positionVsParent: uiConfig.positionVsParent || 'atEquipment',
distance: uiConfig.hasDistance ? uiConfig.distance : undefined
},
output: {
process: uiConfig.processOutputFormat || 'process',
dbase: uiConfig.dbaseOutputFormat || 'influxdb'
}
};
// Add asset section if UI provides asset fields
if (uiConfig.supplier || uiConfig.category || uiConfig.assetType || uiConfig.model) {
config.asset = {
uuid: uiConfig.uuid || uiConfig.assetUuid || null,
tagCode: uiConfig.tagCode || uiConfig.assetTagCode || null,
supplier: uiConfig.supplier || 'Unknown',
category: uiConfig.category || 'sensor',
type: uiConfig.assetType || 'Unknown',
model: uiConfig.model || 'Unknown',
unit: uiConfig.unit || 'unitless'
};
}
// Merge domain-specific sections
Object.assign(config, domainConfig);
return config;
}
/**
* Migrate a config object from one version to another by applying
* registered migration functions in sequence.
* @param {object} config - The config object to migrate
* @param {string} fromVersion - Current version of the config
* @param {string} toVersion - Target version
* @returns {object} Migrated config with updated version field
*/
migrateConfig(config, fromVersion, toVersion) {
const migrationKey = `${fromVersion}->${toVersion}`;
const migrationFn = this.migrations[migrationKey];
if (migrationFn) {
config = migrationFn(config);
}
// Stamp the current version so it won't re-migrate
config.version = toVersion;
return config;
}
/**
* Get the base config schema (shared across all nodes).
* @returns {object} Base config schema
*/
getBaseConfig() {
return this.getConfig('baseConfig');
}
createEndpoint(nodeName) { createEndpoint(nodeName) {
try { try {
// Load the config for this node
const config = this.getConfig(nodeName); const config = this.getConfig(nodeName);
// Convert config to JSON
const configJSON = JSON.stringify(config, null, 2); const configJSON = JSON.stringify(config, null, 2);
// Assemble the complete script
return ` return `
// Create the namespace structure
window.EVOLV = window.EVOLV || {}; window.EVOLV = window.EVOLV || {};
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} || {};
// Inject the pre-loaded config data directly into the namespace
window.EVOLV.nodes.${nodeName}.config = ${configJSON}; window.EVOLV.nodes.${nodeName}.config = ${configJSON};
console.log('${nodeName} config loaded and endpoint created'); console.log('${nodeName} config loaded and endpoint created');
`; `;
} catch (error) { } catch (error) {

View File

@@ -58,7 +58,7 @@
}, },
"functionality": { "functionality": {
"softwareType": { "softwareType": {
"default": "machineGroup", "default": "machinegroupcontrol",
"rules": { "rules": {
"type": "string", "type": "string",
"description": "Logical name identifying the software type." "description": "Logical name identifying the software type."

View File

@@ -0,0 +1,694 @@
{
"general": {
"name": {
"default": "Pumping Station",
"rules": {
"type": "string",
"description": "A human-readable name or label for this pumping station configuration."
}
},
"id": {
"default": null,
"rules": {
"type": "string",
"nullable": true,
"description": "A unique identifier for this pumping station configuration. If not provided, defaults to null."
}
},
"unit": {
"default": "m3/h",
"rules": {
"type": "string",
"description": "The default flow unit used for reporting station throughput."
}
},
"logging": {
"logLevel": {
"default": "info",
"rules": {
"type": "enum",
"values": [
{
"value": "debug",
"description": "Log verbose diagnostic messages that aid in troubleshooting the station."
},
{
"value": "info",
"description": "Log general informational messages about station behavior."
},
{
"value": "warn",
"description": "Log warnings when station behavior deviates from expected ranges."
},
{
"value": "error",
"description": "Log only error level messages for critical failures."
}
],
"description": "Defines the minimum severity that will be written to the log."
}
},
"enabled": {
"default": true,
"rules": {
"type": "boolean",
"description": "If true, logging is active for the pumping station node."
}
}
}
},
"functionality": {
"softwareType": {
"default": "pumpingstation",
"rules": {
"type": "string",
"description": "Specified software type used to locate the proper default configuration."
}
},
"role": {
"default": "StationController",
"rules": {
"type": "string",
"description": "Describes the station's function within the EVOLV ecosystem."
}
},
"positionVsParent": {
"default": "atEquipment",
"rules": {
"type": "enum",
"description": "Defines how the station is positioned relative to its parent process or site.",
"values": [
{
"value": "atEquipment",
"description": "The station is controlled at the equipment level and represents the primary pumping asset."
},
{
"value": "upstream",
"description": "The station governs flows entering upstream of the parent asset."
},
{
"value": "downstream",
"description": "The station influences conditions downstream of the parent asset, such as discharge or transfer."
}
]
}
},
"tickIntervalMs": {
"default": 1000,
"rules": {
"type": "number",
"min": 100,
"description": "Interval in milliseconds between internal evaluation cycles and output refreshes."
}
},
"supportsSimulation": {
"default": true,
"rules": {
"type": "boolean",
"description": "Indicates whether the station can operate using simulated inflow and level data."
}
},
"supportedChildSoftwareTypes": {
"default": [
"measurement"
],
"rules": {
"type": "set",
"itemType": "string",
"description": "List of child node software types that may register with the station."
}
}
},
"asset": {
"uuid": {
"default": null,
"rules": {
"type": "string",
"nullable": true,
"description": "Asset tag number which is a universally unique identifier for this pumping station."
}
},
"tagCode": {
"default": null,
"rules": {
"type": "string",
"nullable": true,
"description": "Asset tag code which uniquely identifies the pumping station. May be null if not assigned."
}
},
"category": {
"default": "station",
"rules": {
"type": "enum",
"values": [
{
"value": "station",
"description": "Represents a dedicated pumping station asset."
}
],
"description": "High level classification for asset reporting."
}
},
"type": {
"default": "pumpingstation",
"rules": {
"type": "string",
"description": "Specific asset type used to identify this configuration."
}
},
"model": {
"default": "Unknown",
"rules": {
"type": "string",
"description": "Manufacturer or integrator model designation for the station."
}
},
"supplier": {
"default": "Unknown",
"rules": {
"type": "string",
"description": "Primary supplier or maintainer responsible for the station."
}
},
"geoLocation": {
"default": {
"x": 0,
"y": 0,
"z": 0
},
"rules": {
"type": "object",
"description": "Coordinate reference for locating the pumping station.",
"schema": {
"x": {
"default": 0,
"rules": {
"type": "number",
"description": "X coordinate in meters or site units."
}
},
"y": {
"default": 0,
"rules": {
"type": "number",
"description": "Y coordinate in meters or site units."
}
},
"z": {
"default": 0,
"rules": {
"type": "number",
"description": "Z coordinate in meters or site units."
}
}
}
}
}
},
"basin": {
"volume": {
"default": "1",
"rules": {
"type": "number",
"description": "Total volume of empty basin in m3"
}
},
"height": {
"default": "1",
"rules": {
"type": "number",
"description": "Total height of basin in m"
}
},
"levelUnit": {
"default": "m",
"rules": {
"type": "string",
"description": "Unit used for level related setpoints and thresholds."
}
},
"heightInlet": {
"default": 2,
"rules": {
"type": "number",
"min": 0,
"description": "Height of the inlet pipe measured from the basin floor (m)."
}
},
"heightOutlet": {
"default": 0.2,
"rules": {
"type": "number",
"min": 0,
"description": "Height of the outlet pipe measured from the basin floor (m)."
}
},
"heightOverflow": {
"default": 2.5,
"rules": {
"type": "number",
"min": 0,
"description": "Height of the overflow point measured from the basin floor (m)."
}
},
"inletPipeDiameter": {
"default": 0.4,
"rules": {
"type": "number",
"min": 0,
"description": "Nominal inlet pipe diameter (m)."
}
},
"outletPipeDiameter": {
"default": 0.4,
"rules": {
"type": "number",
"min": 0,
"description": "Nominal outlet pipe diameter (m)."
}
}
},
"hydraulics": {
"maxInflowRate": {
"default": 200,
"rules": {
"type": "number",
"min": 0,
"description": "Maximum expected inflow during peak events (m3/h)."
}
},
"refHeight": {
"default": "NAP",
"rules": {
"type": "enum",
"values": [
{
"value": "NAP",
"description": "NAP (Normaal Amsterdams Peil)"
},
{
"value": "EVRF",
"description": "EVRF (European Vertical Reference Frame)"
},
{
"value": "EGM2008",
"description": "EGM2008 / EGM96 (satellietmetingen) Geopotentieel model earth "
}
],
"description": "Reference height to use to identify the height vs other basins with. This will say something more about the expected pressure loss in m head"
}
},
"staticHead": {
"default": 12,
"rules": {
"type": "number",
"min": 0,
"description": "Static head between station suction and discharge point (m)."
}
},
"maxDischargeHead": {
"default": 24,
"rules": {
"type": "number",
"min": 0,
"description": "Maximum allowable discharge head before calling for alarms (m)."
}
},
"pipelineLength": {
"default": 80,
"rules": {
"type": "number",
"min": 0,
"description": "Length of the discharge pipeline considered in calculations (m)."
}
},
"defaultFluid": {
"default": "wastewater",
"rules": {
"type": "enum",
"values": [
{
"value": "wastewater",
"description": "The wet well is primarily cylindrical."
},
{
"value": "water",
"description": "The wet well is rectangular or box shaped."
}
]
}
},
"temperatureReferenceDegC": {
"default": 15,
"rules": {
"type": "number",
"description": "Reference fluid temperature for property lookups (degC)."
}
}
},
"control": {
"controlStrategy": {
"default": "levelBased",
"rules": {
"type": "enum",
"values": [
{
"value": "levelBased",
"description": "Lead and lag pumps are controlled by basin level thresholds."
},
{
"value": "pressureBased",
"description": "Pumps target a discharge pressure setpoint."
},
{
"value": "flowTracking",
"description": "Pumps modulate to match measured inflow or downstream demand."
},
{
"value": "manual",
"description": "Pumps are operated manually or by an external controller."
}
],
"description": "Primary control philosophy for pump actuation."
}
},
"levelSetpoints": {
"default": {
"startLeadPump": 1.2,
"stopLeadPump": 0.8,
"startLagPump": 1.8,
"stopLagPump": 1.4,
"alarmHigh": 2.3,
"alarmLow": 0.3
},
"rules": {
"type": "object",
"description": "Level thresholds that govern pump staging and alarms (m).",
"schema": {
"startLeadPump": {
"default": 1.2,
"rules": {
"type": "number",
"description": "Level that starts the lead pump."
}
},
"stopLeadPump": {
"default": 0.8,
"rules": {
"type": "number",
"description": "Level that stops the lead pump."
}
},
"startLagPump": {
"default": 1.8,
"rules": {
"type": "number",
"description": "Level that starts the lag pump."
}
},
"stopLagPump": {
"default": 1.4,
"rules": {
"type": "number",
"description": "Level that stops the lag pump."
}
},
"alarmHigh": {
"default": 2.3,
"rules": {
"type": "number",
"description": "High level alarm threshold."
}
},
"alarmLow": {
"default": 0.3,
"rules": {
"type": "number",
"description": "Low level alarm threshold."
}
}
}
}
},
"pressureSetpoint": {
"default": 250,
"rules": {
"type": "number",
"min": 0,
"description": "Target discharge pressure when operating in pressure control (kPa)."
}
},
"alarmDebounceSeconds": {
"default": 10,
"rules": {
"type": "number",
"min": 0,
"description": "Time a condition must persist before raising an alarm (seconds)."
}
},
"equalizationTargetPercent": {
"default": 60,
"rules": {
"type": "number",
"min": 0,
"max": 100,
"description": "Target fill percentage of the basin when operating in equalization mode."
}
},
"autoRestartAfterPowerLoss": {
"default": true,
"rules": {
"type": "boolean",
"description": "If true, pumps resume based on last known state after power restoration."
}
},
"manualOverrideTimeoutMinutes": {
"default": 30,
"rules": {
"type": "number",
"min": 0,
"description": "Duration after which a manual override expires automatically (minutes)."
}
},
"flowBalanceTolerance": {
"default": 5,
"rules": {
"type": "number",
"min": 0,
"description": "Allowable error between inflow and outflow before adjustments are triggered (m3/h)."
}
}
},
"alarms": {
"default": {
"highLevel": {
"enabled": true,
"threshold": 2.3,
"delaySeconds": 30,
"severity": "critical",
"acknowledgmentRequired": true
},
"lowLevel": {
"enabled": true,
"threshold": 0.2,
"delaySeconds": 15,
"severity": "warning",
"acknowledgmentRequired": false
}
},
"rules": {
"type": "object",
"description": "Alarm configuration for the pumping station.",
"schema": {
"highLevel": {
"default": {
"enabled": true,
"threshold": 2.3,
"delaySeconds": 30,
"severity": "critical",
"acknowledgmentRequired": true
},
"rules": {
"type": "object",
"schema": {
"enabled": {
"default": true,
"rules": {
"type": "boolean",
"description": "Enable or disable the high level alarm."
}
},
"threshold": {
"default": 2.3,
"rules": {
"type": "number",
"description": "Level threshold that triggers the high level alarm (m)."
}
},
"delaySeconds": {
"default": 30,
"rules": {
"type": "number",
"min": 0,
"description": "Delay before issuing the high level alarm (seconds)."
}
},
"severity": {
"default": "critical",
"rules": {
"type": "enum",
"values": [
{
"value": "info",
"description": "Informational notification."
},
{
"value": "warning",
"description": "Warning condition requiring attention."
},
{
"value": "critical",
"description": "Critical alarm requiring immediate intervention."
}
],
"description": "Severity associated with the high level alarm."
}
},
"acknowledgmentRequired": {
"default": true,
"rules": {
"type": "boolean",
"description": "If true, this alarm must be acknowledged by an operator."
}
}
}
}
},
"lowLevel": {
"default": {
"enabled": true,
"threshold": 0.2,
"delaySeconds": 15,
"severity": "warning",
"acknowledgmentRequired": false
},
"rules": {
"type": "object",
"schema": {
"enabled": {
"default": true,
"rules": {
"type": "boolean",
"description": "Enable or disable the low level alarm."
}
},
"threshold": {
"default": 0.2,
"rules": {
"type": "number",
"description": "Level threshold that triggers the low level alarm (m)."
}
},
"delaySeconds": {
"default": 15,
"rules": {
"type": "number",
"min": 0,
"description": "Delay before issuing the low level alarm (seconds)."
}
},
"severity": {
"default": "warning",
"rules": {
"type": "enum",
"values": [
{
"value": "info",
"description": "Informational notification."
},
{
"value": "warning",
"description": "Warning condition requiring attention."
},
{
"value": "critical",
"description": "Critical alarm requiring immediate intervention."
}
],
"description": "Severity associated with the low level alarm."
}
},
"acknowledgmentRequired": {
"default": false,
"rules": {
"type": "boolean",
"description": "If true, this alarm must be acknowledged by an operator."
}
}
}
}
}
}
}
},
"simulation": {
"enabled": {
"default": false,
"rules": {
"type": "boolean",
"description": "If true, the station operates in simulation mode using generated inflow and level data."
}
},
"mode": {
"default": "diurnal",
"rules": {
"type": "enum",
"values": [
{
"value": "static",
"description": "Use constant inflow and level conditions."
},
{
"value": "diurnal",
"description": "Use a typical diurnal inflow curve to drive simulation."
},
{
"value": "storm",
"description": "Use an elevated inflow profile representing a storm event."
}
],
"description": "Defines which synthetic profile drives the simulation."
}
},
"seed": {
"default": 42,
"rules": {
"type": "number",
"description": "Seed used for pseudo-random components in simulation."
}
},
"applyRandomNoise": {
"default": true,
"rules": {
"type": "boolean",
"description": "If true, adds small noise to simulated measurements."
}
},
"inflowProfile": {
"default": [
80,
110,
160,
120,
90
],
"rules": {
"type": "array",
"itemType": "number",
"minLength": 1,
"description": "Relative inflow profile used when mode is set to diurnal or storm (percentage of design inflow)."
}
}
}
}

202
src/configs/reactor.json Normal file
View File

@@ -0,0 +1,202 @@
{
"general": {
"name": {
"default": "Reactor",
"rules": {
"type": "string",
"description": "A human-readable name for this reactor."
}
},
"id": {
"default": null,
"rules": {
"type": "string",
"nullable": true,
"description": "Unique identifier for this reactor node."
}
},
"unit": {
"default": null,
"rules": {
"type": "string",
"nullable": true,
"description": "Default measurement unit."
}
},
"logging": {
"logLevel": {
"default": "info",
"rules": {
"type": "enum",
"values": [
{ "value": "debug", "description": "Verbose diagnostic messages." },
{ "value": "info", "description": "General informational messages." },
{ "value": "warn", "description": "Warning messages." },
{ "value": "error", "description": "Error level messages only." }
]
}
},
"enabled": {
"default": true,
"rules": {
"type": "boolean",
"description": "Enable or disable logging."
}
}
}
},
"functionality": {
"softwareType": {
"default": "reactor",
"rules": {
"type": "string",
"description": "Software type identifier for parent-child registration."
}
},
"role": {
"default": "Biological reactor for wastewater treatment",
"rules": {
"type": "string",
"description": "Describes the functional role of this node."
}
},
"positionVsParent": {
"default": "atEquipment",
"rules": {
"type": "enum",
"values": [
{ "value": "upstream", "description": "Upstream of parent equipment." },
{ "value": "atEquipment", "description": "At equipment level." },
{ "value": "downstream", "description": "Downstream of parent equipment." }
]
}
}
},
"reactor": {
"reactor_type": {
"default": "CSTR",
"rules": {
"type": "enum",
"values": [
{ "value": "CSTR", "description": "Continuous Stirred Tank Reactor - fully mixed." },
{ "value": "PFR", "description": "Plug Flow Reactor - spatial gradient along length." }
]
}
},
"volume": {
"default": 1000,
"rules": {
"type": "number",
"min": 0,
"unit": "m3",
"description": "Reactor volume in cubic meters."
}
},
"length": {
"default": 10,
"rules": {
"type": "number",
"min": 0,
"unit": "m",
"description": "Reactor length (relevant for PFR spatial discretization)."
}
},
"resolution_L": {
"default": 10,
"rules": {
"type": "integer",
"min": 1,
"description": "Number of spatial segments for PFR discretization."
}
},
"alpha": {
"default": 0.5,
"rules": {
"type": "number",
"min": 0,
"max": 1,
"description": "Dispersion coefficient alpha (0 = plug flow, 1 = fully mixed)."
}
},
"n_inlets": {
"default": 1,
"rules": {
"type": "integer",
"min": 1,
"description": "Number of inlet points along the reactor."
}
},
"kla": {
"default": 0,
"rules": {
"type": "number",
"min": 0,
"unit": "1/h",
"description": "Oxygen mass transfer coefficient (KLa)."
}
},
"timeStep": {
"default": 0.001,
"rules": {
"type": "number",
"min": 0.0001,
"unit": "h",
"description": "Integration time step for the reactor model."
}
}
},
"initialState": {
"S_O": {
"default": 0,
"rules": { "type": "number", "unit": "mg/L", "description": "Initial dissolved oxygen concentration." }
},
"S_I": {
"default": 30,
"rules": { "type": "number", "unit": "mg/L", "description": "Initial inert soluble COD." }
},
"S_S": {
"default": 70,
"rules": { "type": "number", "unit": "mg/L", "description": "Initial readily biodegradable substrate." }
},
"S_NH": {
"default": 25,
"rules": { "type": "number", "unit": "mg/L", "description": "Initial ammonium nitrogen." }
},
"S_N2": {
"default": 0,
"rules": { "type": "number", "unit": "mg/L", "description": "Initial dinitrogen (N2)." }
},
"S_NO": {
"default": 0,
"rules": { "type": "number", "unit": "mg/L", "description": "Initial nitrate and nitrite nitrogen." }
},
"S_HCO": {
"default": 5,
"rules": { "type": "number", "unit": "mmol/L", "description": "Initial alkalinity (bicarbonate)." }
},
"X_I": {
"default": 1000,
"rules": { "type": "number", "unit": "mg/L", "description": "Initial inert particulate COD." }
},
"X_S": {
"default": 100,
"rules": { "type": "number", "unit": "mg/L", "description": "Initial slowly biodegradable substrate." }
},
"X_H": {
"default": 2000,
"rules": { "type": "number", "unit": "mg/L", "description": "Initial heterotrophic biomass." }
},
"X_STO": {
"default": 0,
"rules": { "type": "number", "unit": "mg/L", "description": "Initial stored COD in biomass." }
},
"X_A": {
"default": 200,
"rules": { "type": "number", "unit": "mg/L", "description": "Initial autotrophic biomass." }
},
"X_TS": {
"default": 3500,
"rules": { "type": "number", "unit": "mg/L", "description": "Initial total suspended solids." }
}
}
}

View File

@@ -58,7 +58,7 @@
}, },
"functionality": { "functionality": {
"softwareType": { "softwareType": {
"default": "machine", "default": "rotatingmachine",
"rules": { "rules": {
"type": "string", "type": "string",
"description": "Specified software type for this configuration." "description": "Specified software type for this configuration."
@@ -412,6 +412,14 @@
], ],
"description": "The frequency at which calculations are performed." "description": "The frequency at which calculations are performed."
} }
},
"flowNumber": {
"default": 1,
"rules": {
"type": "number",
"nullable": false,
"description": "Defines which effluent flow of the parent node to handle."
}
} }
} }

75
src/configs/settler.json Normal file
View File

@@ -0,0 +1,75 @@
{
"general": {
"name": {
"default": "Settler",
"rules": {
"type": "string",
"description": "A human-readable name for this settler."
}
},
"id": {
"default": null,
"rules": {
"type": "string",
"nullable": true,
"description": "Unique identifier for this settler node."
}
},
"unit": {
"default": null,
"rules": {
"type": "string",
"nullable": true,
"description": "Default measurement unit."
}
},
"logging": {
"logLevel": {
"default": "info",
"rules": {
"type": "enum",
"values": [
{ "value": "debug", "description": "Verbose diagnostic messages." },
{ "value": "info", "description": "General informational messages." },
{ "value": "warn", "description": "Warning messages." },
{ "value": "error", "description": "Error level messages only." }
]
}
},
"enabled": {
"default": true,
"rules": {
"type": "boolean",
"description": "Enable or disable logging."
}
}
}
},
"functionality": {
"softwareType": {
"default": "settler",
"rules": {
"type": "string",
"description": "Software type identifier for parent-child registration."
}
},
"role": {
"default": "Secondary settler for sludge separation",
"rules": {
"type": "string",
"description": "Describes the functional role of this node."
}
},
"positionVsParent": {
"default": "downstream",
"rules": {
"type": "enum",
"values": [
{ "value": "upstream", "description": "Upstream of parent equipment." },
{ "value": "atEquipment", "description": "At equipment level." },
{ "value": "downstream", "description": "Downstream of parent equipment." }
]
}
}
}
}

View File

@@ -60,7 +60,7 @@
}, },
"functionality": { "functionality": {
"softwareType": { "softwareType": {
"default": "valveGroupControl", "default": "valvegroupcontrol",
"rules": { "rules": {
"type": "string", "type": "string",
"description": "Specified software type for this configuration." "description": "Specified software type for this configuration."

View File

@@ -0,0 +1,18 @@
/**
* Canonical position constants for parent-child relationships.
* Use these instead of hardcoded strings throughout the codebase.
*/
const POSITIONS = Object.freeze({
UPSTREAM: 'upstream',
DOWNSTREAM: 'downstream',
AT_EQUIPMENT: 'atEquipment',
DELTA: 'delta',
});
const POSITION_VALUES = Object.freeze(Object.values(POSITIONS));
function isValidPosition(pos) {
return POSITION_VALUES.includes(pos);
}
module.exports = { POSITIONS, POSITION_VALUES, isValidPosition };

View File

@@ -1,5 +1,4 @@
var metric var metric;
, imperial;
metric = { metric = {
ea: { ea: {

View File

@@ -1,5 +1,4 @@
var metric var metric;
, imperial;
metric = { metric = {
ppm: { ppm: {

View File

@@ -127,7 +127,7 @@ Converter.prototype.toBest = function(options) {
if(!this.origin) if(!this.origin)
throw new Error('.toBest must be called after .from'); throw new Error('.toBest must be called after .from');
var options = Object.assign({ options = Object.assign({
exclude: [], exclude: [],
cutOffNumber: 1, cutOffNumber: 1,
}, options) }, options)
@@ -249,7 +249,7 @@ Converter.prototype.list = function (measure) {
Converter.prototype.throwUnsupportedUnitError = function (what) { Converter.prototype.throwUnsupportedUnitError = function (what) {
var validUnits = []; var validUnits = [];
each(measures, function (systems, measure) { each(measures, function (systems, _measure) {
each(systems, function (units, system) { each(systems, function (units, system) {
if(system == '_anchors') if(system == '_anchors')
return false; return false;

View File

@@ -7,7 +7,6 @@
* Available under MIT license <http://lodash.com/license> * Available under MIT license <http://lodash.com/license>
*/ */
var isObject = require('./../lodash.isobject'), var isObject = require('./../lodash.isobject'),
noop = require('./../lodash.noop'),
reNative = require('./../lodash._renative'); reNative = require('./../lodash._renative');
/* Native method shortcuts for methods with the same name as other `lodash` methods */ /* Native method shortcuts for methods with the same name as other `lodash` methods */
@@ -21,12 +20,12 @@ var nativeCreate = reNative.test(nativeCreate = Object.create) && nativeCreate;
* @param {Object} prototype The object to inherit from. * @param {Object} prototype The object to inherit from.
* @returns {Object} Returns the new object. * @returns {Object} Returns the new object.
*/ */
function baseCreate(prototype, properties) { function baseCreate(prototype, _properties) { // eslint-disable-line no-func-assign
return isObject(prototype) ? nativeCreate(prototype) : {}; return isObject(prototype) ? nativeCreate(prototype) : {};
} }
// fallback for browsers without `Object.create` // fallback for browsers without `Object.create`
if (!nativeCreate) { if (!nativeCreate) {
baseCreate = (function() { baseCreate = (function() { // eslint-disable-line no-func-assign
function Object() {} function Object() {}
return function(prototype) { return function(prototype) {
if (isObject(prototype)) { if (isObject(prototype)) {

View File

@@ -47,7 +47,7 @@ function createWrapper(func, bitmask, partialArgs, partialRightArgs, thisArg, ar
var isBind = bitmask & 1, var isBind = bitmask & 1,
isBindKey = bitmask & 2, isBindKey = bitmask & 2,
isCurry = bitmask & 4, isCurry = bitmask & 4,
isCurryBound = bitmask & 8, /* isCurryBound = bitmask & 8, */
isPartial = bitmask & 16, isPartial = bitmask & 16,
isPartialRight = bitmask & 32; isPartialRight = bitmask & 32;

View File

@@ -24,7 +24,7 @@ var defineProperty = (function() {
var o = {}, var o = {},
func = reNative.test(func = Object.defineProperty) && func, func = reNative.test(func = Object.defineProperty) && func,
result = func(o, o, o) && func; result = func(o, o, o) && func;
} catch(e) { } } catch(e) { /* intentionally empty */ }
return result; return result;
}()); }());

View File

@@ -7,7 +7,6 @@
* Available under MIT license <http://lodash.com/license> * Available under MIT license <http://lodash.com/license>
*/ */
var createWrapper = require('./../lodash._createwrapper'), var createWrapper = require('./../lodash._createwrapper'),
reNative = require('./../lodash._renative'),
slice = require('./../lodash._slice'); slice = require('./../lodash._slice');
/** /**

2
src/coolprop-node/.gitattributes vendored Normal file
View File

@@ -0,0 +1,2 @@
# Auto detect text files and perform LF normalization
* text=auto

21
src/coolprop-node/LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Craig Zych
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

253
src/coolprop-node/README.md Normal file
View File

@@ -0,0 +1,253 @@
# CoolProp-Node
A Node.js wrapper for CoolProp providing an easy-to-use interface for thermodynamic calculations and refrigerant properties. Unlike all the other CoolProp npm packages I've seen, this one should actually work. Please report any issues.
## Installation
```bash
npm install coolprop-node
```
## Features
- Easy-to-use async interface for CoolProp
- Unit conversion support (Temperature: K/C/F, Pressure: Pa/kPa/bar/psi)
- Automatic initialization
- Configurable defaults
- Comprehensive error handling
## Dependencies
No External Dependencies, as CoolProp.js and CoolProp.wasm are bundled with the package.
- [CoolProp](https://github.com/CoolProp/CoolProp) for the powerful thermodynamic library
## Quick Start
```javascript
const nodeprop = require('coolprop-node');
async function example() {
// Initialize with defaults (optional)
await nodeprop.init({
refrigerant: 'R404A',
tempUnit: 'C',
pressureUnit: 'bar'
});
// Calculate superheat
const result = await nodeprop.calculateSuperheat({
temperature: 25, // 25°C
pressure: 10, // 10 bar
refrigerant: 'R404A' // optional if set in init
});
console.log(result);
// expected output:
{
type: 'success',
superheat: 5.2,
saturationTemperature: 19.8,
refrigerant: 'R404A',
units: {
temperature: 'C',
pressure: 'bar'
}
}
}
example();
```
## API Reference
### nodeprop.init(config)
Initializes the wrapper with optional configuration.
###### Note: Calling `init()` is optional. The library will initialize automatically when you make your first call to any function, but you must provide a `refrigerant` parameter in that first call.
```javascript
await nodeprop.init({
refrigerant: 'R404A', // Required on first init
tempUnit: 'C', // Optional, defaults to 'K'
pressureUnit: 'bar' // Optional, defaults to 'Pa'
});
```
### nodeprop.calculateSuperheat(input)
Calculates superheat for a given refrigerant.
```javascript
const result = await nodeprop.calculateSuperheat({
temperature: 25, // 25°C
pressure: 10, // 10 bar
refrigerant: 'R404A' // optional if set in init
});
returns:
{
type: 'success',
superheat: 5.2,
saturationTemperature: 19.8,
refrigerant: 'R404A',
units: {
temperature: 'C',
pressure: 'bar'
}
}
```
### nodeprop.getSaturationTemperature(input)
Calculates saturation temperature for a given refrigerant.
```javascript
const result = await nodeprop.calculateSaturationTemperature({
temperature: 25, // 25°C
pressure: 10, // 10 bar
refrigerant: 'R404A' // optional if set in init
});
returns:
{
type: 'success',
temperature: 19.8,
refrigerant: 'R404A',
units: {
temperature: 'C',
pressure: 'bar'
}
}
```
### nodeprop.getSaturationPressure(input)
Calculates saturation pressure for a given refrigerant.
```javascript
const result = await nodeprop.calculateSaturationPressure({
temperature: 25, // 25°C
refrigerant: 'R404A' // optional if set in init
});
returns:
{
type: 'success',
pressure: 10,
refrigerant: 'R404A',
units: {
temperature: 'C',
pressure: 'bar'
}
}
```
### nodeprop.calculateSubcooling(input)
Calculates subcooling for a given refrigerant.
```javascript
const result = await nodeprop.calculateSubcooling({
temperature: 25, // 25°C
pressure: 10, // 10 bar
refrigerant: 'R404A' // optional if set in init
});
returns:
{
type: 'success',
subcooling: 5.2,
saturationTemperature: 19.8,
refrigerant: 'R404A',
units: {
temperature: 'C',
pressure: 'bar'
}
}
```
### nodeprop.calculateSuperheat(input)
Calculates superheat for a given refrigerant.
```javascript
const result = await nodeprop.calculateSuperheat({
temperature: 25, // 25°C
pressure: 10, // 10 bar
refrigerant: 'R404A' // optional if set in init
});
returns:
{
type: 'success',
superheat: 5.2,
saturationTemperature: 19.8,
refrigerant: 'R404A',
units: {
temperature: 'C',
pressure: 'bar'
}
}
```
### nodeprop.getProperties(input)
Gets all properties for a given refrigerant.
```javascript
const result = await nodeprop.getProperties({
temperature: 25, // 25°C
pressure: 10, // 10 bar
refrigerant: 'R404A' // optional if set in init
});
returns:
{
type: 'success',
properties: {
temperature: 25, // in configured temperature unit (e.g., °C)
pressure: 10, // in configured pressure unit (e.g., bar)
density: 1234.56, // in kg/m³
enthalpy: 400000, // in J/kg
entropy: 1750, // in J/kg/K
quality: 1, // dimensionless (0-1)
conductivity: 0.013, // in W/m/K
viscosity: 1.2e-5, // in Pa·s
specificHeat: 850 // in J/kg/K
},
refrigerant: 'R404A',
units: {
temperature: 'C',
pressure: 'bar',
density: 'kg/m³',
enthalpy: 'J/kg',
entropy: 'J/kg/K',
quality: 'dimensionless',
conductivity: 'W/m/K',
viscosity: 'Pa·s',
specificHeat: 'J/kg/K'
}
}
```
### nodeprop.PropsSI
Direct access to CoolProp's PropsSI function.
```javascript
const PropsSI = await nodeprop.getPropsSI();
const result = PropsSI('H', 'T', 298.15, 'P', 101325, 'R134a');
```
### Error Handling
```javascript
const result = await nodeprop.calculateSuperheat({
temperature: 25, // 25°C
pressure: 10, // 10 bar
refrigerant: 'R404' // Invalid refrigerant. Must be supported by CoolProp, but R404 is not even a valid refrigerant.
});
returns:
{
type: 'error',
message: 'Invalid refrigerant'
}
```
### Acknowledgements
- [CoolProp](https://github.com/CoolProp/CoolProp) for the powerful thermodynamic library

View File

@@ -0,0 +1,80 @@
const coolprop = require('./src/index.js');
// Function to generate random number between min and max
function getRandomNumber(min, max) {
return min + Math.random() * (max - min);
}
// Generate 1000 combinations of temperature and pressure
function generateCombinations(count) {
const combinations = [];
// For R744 (CO2), using realistic ranges from test files
// Temperature range: -40°F to 32°F
// Pressure range: 131 psig to 491 psig
for (let i = 0; i < count; i++) {
const temperature = getRandomNumber(-40, 32);
const pressure = getRandomNumber(131, 491);
combinations.push({
temperature,
pressure,
refrigerant: 'R744',
tempUnit: 'F',
pressureUnit: 'psig'
});
}
return combinations;
}
async function runBenchmark() {
console.log('Generating 1000 temperature and pressure combinations...');
const combinations = generateCombinations(1000);
console.log('Combinations generated.');
// Pre-initialize the library
console.log('Initializing library...');
await coolprop.init({
refrigerant: 'R744',
tempUnit: 'F',
pressureUnit: 'psig'
});
console.log('Library initialized.');
// Run benchmark
console.log('Starting benchmark...');
const startTime = performance.now();
const results = [];
for (let i = 0; i < combinations.length; i++) {
const result = await coolprop.calculateSuperheat(combinations[i]);
results.push(result);
// Show progress every 100 calculations
if ((i + 1) % 100 === 0) {
console.log(`Processed ${i + 1} / ${combinations.length} calculations`);
}
}
const endTime = performance.now();
const totalTime = endTime - startTime;
const avgTime = totalTime / combinations.length;
// Report results
console.log('\nBenchmark Results:');
console.log(`Total time: ${totalTime.toFixed(2)} ms`);
console.log(`Average time per calculation: ${avgTime.toFixed(2)} ms`);
console.log(`Calculations per second: ${(1000 / avgTime).toFixed(2)}`);
// Count success and error results
const successful = results.filter(r => r.type === 'success').length;
const failed = results.filter(r => r.type === 'error').length;
console.log(`\nSuccessful calculations: ${successful}`);
console.log(`Failed calculations: ${failed}`);
}
// Run the benchmark
runBenchmark().catch(error => {
console.error('Benchmark failed:', error);
});

File diff suppressed because one or more lines are too long

Binary file not shown.

View File

@@ -0,0 +1,31 @@
{
"name": "coolprop-node",
"version": "1.0.20",
"main": "src/index.js",
"scripts": {
"test": "jest",
"test:watch": "jest --watch"
},
"keywords": [
"coolprop",
"thermodynamics",
"fluid properties",
"refrigerant",
"refrigeration",
"refprop"
],
"author": "Craig Zych",
"license": "MIT",
"description": "A Node.js wrapper for CoolProp providing an easy-to-use interface for thermodynamic calculations and refrigerant properties. Unlike all the other CoolProp npm packages I've seen, this one should actually work. Please report any issues. ",
"devDependencies": {
"jest": "^29.7.0"
},
"jest": {
"testEnvironment": "node",
"verbose": true
},
"repository": {
"type": "git",
"url": "https://github.com/Craigzyc/coolprop-node.git"
}
}

View File

@@ -0,0 +1,92 @@
// Load and configure the CoolProp module
const fs = require('fs');
const path = require('path');
const vm = require('vm');
// Mock XMLHttpRequest
class XMLHttpRequest {
open(method, url) {
this.method = method;
this.url = url;
}
send() {
try {
// Convert the URL to a local file path
const localPath = path.join(__dirname, '..', 'coolprop', path.basename(this.url));
const data = fs.readFileSync(localPath);
this.status = 200;
this.response = data;
this.responseType = 'arraybuffer';
if (this.onload) {
this.onload();
}
} catch (error) {
if (this.onerror) {
this.onerror(error);
}
}
}
}
// Read the coolprop.js file
const coolpropJs = fs.readFileSync(path.join(__dirname, '../coolprop/coolprop.js'), 'utf8');
// Create a context for the module
const context = {
window: {},
self: {},
Module: {
onRuntimeInitialized: function() {
context.Module.initialized = true;
}
},
importScripts: () => {},
console: console,
location: {
href: 'file://' + __dirname,
pathname: __dirname,
},
document: {
currentScript: { src: '' }
},
XMLHttpRequest: XMLHttpRequest
};
// Make self reference the context itself
context.self = context;
// Make window reference the context itself
context.window = context;
// Execute coolprop.js in our custom context
vm.createContext(context);
vm.runInContext(coolpropJs, context);
// Wait for initialization
function waitForInit(timeout = 5000) {
return new Promise((resolve, reject) => {
const start = Date.now();
const check = () => {
if (context.Module.initialized) {
resolve(context.Module);
} else if (Date.now() - start > timeout) {
reject(new Error('CoolProp initialization timed out'));
} else {
setTimeout(check, 100);
}
};
check();
});
}
module.exports = {
init: () => waitForInit(),
PropsSI: (...args) => {
if (!context.Module.initialized) {
throw new Error('CoolProp not initialized. Call init() first');
}
return context.Module.PropsSI(...args);
}
};

View File

@@ -0,0 +1,419 @@
const coolprop = require('./cp.js');
const customRefs = require('./refData.js');
class CoolPropWrapper {
constructor() {
this.initialized = false;
this.defaultRefrigerant = null;
this.defaultTempUnit = 'K'; // K, C, F
this.defaultPressureUnit = 'Pa' // Pa, kPa, bar, psi
this.customRef = false;
}
// Temperature conversion helpers
_convertTempToK(value, unit = this.defaultTempUnit) {
switch(unit.toUpperCase()) {
case 'K': return value;
case 'C': return value + 273.15;
case 'F': return (value + 459.67) * 5/9;
default: throw new Error('Unsupported temperature unit');
}
}
_convertTempFromK(value, unit = this.defaultTempUnit) {
switch(unit.toUpperCase()) {
case 'K': return value;
case 'C': return value - 273.15;
case 'F': return value * 9/5 - 459.67;
default: throw new Error('Unsupported temperature unit');
}
}
_convertDeltaTempFromK(value, unit = this.defaultTempUnit) {
switch(unit.toUpperCase()) {
case 'K': return value;
case 'C': return value;
case 'F': return (value * 1.8);
default: throw new Error('Unsupported temperature unit');
}
}
// Pressure conversion helpers
_convertPressureToPa(value, unit = this.defaultPressureUnit) {
switch(unit.toUpperCase()) {
case 'PAA': return value; // Absolute Pascal
case 'PAG':
case 'PA': return value + 101325; // Gauge Pascal
case 'KPAA': return value * 1000; // Absolute kiloPascal
case 'KPAG':
case 'KPA': return value * 1000 + 101325; // Gauge kiloPascal
case 'BARA': return value * 100000; // Absolute bar
case 'BARG':
case 'BAR': return value * 100000 + 101325; // Gauge bar
case 'PSIA': return value * 6894.76; // Absolute PSI
case 'PSIG':
case 'PSI': return value * 6894.76 + 101325;// Gauge PSI
default: throw new Error('Unsupported pressure unit');
}
}
_convertPressureFromPa(value, unit = this.defaultPressureUnit) {
switch(unit.toUpperCase()) {
case 'PAA': return value; // Absolute Pascal
case 'PAG':
case 'PA': return value - 101325; // Gauge Pascal
case 'KPAA': return value / 1000; // Absolute kiloPascal
case 'KPAG':
case 'KPA': return (value - 101325) / 1000; // Gauge kiloPascal
case 'BARA': return value / 100000; // Absolute bar
case 'BARG':
case 'BAR': return (value - 101325) / 100000;// Gauge bar
case 'PSIA': return value / 6894.76; // Absolute PSI
case 'PSIG':
case 'PSI': return (value - 101325) / 6894.76;// Gauge PSI
default: throw new Error('Unsupported pressure unit');
}
}
async init(config = {}) {
try {
// If already initialized, only update defaults if provided
if (this.initialized) {
if (config.refrigerant) this.defaultRefrigerant = config.refrigerant;
if (config.tempUnit) {
if (!['K', 'C', 'F'].includes(config.tempUnit.toUpperCase())) {
return { type: 'error', message: 'Invalid temperature unit. Must be K, C, or F' };
}
this.defaultTempUnit = config.tempUnit;
}
if (config.pressureUnit) {
if (!['PA', 'PAA', 'KPA', 'KPAA', 'BAR', 'BARA', 'PSI', 'PSIA'].includes(config.pressureUnit.toUpperCase())) {
return { type: 'error', message: 'Invalid pressure unit. Must be Pa, Paa, kPa, kPaa, bar, bara, psi, or psia' };
}
this.defaultPressureUnit = config.pressureUnit;
}
return { type: 'success', message: 'Default settings updated' };
}
// First time initialization
if (!config.refrigerant) {
throw new Error('Refrigerant must be specified during initialization');
}
// Validate temperature unit if provided
if (config.tempUnit && !['K', 'C', 'F'].includes(config.tempUnit.toUpperCase())) {
throw new Error('Invalid temperature unit. Must be K, C, or F');
}
// Validate pressure unit if provided
if (config.pressureUnit && !['PA', 'PAA', 'KPA', 'KPAA', 'BAR', 'BARA', 'PSI', 'PSIA'].includes(config.pressureUnit.toUpperCase())) {
throw new Error('Invalid pressure unit. Must be Pa, Paa, kPa, kPaa, bar, bara, psi, or psia');
}
await coolprop.init();
this.initialized = true;
this.defaultRefrigerant = config.refrigerant;
this.defaultTempUnit = config.tempUnit || this.defaultTempUnit;
this.defaultPressureUnit = config.pressureUnit || this.defaultPressureUnit;
return { type: 'success', message: 'Initialized successfully' };
} catch (error) {
return { type: 'error', message: error.message };
}
}
async _ensureInit(config = {}) {
// Initialize CoolProp if not already done
if (!this.initialized) {
if (!config.refrigerant && !this.defaultRefrigerant) {
throw new Error('Refrigerant must be specified either during initialization or in the method call');
}
await coolprop.init();
this.initialized = true;
}
// Validate temperature unit if provided
if (config.tempUnit && !['K', 'C', 'F'].includes(config.tempUnit.toUpperCase())) {
throw new Error('Invalid temperature unit. Must be K, C, or F');
}
// Validate pressure unit if provided
if (config.pressureUnit && !['PA', 'PAA', 'PAG', 'KPA', 'KPAA', 'KPAG', 'BAR', 'BARA', 'BARG', 'PSI', 'PSIA', 'PSIG'].includes(config.pressureUnit.toUpperCase())) {
throw new Error('Invalid pressure unit. Must be Pa, Paa, Pag, kPa, kPaa, kPag, bar, bara, barg, psi, psia, or psig');
}
// Validate refrigerant if provided
if (config.refrigerant && typeof config.refrigerant !== 'string') {
throw new Error('Invalid refrigerant type');
}
if (config.refrigerant && Object.keys(customRefs).includes(config.refrigerant)) {
this.customRef = true;
this.defaultRefrigerant = config.refrigerant;
//console.log(`Using custom refrigerant flag for ${this.defaultRefrigerant}`);
}else if(this.customRef && config.refrigerant){
this.customRef = false;
//console.log(`Cleared custom refrigerant flag`);
}
// Update instance variables with new config values if provided
if (config.refrigerant) this.defaultRefrigerant = config.refrigerant;
if (config.tempUnit) this.defaultTempUnit = config.tempUnit.toUpperCase();
if (config.pressureUnit) this.defaultPressureUnit = config.pressureUnit.toUpperCase();
}
async getConfig() {
return {
refrigerant: this.defaultRefrigerant,
tempUnit: this.defaultTempUnit,
pressureUnit: this.defaultPressureUnit
};
}
async setConfig(config) {
await this.init(config);
return {
type: 'success',
message: 'Config updated successfully',
config: await this.getConfig()
};
}
// Helper method for linear interpolation/extrapolation
_interpolateSaturationTemperature(pressurePa, saturationData, pressureType = 'liquid') {
const data = saturationData.sort((a, b) => a[pressureType] - b[pressureType]); // Sort by specified pressure type
// If pressure is below the lowest data point, extrapolate using first two points
if (pressurePa <= data[0][pressureType]) {
if (data.length < 2) return data[0].K;
const p1 = data[0], p2 = data[1];
const slope = (p2.K - p1.K) / (p2[pressureType] - p1[pressureType]);
return p1.K + slope * (pressurePa - p1[pressureType]);
}
// If pressure is above the highest data point, extrapolate using last two points
if (pressurePa >= data[data.length - 1][pressureType]) {
if (data.length < 2) return data[data.length - 1].K;
const p1 = data[data.length - 2], p2 = data[data.length - 1];
const slope = (p2.K - p1.K) / (p2[pressureType] - p1[pressureType]);
return p1.K + slope * (pressurePa - p1[pressureType]);
}
// Find the two adjacent points for interpolation
for (let i = 0; i < data.length - 1; i++) {
if (pressurePa >= data[i][pressureType] && pressurePa <= data[i + 1][pressureType]) {
const p1 = data[i], p2 = data[i + 1];
// Linear interpolation
const slope = (p2.K - p1.K) / (p2[pressureType] - p1[pressureType]);
return p1.K + slope * (pressurePa - p1[pressureType]);
}
}
// Fallback (shouldn't reach here)
return data[0].K;
}
// Helper method for linear interpolation/extrapolation of saturation pressure
_interpolateSaturationPressure(tempK, saturationData, pressureType = 'liquid') {
const data = saturationData.sort((a, b) => a.K - b.K); // Sort by temperature
// If temperature is below the lowest data point, extrapolate using first two points
if (tempK <= data[0].K) {
if (data.length < 2) return data[0][pressureType];
const p1 = data[0], p2 = data[1];
const slope = (p2[pressureType] - p1[pressureType]) / (p2.K - p1.K);
return p1[pressureType] + slope * (tempK - p1.K);
}
// If temperature is above the highest data point, extrapolate using last two points
if (tempK >= data[data.length - 1].K) {
if (data.length < 2) return data[data.length - 1][pressureType];
const p1 = data[data.length - 2], p2 = data[data.length - 1];
const slope = (p2[pressureType] - p1[pressureType]) / (p2.K - p1.K);
return p1[pressureType] + slope * (tempK - p1.K);
}
// Find the two adjacent points for interpolation
for (let i = 0; i < data.length - 1; i++) {
if (tempK >= data[i].K && tempK <= data[i + 1].K) {
const p1 = data[i], p2 = data[i + 1];
// Linear interpolation
const slope = (p2[pressureType] - p1[pressureType]) / (p2.K - p1.K);
return p1[pressureType] + slope * (tempK - p1.K);
}
}
// Fallback (shouldn't reach here)
return data[0][pressureType];
}
async getSaturationTemperature({ pressure, refrigerant = this.defaultRefrigerant, pressureUnit = this.defaultPressureUnit, tempUnit = this.defaultTempUnit }) {
try {
await this._ensureInit({ refrigerant, pressureUnit, tempUnit });
const pressurePa = this._convertPressureToPa(pressure, pressureUnit);
let tempK;
if(this.customRef){
tempK = this._interpolateSaturationTemperature(pressurePa, customRefs[refrigerant].saturation);
}else{
tempK = coolprop.PropsSI('T', 'P', pressurePa, 'Q', 0, this.customRefString || refrigerant);
}
return {
type: 'success',
temperature: this._convertTempFromK(tempK, tempUnit),
refrigerant,
units: {
temperature: tempUnit,
pressure: pressureUnit
}
};
} catch (error) {
return { type: 'error', message: error.message };
}
}
async getSaturationPressure({ temperature, refrigerant = this.defaultRefrigerant, tempUnit = this.defaultTempUnit, pressureUnit = this.defaultPressureUnit }) {
try {
await this._ensureInit({ refrigerant, tempUnit, pressureUnit });
const tempK = this._convertTempToK(temperature, tempUnit);
let pressurePa;
if(this.customRef){
pressurePa = this._interpolateSaturationPressure(tempK, customRefs[refrigerant].saturation);
}else{
pressurePa = coolprop.PropsSI('P', 'T', tempK, 'Q', 0, this.customRefString || refrigerant);
}
return {
type: 'success',
pressure: this._convertPressureFromPa(pressurePa, pressureUnit),
refrigerant,
units: {
temperature: tempUnit,
pressure: pressureUnit
}
};
} catch (error) {
return { type: 'error', message: error.message };
}
}
async calculateSubcooling({ temperature, pressure, refrigerant = this.defaultRefrigerant, tempUnit = this.defaultTempUnit, pressureUnit = this.defaultPressureUnit }) {
try {
await this._ensureInit({ refrigerant, tempUnit, pressureUnit });
const tempK = this._convertTempToK(temperature, tempUnit);
const pressurePa = this._convertPressureToPa(pressure, pressureUnit);
let satTempK;
if(this.customRef){
// Use liquid pressure for subcooling
satTempK = this._interpolateSaturationTemperature(pressurePa, customRefs[refrigerant].saturation, 'liquid');
}else{
satTempK = coolprop.PropsSI('T', 'P', pressurePa, 'Q', 0, this.customRefString || refrigerant);
}
const subcooling = satTempK - tempK;
const result = {
type: 'success',
subcooling: Math.max(0, this._convertDeltaTempFromK(subcooling, tempUnit)), // can't have less than 0 degrees subcooling
saturationTemperature: this._convertTempFromK(satTempK, tempUnit),
refrigerant,
units: {
temperature: tempUnit,
pressure: pressureUnit
}
};
if(result.subcooling == Infinity && result.saturationTemperature == Infinity) {
return { type: 'error', message: 'Subcooling is infinity', note: 'If the pressures are in an expected range that this should work, please check your refrigerant type works in coolprop. "R507" for example is not supported, as it needs to be "R507a"'};
}
return result;
} catch (error) {
return { type: 'error', message: error.message };
}
}
async calculateSuperheat({ temperature, pressure, refrigerant = this.defaultRefrigerant, tempUnit = this.defaultTempUnit, pressureUnit = this.defaultPressureUnit }) {
try {
await this._ensureInit({ refrigerant, tempUnit, pressureUnit });
const tempK = this._convertTempToK(temperature, tempUnit);
const pressurePa = this._convertPressureToPa(pressure, pressureUnit);
//console.log(`In calculateSuperheat, pressurePa: ${pressurePa}, pressure: ${pressure}, pressureUnit: ${pressureUnit}, refrigerant: ${this.customRefString || refrigerant}`);
let satTempK;
if(this.customRef){
// Use vapor pressure for superheat
satTempK = this._interpolateSaturationTemperature(pressurePa, customRefs[refrigerant].saturation, 'vapor');
}else{
satTempK = coolprop.PropsSI('T', 'P', pressurePa, 'Q', 1, this.customRefString || refrigerant);
}
const superheat = tempK - satTempK;
//console.log(`superheat: ${superheat}, calculatedSuperheat: ${this._convertDeltaTempFromK(superheat, tempUnit)}, calculatedSatTempK: ${this._convertTempFromK(satTempK, tempUnit)}, tempK: ${tempK}, tempUnit: ${tempUnit}, pressurePa: ${pressurePa}, pressureUnit: ${pressureUnit}`);
const result = {
type: 'success',
superheat: Math.max(0, this._convertDeltaTempFromK(superheat, tempUnit)), // can't have less than 0 degrees superheat
saturationTemperature: this._convertTempFromK(satTempK, tempUnit),
refrigerant,
units: {
temperature: tempUnit,
pressure: pressureUnit
}
};
if(result.superheat == Infinity && result.saturationTemperature == Infinity) {
return { type: 'error', message: 'Superheat is infinity', note: 'If the pressures are in an expected range that this should work, please check your refrigerant type works in coolprop. "R507" for example is not supported, as it needs to be "R507a"'};
}
return result;
} catch (error) {
return { type: 'error', message: error.message };
}
}
async getProperties({ temperature, pressure, refrigerant = this.defaultRefrigerant, tempUnit = this.defaultTempUnit, pressureUnit = this.defaultPressureUnit }) {
try {
await this._ensureInit({ refrigerant, tempUnit, pressureUnit });
const tempK = this._convertTempToK(temperature, tempUnit);
const pressurePa = this._convertPressureToPa(pressure, pressureUnit);
if(this.customRef){
return { type: 'error', message: 'Custom refrigerants are not supported for getProperties' };
}
const props = {
temperature: this._convertTempFromK(tempK, tempUnit),
pressure: this._convertPressureFromPa(pressurePa, pressureUnit),
density: coolprop.PropsSI('D', 'T', tempK, 'P', pressurePa, this.customRefString || refrigerant),
enthalpy: coolprop.PropsSI('H', 'T', tempK, 'P', pressurePa, this.customRefString || refrigerant),
entropy: coolprop.PropsSI('S', 'T', tempK, 'P', pressurePa, this.customRefString || refrigerant),
quality: coolprop.PropsSI('Q', 'T', tempK, 'P', pressurePa, this.customRefString || refrigerant),
conductivity: coolprop.PropsSI('L', 'T', tempK, 'P', pressurePa, this.customRefString || refrigerant),
viscosity: coolprop.PropsSI('V', 'T', tempK, 'P', pressurePa, this.customRefString || refrigerant),
specificHeat: coolprop.PropsSI('C', 'T', tempK, 'P', pressurePa, this.customRefString || refrigerant)
};
return {
type: 'success',
properties: props,
refrigerant,
units: {
temperature: tempUnit,
pressure: pressureUnit,
density: 'kg/m³',
enthalpy: 'J/kg',
entropy: 'J/kg/K',
quality: 'dimensionless',
conductivity: 'W/m/K',
viscosity: 'Pa·s',
specificHeat: 'J/kg/K'
}
};
} catch (error) {
return { type: 'error', message: error.message };
}
}
// Direct access to CoolProp functions
async getPropsSI() {
if(!this.initialized) {
await coolprop.init();
}
return coolprop.PropsSI;
}
}
module.exports = new CoolPropWrapper();

View File

@@ -0,0 +1,308 @@
module.exports.R448a = {
saturation: [{
//values in kelvin, pascal
"K": 233.15,
"liquid": 135137.24,
"vapor": 101352.93
},
{
"K": 238.71,
"liquid": 173058.40,
"vapor": 131689.86
},
{
"K": 244.26,
"liquid": 218563.80,
"vapor": 168921.55
},
{
"K": 249.82,
"liquid": 273032.38,
"vapor": 214426.94
},
{
"K": 255.37,
"liquid": 337153.62,
"vapor": 268895.52
},
{
"K": 260.93,
"liquid": 412306.47,
"vapor": 333016.76
},
{
"K": 266.48,
"liquid": 499869.88,
"vapor": 408859.09
},
{
"K": 272.04,
"liquid": 599843.86,
"vapor": 496422.50
},
{
"K": 277.59,
"liquid": 714986.30,
"vapor": 598464.91
},
{
"K": 283.15,
"liquid": 845986.68,
"vapor": 714986.30
},
{
"K": 288.71,
"liquid": 990776.58,
"vapor": 845986.68
},
{
"K": 294.26,
"liquid": 1163145.51,
"vapor": 997671.34
},
{
"K": 299.82,
"liquid": 1349303.94,
"vapor": 1170040.26
},
{
"K": 305.37,
"liquid": 1556146.65,
"vapor": 1363093.46
},
{
"K": 310.93,
"liquid": 1783673.64,
"vapor": 1576830.93
},
{
"K": 316.48,
"liquid": 2038779.64,
"vapor": 1818147.42
},
{
"K": 322.04,
"liquid": 2314569.92,
"vapor": 2087042.94
},
{
"K": 327.59,
"liquid": 2617939.23,
"vapor": 2383517.49
},
{
"K": 333.15,
"liquid": 2955782.33,
"vapor": 2714465.83
},
{
"K": 338.71,
"liquid": 3321204.45,
"vapor": 3086782.71
}]
}
module.exports.R448A = module.exports.R448a;
module.exports.R449A = {
saturation: [
{
// values in kelvin, pascal
"K": 233.15,
"liquid": 134447.82,
"vapor": 101352.97
},
{
"K": 235.93,
"liquid": 152374.20,
"vapor": 115121.57
},
{
"K": 238.71,
"liquid": 171679.52,
"vapor": 131689.92
},
{
"K": 241.48,
"liquid": 193052.21,
"vapor": 148949.73
},
{
"K": 244.26,
"liquid": 216503.85,
"vapor": 168255.05
},
{
"K": 247.04,
"liquid": 242702.42,
"vapor": 189627.74
},
{
"K": 249.82,
"liquid": 270979.90,
"vapor": 213768.86
},
{
"K": 252.59,
"liquid": 301336.31,
"vapor": 240051.48
},
{
"K": 255.37,
"liquid": 334440.63,
"vapor": 267609.92
},
{
"K": 258.15,
"liquid": 370292.86,
"vapor": 298655.80
},
{
"K": 260.93,
"liquid": 408892.90,
"vapor": 331760.12
},
{
"K": 263.71,
"liquid": 450240.76,
"vapor": 367612.35
},
{
"K": 266.48,
"liquid": 495036.08,
"vapor": 406831.32
},
{
"K": 269.26,
"liquid": 542579.32,
"vapor": 448868.64
},
{
"K": 272.04,
"liquid": 594279.82,
"vapor": 493663.96
},
{
"K": 274.82,
"liquid": 649728.18,
"vapor": 542579.32
},
{
"K": 277.59,
"liquid": 708053.32,
"vapor": 594969.28
},
{
"K": 280.37,
"liquid": 770873.08,
"vapor": 650767.64
},
{
"K": 283.15,
"liquid": 839126.92,
"vapor": 710801.16
},
{
"K": 285.93,
"liquid": 912814.72,
"vapor": 774989.44
},
{
"K": 288.71,
"liquid": 983940.92,
"vapor": 845977.32
},
{
"K": 291.48,
"liquid": 1066606.52,
"vapor": 914889.32
},
{
"K": 294.26,
"liquid": 1151351.00,
"vapor": 990835.62
},
{
"K": 297.04,
"liquid": 1238843.30,
"vapor": 1073501.22
},
{
"K": 299.82,
"liquid": 1335552.20,
"vapor": 1165089.32
},
{
"K": 302.59,
"liquid": 1432261.10,
"vapor": 1256677.42
},
{
"K": 305.37,
"liquid": 1535864.72,
"vapor": 1357134.12
},
{
"K": 308.15,
"liquid": 1646363.00,
"vapor": 1457590.92
},
{
"K": 310.93,
"liquid": 1763756.02,
"vapor": 1568089.12
},
{
"K": 313.71,
"liquid": 1887043.62,
"vapor": 1678587.32
},
{
"K": 316.48,
"liquid": 2017225.92,
"vapor": 1802217.02
},
{
"K": 319.26,
"liquid": 2147408.22,
"vapor": 1934952.12
},
{
"K": 322.04,
"liquid": 2291329.82,
"vapor": 2072621.52
},
{
"K": 324.82,
"liquid": 2435251.42,
"vapor": 2217185.62
},
{
"K": 327.59,
"liquid": 2592912.32,
"vapor": 2368644.42
},
{
"K": 330.37,
"liquid": 2750573.22,
"vapor": 2526305.32
},
{
"K": 333.15,
"liquid": 2925424.52,
"vapor": 2690860.82
},
{
"K": 335.93,
"liquid": 3100275.92,
"vapor": 2871668.52
},
{
"K": 338.71,
"liquid": 3288922.02,
"vapor": 3059370.92
}
]}
module.exports.R449a = module.exports.R449A;

View File

@@ -0,0 +1,94 @@
const coolprop = require('../src/index.js');
describe('R448a Real Values', () => {
it('should calculate superheat correctly at -40°C saturation', async () => {
const result = await coolprop.calculateSuperheat({
temperature: -35, // 5K above saturation temp of -40°C
pressure: 0, // saturation pressure at -40°C (from chart)
refrigerant: 'R448a',
tempUnit: 'C',
pressureUnit: 'psig'
});
expect(result.type).toBe('success');
expect(Math.abs(result.superheat - 5)).toBeLessThan(0.2); // Should be ~5K superheat
});
it('should calculate superheat correctly at -20°C saturation', async () => {
const result = await coolprop.calculateSuperheat({
temperature: -15, // 5K above saturation temp of -20°C
pressure: 21.0, // saturation pressure at -20°C (from chart)
refrigerant: 'R448a',
tempUnit: 'C',
pressureUnit: 'psig'
});
//console.log(result);
expect(result.type).toBe('success');
expect(Math.abs(result.superheat - 5)).toBeLessThan(0.2); // Should be ~5K superheat
});
it('should calculate subcooling correctly at 30°C saturation', async () => {
const result = await coolprop.calculateSubcooling({
temperature: 25, // 5K below saturation temp of 30°C
pressure: 198.1, // saturation pressure at 30°C (from chart)
refrigerant: 'R448a',
tempUnit: 'C',
pressureUnit: 'psig'
});
expect(result.type).toBe('success');
expect(Math.abs(result.subcooling - 5)).toBeLessThan(0.2); // Should be ~5K subcooling
});
it('should calculate subcooling correctly at 40°C saturation', async () => {
const result = await coolprop.calculateSubcooling({
temperature: 35, // 5K below saturation temp of 40°C
pressure: 258.0, // saturation pressure at 40°C (from chart)
refrigerant: 'R448a',
tempUnit: 'C',
pressureUnit: 'psig'
});
expect(result.type).toBe('success');
expect(Math.abs(result.subcooling - 5)).toBeLessThan(0.2); // Should be ~5K subcooling
});
it('should calculate zero superheat at saturation point', async () => {
const result = await coolprop.calculateSuperheat({
temperature: 0, // Exact saturation temperature
pressure: 60.1, // Matching saturation pressure from chart
refrigerant: 'R448a',
tempUnit: 'C',
pressureUnit: 'psig'
});
expect(result.type).toBe('success');
expect(Math.abs(result.superheat)).toBeLessThan(0.2); // Should be ~0K superheat
});
it('should calculate zero subcooling at saturation point', async () => {
const result = await coolprop.calculateSubcooling({
temperature: 20, // Exact saturation temperature
pressure: 148.5, // Matching saturation pressure from chart
refrigerant: 'R448a',
tempUnit: 'C',
pressureUnit: 'psig'
});
expect(result.type).toBe('success');
expect(Math.abs(result.subcooling)).toBeLessThan(0.2); // Should be ~0K subcooling
});
it('It should also work with R448A (capital A)', async () => {
const result = await coolprop.calculateSubcooling({
temperature: 20, // Exact saturation temperature
pressure: 148.5, // Matching saturation pressure from chart
refrigerant: 'R448A',
tempUnit: 'C',
pressureUnit: 'psig'
});
expect(result.type).toBe('success');
expect(Math.abs(result.subcooling)).toBeLessThan(0.2); // Should be ~0K subcooling
});
});

View File

@@ -0,0 +1,94 @@
const coolprop = require('../src/index.js');
describe('R449a Real Values', () => {
it('should calculate superheat correctly at -40°C saturation', async () => {
const result = await coolprop.calculateSuperheat({
temperature: -35, // 5K above saturation temp of -40°C
pressure: 0, // saturation pressure at -40°C (from chart)
refrigerant: 'R449a',
tempUnit: 'C',
pressureUnit: 'psig'
});
expect(result.type).toBe('success');
expect(Math.abs(result.superheat - 5)).toBeLessThan(0.2); // Should be ~5K superheat
});
it('should calculate superheat correctly at -20°C saturation', async () => {
const result = await coolprop.calculateSuperheat({
temperature: -15, // 5K above saturation temp of -20°C
pressure: 20.96, // saturation pressure at -20°C (from chart)
refrigerant: 'R449a',
tempUnit: 'C',
pressureUnit: 'psig'
});
//console.log(result);
expect(result.type).toBe('success');
expect(Math.abs(result.superheat - 5)).toBeLessThan(0.2); // Should be ~5K superheat
});
it('should calculate subcooling correctly at 30°C saturation', async () => {
const result = await coolprop.calculateSubcooling({
temperature: 25, // 5K below saturation temp of 30°C
pressure: 195, // saturation pressure at 30°C (from chart)
refrigerant: 'R449a',
tempUnit: 'C',
pressureUnit: 'psig'
});
expect(result.type).toBe('success');
expect(Math.abs(result.subcooling - 5)).toBeLessThan(0.2); // Should be ~5K subcooling
});
it('should calculate subcooling correctly at 40°C saturation', async () => {
const result = await coolprop.calculateSubcooling({
temperature: 35, // 5K below saturation temp of 40°C
pressure: 254.2, // saturation pressure at 40°C (from chart)
refrigerant: 'R449a',
tempUnit: 'C',
pressureUnit: 'psig'
});
expect(result.type).toBe('success');
expect(Math.abs(result.subcooling - 5)).toBeLessThan(0.2); // Should be ~5K subcooling
});
it('should calculate zero superheat at saturation point', async () => {
const result = await coolprop.calculateSuperheat({
temperature: 0, // Exact saturation temperature
pressure: 74.05, // Matching saturation pressure from chart
refrigerant: 'R449a',
tempUnit: 'C',
pressureUnit: 'psig'
});
expect(result.type).toBe('success');
expect(Math.abs(result.superheat)).toBeLessThan(0.2); // Should be ~0K superheat
});
it('should calculate zero subcooling at saturation point', async () => {
const result = await coolprop.calculateSubcooling({
temperature: 20, // Exact saturation temperature
pressure: 146.0, // Matching saturation pressure from chart
refrigerant: 'R449a',
tempUnit: 'C',
pressureUnit: 'psig'
});
expect(result.type).toBe('success');
expect(Math.abs(result.subcooling)).toBeLessThan(0.2); // Should be ~0K subcooling
});
it('It should also work with R449A (capital A)', async () => {
const result = await coolprop.calculateSubcooling({
temperature: 20, // Exact saturation temperature
pressure: 146.0, // Matching saturation pressure from chart
refrigerant: 'R449A',
tempUnit: 'C',
pressureUnit: 'psig'
});
expect(result.type).toBe('success');
expect(Math.abs(result.subcooling)).toBeLessThan(0.2); // Should be ~0K subcooling
});
});

View File

@@ -0,0 +1,97 @@
const coolprop = require('../src/index.js');
describe('R507 Real Values', () => {
it('should calculate superheat correctly at -40°C saturation', async () => {
const result = await coolprop.calculateSuperheat({
temperature: -35, // 5K above saturation temp of -40°C
pressure: 5.4, // saturation pressure at -40°C (from chart)
refrigerant: 'R507a',
tempUnit: 'C',
pressureUnit: 'psig'
});
expect(result.type).toBe('success');
expect(Math.abs(result.superheat - 5)).toBeLessThan(0.1); // Should be ~5K superheat
});
it('should calculate superheat correctly at -20°C saturation', async () => {
const result = await coolprop.calculateSuperheat({
temperature: -15, // 5K above saturation temp of -20°C
pressure: 30.9, // saturation pressure at -20°C (from chart)
refrigerant: 'R507a',
tempUnit: 'C',
pressureUnit: 'psig'
});
expect(result.type).toBe('success');
expect(Math.abs(result.superheat - 5)).toBeLessThan(0.1); // Should be ~5K superheat
});
it('should calculate subcooling correctly at 30°C saturation', async () => {
const result = await coolprop.calculateSubcooling({
temperature: 25, // 5K below saturation temp of 30°C
pressure: 196.9, // saturation pressure at 30°C (from chart)
refrigerant: 'R507a',
tempUnit: 'C',
pressureUnit: 'psig'
});
expect(result.type).toBe('success');
expect(Math.abs(result.subcooling - 5)).toBeLessThan(0.1); // Should be ~5K subcooling
});
it('should calculate subcooling correctly at 40°C saturation', async () => {
const result = await coolprop.calculateSubcooling({
temperature: 35, // 5K below saturation temp of 40°C
pressure: 256.2, // saturation pressure at 40°C (from chart)
refrigerant: 'R507a',
tempUnit: 'C',
pressureUnit: 'psig'
});
expect(result.type).toBe('success');
expect(Math.abs(result.subcooling - 5)).toBeLessThan(0.1); // Should be ~5K subcooling
});
it('should calculate zero superheat at saturation point', async () => {
const result = await coolprop.calculateSuperheat({
temperature: 0, // Exact saturation temperature
pressure: 75.8, // Matching saturation pressure from chart
refrigerant: 'R507a',
tempUnit: 'C',
pressureUnit: 'psig'
});
expect(result.type).toBe('success');
expect(Math.abs(result.superheat)).toBeLessThan(0.1); // Should be ~0K superheat
});
it('should calculate zero subcooling at saturation point', async () => {
const result = await coolprop.calculateSubcooling({
temperature: 20, // Exact saturation temperature
pressure: 148, // Matching saturation pressure from chart
refrigerant: 'R507a',
tempUnit: 'C',
pressureUnit: 'psig'
});
expect(result.type).toBe('success');
expect(Math.abs(result.subcooling)).toBeLessThan(0.1); // Should be ~0K subcooling
});
it('should calculate subcooling correctly at 30°C saturation', async () => {
const result = await coolprop.calculateSubcooling({
temperature: 25, // 5K below saturation temp of 30°C
pressure: 196.9, // saturation pressure at 30°C (from chart)
refrigerant: 'R507',
tempUnit: 'C',
pressureUnit: 'psig'
});
expect(result.type).toBe('error');
expect(result.message).toBe('Subcooling is infinity');
expect(result.note).toBeDefined();
});
});

View File

@@ -0,0 +1,55 @@
const coolprop = require('../src/index.js');
describe('R744 (CO2) Real Values', () => {
it('should calculate superheat correctly at -40°C saturation', async () => {
const result = await coolprop.calculateSuperheat({
temperature: -35, // 5K above saturation temp of -40°C
pressure: 9.03, // saturation pressure at -40°C (from chart)
refrigerant: 'R744',
tempUnit: 'C',
pressureUnit: 'bar'
});
expect(result.type).toBe('success');
expect(Math.abs(result.superheat - 5)).toBeLessThan(0.1); // Should be ~5K superheat
});
it('should calculate subcooling correctly at 0°C saturation', async () => {
const result = await coolprop.calculateSubcooling({
temperature: -5, // 5K below saturation temp of 0°C
pressure: 33.84, // saturation pressure at 0°C (from chart)
refrigerant: 'R744',
tempUnit: 'C',
pressureUnit: 'bar'
});
expect(result.type).toBe('success');
expect(Math.abs(result.subcooling - 5)).toBeLessThan(0.1); // Should be ~5K subcooling
});
it('should calculate zero superheat at saturation point', async () => {
const result = await coolprop.calculateSuperheat({
temperature: -20, // Exact saturation temperature
pressure: 18.68, // Matching saturation pressure from chart
refrigerant: 'R744',
tempUnit: 'C',
pressureUnit: 'bar'
});
expect(result.type).toBe('success');
expect(Math.abs(result.superheat)).toBeLessThan(0.1); // Should be ~0K superheat
});
it('should calculate zero subcooling at saturation point', async () => {
const result = await coolprop.calculateSubcooling({
temperature: 10, // Exact saturation temperature
pressure: 44.01, // Matching saturation pressure from chart
refrigerant: 'R744',
tempUnit: 'C',
pressureUnit: 'bar'
});
expect(result.type).toBe('success');
expect(Math.abs(result.subcooling)).toBeLessThan(0.1); // Should be ~0K subcooling
});
});

View File

@@ -0,0 +1,55 @@
const coolprop = require('../src/index.js');
describe('R744 (CO2) Real Values', () => {
it('should calculate superheat correctly at -40°F saturation', async () => {
const result = await coolprop.calculateSuperheat({
temperature: -35, // 5°F above saturation temp of -40°F
pressure: 131, // saturation pressure at -40°F (from chart)
refrigerant: 'R744',
tempUnit: 'F', // Changed to F
pressureUnit: 'psig'
});
expect(result.type).toBe('success');
expect(Math.abs(result.superheat - 5)).toBeLessThan(0.1); // Should be ~5°F superheat
});
it('should calculate subcooling correctly at 32°F saturation', async () => {
const result = await coolprop.calculateSubcooling({
temperature: 27, // 5°F below saturation temp of 32°F
pressure: 490.8, // saturation pressure at 32°F (from chart)
refrigerant: 'R744',
tempUnit: 'F', // Changed to F
pressureUnit: 'psig'
});
expect(result.type).toBe('success');
expect(Math.abs(result.subcooling - 5)).toBeLessThan(0.1); // Should be ~5°F subcooling
});
it('should calculate zero superheat at saturation point', async () => {
const result = await coolprop.calculateSuperheat({
temperature: 32, // Exact saturation temperature
pressure: 490.8, // Matching saturation pressure from chart
refrigerant: 'R744',
tempUnit: 'F', // Changed to F
pressureUnit: 'psig'
});
expect(result.type).toBe('success');
expect(Math.abs(result.superheat)).toBeLessThan(0.1); // Should be ~0°F superheat
});
it('should calculate zero subcooling at saturation point', async () => {
const result = await coolprop.calculateSubcooling({
temperature: 32, // Exact saturation temperature
pressure: 490.8, // Matching saturation pressure from chart
refrigerant: 'R744',
tempUnit: 'F', // Changed to F
pressureUnit: 'psig'
});
expect(result.type).toBe('success');
expect(Math.abs(result.subcooling)).toBeLessThan(0.1); // Should be ~0°F subcooling
});
});

View File

@@ -0,0 +1,296 @@
const coolprop = require('../src/index.js');
describe('CoolProp Wrapper', () => {
describe('Initialization', () => {
it('should fail without refrigerant', async () => {
const result = await coolprop.init({});
expect(result.type).toBe('error');
expect(result.message).toContain('Refrigerant must be specified');
});
it('should fail with invalid temperature unit', async () => {
const result = await coolprop.init({ refrigerant: 'R404A', tempUnit: 'X' });
expect(result.type).toBe('error');
expect(result.message).toContain('Invalid temperature unit');
});
it('should fail with invalid pressure unit', async () => {
const result = await coolprop.init({ refrigerant: 'R404A', pressureUnit: 'X' });
expect(result.type).toBe('error');
expect(result.message).toContain('Invalid pressure unit');
});
it('should succeed with valid config', async () => {
const result = await coolprop.init({
refrigerant: 'R404A',
tempUnit: 'C',
pressureUnit: 'bar'
});
console.log(result);
expect(result.type).toBe('success');
});
});
describe('Auto-initialization', () => {
it('should work without explicit init', async () => {
const result = await coolprop.calculateSuperheat({
temperature: 25,
pressure: 10,
refrigerant: 'R404A',
tempUnit: 'C',
pressureUnit: 'bar'
});
expect(result.type).toBe('success');
expect(result.superheat).toBeDefined();
});
});
describe('Unit Conversions', () => {
it('should correctly convert temperature units', async () => {
const resultC = await coolprop.getSaturationTemperature({
pressure: 10,
refrigerant: 'R404A',
pressureUnit: 'bar',
tempUnit: 'C'
});
const resultF = await coolprop.getSaturationTemperature({
pressure: 10,
refrigerant: 'R404A',
pressureUnit: 'bar',
tempUnit: 'F'
});
const resultK = await coolprop.getSaturationTemperature({
pressure: 10,
refrigerant: 'R404A',
pressureUnit: 'bar',
tempUnit: 'K'
});
expect(Math.abs((resultC.temperature * 9/5 + 32) - resultF.temperature)).toBeLessThan(0.01);
expect(Math.abs((resultC.temperature + 273.15) - resultK.temperature)).toBeLessThan(0.01);
});
it('should correctly convert pressure units', async () => {
const resultBar = await coolprop.getSaturationPressure({
temperature: 25,
refrigerant: 'R404A',
tempUnit: 'C',
pressureUnit: 'bar'
});
const resultPsi = await coolprop.getSaturationPressure({
temperature: 25,
refrigerant: 'R404A',
tempUnit: 'C',
pressureUnit: 'psi'
});
expect(Math.abs((resultBar.pressure * 14.5038) - resultPsi.pressure)).toBeLessThan(0.1);
});
});
describe('Refrigerant Calculations', () => {
const refrigerants = ['R404A', 'R134a', 'R507A', 'R744'];
refrigerants.forEach(refrigerant => {
describe(refrigerant, () => {
it('should calculate superheat', async () => {
const result = await coolprop.calculateSuperheat({
temperature: 25,
pressure: 10,
refrigerant,
tempUnit: 'C',
pressureUnit: 'bar'
});
expect(result.type).toBe('success');
expect(result.superheat).toBeDefined();
expect(result.refrigerant).toBe(refrigerant);
expect(result.units).toEqual(expect.objectContaining({
temperature: 'C',
pressure: 'bar'
}));
});
it('should calculate subcooling', async () => {
const result = await coolprop.calculateSubcooling({
temperature: 20,
pressure: 20,
refrigerant,
tempUnit: 'C',
pressureUnit: 'bar'
});
expect(result.type).toBe('success');
expect(result.subcooling).toBeDefined();
expect(result.refrigerant).toBe(refrigerant);
});
it('should get all properties', async () => {
const result = await coolprop.getProperties({
temperature: 25,
pressure: 10,
refrigerant,
tempUnit: 'C',
pressureUnit: 'bar'
});
expect(result.type).toBe('success');
expect(result.properties).toBeDefined();
expect(result.refrigerant).toBe(refrigerant);
// Check all required properties exist
const requiredProps = [
'temperature', 'pressure', 'density', 'enthalpy',
'entropy', 'quality', 'conductivity', 'viscosity', 'specificHeat'
];
requiredProps.forEach(prop => {
expect(result.properties[prop]).toBeDefined();
expect(typeof result.properties[prop]).toBe('number');
});
});
});
});
});
describe('Default Override Behavior', () => {
beforeAll(async () => {
await coolprop.init({
refrigerant: 'R404A',
tempUnit: 'C',
pressureUnit: 'bar'
});
});
it('should use defaults when no overrides provided', async () => {
const result = await coolprop.calculateSuperheat({
temperature: 25,
pressure: 10
});
expect(result.refrigerant).toBe('R404A');
expect(result.units.temperature).toBe('C');
expect(result.units.pressure).toBe('bar');
});
it('should allow refrigerant override', async () => {
const result = await coolprop.calculateSuperheat({
temperature: 25,
pressure: 10,
refrigerant: 'R134a'
});
expect(result.refrigerant).toBe('R134a');
});
it('should allow unit overrides', async () => {
const result = await coolprop.calculateSuperheat({
temperature: 77,
pressure: 145,
tempUnit: 'F',
pressureUnit: 'psi'
});
expect(result.units.temperature).toBe('F');
expect(result.units.pressure).toBe('psi');
});
});
describe('Default Settings Management', () => {
it('should allow updating defaults after initialization', async () => {
// Initial setup
await coolprop.init({
refrigerant: 'R404A',
tempUnit: 'C',
pressureUnit: 'bar'
});
// Update defaults
const updateResult = await coolprop.init({
refrigerant: 'R134a',
tempUnit: 'F',
pressureUnit: 'psi'
});
expect(updateResult.type).toBe('success');
expect(updateResult.message).toBe('Default settings updated');
// Verify new defaults are used
const result = await coolprop.calculateSuperheat({
temperature: 77,
pressure: 145
});
expect(result.refrigerant).toBe('R134a');
expect(result.units.temperature).toBe('F');
expect(result.units.pressure).toBe('psi');
});
it('should update the coolprop instance if refrigerant is changed', async () => {
// Set initial defaults
await coolprop.init({
refrigerant: 'R404A',
tempUnit: 'C',
pressureUnit: 'bar'
});
const config = await coolprop.getConfig();
// First call with overrides
const result1 = await coolprop.calculateSuperheat({
temperature: 25,
pressure: 10,
refrigerant: 'R507A',
tempUnit: 'C',
pressureUnit: 'bar'
});
// Second call using defaults
const result2 = await coolprop.calculateSuperheat({
temperature: 25,
pressure: 10
});
const config2 = await coolprop.getConfig();
expect(config.refrigerant).toBe('R404A');
expect(config2.refrigerant).toBe('R507A');
expect(result1.refrigerant).toBe('R507A');
expect(result2.refrigerant).toBe('R507A');
});
it('should allow partial updates of defaults', async () => {
// Initial setup
await coolprop.init({
refrigerant: 'R404A',
tempUnit: 'C',
pressureUnit: 'bar'
});
// Update only temperature unit
await coolprop.init({
tempUnit: 'F'
});
const result = await coolprop.calculateSuperheat({
temperature: 77,
pressure: 10
});
expect(result.refrigerant).toBe('R404A'); // unchanged
expect(result.units.temperature).toBe('F'); // updated
expect(result.units.pressure).toBe('bar'); // unchanged
});
it('should validate units when updating defaults', async () => {
await coolprop.init({
refrigerant: 'R404A',
tempUnit: 'C',
pressureUnit: 'bar'
});
const result = await coolprop.init({
tempUnit: 'X' // invalid unit
});
expect(result.type).toBe('error');
expect(result.message).toContain('Invalid temperature unit');
});
});
});

View File

@@ -0,0 +1,58 @@
const coolprop = require('../src/index.js');
describe('Pressure Conversion Chain Tests', () => {
test('bar -> pa -> bara -> pa -> bar conversion chain', () => {
const startValue = 2; // 2 bar gauge
const toPa = coolprop._convertPressureToPa(startValue, 'bar');
// console.log('bar to Pa:', toPa);
const toBara = coolprop._convertPressureFromPa(toPa, 'bara');
// console.log('Pa to bara:', toBara);
const backToPa = coolprop._convertPressureToPa(toBara, 'bara');
// console.log('bara to Pa:', backToPa);
const backToBar = coolprop._convertPressureFromPa(backToPa, 'bar');
// console.log('Pa to bar:', backToBar);
expect(Math.round(backToBar * 1000) / 1000).toBe(startValue);
});
test('psi -> pa -> psia -> pa -> psi conversion chain', () => {
const startValue = 30; // 30 psi gauge
const toPa = coolprop._convertPressureToPa(startValue, 'psi');
// console.log('psi to Pa:', toPa);
const toPsia = coolprop._convertPressureFromPa(toPa, 'psia');
// console.log('Pa to psia:', toPsia);
const backToPa = coolprop._convertPressureToPa(toPsia, 'psia');
// console.log('psia to Pa:', backToPa);
const backToPsi = coolprop._convertPressureFromPa(backToPa, 'psi');
// console.log('Pa to psi:', backToPsi);
expect(Math.round(backToPsi * 1000) / 1000).toBe(startValue);
});
test('kpa -> pa -> kpaa -> pa -> kpa conversion chain', () => {
const startValue = 200; // 200 kPa gauge
const toPa = coolprop._convertPressureToPa(startValue, 'kpa');
// console.log('kpa to Pa:', toPa);
const toKpaa = coolprop._convertPressureFromPa(toPa, 'kpaa');
// console.log('Pa to kpaa:', toKpaa);
const backToPa = coolprop._convertPressureToPa(toKpaa, 'kpaa');
// console.log('kpaa to Pa:', backToPa);
const backToKpa = coolprop._convertPressureFromPa(backToPa, 'kpa');
// console.log('Pa to kpa:', backToKpa);
expect(Math.round(backToKpa * 1000) / 1000).toBe(startValue);
});
});

View File

@@ -0,0 +1,50 @@
const coolProp = require('../src/index.js');
describe('PropsSI Direct Access', () => {
let PropsSI;
beforeAll(async () => {
// Get the PropsSI function
PropsSI = await coolProp.getPropsSI();
});
test('should initialize and return PropsSI function', async () => {
expect(typeof PropsSI).toBe('function');
});
test('should calculate saturation temperature of R134a at 1 bar', () => {
const pressure = 100000; // 1 bar in Pa
const temp = PropsSI('T', 'P', pressure, 'Q', 0, 'R134a');
expect(temp).toBeCloseTo(246.79, 1); // ~246.79 K at 1 bar
});
test('should calculate density of R134a at specific conditions', () => {
const temp = 300; // 300 K
const pressure = 100000; // 1 bar in Pa
const density = PropsSI('D', 'T', temp, 'P', pressure, 'R134a');
expect(density).toBeGreaterThan(0)
expect(density).toBeLessThan(Infinity);
});
test('should throw error for invalid refrigerant', () => {
const temp = 300;
const pressure = 100000;
expect(() => {
let result = PropsSI('D', 'T', temp, 'P', pressure, 'INVALID_REFRIGERANT');
if(result == Infinity) {
throw new Error('Infinity due to invalid refrigerant');
}
}).toThrow();
});
test('should throw error for invalid input parameter', () => {
const temp = 300;
const pressure = 100000;
expect(() => {
let result = PropsSI('INVALID_PARAM', 'T', temp, 'P', pressure, 'R134a');
if(result == Infinity) {
throw new Error('Infinity due to invalid input parameter');
}
}).toThrow();
});
});

View File

@@ -0,0 +1,128 @@
const coolprop = require('../src/index.js');
describe('Temperature Conversion Tests', () => {
describe('Regular Temperature Conversions', () => {
const testCases = [
{
startUnit: 'C',
startValue: 25,
expectedK: 298.15,
conversions: {
F: 77,
K: 298.15,
C: 25
}
},
{
startUnit: 'F',
startValue: 77,
expectedK: 298.15,
conversions: {
F: 77,
K: 298.15,
C: 25
}
},
{
startUnit: 'K',
startValue: 298.15,
expectedK: 298.15,
conversions: {
F: 77,
K: 298.15,
C: 25
}
}
];
testCases.forEach(({ startUnit, startValue, expectedK, conversions }) => {
test(`${startValue}${startUnit} conversion chain`, () => {
// First convert to Kelvin
const toK = coolprop._convertTempToK(startValue, startUnit);
expect(Math.round(toK * 100) / 100).toBe(expectedK);
// Then convert from Kelvin to each unit
Object.entries(conversions).forEach(([unit, expected]) => {
const converted = coolprop._convertTempFromK(toK, unit);
expect(Math.round(converted * 100) / 100).toBe(expected);
});
});
});
});
describe('Delta Temperature Conversions', () => {
const testCases = [
{
startValue: 10, // 10K temperature difference
expected: {
K: 10,
C: 10,
F: 18 // 10K = 18°F difference
}
}
];
testCases.forEach(({ startValue, expected }) => {
test(`${startValue}K delta conversion to all units`, () => {
Object.entries(expected).forEach(([unit, expectedValue]) => {
const converted = coolprop._convertDeltaTempFromK(startValue, unit);
expect(Math.round(converted * 100) / 100).toBe(expectedValue);
});
});
});
});
describe('Common Temperature Points', () => {
const commonPoints = [
{
description: 'Water freezing point',
C: 0,
F: 32,
K: 273.15
},
{
description: 'Water boiling point',
C: 100,
F: 212,
K: 373.15
},
{
description: 'Room temperature',
C: 20,
F: 68,
K: 293.15
},
{
description: 'Typical refrigeration evaporator',
C: 5,
F: 41,
K: 278.15
},
{
description: 'Typical refrigeration condenser',
C: 35,
F: 95,
K: 308.15
}
];
commonPoints.forEach(point => {
test(`${point.description} conversions`, () => {
// Test conversion to Kelvin from each unit
const fromC = coolprop._convertTempToK(point.C, 'C');
const fromF = coolprop._convertTempToK(point.F, 'F');
expect(Math.round(fromC * 100) / 100).toBe(point.K);
expect(Math.round(fromF * 100) / 100).toBe(point.K);
// Test conversion from Kelvin to each unit
const toC = coolprop._convertTempFromK(point.K, 'C');
const toF = coolprop._convertTempFromK(point.K, 'F');
expect(Math.round(toC * 100) / 100).toBe(point.C);
expect(Math.round(toF * 100) / 100).toBe(point.F);
});
});
});
});

View File

@@ -16,7 +16,7 @@ class Assertions {
assertNoNaN(arr, label = "array") { assertNoNaN(arr, label = "array") {
if (Array.isArray(arr)) { if (Array.isArray(arr)) {
for (const el of arr) { for (const el of arr) {
assertNoNaN(el, label); this.assertNoNaN(el, label);
} }
} else { } else {
if (Number.isNaN(arr)) { if (Number.isNaN(arr)) {

View File

@@ -1,3 +1,5 @@
export function getAssetVariables() { function getAssetVariables() {
} }
module.exports = { getAssetVariables };

View File

@@ -5,14 +5,18 @@ class ChildRegistrationUtils {
this.registeredChildren = new Map(); this.registeredChildren = new Map();
} }
async registerChild(child, positionVsParent, distance) { async registerChild(child, positionVsParent, _distance) {
const { softwareType } = child.config.functionality; const softwareType = (child.config.functionality.softwareType || '').toLowerCase();
const { name, id } = child.config.general; const { name, id } = child.config.general;
this.logger.debug(`Registering child: ${name} (${id}) as ${softwareType} at ${positionVsParent}`); this.logger.debug(`Registering child: ${name} (${id}) as ${softwareType} at ${positionVsParent}`);
// Enhanced child setup // Enhanced child setup - multiple parents
child.parent = this.mainClass; if (Array.isArray(child.parent)) {
child.parent.push(this.mainClass);
} else {
child.parent = [this.mainClass];
}
child.positionVsParent = positionVsParent; child.positionVsParent = positionVsParent;
// Enhanced measurement container with rich context // Enhanced measurement container with rich context
@@ -35,7 +39,7 @@ class ChildRegistrationUtils {
// IMPORTANT: Only call parent registration - no automatic handling and if parent has this function then try to register this child // IMPORTANT: Only call parent registration - no automatic handling and if parent has this function then try to register this child
if (typeof this.mainClass.registerChild === 'function') { if (typeof this.mainClass.registerChild === 'function') {
this.mainClass.registerChild(child, softwareType); return this.mainClass.registerChild(child, softwareType);
} }
this.logger.info(`✅ Child ${name} registered successfully`); this.logger.info(`✅ Child ${name} registered successfully`);

View File

@@ -1,260 +0,0 @@
// ChildRegistrationUtils.js
class ChildRegistrationUtils {
constructor(mainClass) {
this.mainClass = mainClass; // Reference to the main class
this.logger = mainClass.logger;
}
async registerChild(child, positionVsParent) {
this.logger.debug(`Registering child: ${child.id} with position=${positionVsParent}`);
const { softwareType } = child.config.functionality;
const { name, id, unit } = child.config.general;
const { category = "", type = "" } = child.config.asset || {};
console.log(`Registering child: ${name}, id: ${id}, softwareType: ${softwareType}, category: ${category}, type: ${type}, positionVsParent: ${positionVsParent}` );
const emitter = child.emitter;
//define position vs parent in child
child.positionVsParent = positionVsParent;
child.parent = this.mainClass;
if (!this.mainClass.child) this.mainClass.child = {};
if (!this.mainClass.child[softwareType])
this.mainClass.child[softwareType] = {};
if (!this.mainClass.child[softwareType][category])
this.mainClass.child[softwareType][category] = {};
if (!this.mainClass.child[softwareType][category][type])
this.mainClass.child[softwareType][category][type] = {};
// Use an array to handle multiple categories
if (!Array.isArray(this.mainClass.child[softwareType][category][type])) {
this.mainClass.child[softwareType][category][type] = [];
}
// Push the new child to the array of the mainclass so we can track the childs
this.mainClass.child[softwareType][category][type].push({
name,
id,
unit,
emitter,
});
//then connect the child depending on the type type etc..
this.connectChild(
id,
softwareType,
emitter,
category,
child,
type,
positionVsParent
);
}
connectChild(
id,
softwareType,
emitter,
category,
child,
type,
positionVsParent
) {
this.logger.debug(
`Connecting child id=${id}: desc=${softwareType}, category=${category},type=${type}, position=${positionVsParent}`
);
switch (softwareType) {
case "measurement":
this.logger.debug(
`Registering measurement child: ${id} with category=${category}`
);
this.connectMeasurement(child, type, positionVsParent);
break;
case "machine":
this.logger.debug(`Registering complete machine child: ${id}`);
this.connectMachine(child);
break;
case "valve":
this.logger.debug(`Registering complete valve child: ${id}`);
this.connectValve(child);
break;
case "machineGroup":
this.logger.debug(`Registering complete machineGroup child: ${id}`);
this.connectMachineGroup(child);
break;
case "actuator":
this.logger.debug(`Registering linear actuator child: ${id}`);
this.connectActuator(child,positionVsParent);
break;
default:
this.logger.error(`Child registration unrecognized desc: ${desc}`);
this.logger.error(`Unrecognized softwareType: ${softwareType}`);
}
}
connectMeasurement(child, type, position) {
this.logger.debug(
`Connecting measurement child: ${type} with position=${position}`
);
// Check if type is valid
if (!type) {
this.logger.error(`Invalid type for measurement: ${type}`);
return;
}
// initialize the measurement to a number - logging each step for debugging
try {
this.logger.debug(
`Initializing measurement: ${type}, position: ${position} value: 0`
);
const typeResult = this.mainClass.measurements.type(type);
const variantResult = typeResult.variant("measured");
const positionResult = variantResult.position(position);
positionResult.value(0);
this.logger.debug(
`Subscribing on mAbs event for measurement: ${type}, position: ${position}`
);
// Listen for the mAbs event and update the measurement
this.logger.debug(
`Successfully initialized measurement: ${type}, position: ${position}`
);
} catch (error) {
this.logger.error(`Failed to initialize measurement: ${error.message}`);
return;
}
//testing new emitter strategy
child.measurements.emitter.on("newValue", (data) => {
this.logger.warn(
`Value change event received for measurement: ${type}, position: ${position}, value: ${data.value}`
);
});
child.emitter.on("mAbs", (value) => {
// Use the same method chaining approach that worked during initialization
this.mainClass.measurements
.type(type)
.variant("measured")
.position(position)
.value(value);
this.mainClass.updateMeasurement("measured", type, value, position);
//this.logger.debug(`--------->>>>>>>>>Updated measurement: ${type}, value: ${value}, position: ${position}`);
});
}
connectMachine(machine) {
if (!machine) {
this.logger.error("Invalid machine provided.");
return;
}
const machineId = Object.keys(this.mainClass.machines).length + 1;
this.mainClass.machines[machineId] = machine;
this.logger.info(
`Setting up pressureChange listener for machine ${machineId}`
);
machine.emitter.on("pressureChange", () =>
this.mainClass.handlePressureChange(machine)
);
//update of child triggers the handler
this.mainClass.handleChildChange();
this.logger.info(`Machine ${machineId} registered successfully.`);
}
connectValve(valve) {
if (!valve) {
this.logger.warn("Invalid valve provided.");
return;
}
const valveId = Object.keys(this.mainClass.valves).length + 1;
this.mainClass.valves[valveId] = valve; // Gooit valve object in de valves attribute met valve objects
valve.state.emitter.on("positionChange", (data) => {
//ValveGroupController abboneren op klepstand verandering
this.mainClass.logger.debug(`Position change of valve detected: ${data}`);
this.mainClass.calcValveFlows();
}); //bepaal nieuwe flow per valve
valve.emitter.on("deltaPChange", () => {
this.mainClass.logger.debug("DeltaP change of valve detected");
this.mainClass.calcMaxDeltaP();
}); //bepaal nieuwe max deltaP
this.logger.info(`Valve ${valveId} registered successfully.`);
}
connectMachineGroup(machineGroup) {
if (!machineGroup) {
this.logger.warn("Invalid machineGroup provided.");
return;
}
try {
const machineGroupId = Object.keys(this.mainClass.machineGroups).length + 1;
this.mainClass.machineGroups[machineGroupId] = machineGroup;
} catch (error) {
this.logger.warn(`Skip machinegroup connnection: ${error.message}`);
}
machineGroup.emitter.on("totalFlowChange", (data) => {
this.mainClass.logger.debug('Total flow change of machineGroup detected');
this.mainClass.handleInput("parent", "totalFlowChange", data)}); //Geef nieuwe totale flow door aan valveGrouControl
this.logger.info(`MachineGroup ${machineGroup.config.general.name} registered successfully.`);
}
connectActuator(actuator, positionVsParent) {
if (!actuator) {
this.logger.warn("Invalid actuator provided.");
return;
}
//Special case gateGroupControl
if (
this.mainClass.config.functionality.softwareType == "gateGroupControl"
) {
if (Object.keys(this.mainClass.actuators).length < 2) {
if (positionVsParent == "downstream") {
this.mainClass.actuators[0] = actuator;
}
if (positionVsParent == "upstream") {
this.mainClass.actuators[1] = actuator;
}
//define emitters
actuator.state.emitter.on("positionChange", (data) => {
this.mainClass.logger.debug(`Position change of actuator detected: ${data}`);
this.mainClass.eventUpdate();
});
//define emitters
actuator.state.emitter.on("stateChange", (data) => {
this.mainClass.logger.debug(`State change of actuator detected: ${data}`);
this.mainClass.eventUpdate();
});
} else {
this.logger.error(
"Too many actuators registered. Only two are allowed."
);
}
}
}
//wanneer hij deze ontvangt is deltaP van een van de valves veranderd (kan ook zijn niet child zijn, maar dat maakt niet uit)
}
module.exports = ChildRegistrationUtils;

View File

@@ -76,7 +76,7 @@ class ConfigUtils {
// loop through objects and merge them obj1 will be updated with obj2 values // loop through objects and merge them obj1 will be updated with obj2 values
mergeObjects(obj1, obj2) { mergeObjects(obj1, obj2) {
for (let key in obj2) { for (let key in obj2) {
if (obj2.hasOwnProperty(key)) { if (Object.prototype.hasOwnProperty.call(obj2, key)) {
if (typeof obj2[key] === 'object') { if (typeof obj2[key] === 'object') {
if (!obj1[key]) { if (!obj1[key]) {
obj1[key] = {}; obj1[key] = {};

View File

@@ -0,0 +1,44 @@
/**
* CSV formatter
* Produces a single CSV line: timestamp,measurement,field1=val1,field2=val2,...
*
* Values are escaped if they contain commas or quotes.
*
* @param {string} measurement - The measurement name (e.g. node name)
* @param {object} metadata - { fields, tags }
* - fields: key/value pairs of changed data points
* - tags: flat key/value string pairs (included as columns)
* @returns {string} CSV-formatted line
*/
function format(measurement, metadata) {
const { fields, tags } = metadata;
const timestamp = new Date().toISOString();
const parts = [escapeCSV(timestamp), escapeCSV(measurement)];
// Append tags first, then fields
if (tags) {
for (const key of Object.keys(tags).sort()) {
parts.push(escapeCSV(`${key}=${tags[key]}`));
}
}
for (const key of Object.keys(fields).sort()) {
parts.push(escapeCSV(`${key}=${fields[key]}`));
}
return parts.join(',');
}
/**
* Escapes a value for safe inclusion in a CSV field.
* Wraps in double quotes if the value contains a comma, quote, or newline.
*/
function escapeCSV(value) {
const str = String(value);
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
return '"' + str.replace(/"/g, '""') + '"';
}
return str;
}
module.exports = { format };

View File

@@ -0,0 +1,60 @@
/**
* Formatter Registry
* ------------------
* Maps format names to formatter modules.
* Each formatter exports: format(measurement, metadata) => string|object
*
* Usage:
* const { getFormatter, registerFormatter } = require('./formatters');
* const fmt = getFormatter('json');
* const output = fmt.format('pump1', { fields: {...}, tags: {...} });
*/
const influxdbFormatter = require('./influxdbFormatter');
const jsonFormatter = require('./jsonFormatter');
const csvFormatter = require('./csvFormatter');
const processFormatter = require('./processFormatter');
// Built-in registry
const registry = {
influxdb: influxdbFormatter,
json: jsonFormatter,
csv: csvFormatter,
process: processFormatter,
};
/**
* Retrieve a formatter by name.
* @param {string} name - Format name (e.g. 'influxdb', 'json', 'csv')
* @returns {object} Formatter with a .format() method
* @throws {Error} If the format name is not registered
*/
function getFormatter(name) {
const formatter = registry[name];
if (!formatter) {
throw new Error(`Unknown output format: "${name}". Registered formats: ${Object.keys(registry).join(', ')}`);
}
return formatter;
}
/**
* Register a custom formatter at runtime.
* @param {string} name - Format name
* @param {object} formatter - Object with a .format(measurement, metadata) method
*/
function registerFormatter(name, formatter) {
if (typeof formatter.format !== 'function') {
throw new Error('Formatter must have a .format(measurement, metadata) method');
}
registry[name] = formatter;
}
/**
* List all registered format names.
* @returns {string[]}
*/
function getRegisteredFormats() {
return Object.keys(registry);
}
module.exports = { getFormatter, registerFormatter, getRegisteredFormats };

View File

@@ -0,0 +1,22 @@
/**
* InfluxDB formatter
* Produces the structured object expected by Node-RED InfluxDB nodes:
* { measurement, fields, tags, timestamp }
*
* @param {string} measurement - The measurement name (e.g. node name)
* @param {object} metadata - { fields, tags }
* - fields: key/value pairs of changed data points
* - tags: flat key/value string pairs (InfluxDB tags)
* @returns {string|object} Formatted payload (object for InfluxDB)
*/
function format(measurement, metadata) {
const { fields, tags } = metadata;
return {
measurement: measurement,
fields: fields,
tags: tags || {},
timestamp: new Date(),
};
}
module.exports = { format };

View File

@@ -0,0 +1,22 @@
/**
* JSON formatter
* Produces a JSON string suitable for MQTT, REST APIs, etc.
*
* @param {string} measurement - The measurement name (e.g. node name)
* @param {object} metadata - { fields, tags }
* - fields: key/value pairs of changed data points
* - tags: flat key/value string pairs
* @returns {string} JSON-encoded string
*/
function format(measurement, metadata) {
const { fields, tags } = metadata;
const payload = {
measurement: measurement,
fields: fields,
tags: tags || {},
timestamp: new Date().toISOString(),
};
return JSON.stringify(payload);
}
module.exports = { format };

View File

@@ -0,0 +1,9 @@
/**
* Process formatter
* Keeps the existing process-port behaviour: emit only changed fields as an object.
*/
function format(_measurement, metadata) {
return metadata.fields;
}
module.exports = { format };

View File

@@ -44,7 +44,7 @@ class Logger {
if (this.levels.includes(level)) { if (this.levels.includes(level)) {
this.logLevel = level; this.logLevel = level;
} else { } else {
console.error(`[ERROR ${nameModule}]: Invalid log level: ${level}`); console.error(`[ERROR ${this.nameModule}]: Invalid log level: ${level}`);
} }
} }

View File

@@ -0,0 +1,123 @@
/**
* Data fetching methods for MenuUtils.
* Handles primary/fallback URL fetching and API calls.
*/
const dataFetching = {
async fetchData(url, fallbackUrl) {
try {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const responsData = await response.json();
//responsData
const data = responsData.data;
/* .map(item => {
const { vendor_name, ...rest } = item;
return {
name: vendor_name,
...rest
};
}); */
console.log(url);
console.log("Response Data: ", data);
return data;
} catch (err) {
console.warn(
`Primary URL failed: ${url}. Trying fallback URL: ${fallbackUrl}`,
err
);
try {
const response = await fetch(fallbackUrl);
if (!response.ok)
throw new Error(`HTTP error! status: ${response.status}`);
return await response.json();
} catch (fallbackErr) {
console.error("Both primary and fallback URLs failed:", fallbackErr);
return [];
}
}
},
async fetchProjectData(url) {
try {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const responsData = await response.json();
console.log("Response Data: ", responsData);
return responsData;
} catch (err) {
/* intentionally empty */
}
},
// Save changes to API
async apiCall(node) {
try{
// OLFIANT when a browser refreshes the tag code is lost!!! fix this later!!!!!
// FIX UUID ALSO LATER
if(node.assetTagCode !== "" || node.assetTagCode !== null){ /* intentionally empty */ }
// API call to register or check asset in central database
let assetregisterAPI = node.configUrls.cloud.taggcodeAPI + "/asset/create_asset.php";
const assetModelId = node.modelMetadata.id; //asset_product_model_id
const uuid = node.uuid; //asset_product_model_uuid
const assetName = node.assetType; //asset_name / type?
const description = node.name; // asset_description
const assetStatus = "actief"; //asset_status -> koppel aan enable / disable node ? or make dropdown ?
const assetProfileId = 1; //asset_profile_id these are the rules to check if the childs are valid under this node (parent / child id?)
const child_assets = ["63247"]; //child_assets tagnummer of id?
const assetProcessId = node.processId; //asset_process_id
const assetLocationId = node.locationId; //asset_location_id
const tagCode = node.assetTagCode; // if already exists in the node information use it to tell the api it exists and it will update else we will get it from the api call
//console.log(`this is my tagCode: ${tagCode}`);
// Build base URL with required parameters
let apiUrl = `?asset_product_model_id=${assetModelId}&asset_product_model_uuid=${uuid}&asset_name=${assetName}&asset_description=${description}&asset_status=${assetStatus}&asset_profile_id=${assetProfileId}&asset_location_id=${assetLocationId}&asset_process_id=${assetProcessId}&child_assets=${child_assets}`;
// Only add tagCode to URL if it exists
if (tagCode) {
apiUrl += `&asset_tag_number=${tagCode}`;
console.log('hello there');
}
assetregisterAPI += apiUrl;
console.log("API call to register asset in central database", assetregisterAPI);
const response = await fetch(assetregisterAPI, {
method: "POST"
});
// Get the response text first
const responseText = await response.text();
console.log("Raw API response:", responseText);
// Try to parse the JSON, handling potential parsing errors
let jsonResponse;
try {
jsonResponse = JSON.parse(responseText);
} catch (parseError) {
console.error("JSON Parsing Error:", parseError);
console.error("Response that could not be parsed:", responseText);
throw new Error("Failed to parse API response");
}
console.log(jsonResponse);
if(jsonResponse.success){
console.log(`${jsonResponse.message}, tag number: ${jsonResponse.asset_tag_number}, asset id: ${jsonResponse.asset_id}`);
// Save the asset tag number and id to the node
} else {
console.log("Asset not registered in central database");
}
return jsonResponse;
} catch (error) {
console.log("Error saving changes to asset register API", error);
}
},
};
module.exports = dataFetching;

View File

@@ -0,0 +1,283 @@
/**
* Dropdown population methods for MenuUtils.
* Handles populating and cascading dropdown menus for assets, suppliers, models, units, etc.
*/
const dropdownPopulation = {
populateSmoothingMethods(configUrls, elements, node) {
this.fetchData(configUrls.cloud.config, configUrls.local.config)
.then((configData) => {
const smoothingMethods =
configData.smoothing?.smoothMethod?.rules?.values?.map(
(o) => o.value
) || [];
this.populateDropdown(
elements.smoothMethod,
smoothingMethods,
node,
"smooth_method"
);
})
.catch((err) => {
console.error("Error loading smoothing methods", err);
});
},
populateInterpolationMethods(configUrls, elements, node) {
this.fetchData(configUrls.cloud.config, configUrls.local.config)
.then((configData) => {
const interpolationMethods =
configData?.interpolation?.type?.rules?.values.map((m) => m.value) ||
[];
this.populateDropdown(
elements.interpolationMethodInput,
interpolationMethods,
node,
"interpolationMethod"
);
// Find the selected method and use it to spawn 1 more field to fill in tension
//const selectedMethod = interpolationMethods.find(m => m === node.interpolationMethod);
this.initTensionToggles(elements, node);
})
.catch((err) => {
console.error("Error loading interpolation methods", err);
});
},
populateLogLevelOptions(logLevelSelect, configData, node) {
// debug log level
//console.log("Displaying configData => ", configData) ;
const logLevels =
configData?.general?.logging?.logLevel?.rules?.values?.map(
(l) => l.value
) || [];
//console.log("Displaying logLevels => ", logLevels);
// Reuse your existing generic populateDropdown helper
this.populateDropdown(logLevelSelect, logLevels, node.logLevel);
},
//cascade dropdowns for asset type, supplier, subType, model, unit
fetchAndPopulateDropdowns(configUrls, elements, node) {
this.fetchData(configUrls.cloud.config, configUrls.local.config)
.then((configData) => {
const assetType = configData.asset?.type?.default;
const localSuppliersUrl = this.constructUrl(configUrls.local.taggcodeAPI,`${assetType}s`,"suppliers.json");
const cloudSuppliersUrl = this.constructCloudURL(configUrls.cloud.taggcodeAPI, "/vendor/get_vendors.php");
return this.fetchData(cloudSuppliersUrl, localSuppliersUrl)
.then((supplierData) => {
const suppliers = supplierData.map((supplier) => supplier.name);
// Populate suppliers dropdown and set up its change handler
return this.populateDropdown(
elements.supplier,
suppliers,
node,
"supplier",
function (selectedSupplier) {
if (selectedSupplier) {
this.populateSubTypes(configUrls, elements, node, selectedSupplier);
}
}
);
})
.then(() => {
// If we have a saved supplier, trigger subTypes population
if (node.supplier) {
this.populateSubTypes(configUrls, elements, node, node.supplier);
}
});
})
.catch((error) => {
console.error("Error in initial dropdown population:", error);
});
},
populateSubTypes(configUrls, elements, node, selectedSupplier) {
this.fetchData(configUrls.cloud.config, configUrls.local.config)
.then((configData) => {
const assetType = configData.asset?.type?.default;
const supplierFolder = this.constructUrl( configUrls.local.taggcodeAPI, `${assetType}s`, selectedSupplier );
const localSubTypesUrl = this.constructUrl(supplierFolder, "subtypes.json");
const cloudSubTypesUrl = this.constructCloudURL(configUrls.cloud.taggcodeAPI, "/product/get_subtypesFromVendor.php?vendor_name=" + selectedSupplier);
return this.fetchData(cloudSubTypesUrl, localSubTypesUrl)
.then((subTypeData) => {
const subTypes = subTypeData.map((subType) => subType.name);
return this.populateDropdown(
elements.subType,
subTypes,
node,
"subType",
function (selectedSubType) {
if (selectedSubType) {
// When subType changes, update both models and units
this.populateModels(
configUrls,
elements,
node,
selectedSupplier,
selectedSubType
);
this.populateUnitsForSubType(
configUrls,
elements,
node,
selectedSubType
);
}
}
);
})
.then(() => {
// If we have a saved subType, trigger both models and units population
if (node.subType) {
this.populateModels(
configUrls,
elements,
node,
selectedSupplier,
node.subType
);
this.populateUnitsForSubType(configUrls, elements, node, node.subType);
}
//console.log("In fetch part of subtypes ");
// Store all data from selected model
/* node["modelMetadata"] = modelData.find(
(model) => model.name === node.model
);
console.log("Model Metadata: ", node["modelMetadata"]); */
});
})
.catch((error) => {
console.error("Error populating subtypes:", error);
});
},
populateUnitsForSubType(configUrls, elements, node, selectedSubType) {
// Fetch the units data
this.fetchData(configUrls.cloud.units, configUrls.local.units)
.then((unitsData) => {
// Find the category that matches the subType name
const categoryData = unitsData.units.find(
(category) =>
category.category.toLowerCase() === selectedSubType.toLowerCase()
);
if (categoryData) {
// Extract just the unit values and descriptions
const units = categoryData.values.map((unit) => ({
value: unit.value,
description: unit.description,
}));
// Create the options array with descriptions as labels
const options = units.map((unit) => ({
value: unit.value,
label: `${unit.value} - ${unit.description}`,
}));
// Populate the units dropdown
this.populateDropdown(
elements.unit,
options.map((opt) => opt.value),
node,
"unit"
);
// If there's no currently selected unit but we have options, select the first one
if (!node.unit && options.length > 0) {
node.unit = options[0].value;
elements.unit.value = options[0].value;
}
} else {
// If no matching category is found, provide a default % option
const defaultUnits = [{ value: "%", description: "Percentage" }];
this.populateDropdown(
elements.unit,
defaultUnits.map((unit) => unit.value),
node,
"unit"
);
console.warn(
`No matching unit category found for subType: ${selectedSubType}`
);
}
})
.catch((error) => {
console.error("Error fetching units:", error);
});
},
populateModels(
configUrls,
elements,
node,
selectedSupplier,
selectedSubType
) {
this.fetchData(configUrls.cloud.config, configUrls.local.config)
.then((configData) => {
const assetType = configData.asset?.type?.default;
// save assetType to fetch later
node.assetType = assetType;
const supplierFolder = this.constructUrl( configUrls.local.taggcodeAPI,`${assetType}s`,selectedSupplier);
const subTypeFolder = this.constructUrl(supplierFolder, selectedSubType);
const localModelsUrl = this.constructUrl(subTypeFolder, "models.json");
const cloudModelsUrl = this.constructCloudURL(configUrls.cloud.taggcodeAPI, "/product/get_product_models.php?vendor_name=" + selectedSupplier + "&product_subtype_name=" + selectedSubType);
return this.fetchData(cloudModelsUrl, localModelsUrl).then((modelData) => {
const models = modelData.map((model) => model.name); // use this to populate the dropdown
// If a model is already selected, store its metadata immediately
if (node.model) {
node["modelMetadata"] = modelData.find((model) => model.name === node.model);
}
this.populateDropdown(elements.model, models, node, "model", (selectedModel) => {
// Store only the metadata for the selected model
node["modelMetadata"] = modelData.find((model) => model.name === selectedModel);
});
/*
console.log('hello here I am:');
console.log(node["modelMetadata"]);
*/
});
})
.catch((error) => {
console.error("Error populating models:", error);
});
},
async populateDropdown(
htmlElement,
options,
node,
property,
callback
) {
this.generateHtml(htmlElement, options, node[property]);
htmlElement.addEventListener("change", async (e) => {
const newValue = e.target.value;
console.log(`Dropdown changed: ${property} = ${newValue}`);
node[property] = newValue;
RED.nodes.dirty(true);
if (callback) await callback(newValue); // Ensure async callback completion
});
},
};
module.exports = dropdownPopulation;

View File

@@ -0,0 +1,73 @@
/**
* HTML generation and endpoint methods for MenuUtils.
* Handles generating dropdown HTML and serving MenuUtils code to the browser.
*/
const htmlGeneration = {
generateHtml(htmlElement, options, savedValue) {
htmlElement.innerHTML = options.length
? `<option value="">Select...</option>${options
.map((opt) => `<option value="${opt}">${opt}</option>`)
.join("")}`
: "<option value=''>No options available</option>";
if (savedValue && options.includes(savedValue)) {
htmlElement.value = savedValue;
}
},
createMenuUtilsEndpoint(RED, nodeName, customHelpers = {}) {
RED.httpAdmin.get(`/${nodeName}/resources/menuUtils.js`, function(req, res) {
console.log(`Serving menuUtils.js for ${nodeName} node`);
res.set('Content-Type', 'application/javascript');
const browserCode = this.generateMenuUtilsCode(nodeName, customHelpers);
res.send(browserCode);
}.bind(this));
},
generateMenuUtilsCode(nodeName, customHelpers = {}) {
const defaultHelpers = {
validateRequired: `function(value) {
return value && value.toString().trim() !== '';
}`,
formatDisplayValue: `function(value, unit) {
return \`\${value} \${unit || ''}\`.trim();
}`
};
const allHelpers = { ...defaultHelpers, ...customHelpers };
const helpersCode = Object.entries(allHelpers)
.map(([name, func]) => ` ${name}: ${func}`)
.join(',\n');
const classCode = this.constructor.toString(); // <-- this gives full class MenuUtils {...}
return `
// Create EVOLV namespace structure
window.EVOLV = window.EVOLV || {};
window.EVOLV.nodes = window.EVOLV.nodes || {};
window.EVOLV.nodes.${nodeName} = window.EVOLV.nodes.${nodeName} || {};
// Inject MenuUtils class
${classCode}
// Expose MenuUtils instance to namespace
window.EVOLV.nodes.${nodeName}.utils = {
menuUtils: new MenuUtils(),
helpers: {
${helpersCode}
}
};
// Optionally expose globally
window.MenuUtils = MenuUtils;
console.log('${nodeName} utilities loaded in namespace');
`;
},
};
module.exports = htmlGeneration;

18
src/helper/menu/index.js Normal file
View File

@@ -0,0 +1,18 @@
/**
* menu/index.js
* Barrel file for the menu module components.
*/
const toggles = require('./toggles');
const dataFetching = require('./dataFetching');
const urlUtils = require('./urlUtils');
const dropdownPopulation = require('./dropdownPopulation');
const htmlGeneration = require('./htmlGeneration');
module.exports = {
toggles,
dataFetching,
urlUtils,
dropdownPopulation,
htmlGeneration,
};

View File

@@ -0,0 +1,56 @@
/**
* Toggle initialization methods for MenuUtils.
* Controls visibility of UI elements based on checkbox/dropdown state.
*/
const toggles = {
initBasicToggles(elements) {
// Toggle visibility for log level
elements.logCheckbox.addEventListener("change", function () {
elements.rowLogLevel.style.display = this.checked ? "block" : "none";
});
elements.rowLogLevel.style.display = elements.logCheckbox.checked
? "block"
: "none";
},
// Define the initialize toggles function within scope
initMeasurementToggles(elements) {
// Toggle visibility for scaling inputs
elements.scalingCheckbox.addEventListener("change", function () {
elements.rowInputMin.style.display = this.checked ? "block" : "none";
elements.rowInputMax.style.display = this.checked ? "block" : "none";
});
// Set initial states
elements.rowInputMin.style.display = elements.scalingCheckbox.checked
? "block"
: "none";
elements.rowInputMax.style.display = elements.scalingCheckbox.checked
? "block"
: "none";
},
initTensionToggles(elements, node) {
const currentMethod = node.interpolationMethod;
elements.rowTension.style.display =
currentMethod === "monotone_cubic_spline" ? "block" : "none";
console.log(
"Initial tension row display: ",
elements.rowTension.style.display
);
elements.interpolationMethodInput.addEventListener("change", function () {
const selectedMethod = this.value;
console.log(`Interpolation method changed: ${selectedMethod}`);
node.interpolationMethod = selectedMethod;
// Toggle visibility for tension input
elements.rowTension.style.display =
selectedMethod === "monotone_cubic_spline" ? "block" : "none";
console.log("Tension row display: ", elements.rowTension.style.display);
});
},
};
module.exports = toggles;

View File

@@ -0,0 +1,39 @@
/**
* URL construction methods for MenuUtils.
* Helpers for building API and config URLs.
*/
const urlUtils = {
getSpecificConfigUrl(nodeName, cloudAPI) {
const cloudConfigURL = cloudAPI + "/config/" + nodeName + ".json";
const localConfigURL = "http://localhost:1880/"+ nodeName + "/dependencies/"+ nodeName + "/" + nodeName + "Config.json";
return { cloudConfigURL, localConfigURL };
},
// Helper function to construct a URL from a base and path internal
constructUrl(base, ...paths) {
// Remove trailing slash from base and leading slashes from paths
const sanitizedBase = (base || "").replace(/\/+$/, "");
const sanitizedPaths = paths.map((path) => path.replace(/^\/+|\/+$/g, ""));
// Join sanitized base and paths
const url = `${sanitizedBase}/${sanitizedPaths.join("/")}`;
console.log("Base:", sanitizedBase);
console.log("Paths:", sanitizedPaths);
console.log("Constructed URL:", url);
return url;
},
//Adjust for API Gateway
constructCloudURL(base, ...paths) {
// Remove trailing slash from base and leading slashes from paths
const sanitizedBase = base.replace(/\/+$/, "");
const sanitizedPaths = paths.map((path) => path.replace(/^\/+|\/+$/g, ""));
// Join sanitized base and paths
const url = `${sanitizedBase}/${sanitizedPaths.join("/")}`;
return url;
},
};
module.exports = urlUtils;

View File

@@ -1,543 +1,34 @@
/**
* MenuUtils — UI menu helper for Node-RED editor.
* Methods are split across focused modules under ./menu/ and mixed onto the prototype.
*/
const toggles = require('./menu/toggles');
const dataFetching = require('./menu/dataFetching');
const urlUtils = require('./menu/urlUtils');
const dropdownPopulation = require('./menu/dropdownPopulation');
const htmlGeneration = require('./menu/htmlGeneration');
class MenuUtils { class MenuUtils {
constructor() {
this.isCloud = false;
this.configData = null;
}
}
// Mix all method groups onto the prototype
initBasicToggles(elements) { const mixins = [toggles, dataFetching, urlUtils, dropdownPopulation, htmlGeneration];
// Toggle visibility for log level for (const mixin of mixins) {
elements.logCheckbox.addEventListener("change", function () { for (const [name, fn] of Object.entries(mixin)) {
elements.rowLogLevel.style.display = this.checked ? "block" : "none"; if (typeof fn === 'function') {
Object.defineProperty(MenuUtils.prototype, name, {
value: fn,
writable: true,
configurable: true,
enumerable: false,
}); });
elements.rowLogLevel.style.display = elements.logCheckbox.checked
? "block"
: "none";
}
// Define the initialize toggles function within scope
initMeasurementToggles(elements) {
// Toggle visibility for scaling inputs
elements.scalingCheckbox.addEventListener("change", function () {
elements.rowInputMin.style.display = this.checked ? "block" : "none";
elements.rowInputMax.style.display = this.checked ? "block" : "none";
});
// Set initial states
elements.rowInputMin.style.display = elements.scalingCheckbox.checked
? "block"
: "none";
elements.rowInputMax.style.display = elements.scalingCheckbox.checked
? "block"
: "none";
}
initTensionToggles(elements, node) {
const currentMethod = node.interpolationMethod;
elements.rowTension.style.display =
currentMethod === "monotone_cubic_spline" ? "block" : "none";
console.log(
"Initial tension row display: ",
elements.rowTension.style.display
);
elements.interpolationMethodInput.addEventListener("change", function () {
const selectedMethod = this.value;
console.log(`Interpolation method changed: ${selectedMethod}`);
node.interpolationMethod = selectedMethod;
// Toggle visibility for tension input
elements.rowTension.style.display =
selectedMethod === "monotone_cubic_spline" ? "block" : "none";
console.log("Tension row display: ", elements.rowTension.style.display);
});
}
// Define the smoothing methods population function within scope
populateSmoothingMethods(configUrls, elements, node) {
this.fetchData(configUrls.cloud.config, configUrls.local.config)
.then((configData) => {
const smoothingMethods =
configData.smoothing?.smoothMethod?.rules?.values?.map(
(o) => o.value
) || [];
this.populateDropdown(
elements.smoothMethod,
smoothingMethods,
node,
"smooth_method"
);
})
.catch((err) => {
console.error("Error loading smoothing methods", err);
});
}
populateInterpolationMethods(configUrls, elements, node) {
this.fetchData(configUrls.cloud.config, configUrls.local.config)
.then((configData) => {
const interpolationMethods =
configData?.interpolation?.type?.rules?.values.map((m) => m.value) ||
[];
this.populateDropdown(
elements.interpolationMethodInput,
interpolationMethods,
node,
"interpolationMethod"
);
// Find the selected method and use it to spawn 1 more field to fill in tension
//const selectedMethod = interpolationMethods.find(m => m === node.interpolationMethod);
this.initTensionToggles(elements, node);
})
.catch((err) => {
console.error("Error loading interpolation methods", err);
});
}
populateLogLevelOptions(logLevelSelect, configData, node) {
// debug log level
//console.log("Displaying configData => ", configData) ;
const logLevels =
configData?.general?.logging?.logLevel?.rules?.values?.map(
(l) => l.value
) || [];
//console.log("Displaying logLevels => ", logLevels);
// Reuse your existing generic populateDropdown helper
this.populateDropdown(logLevelSelect, logLevels, node.logLevel);
}
//cascade dropdowns for asset type, supplier, subType, model, unit
fetchAndPopulateDropdowns(configUrls, elements, node) {
this.fetchData(configUrls.cloud.config, configUrls.local.config)
.then((configData) => {
const assetType = configData.asset?.type?.default;
const localSuppliersUrl = this.constructUrl(configUrls.local.taggcodeAPI,`${assetType}s`,"suppliers.json");
const cloudSuppliersUrl = this.constructCloudURL(configUrls.cloud.taggcodeAPI, "/vendor/get_vendors.php");
return this.fetchData(cloudSuppliersUrl, localSuppliersUrl)
.then((supplierData) => {
const suppliers = supplierData.map((supplier) => supplier.name);
// Populate suppliers dropdown and set up its change handler
return this.populateDropdown(
elements.supplier,
suppliers,
node,
"supplier",
function (selectedSupplier) {
if (selectedSupplier) {
this.populateSubTypes(configUrls, elements, node, selectedSupplier);
} }
} }
);
})
.then(() => {
// If we have a saved supplier, trigger subTypes population
if (node.supplier) {
this.populateSubTypes(configUrls, elements, node, node.supplier);
}
});
})
.catch((error) => {
console.error("Error in initial dropdown population:", error);
});
}
getSpecificConfigUrl(nodeName,cloudAPI) {
const cloudConfigURL = cloudAPI + "/config/" + nodeName + ".json";
const localConfigURL = "http://localhost:1880/"+ nodeName + "/dependencies/"+ nodeName + "/" + nodeName + "Config.json";
return { cloudConfigURL, localConfigURL };
}
// Save changes to API
async apiCall(node) {
try{
// OLFIANT when a browser refreshes the tag code is lost!!! fix this later!!!!!
// FIX UUID ALSO LATER
if(node.assetTagCode !== "" || node.assetTagCode !== null){ }
// API call to register or check asset in central database
let assetregisterAPI = node.configUrls.cloud.taggcodeAPI + "/asset/create_asset.php";
const assetModelId = node.modelMetadata.id; //asset_product_model_id
const uuid = node.uuid; //asset_product_model_uuid
const assetName = node.assetType; //asset_name / type?
const description = node.name; // asset_description
const assetStatus = "actief"; //asset_status -> koppel aan enable / disable node ? or make dropdown ?
const assetProfileId = 1; //asset_profile_id these are the rules to check if the childs are valid under this node (parent / child id?)
const child_assets = ["63247"]; //child_assets tagnummer of id?
const assetProcessId = node.processId; //asset_process_id
const assetLocationId = node.locationId; //asset_location_id
const tagCode = node.assetTagCode; // if already exists in the node information use it to tell the api it exists and it will update else we will get it from the api call
//console.log(`this is my tagCode: ${tagCode}`);
// Build base URL with required parameters
let apiUrl = `?asset_product_model_id=${assetModelId}&asset_product_model_uuid=${uuid}&asset_name=${assetName}&asset_description=${description}&asset_status=${assetStatus}&asset_profile_id=${assetProfileId}&asset_location_id=${assetLocationId}&asset_process_id=${assetProcessId}&child_assets=${child_assets}`;
// Only add tagCode to URL if it exists
if (tagCode) {
apiUrl += `&asset_tag_number=${tagCode}`;
console.log('hello there');
}
assetregisterAPI += apiUrl;
console.log("API call to register asset in central database", assetregisterAPI);
const response = await fetch(assetregisterAPI, {
method: "POST"
});
// Get the response text first
const responseText = await response.text();
console.log("Raw API response:", responseText);
// Try to parse the JSON, handling potential parsing errors
let jsonResponse;
try {
jsonResponse = JSON.parse(responseText);
} catch (parseError) {
console.error("JSON Parsing Error:", parseError);
console.error("Response that could not be parsed:", responseText);
throw new Error("Failed to parse API response");
}
console.log(jsonResponse);
if(jsonResponse.success){
console.log(`${jsonResponse.message}, tag number: ${jsonResponse.asset_tag_number}, asset id: ${jsonResponse.asset_id}`);
// Save the asset tag number and id to the node
} else {
console.log("Asset not registered in central database");
}
return jsonResponse;
} catch (error) {
console.log("Error saving changes to asset register API", error);
}
}
async fetchData(url, fallbackUrl) {
try {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const responsData = await response.json();
//responsData
const data = responsData.data;
/* .map(item => {
const { vendor_name, ...rest } = item;
return {
name: vendor_name,
...rest
};
}); */
console.log(url);
console.log("Response Data: ", data);
return data;
} catch (err) {
console.warn(
`Primary URL failed: ${url}. Trying fallback URL: ${fallbackUrl}`,
err
);
try {
const response = await fetch(fallbackUrl);
if (!response.ok)
throw new Error(`HTTP error! status: ${response.status}`);
return await response.json();
} catch (fallbackErr) {
console.error("Both primary and fallback URLs failed:", fallbackErr);
return [];
}
}
}
async fetchProjectData(url) {
try {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const responsData = await response.json();
console.log("Response Data: ", responsData);
return responsData;
} catch (err) {
}
}
async populateDropdown(
htmlElement,
options,
node,
property,
callback
) {
this.generateHtml(htmlElement, options, node[property]);
htmlElement.addEventListener("change", async (e) => {
const newValue = e.target.value;
console.log(`Dropdown changed: ${property} = ${newValue}`);
node[property] = newValue;
RED.nodes.dirty(true);
if (callback) await callback(newValue); // Ensure async callback completion
});
}
// Helper function to construct a URL from a base and path internal
constructUrl(base, ...paths) {
// Remove trailing slash from base and leading slashes from paths
const sanitizedBase = (base || "").replace(/\/+$/, "");
const sanitizedPaths = paths.map((path) => path.replace(/^\/+|\/+$/g, ""));
// Join sanitized base and paths
const url = `${sanitizedBase}/${sanitizedPaths.join("/")}`;
console.log("Base:", sanitizedBase);
console.log("Paths:", sanitizedPaths);
console.log("Constructed URL:", url);
return url;
}
//Adjust for API Gateway
constructCloudURL(base, ...paths) {
// Remove trailing slash from base and leading slashes from paths
const sanitizedBase = base.replace(/\/+$/, "");
const sanitizedPaths = paths.map((path) => path.replace(/^\/+|\/+$/g, ""));
// Join sanitized base and paths
const url = `${sanitizedBase}/${sanitizedPaths.join("/")}`;
return url;
}
populateSubTypes(configUrls, elements, node, selectedSupplier) {
this.fetchData(configUrls.cloud.config, configUrls.local.config)
.then((configData) => {
const assetType = configData.asset?.type?.default;
const supplierFolder = this.constructUrl( configUrls.local.taggcodeAPI, `${assetType}s`, selectedSupplier );
const localSubTypesUrl = this.constructUrl(supplierFolder, "subtypes.json");
const cloudSubTypesUrl = this.constructCloudURL(configUrls.cloud.taggcodeAPI, "/product/get_subtypesFromVendor.php?vendor_name=" + selectedSupplier);
return this.fetchData(cloudSubTypesUrl, localSubTypesUrl)
.then((subTypeData) => {
const subTypes = subTypeData.map((subType) => subType.name);
return this.populateDropdown(
elements.subType,
subTypes,
node,
"subType",
function (selectedSubType) {
if (selectedSubType) {
// When subType changes, update both models and units
this.populateModels(
configUrls,
elements,
node,
selectedSupplier,
selectedSubType
);
this.populateUnitsForSubType(
configUrls,
elements,
node,
selectedSubType
);
}
}
);
})
.then(() => {
// If we have a saved subType, trigger both models and units population
if (node.subType) {
this.populateModels(
configUrls,
elements,
node,
selectedSupplier,
node.subType
);
this.populateUnitsForSubType(configUrls, elements, node, node.subType);
}
//console.log("In fetch part of subtypes ");
// Store all data from selected model
/* node["modelMetadata"] = modelData.find(
(model) => model.name === node.model
);
console.log("Model Metadata: ", node["modelMetadata"]); */
});
})
.catch((error) => {
console.error("Error populating subtypes:", error);
});
}
populateUnitsForSubType(configUrls, elements, node, selectedSubType) {
// Fetch the units data
this.fetchData(configUrls.cloud.units, configUrls.local.units)
.then((unitsData) => {
// Find the category that matches the subType name
const categoryData = unitsData.units.find(
(category) =>
category.category.toLowerCase() === selectedSubType.toLowerCase()
);
if (categoryData) {
// Extract just the unit values and descriptions
const units = categoryData.values.map((unit) => ({
value: unit.value,
description: unit.description,
}));
// Create the options array with descriptions as labels
const options = units.map((unit) => ({
value: unit.value,
label: `${unit.value} - ${unit.description}`,
}));
// Populate the units dropdown
this.populateDropdown(
elements.unit,
options.map((opt) => opt.value),
node,
"unit"
);
// If there's no currently selected unit but we have options, select the first one
if (!node.unit && options.length > 0) {
node.unit = options[0].value;
elements.unit.value = options[0].value;
}
} else {
// If no matching category is found, provide a default % option
const defaultUnits = [{ value: "%", description: "Percentage" }];
this.populateDropdown(
elements.unit,
defaultUnits.map((unit) => unit.value),
node,
"unit"
);
console.warn(
`No matching unit category found for subType: ${selectedSubType}`
);
}
})
.catch((error) => {
console.error("Error fetching units:", error);
});
}
populateModels(
configUrls,
elements,
node,
selectedSupplier,
selectedSubType
) {
this.fetchData(configUrls.cloud.config, configUrls.local.config)
.then((configData) => {
const assetType = configData.asset?.type?.default;
// save assetType to fetch later
node.assetType = assetType;
const supplierFolder = this.constructUrl( configUrls.local.taggcodeAPI,`${assetType}s`,selectedSupplier);
const subTypeFolder = this.constructUrl(supplierFolder, selectedSubType);
const localModelsUrl = this.constructUrl(subTypeFolder, "models.json");
const cloudModelsUrl = this.constructCloudURL(configUrls.cloud.taggcodeAPI, "/product/get_product_models.php?vendor_name=" + selectedSupplier + "&product_subtype_name=" + selectedSubType);
return this.fetchData(cloudModelsUrl, localModelsUrl).then((modelData) => {
const models = modelData.map((model) => model.name); // use this to populate the dropdown
// If a model is already selected, store its metadata immediately
if (node.model) {
node["modelMetadata"] = modelData.find((model) => model.name === node.model);
}
this.populateDropdown(elements.model, models, node, "model", (selectedModel) => {
// Store only the metadata for the selected model
node["modelMetadata"] = modelData.find((model) => model.name === selectedModel);
});
/*
console.log('hello here I am:');
console.log(node["modelMetadata"]);
*/
});
})
.catch((error) => {
console.error("Error populating models:", error);
});
}
generateHtml(htmlElement, options, savedValue) {
htmlElement.innerHTML = options.length
? `<option value="">Select...</option>${options
.map((opt) => `<option value="${opt}">${opt}</option>`)
.join("")}`
: "<option value=''>No options available</option>";
if (savedValue && options.includes(savedValue)) {
htmlElement.value = savedValue;
}
}
createMenuUtilsEndpoint(RED, nodeName, customHelpers = {}) {
RED.httpAdmin.get(`/${nodeName}/resources/menuUtils.js`, function(req, res) {
console.log(`Serving menuUtils.js for ${nodeName} node`);
res.set('Content-Type', 'application/javascript');
const browserCode = this.generateMenuUtilsCode(nodeName, customHelpers);
res.send(browserCode);
}.bind(this));
}
generateMenuUtilsCode(nodeName, customHelpers = {}) {
const defaultHelpers = {
validateRequired: `function(value) {
return value && value.toString().trim() !== '';
}`,
formatDisplayValue: `function(value, unit) {
return \`\${value} \${unit || ''}\`.trim();
}`
};
const allHelpers = { ...defaultHelpers, ...customHelpers };
const helpersCode = Object.entries(allHelpers)
.map(([name, func]) => ` ${name}: ${func}`)
.join(',\n');
const classCode = MenuUtils.toString(); // <-- this gives full class MenuUtils {...}
return `
// Create EVOLV namespace structure
window.EVOLV = window.EVOLV || {};
window.EVOLV.nodes = window.EVOLV.nodes || {};
window.EVOLV.nodes.${nodeName} = window.EVOLV.nodes.${nodeName} || {};
// Inject MenuUtils class
${classCode}
// Expose MenuUtils instance to namespace
window.EVOLV.nodes.${nodeName}.utils = {
menuUtils: new MenuUtils(),
helpers: {
${helpersCode}
}
};
// Optionally expose globally
window.MenuUtils = MenuUtils;
console.log('${nodeName} utilities loaded in namespace');
`;
}
} }
module.exports = MenuUtils; module.exports = MenuUtils;

View File

@@ -1,543 +0,0 @@
class MenuUtils {
initBasicToggles(elements) {
// Toggle visibility for log level
elements.logCheckbox.addEventListener("change", function () {
elements.rowLogLevel.style.display = this.checked ? "block" : "none";
});
elements.rowLogLevel.style.display = elements.logCheckbox.checked
? "block"
: "none";
}
// Define the initialize toggles function within scope
initMeasurementToggles(elements) {
// Toggle visibility for scaling inputs
elements.scalingCheckbox.addEventListener("change", function () {
elements.rowInputMin.style.display = this.checked ? "block" : "none";
elements.rowInputMax.style.display = this.checked ? "block" : "none";
});
// Set initial states
elements.rowInputMin.style.display = elements.scalingCheckbox.checked
? "block"
: "none";
elements.rowInputMax.style.display = elements.scalingCheckbox.checked
? "block"
: "none";
}
initTensionToggles(elements, node) {
const currentMethod = node.interpolationMethod;
elements.rowTension.style.display =
currentMethod === "monotone_cubic_spline" ? "block" : "none";
console.log(
"Initial tension row display: ",
elements.rowTension.style.display
);
elements.interpolationMethodInput.addEventListener("change", function () {
const selectedMethod = this.value;
console.log(`Interpolation method changed: ${selectedMethod}`);
node.interpolationMethod = selectedMethod;
// Toggle visibility for tension input
elements.rowTension.style.display =
selectedMethod === "monotone_cubic_spline" ? "block" : "none";
console.log("Tension row display: ", elements.rowTension.style.display);
});
}
// Define the smoothing methods population function within scope
populateSmoothingMethods(configUrls, elements, node) {
this.fetchData(configUrls.cloud.config, configUrls.local.config)
.then((configData) => {
const smoothingMethods =
configData.smoothing?.smoothMethod?.rules?.values?.map(
(o) => o.value
) || [];
this.populateDropdown(
elements.smoothMethod,
smoothingMethods,
node,
"smooth_method"
);
})
.catch((err) => {
console.error("Error loading smoothing methods", err);
});
}
populateInterpolationMethods(configUrls, elements, node) {
this.fetchData(configUrls.cloud.config, configUrls.local.config)
.then((configData) => {
const interpolationMethods =
configData?.interpolation?.type?.rules?.values.map((m) => m.value) ||
[];
this.populateDropdown(
elements.interpolationMethodInput,
interpolationMethods,
node,
"interpolationMethod"
);
// Find the selected method and use it to spawn 1 more field to fill in tension
//const selectedMethod = interpolationMethods.find(m => m === node.interpolationMethod);
this.initTensionToggles(elements, node);
})
.catch((err) => {
console.error("Error loading interpolation methods", err);
});
}
populateLogLevelOptions(logLevelSelect, configData, node) {
// debug log level
//console.log("Displaying configData => ", configData) ;
const logLevels =
configData?.general?.logging?.logLevel?.rules?.values?.map(
(l) => l.value
) || [];
//console.log("Displaying logLevels => ", logLevels);
// Reuse your existing generic populateDropdown helper
this.populateDropdown(logLevelSelect, logLevels, node.logLevel);
}
//cascade dropdowns for asset type, supplier, subType, model, unit
fetchAndPopulateDropdowns(configUrls, elements, node) {
this.fetchData(configUrls.cloud.config, configUrls.local.config)
.then((configData) => {
const assetType = configData.asset?.type?.default;
const localSuppliersUrl = this.constructUrl(configUrls.local.taggcodeAPI,`${assetType}s`,"suppliers.json");
const cloudSuppliersUrl = this.constructCloudURL(configUrls.cloud.taggcodeAPI, "/vendor/get_vendors.php");
return this.fetchData(cloudSuppliersUrl, localSuppliersUrl)
.then((supplierData) => {
const suppliers = supplierData.map((supplier) => supplier.name);
// Populate suppliers dropdown and set up its change handler
return this.populateDropdown(
elements.supplier,
suppliers,
node,
"supplier",
function (selectedSupplier) {
if (selectedSupplier) {
this.populateSubTypes(configUrls, elements, node, selectedSupplier);
}
}
);
})
.then(() => {
// If we have a saved supplier, trigger subTypes population
if (node.supplier) {
this.populateSubTypes(configUrls, elements, node, node.supplier);
}
});
})
.catch((error) => {
console.error("Error in initial dropdown population:", error);
});
}
getSpecificConfigUrl(nodeName,cloudAPI) {
const cloudConfigURL = cloudAPI + "/config/" + nodeName + ".json";
const localConfigURL = "http://localhost:1880/"+ nodeName + "/dependencies/"+ nodeName + "/" + nodeName + "Config.json";
return { cloudConfigURL, localConfigURL };
}
// Save changes to API
async apiCall(node) {
try{
// OLFIANT when a browser refreshes the tag code is lost!!! fix this later!!!!!
// FIX UUID ALSO LATER
if(node.assetTagCode !== "" || node.assetTagCode !== null){ }
// API call to register or check asset in central database
let assetregisterAPI = node.configUrls.cloud.taggcodeAPI + "/asset/create_asset.php";
const assetModelId = node.modelMetadata.id; //asset_product_model_id
const uuid = node.uuid; //asset_product_model_uuid
const assetName = node.assetType; //asset_name / type?
const description = node.name; // asset_description
const assetStatus = "actief"; //asset_status -> koppel aan enable / disable node ? or make dropdown ?
const assetProfileId = 1; //asset_profile_id these are the rules to check if the childs are valid under this node (parent / child id?)
const child_assets = ["63247"]; //child_assets tagnummer of id?
const assetProcessId = node.processId; //asset_process_id
const assetLocationId = node.locationId; //asset_location_id
const tagCode = node.assetTagCode; // if already exists in the node information use it to tell the api it exists and it will update else we will get it from the api call
//console.log(`this is my tagCode: ${tagCode}`);
// Build base URL with required parameters
let apiUrl = `?asset_product_model_id=${assetModelId}&asset_product_model_uuid=${uuid}&asset_name=${assetName}&asset_description=${description}&asset_status=${assetStatus}&asset_profile_id=${assetProfileId}&asset_location_id=${assetLocationId}&asset_process_id=${assetProcessId}&child_assets=${child_assets}`;
// Only add tagCode to URL if it exists
if (tagCode) {
apiUrl += `&asset_tag_number=${tagCode}`;
console.log('hello there');
}
assetregisterAPI += apiUrl;
console.log("API call to register asset in central database", assetregisterAPI);
const response = await fetch(assetregisterAPI, {
method: "POST"
});
// Get the response text first
const responseText = await response.text();
console.log("Raw API response:", responseText);
// Try to parse the JSON, handling potential parsing errors
let jsonResponse;
try {
jsonResponse = JSON.parse(responseText);
} catch (parseError) {
console.error("JSON Parsing Error:", parseError);
console.error("Response that could not be parsed:", responseText);
throw new Error("Failed to parse API response");
}
console.log(jsonResponse);
if(jsonResponse.success){
console.log(`${jsonResponse.message}, tag number: ${jsonResponse.asset_tag_number}, asset id: ${jsonResponse.asset_id}`);
// Save the asset tag number and id to the node
} else {
console.log("Asset not registered in central database");
}
return jsonResponse;
} catch (error) {
console.log("Error saving changes to asset register API", error);
}
}
async fetchData(url, fallbackUrl) {
try {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const responsData = await response.json();
//responsData
const data = responsData.data;
/* .map(item => {
const { vendor_name, ...rest } = item;
return {
name: vendor_name,
...rest
};
}); */
console.log(url);
console.log("Response Data: ", data);
return data;
} catch (err) {
console.warn(
`Primary URL failed: ${url}. Trying fallback URL: ${fallbackUrl}`,
err
);
try {
const response = await fetch(fallbackUrl);
if (!response.ok)
throw new Error(`HTTP error! status: ${response.status}`);
return await response.json();
} catch (fallbackErr) {
console.error("Both primary and fallback URLs failed:", fallbackErr);
return [];
}
}
}
async fetchProjectData(url) {
try {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const responsData = await response.json();
console.log("Response Data: ", responsData);
return responsData;
} catch (err) {
}
}
async populateDropdown(
htmlElement,
options,
node,
property,
callback
) {
this.generateHtml(htmlElement, options, node[property]);
htmlElement.addEventListener("change", async (e) => {
const newValue = e.target.value;
console.log(`Dropdown changed: ${property} = ${newValue}`);
node[property] = newValue;
RED.nodes.dirty(true);
if (callback) await callback(newValue); // Ensure async callback completion
});
}
// Helper function to construct a URL from a base and path internal
constructUrl(base, ...paths) {
// Remove trailing slash from base and leading slashes from paths
const sanitizedBase = (base || "").replace(/\/+$/, "");
const sanitizedPaths = paths.map((path) => path.replace(/^\/+|\/+$/g, ""));
// Join sanitized base and paths
const url = `${sanitizedBase}/${sanitizedPaths.join("/")}`;
console.log("Base:", sanitizedBase);
console.log("Paths:", sanitizedPaths);
console.log("Constructed URL:", url);
return url;
}
//Adjust for API Gateway
constructCloudURL(base, ...paths) {
// Remove trailing slash from base and leading slashes from paths
const sanitizedBase = base.replace(/\/+$/, "");
const sanitizedPaths = paths.map((path) => path.replace(/^\/+|\/+$/g, ""));
// Join sanitized base and paths
const url = `${sanitizedBase}/${sanitizedPaths.join("/")}`;
return url;
}
populateSubTypes(configUrls, elements, node, selectedSupplier) {
this.fetchData(configUrls.cloud.config, configUrls.local.config)
.then((configData) => {
const assetType = configData.asset?.type?.default;
const supplierFolder = this.constructUrl( configUrls.local.taggcodeAPI, `${assetType}s`, selectedSupplier );
const localSubTypesUrl = this.constructUrl(supplierFolder, "subtypes.json");
const cloudSubTypesUrl = this.constructCloudURL(configUrls.cloud.taggcodeAPI, "/product/get_subtypesFromVendor.php?vendor_name=" + selectedSupplier);
return this.fetchData(cloudSubTypesUrl, localSubTypesUrl)
.then((subTypeData) => {
const subTypes = subTypeData.map((subType) => subType.name);
return this.populateDropdown(
elements.subType,
subTypes,
node,
"subType",
function (selectedSubType) {
if (selectedSubType) {
// When subType changes, update both models and units
this.populateModels(
configUrls,
elements,
node,
selectedSupplier,
selectedSubType
);
this.populateUnitsForSubType(
configUrls,
elements,
node,
selectedSubType
);
}
}
);
})
.then(() => {
// If we have a saved subType, trigger both models and units population
if (node.subType) {
this.populateModels(
configUrls,
elements,
node,
selectedSupplier,
node.subType
);
this.populateUnitsForSubType(configUrls, elements, node, node.subType);
}
//console.log("In fetch part of subtypes ");
// Store all data from selected model
/* node["modelMetadata"] = modelData.find(
(model) => model.name === node.model
);
console.log("Model Metadata: ", node["modelMetadata"]); */
});
})
.catch((error) => {
console.error("Error populating subtypes:", error);
});
}
populateUnitsForSubType(configUrls, elements, node, selectedSubType) {
// Fetch the units data
this.fetchData(configUrls.cloud.units, configUrls.local.units)
.then((unitsData) => {
// Find the category that matches the subType name
const categoryData = unitsData.units.find(
(category) =>
category.category.toLowerCase() === selectedSubType.toLowerCase()
);
if (categoryData) {
// Extract just the unit values and descriptions
const units = categoryData.values.map((unit) => ({
value: unit.value,
description: unit.description,
}));
// Create the options array with descriptions as labels
const options = units.map((unit) => ({
value: unit.value,
label: `${unit.value} - ${unit.description}`,
}));
// Populate the units dropdown
this.populateDropdown(
elements.unit,
options.map((opt) => opt.value),
node,
"unit"
);
// If there's no currently selected unit but we have options, select the first one
if (!node.unit && options.length > 0) {
node.unit = options[0].value;
elements.unit.value = options[0].value;
}
} else {
// If no matching category is found, provide a default % option
const defaultUnits = [{ value: "%", description: "Percentage" }];
this.populateDropdown(
elements.unit,
defaultUnits.map((unit) => unit.value),
node,
"unit"
);
console.warn(
`No matching unit category found for subType: ${selectedSubType}`
);
}
})
.catch((error) => {
console.error("Error fetching units:", error);
});
}
populateModels(
configUrls,
elements,
node,
selectedSupplier,
selectedSubType
) {
this.fetchData(configUrls.cloud.config, configUrls.local.config)
.then((configData) => {
const assetType = configData.asset?.type?.default;
// save assetType to fetch later
node.assetType = assetType;
const supplierFolder = this.constructUrl( configUrls.local.taggcodeAPI,`${assetType}s`,selectedSupplier);
const subTypeFolder = this.constructUrl(supplierFolder, selectedSubType);
const localModelsUrl = this.constructUrl(subTypeFolder, "models.json");
const cloudModelsUrl = this.constructCloudURL(configUrls.cloud.taggcodeAPI, "/product/get_product_models.php?vendor_name=" + selectedSupplier + "&product_subtype_name=" + selectedSubType);
return this.fetchData(cloudModelsUrl, localModelsUrl).then((modelData) => {
const models = modelData.map((model) => model.name); // use this to populate the dropdown
// If a model is already selected, store its metadata immediately
if (node.model) {
node["modelMetadata"] = modelData.find((model) => model.name === node.model);
}
this.populateDropdown(elements.model, models, node, "model", (selectedModel) => {
// Store only the metadata for the selected model
node["modelMetadata"] = modelData.find((model) => model.name === selectedModel);
});
/*
console.log('hello here I am:');
console.log(node["modelMetadata"]);
*/
});
})
.catch((error) => {
console.error("Error populating models:", error);
});
}
generateHtml(htmlElement, options, savedValue) {
htmlElement.innerHTML = options.length
? `<option value="">Select...</option>${options
.map((opt) => `<option value="${opt}">${opt}</option>`)
.join("")}`
: "<option value=''>No options available</option>";
if (savedValue && options.includes(savedValue)) {
htmlElement.value = savedValue;
}
}
createMenuUtilsEndpoint(RED, nodeName, customHelpers = {}) {
RED.httpAdmin.get(`/${nodeName}/resources/menuUtils.js`, function(req, res) {
console.log(`Serving menuUtils.js for ${nodeName} node`);
res.set('Content-Type', 'application/javascript');
const browserCode = this.generateMenuUtilsCode(nodeName, customHelpers);
res.send(browserCode);
}.bind(this));
}
generateMenuUtilsCode(nodeName, customHelpers = {}) {
const defaultHelpers = {
validateRequired: `function(value) {
return value && value.toString().trim() !== '';
}`,
formatDisplayValue: `function(value, unit) {
return \`\${value} \${unit || ''}\`.trim();
}`
};
const allHelpers = { ...defaultHelpers, ...customHelpers };
const helpersCode = Object.entries(allHelpers)
.map(([name, func]) => ` ${name}: ${func}`)
.join(',\n');
const classCode = MenuUtils.toString(); // <-- this gives full class MenuUtils {...}
return `
// Create EVOLV namespace structure
window.EVOLV = window.EVOLV || {};
window.EVOLV.nodes = window.EVOLV.nodes || {};
window.EVOLV.nodes.${nodeName} = window.EVOLV.nodes.${nodeName} || {};
// Inject MenuUtils class
${classCode}
// Expose MenuUtils instance to namespace
window.EVOLV.nodes.${nodeName}.utils = {
menuUtils: new MenuUtils(),
helpers: {
${helpersCode}
}
};
// Optionally expose globally
window.MenuUtils = MenuUtils;
console.log('${nodeName} utilities loaded in namespace');
`;
}
}
module.exports = MenuUtils;

View File

@@ -53,4 +53,4 @@ const nodeTemplates = {
// …add more node “templates” here… // …add more node “templates” here…
}; };
export default nodeTemplates; module.exports = nodeTemplates;

View File

@@ -1,15 +1,16 @@
const { getFormatter } = require('./formatters');
//this class will handle the output events for the node red node //this class will handle the output events for the node red node
class OutputUtils { class OutputUtils {
constructor() { constructor() {
this.output ={}; this.output = {};
this.output['influxdb'] = {};
this.output['process'] = {};
} }
checkForChanges(output, format) { checkForChanges(output, format) {
this.output[format] = this.output[format] || {};
const changedFields = {}; const changedFields = {};
for (const key in output) { for (const key in output) {
if (output.hasOwnProperty(key) && output[key] !== this.output[format][key]) { if (Object.prototype.hasOwnProperty.call(output, key) && output[key] !== this.output[format][key]) {
let value = output[key]; let value = output[key];
// For fields: if the value is an object (and not a Date), stringify it. // For fields: if the value is an object (and not a Date), stringify it.
if (value !== null && typeof value === 'object' && !(value instanceof Date)) { if (value !== null && typeof value === 'object' && !(value instanceof Date)) {
@@ -27,66 +28,55 @@ class OutputUtils {
} }
formatMsg(output, config, format) { formatMsg(output, config, format) {
//define emtpy message
let msg = {}; let msg = {};
// Compare output with last output and only include changed values // Compare output with last output and only include changed values
const changedFields = this.checkForChanges(output,format); const changedFields = this.checkForChanges(output,format);
if (Object.keys(changedFields).length > 0) { if (Object.keys(changedFields).length > 0) {
switch (format) {
case 'influxdb':
// Extract the relevant config properties.
const relevantConfig = this.extractRelevantConfig(config);
// Flatten the tags so that no nested objects are passed on.
const flatTags = this.flattenTags(relevantConfig);
msg = this.influxDBFormat(changedFields, config, flatTags);
break;
case 'process':
// Compare output with last output and only include changed values
msg = this.processFormat(changedFields,config);
//console.log(msg);
break;
default:
console.log('Unknown format in output utils');
break;
}
return msg;
}
}
influxDBFormat(changedFields, config , flatTags) {
// Create the measurement and topic using softwareType and name config.functionality.softwareType + .
const measurement = config.general.name; const measurement = config.general.name;
const payload = { const flatTags = this.flattenTags(this.extractRelevantConfig(config));
measurement: measurement, const formatterName = this.resolveFormatterName(config, format);
const formatter = getFormatter(formatterName);
const payload = formatter.format(measurement, {
fields: changedFields, fields: changedFields,
tags: flatTags, tags: flatTags,
timestamp: new Date(), config,
}; channel: format,
});
const topic = measurement; msg = this.wrapMessage(measurement, payload);
const msg = { topic: topic, payload: payload };
return msg; return msg;
} }
}
resolveFormatterName(config, channel) {
const outputConfig = config.output || {};
if (channel === 'process') {
return outputConfig.process || 'process';
}
if (channel === 'influxdb') {
return outputConfig.dbase || 'influxdb';
}
return outputConfig[channel] || channel;
}
wrapMessage(measurement, payload) {
return {
topic: measurement,
payload,
};
}
flattenTags(obj) { flattenTags(obj) {
const result = {}; const result = {};
for (const key in obj) { for (const key in obj) {
if (obj.hasOwnProperty(key)) { if (Object.prototype.hasOwnProperty.call(obj, key)) {
const value = obj[key]; const value = obj[key];
if (value !== null && typeof value === 'object' && !(value instanceof Date)) { if (value !== null && typeof value === 'object' && !(value instanceof Date)) {
// Recursively flatten the nested object. // Recursively flatten the nested object.
const flatChild = this.flattenTags(value); const flatChild = this.flattenTags(value);
for (const childKey in flatChild) { for (const childKey in flatChild) {
if (flatChild.hasOwnProperty(childKey)) { if (Object.prototype.hasOwnProperty.call(flatChild, childKey)) {
result[`${key}_${childKey}`] = String(flatChild[childKey]); result[`${key}_${childKey}`] = String(flatChild[childKey]);
} }
} }
@@ -118,15 +108,6 @@ class OutputUtils {
model: config.asset?.model, model: config.asset?.model,
}; };
} }
processFormat(changedFields,config) {
// Create the measurement and topic using softwareType and name config.functionality.softwareType + .
const measurement = config.general.name;
const payload = changedFields;
const topic = measurement;
const msg = { topic: topic, payload: payload };
return msg;
}
} }
module.exports = OutputUtils; module.exports = OutputUtils;

View File

@@ -28,11 +28,25 @@
* @module ValidationUtils * @module ValidationUtils
* @requires Logger * @requires Logger
* @exports ValidationUtils * @exports ValidationUtils
* @version 0.1.0 * @version 0.2.0
* @since 0.1.0 * @since 0.1.0
*/ */
const Logger = require("./logger"); const Logger = require("./logger");
const { validateNumber, validateInteger, validateBoolean, validateString, validateEnum } = require("./validators/typeValidators");
const { validateArray, validateSet, validateObject } = require("./validators/collectionValidators");
const { validateCurve, validateMachineCurve } = require("./validators/curveValidator");
// Strategy registry: maps rules.type to a handler function
const VALIDATORS = {
number: (cv, rules, fs, name, key, logger) => validateNumber(cv, rules, fs, name, key, logger),
integer: (cv, rules, fs, name, key, logger) => validateInteger(cv, rules, fs, name, key, logger),
boolean: (cv, _rules, _fs, name, key, logger) => validateBoolean(cv, name, key, logger),
string: (cv, rules, fs, name, key, logger) => validateString(cv, rules, fs, name, key, logger),
enum: (cv, rules, fs, name, key, logger) => validateEnum(cv, rules, fs, name, key, logger),
array: (cv, rules, fs, name, key, logger) => validateArray(cv, rules, fs, name, key, logger),
set: (cv, rules, fs, name, key, logger) => validateSet(cv, rules, fs, name, key, logger),
};
class ValidationUtils { class ValidationUtils {
constructor(IloggerEnabled, IloggerLevel) { constructor(IloggerEnabled, IloggerLevel) {
@@ -105,63 +119,44 @@ class ValidationUtils {
configValue = config[key] !== undefined ? config[key] : fieldSchema.default; configValue = config[key] !== undefined ? config[key] : fieldSchema.default;
} }
// Attempt to parse the value to the expected type if possible // Handle curve types (they use continue, so handle separately)
switch (rules.type) { if (rules.type === "curve") {
validatedConfig[key] = validateCurve(configValue, fieldSchema.default, this.logger);
case "number":
configValue = this.validateNumber(configValue, rules, fieldSchema, name, key);
break;
case "boolean":
configValue = this.validateBoolean(configValue, name, key);
break;
case "string":
configValue = this.validateString(configValue,rules,fieldSchema, name, key);
break;
case "array":
configValue = this.validateArray(configValue, rules, fieldSchema, name, key);
break;
case "set":
configValue = this.validateSet(configValue, rules, fieldSchema, name, key);
break;
case "object":
configValue = this.validateObject(configValue, rules, fieldSchema, name, key);
break;
case "enum":
configValue = this.validateEnum(configValue, rules, fieldSchema, name, key);
break;
case "curve":
validatedConfig[key] = this.validateCurve(configValue,fieldSchema.default);
continue; continue;
}
case "machineCurve": if (rules.type === "machineCurve") {
validatedConfig[key] = this.validateMachineCurve(configValue,fieldSchema.default); validatedConfig[key] = validateMachineCurve(configValue, fieldSchema.default, this.logger);
continue; continue;
}
case "integer": // Handle object type (needs recursive validateSchema reference)
validatedConfig[key] = this.validateInteger(configValue, rules, fieldSchema, name, key); if (rules.type === "object") {
validatedConfig[key] = validateObject(
configValue, rules, fieldSchema, name, key,
(c, s, n) => this.validateSchema(c, s, n),
this.logger
);
continue; continue;
}
case undefined: // Handle undefined type
// If we see 'rules.schema' but no 'rules.type', treat it like an object: if (rules.type === undefined) {
if (rules.schema && !rules.type) { if (rules.schema && !rules.type) {
// Log a warning and skip the extra pass for nested schema
this.logger.warn( this.logger.warn(
`${name}.${key} has a nested schema but no type. ` + `${name}.${key} has a nested schema but no type. ` +
`Treating it as type="object" to skip extra pass.` `Treating it as type="object" to skip extra pass.`
); );
} else { } else {
// Otherwise, fallback to your existing "validateUndefined" logic
validatedConfig[key] = this.validateUndefined(configValue, fieldSchema, name, key); validatedConfig[key] = this.validateUndefined(configValue, fieldSchema, name, key);
} }
continue; continue;
}
default: // Use the strategy registry for all other types
const handler = VALIDATORS[rules.type];
if (handler) {
configValue = handler(configValue, rules, fieldSchema, name, key, this.logger);
} else {
this.logger.warn(`${name}.${key} has an unknown validation type: ${rules.type}. Skipping validation.`); this.logger.warn(`${name}.${key} has an unknown validation type: ${rules.type}. Skipping validation.`);
validatedConfig[key] = fieldSchema.default; validatedConfig[key] = fieldSchema.default;
continue; continue;
@@ -204,318 +199,6 @@ class ValidationUtils {
return obj; return obj;
} }
validateMachineCurve(curve, defaultCurve) {
if (!curve || typeof curve !== "object" || Object.keys(curve).length === 0) {
this.logger.warn("Curve is missing or invalid. Defaulting to basic curve.");
return defaultCurve;
}
// Validate that nq and np exist and are objects
const { nq, np } = curve;
if (!nq || typeof nq !== "object" || !np || typeof np !== "object") {
this.logger.warn("Curve must contain valid 'nq' and 'np' objects. Defaulting to basic curve.");
return defaultCurve;
}
// Validate that each dimension key points to a valid object with x and y arrays
const validatedNq = this.validateDimensionStructure(nq, "nq");
const validatedNp = this.validateDimensionStructure(np, "np");
if (!validatedNq || !validatedNp) {
return defaultCurve;
}
return { nq: validatedNq, np: validatedNp }; // Return the validated curve
}
validateCurve(curve, defaultCurve) {
if (!curve || typeof curve !== "object" || Object.keys(curve).length === 0) {
this.logger.warn("Curve is missing or invalid. Defaulting to basic curve.");
return defaultCurve;
}
// Validate that each dimension key points to a valid object with x and y arrays
const validatedCurve = this.validateDimensionStructure(curve, "curve");
if (!validatedCurve) {
return defaultCurve;
}
return validatedCurve; // Return the validated curve
}
validateDimensionStructure(dimension, name) {
const validatedDimension = {};
for (const [key, value] of Object.entries(dimension)) {
// Validate that each key points to an object with x and y arrays
if (typeof value !== "object") {
this.logger.warn(`Dimension '${name}' key '${key}' is not valid. Returning to default.`);
return false;
}
// Validate that x and y are arrays
else if (!Array.isArray(value.x) || !Array.isArray(value.y)) {
this.logger.warn(`Dimension '${name}' key '${key}' is missing x or y arrays. Converting to arrays.`);
// Try to convert to arrays first
value.x = Object.values(value.x);
value.y = Object.values(value.y);
// If still not arrays return false
if (!Array.isArray(value.x) || !Array.isArray(value.y)) {
this.logger.warn(`Dimension '${name}' key '${key}' is not valid. Returning to default.`);
return false;
}
}
// Validate that x and y arrays are the same length
else if (value.x.length !== value.y.length) {
this.logger.warn(`Dimension '${name}' key '${key}' has mismatched x and y lengths. Ignoring this key.`);
return false;
}
// Validate that x values are in ascending order
else if (!this.isSorted(value.x)) {
this.logger.warn(`Dimension '${name}' key '${key}' has unsorted x values. Sorting...`);
return false;
}
// Validate that x values are unique
else if (!this.isUnique(value.x)) {
this.logger.warn(`Dimension '${name}' key '${key}' has duplicate x values. Removing duplicates...`);
return false;
}
// Validate that y values are numbers
else if (!this.areNumbers(value.y)) {
this.logger.warn(`Dimension '${name}' key '${key}' has non-numeric y values. Ignoring this key.`);
return false;
}
validatedDimension[key] = value;
}
return validatedDimension;
}
isSorted(arr) {
return arr.every((_, i) => i === 0 || arr[i] >= arr[i - 1]);
}
isUnique(arr) {
return new Set(arr).size === arr.length;
}
areNumbers(arr) {
return arr.every((x) => typeof x === "number");
}
validateNumber(configValue, rules, fieldSchema, name, key) {
if (typeof configValue !== "number") {
const parsedValue = parseFloat(configValue);
if (!isNaN(parsedValue)) {
this.logger.warn(`${name}.${key} was parsed to a number: ${configValue} -> ${parsedValue}`);
configValue = parsedValue;
}
}
if (rules.min !== undefined && configValue < rules.min) {
this.logger.warn(
`${name}.${key} is below the minimum (${rules.min}). Using default value.`
);
return fieldSchema.default;
}
if (rules.max !== undefined && configValue > rules.max) {
this.logger.warn(
`${name}.${key} exceeds the maximum (${rules.max}). Using default value.`
);
return fieldSchema.default;
}
this.logger.debug(`${name}.${key} is a valid number: ${configValue}`);
return configValue;
}
validateInteger(configValue, rules, fieldSchema, name, key) {
if (typeof configValue !== "number" || !Number.isInteger(configValue)) {
const parsedValue = parseInt(configValue, 10);
if (!isNaN(parsedValue) && Number.isInteger(parsedValue)) {
this.logger.warn(`${name}.${key} was parsed to an integer: ${configValue} -> ${parsedValue}`);
configValue = parsedValue;
} else {
this.logger.warn(`${name}.${key} is not a valid integer. Using default value.`);
return fieldSchema.default;
}
}
if (rules.min !== undefined && configValue < rules.min) {
this.logger.warn(`${name}.${key} is below the minimum integer value (${rules.min}). Using default value.`);
return fieldSchema.default;
}
if (rules.max !== undefined && configValue > rules.max) {
this.logger.warn(`${name}.${key} exceeds the maximum integer value (${rules.max}). Using default value.`);
return fieldSchema.default;
}
this.logger.debug(`${name}.${key} is a valid integer: ${configValue}`);
return configValue;
}
validateBoolean(configValue, name, key) {
if (typeof configValue !== "boolean") {
if (configValue === "true" || configValue === "false") {
const parsedValue = configValue === "true";
this.logger.debug(`${name}.${key} was parsed to a boolean: ${configValue} -> ${parsedValue}`);
configValue = parsedValue;
}
}
return configValue;
}
validateString(configValue, rules, fieldSchema, name, key) {
let newConfigValue = configValue;
if (typeof configValue !== "string") {
//check if the value is nullable
if(rules.nullable){
if(configValue === null){
return null;
}
}
this.logger.warn(`${name}.${key} is not a string. Trying to convert to string.`);
newConfigValue = String(configValue); // Coerce to string if not already
}
//check if the string is a valid string after conversion
if (typeof newConfigValue !== "string") {
this.logger.warn(`${name}.${key} is not a valid string. Using default value.`);
return fieldSchema.default;
}
// Check for uppercase characters and convert to lowercase if present
if (newConfigValue !== newConfigValue.toLowerCase()) {
this.logger.warn(`${name}.${key} contains uppercase characters. Converting to lowercase: ${newConfigValue} -> ${newConfigValue.toLowerCase()}`);
newConfigValue = newConfigValue.toLowerCase();
}
return newConfigValue;
}
validateSet(configValue, rules, fieldSchema, name, key) {
// 1. Ensure we have a Set. If not, use default.
if (!(configValue instanceof Set)) {
this.logger.info(`${name}.${key} is not a Set. Converting to one using default value.`);
return new Set(fieldSchema.default);
}
// 2. Convert the Set to an array for easier filtering.
const validatedArray = [...configValue]
.filter((item) => {
// 3. Filter based on `rules.itemType`.
switch (rules.itemType) {
case "number":
return typeof item === "number";
case "string":
return typeof item === "string";
case "null":
// "null" might mean no type restriction (your usage may vary).
return true;
default:
// Fallback if itemType is something else
return typeof item === rules.itemType;
}
})
.slice(0, rules.maxLength || Infinity);
// 4. Check if the filtered array meets the minimum length.
if (validatedArray.length < (rules.minLength || 1)) {
this.logger.warn(
`${name}.${key} contains fewer items than allowed (${rules.minLength}). Using default value.`
);
return new Set(fieldSchema.default);
}
// 5. Return a new Set containing only the valid items.
return new Set(validatedArray);
}
validateArray(configValue, rules, fieldSchema, name, key) {
if (!Array.isArray(configValue)) {
this.logger.info(`${name}.${key} is not an array. Using default value.`);
return fieldSchema.default;
}
// Validate individual items in the array
const validatedArray = configValue
.filter((item) => {
switch (rules.itemType) {
case "number":
return typeof item === "number";
case "string":
return typeof item === "string";
case "null":
// anything goes
return true;
default:
return typeof item === rules.itemType;
}
})
.slice(0, rules.maxLength || Infinity);
if (validatedArray.length < (rules.minLength || 1)) {
this.logger.warn(
`${name}.${key} contains fewer items than allowed (${rules.minLength}). Using default value.`
);
return fieldSchema.default;
}
return validatedArray;
}
validateObject(configValue, rules, fieldSchema, name, key) {
if (typeof configValue !== "object" || Array.isArray(configValue)) {
this.logger.warn(`${name}.${key} is not a valid object. Using default value.`);
return fieldSchema.default;
}
if (rules.schema) {
// Recursively validate nested objects if a schema is defined
return this.validateSchema(configValue || {}, rules.schema, `${name}.${key}`);
} else {
// If no schema is defined, log a warning and use the default
this.logger.warn(`${name}.${key} is an object with no schema. Using default value.`);
return fieldSchema.default;
}
}
validateEnum(configValue, rules, fieldSchema, name, key) {
if (Array.isArray(rules.values)) {
//if value is null take default
if(configValue === null){
this.logger.warn(`${name}.${key} is null. Using default value.`);
return fieldSchema.default;
}
const validValues = rules.values.map(e => e.value.toLowerCase());
//remove caps
configValue = configValue.toLowerCase();
if (!validValues.includes(configValue)) {
this.logger.warn(
`${name}.${key} has an invalid value : ${configValue}. Allowed values: [${validValues.join(", ")}]. Using default value.`
);
return fieldSchema.default;
}
} else {
this.logger.warn(
`${name}.${key} is an enum with no 'values' array. Using default value.`
);
return fieldSchema.default;
}
return configValue;
}
validateUndefined(configValue, fieldSchema, name, key) { validateUndefined(configValue, fieldSchema, name, key) {
if (typeof configValue === "object" && !Array.isArray(configValue)) { if (typeof configValue === "object" && !Array.isArray(configValue)) {

View File

@@ -0,0 +1,66 @@
/**
* Standalone collection validation functions extracted from validationUtils.js.
*/
function validateArray(configValue, rules, fieldSchema, name, key, logger) {
if (!Array.isArray(configValue)) {
logger.info(`${name}.${key} is not an array. Using default value.`);
return fieldSchema.default;
}
const validatedArray = configValue
.filter((item) => {
switch (rules.itemType) {
case "number": return typeof item === "number";
case "string": return typeof item === "string";
case "null": return true;
default: return typeof item === rules.itemType;
}
})
.slice(0, rules.maxLength || Infinity);
if (validatedArray.length < (rules.minLength || 1)) {
logger.warn(
`${name}.${key} contains fewer items than allowed (${rules.minLength}). Using default value.`
);
return fieldSchema.default;
}
return validatedArray;
}
function validateSet(configValue, rules, fieldSchema, name, key, logger) {
if (!(configValue instanceof Set)) {
logger.info(`${name}.${key} is not a Set. Converting to one using default value.`);
return new Set(fieldSchema.default);
}
const validatedArray = [...configValue]
.filter((item) => {
switch (rules.itemType) {
case "number": return typeof item === "number";
case "string": return typeof item === "string";
case "null": return true;
default: return typeof item === rules.itemType;
}
})
.slice(0, rules.maxLength || Infinity);
if (validatedArray.length < (rules.minLength || 1)) {
logger.warn(
`${name}.${key} contains fewer items than allowed (${rules.minLength}). Using default value.`
);
return new Set(fieldSchema.default);
}
return new Set(validatedArray);
}
function validateObject(configValue, rules, fieldSchema, name, key, validateSchemaFn, logger) {
if (typeof configValue !== "object" || Array.isArray(configValue)) {
logger.warn(`${name}.${key} is not a valid object. Using default value.`);
return fieldSchema.default;
}
if (rules.schema) {
return validateSchemaFn(configValue || {}, rules.schema, `${name}.${key}`);
} else {
logger.warn(`${name}.${key} is an object with no schema. Using default value.`);
return fieldSchema.default;
}
}
module.exports = { validateArray, validateSet, validateObject };

View File

@@ -0,0 +1,108 @@
/**
* Curve validation strategies for machine curves and generic curves.
* Extracted from validationUtils.js for modularity.
*/
function isSorted(arr) {
return arr.every((_, i) => i === 0 || arr[i] >= arr[i - 1]);
}
function isUnique(arr) {
return new Set(arr).size === arr.length;
}
function areNumbers(arr) {
return arr.every((x) => typeof x === "number");
}
function validateDimensionStructure(dimension, name, logger) {
const validatedDimension = {};
for (const [key, value] of Object.entries(dimension)) {
if (typeof value !== "object") {
logger.warn(`Dimension '${name}' key '${key}' is not valid. Returning to default.`);
return false;
}
else if (!Array.isArray(value.x) || !Array.isArray(value.y)) {
logger.warn(`Dimension '${name}' key '${key}' is missing x or y arrays. Converting to arrays.`);
value.x = Object.values(value.x);
value.y = Object.values(value.y);
if (!Array.isArray(value.x) || !Array.isArray(value.y)) {
logger.warn(`Dimension '${name}' key '${key}' is not valid. Returning to default.`);
return false;
}
}
else if (value.x.length !== value.y.length) {
logger.warn(`Dimension '${name}' key '${key}' has mismatched x and y lengths. Ignoring this key.`);
return false;
}
else if (!isSorted(value.x)) {
logger.warn(`Dimension '${name}' key '${key}' has unsorted x values. Sorting...`);
const indices = value.x.map((_v, i) => i);
indices.sort((a, b) => value.x[a] - value.x[b]);
value.x = indices.map(i => value.x[i]);
value.y = indices.map(i => value.y[i]);
}
if (!isUnique(value.x)) {
logger.warn(`Dimension '${name}' key '${key}' has duplicate x values. Removing duplicates...`);
const seen = new Set();
const uniqueX = [];
const uniqueY = [];
for (let i = 0; i < value.x.length; i++) {
if (!seen.has(value.x[i])) {
seen.add(value.x[i]);
uniqueX.push(value.x[i]);
uniqueY.push(value.y[i]);
}
}
value.x = uniqueX;
value.y = uniqueY;
}
if (!areNumbers(value.y)) {
logger.warn(`Dimension '${name}' key '${key}' has non-numeric y values. Ignoring this key.`);
return false;
}
validatedDimension[key] = value;
}
return validatedDimension;
}
function validateCurve(configValue, defaultCurve, logger) {
if (!configValue || typeof configValue !== "object" || Object.keys(configValue).length === 0) {
logger.warn("Curve is missing or invalid. Defaulting to basic curve.");
return defaultCurve;
}
const validatedCurve = validateDimensionStructure(configValue, "curve", logger);
if (!validatedCurve) {
return defaultCurve;
}
return validatedCurve;
}
function validateMachineCurve(configValue, defaultCurve, logger) {
if (!configValue || typeof configValue !== "object" || Object.keys(configValue).length === 0) {
logger.warn("Curve is missing or invalid. Defaulting to basic curve.");
return defaultCurve;
}
const { nq, np } = configValue;
if (!nq || typeof nq !== "object" || !np || typeof np !== "object") {
logger.warn("Curve must contain valid 'nq' and 'np' objects. Defaulting to basic curve.");
return defaultCurve;
}
const validatedNq = validateDimensionStructure(nq, "nq", logger);
const validatedNp = validateDimensionStructure(np, "np", logger);
if (!validatedNq || !validatedNp) {
return defaultCurve;
}
return { nq: validatedNq, np: validatedNp };
}
module.exports = {
validateCurve,
validateMachineCurve,
validateDimensionStructure,
isSorted,
isUnique,
areNumbers
};

View File

@@ -0,0 +1,110 @@
/**
* Standalone type validation functions extracted from validationUtils.js.
*/
function validateNumber(configValue, rules, fieldSchema, name, key, logger) {
if (typeof configValue !== "number") {
const parsedValue = parseFloat(configValue);
if (!isNaN(parsedValue)) {
logger.warn(`${name}.${key} was parsed to a number: ${configValue} -> ${parsedValue}`);
configValue = parsedValue;
}
}
if (rules.min !== undefined && configValue < rules.min) {
logger.warn(`${name}.${key} is below the minimum (${rules.min}). Using default value.`);
return fieldSchema.default;
}
if (rules.max !== undefined && configValue > rules.max) {
logger.warn(`${name}.${key} exceeds the maximum (${rules.max}). Using default value.`);
return fieldSchema.default;
}
logger.debug(`${name}.${key} is a valid number: ${configValue}`);
return configValue;
}
function validateInteger(configValue, rules, fieldSchema, name, key, logger) {
if (typeof configValue !== "number" || !Number.isInteger(configValue)) {
const parsedValue = parseInt(configValue, 10);
if (!isNaN(parsedValue) && Number.isInteger(parsedValue)) {
logger.warn(`${name}.${key} was parsed to an integer: ${configValue} -> ${parsedValue}`);
configValue = parsedValue;
} else {
logger.warn(`${name}.${key} is not a valid integer. Using default value.`);
return fieldSchema.default;
}
}
if (rules.min !== undefined && configValue < rules.min) {
logger.warn(`${name}.${key} is below the minimum integer value (${rules.min}). Using default value.`);
return fieldSchema.default;
}
if (rules.max !== undefined && configValue > rules.max) {
logger.warn(`${name}.${key} exceeds the maximum integer value (${rules.max}). Using default value.`);
return fieldSchema.default;
}
logger.debug(`${name}.${key} is a valid integer: ${configValue}`);
return configValue;
}
function validateBoolean(configValue, name, key, logger) {
if (typeof configValue !== "boolean") {
if (configValue === "true" || configValue === "false") {
const parsedValue = configValue === "true";
logger.debug(`${name}.${key} was parsed to a boolean: ${configValue} -> ${parsedValue}`);
configValue = parsedValue;
}
}
return configValue;
}
function validateString(configValue, rules, fieldSchema, name, key, logger) {
let newConfigValue = configValue;
if (typeof configValue !== "string") {
if(rules.nullable){
if(configValue === null){
return null;
}
}
logger.warn(`${name}.${key} is not a string. Trying to convert to string.`);
newConfigValue = String(configValue);
}
if (typeof newConfigValue !== "string") {
logger.warn(`${name}.${key} is not a valid string. Using default value.`);
return fieldSchema.default;
}
if (newConfigValue !== newConfigValue.toLowerCase()) {
logger.warn(`${name}.${key} contains uppercase characters. Converting to lowercase: ${newConfigValue} -> ${newConfigValue.toLowerCase()}`);
newConfigValue = newConfigValue.toLowerCase();
}
return newConfigValue;
}
function validateEnum(configValue, rules, fieldSchema, name, key, logger) {
if (Array.isArray(rules.values)) {
if(configValue === null){
logger.warn(`${name}.${key} is null. Using default value.`);
return fieldSchema.default;
}
const validValues = rules.values.map(e => e.value.toLowerCase());
configValue = configValue.toLowerCase();
if (!validValues.includes(configValue)) {
logger.warn(
`${name}.${key} has an invalid value : ${configValue}. Allowed values: [${validValues.join(", ")}]. Using default value.`
);
return fieldSchema.default;
}
} else {
logger.warn(
`${name}.${key} is an enum with no 'values' array. Using default value.`
);
return fieldSchema.default;
}
return configValue;
}
module.exports = {
validateNumber,
validateInteger,
validateBoolean,
validateString,
validateEnum,
};

View File

@@ -68,6 +68,23 @@ class Measurement {
return this.values[this.values.length - 1]; return this.values[this.values.length - 1];
} }
getLaggedValue(lag){
if(this.values.length <= lag) return null;
return this.values[this.values.length - lag];
}
getLaggedSample(lag){
if (lag < 0) throw new Error('lag must be >= 0');
const index = this.values.length - 1 - lag;
if (index < 0) return null;
return {
value: this.values[index],
timestamp: this.timestamps[index],
unit: this.unit,
};
}
getAverage() { getAverage() {
if (this.values.length === 0) return null; if (this.values.length === 0) return null;
const sum = this.values.reduce((acc, val) => acc + val, 0); const sum = this.values.reduce((acc, val) => acc + val, 0);
@@ -96,7 +113,6 @@ class Measurement {
// Create a new measurement that is the difference between two positions // Create a new measurement that is the difference between two positions
static createDifference(upstreamMeasurement, downstreamMeasurement) { static createDifference(upstreamMeasurement, downstreamMeasurement) {
console.log('hello:');
if (upstreamMeasurement.type !== downstreamMeasurement.type || if (upstreamMeasurement.type !== downstreamMeasurement.type ||
upstreamMeasurement.variant !== downstreamMeasurement.variant) { upstreamMeasurement.variant !== downstreamMeasurement.variant) {
throw new Error('Cannot calculate difference between different measurement types or variants'); throw new Error('Cannot calculate difference between different measurement types or variants');

View File

@@ -1,9 +1,10 @@
const MeasurementBuilder = require('./MeasurementBuilder'); const MeasurementBuilder = require('./MeasurementBuilder');
const EventEmitter = require('events'); const EventEmitter = require('events');
const convertModule = require('../convert/index'); const convertModule = require('../convert/index');
const { POSITIONS } = require('../constants/positions');
class MeasurementContainer { class MeasurementContainer {
constructor(options = {},logger) { constructor(options = {},_logger) {
this.emitter = new EventEmitter(); this.emitter = new EventEmitter();
this.measurements = {}; this.measurements = {};
this.windowSize = options.windowSize || 10; // Default window size this.windowSize = options.windowSize || 10; // Default window size
@@ -94,7 +95,8 @@ class MeasurementContainer {
throw new Error('Variant must be specified before position'); throw new Error('Variant must be specified before position');
} }
this._currentPosition = positionValue;
this._currentPosition = positionValue.toString().toLowerCase();
return this; return this;
} }
@@ -163,11 +165,55 @@ class MeasurementContainer {
// Emit the exact event your parent expects // Emit the exact event your parent expects
this.emitter.emit(`${this._currentType}.${this._currentVariant}.${this._currentPosition}`, eventData); this.emitter.emit(`${this._currentType}.${this._currentVariant}.${this._currentPosition}`, eventData);
console.log(`Emitted event: ${this._currentType}.${this._currentVariant}.${this._currentPosition}`); //console.log(`Emitted event: ${this._currentType}.${this._currentVariant}.${this._currentPosition}`, eventData);
return this; return this;
} }
/**
* Check whether a measurement series exists.
*
* You can rely on the current chain (type/variant/position already set via
* type().variant().position()), or pass them explicitly via the options.
*
* @param {object} options
* @param {string} [options.type] Override the current type
* @param {string} [options.variant] Override the current variant
* @param {string} [options.position] Override the current position
* @param {boolean} [options.requireValues=false]
* When true, the series must contain at least one stored value.
*
* @returns {boolean}
*/
exists({ type, variant, position, requireValues = false } = {}) {
const typeKey = type ?? this._currentType;
if (!typeKey) return false;
const variantKey = variant ?? this._currentVariant;
if (!variantKey) return false;
const positionKey = position ?? this._currentPosition;
const typeBucket = this.measurements[typeKey];
if (!typeBucket) return false;
const variantBucket = typeBucket[variantKey];
if (!variantBucket) return false;
if (!positionKey) {
// No specific position requested just check the variant bucket.
return requireValues
? Object.values(variantBucket).some(m => m?.values?.length > 0)
: Object.keys(variantBucket).length > 0;
}
const measurement = variantBucket[positionKey];
if (!measurement) return false;
return requireValues ? measurement.values?.length > 0 : true;
}
unit(unitName) { unit(unitName) {
if (!this._ensureChainIsValid()) return this; if (!this._ensureChainIsValid()) return this;
@@ -247,37 +293,102 @@ class MeasurementContainer {
return measurement ? measurement.getAllValues() : null; return measurement ? measurement.getAllValues() : null;
} }
getLaggedValue(lag = 1,requestedUnit = null ){
const measurement = this.get();
if (!measurement) return null;
let sample = measurement.getLaggedSample(lag);
if (sample === null) return null;
const value = sample.value;
// Return as-is if no unit conversion requested
if (!requestedUnit) {
return value;
}
// Convert if needed
if (measurement.unit && requestedUnit !== measurement.unit) {
try {
const convertedValue = convertModule(value).from(measurement.unit).to(requestedUnit);
//replace old value in sample and return obj
sample.value = convertedValue ;
sample.unit = requestedUnit;
return sample;
} catch (error) {
if (this.logger) {
this.logger.error(`Unit conversion failed: ${error.message}`);
}
return sample; // Return original value if conversion fails
}
}
return value;
}
getLaggedSample(lag = 1,requestedUnit = null ){
const measurement = this.get();
if (!measurement) return null;
let sample = measurement.getLaggedSample(lag);
if (sample === null) return null;
// Return as-is if no unit conversion requested
if (!requestedUnit) {
return sample;
}
// Convert if needed
if (measurement.unit && requestedUnit !== measurement.unit) {
try {
const convertedValue = convertModule(sample.value).from(measurement.unit).to(requestedUnit);
//replace old value in sample and return obj
sample.value = convertedValue ;
sample.unit = requestedUnit;
return sample;
} catch (error) {
if (this.logger) {
this.logger.error(`Unit conversion failed: ${error.message}`);
}
return sample; // Return original value if conversion fails
}
}
return sample;
}
// Difference calculations between positions // Difference calculations between positions
difference(requestedUnit = null) { difference({ from = POSITIONS.DOWNSTREAM, to = POSITIONS.UPSTREAM, unit: requestedUnit } = {}) {
if (!this._currentType || !this._currentVariant) { if (!this._currentType || !this._currentVariant) {
throw new Error('Type and variant must be specified for difference calculation'); throw new Error("Type and variant must be specified for difference calculation");
} }
const upstream = this.measurements?.[this._currentType]?.[this._currentVariant]?.['upstream'] || null;
const downstream = this.measurements?.[this._currentType]?.[this._currentVariant]?.['downstream'] || null;
if (!upstream || !downstream || upstream.values.length === 0 || downstream.values.length === 0) { const get = pos =>
this.measurements?.[this._currentType]?.[this._currentVariant]?.[pos] || null;
const a = get(from);
const b = get(to);
if (!a || !b || a.values.length === 0 || b.values.length === 0) {
return null; return null;
} }
// Get target unit for conversion const targetUnit = requestedUnit || a.unit || b.unit;
const targetUnit = requestedUnit || upstream.unit || downstream.unit; const aVal = this._convertValueToUnit(a.getCurrentValue(), a.unit, targetUnit);
const bVal = this._convertValueToUnit(b.getCurrentValue(), b.unit, targetUnit);
// Get values in the same unit const aAvg = this._convertValueToUnit(a.getAverage(), a.unit, targetUnit);
const upstreamValue = this._convertValueToUnit(upstream.getCurrentValue(), upstream.unit, targetUnit); const bAvg = this._convertValueToUnit(b.getAverage(), b.unit, targetUnit);
const downstreamValue = this._convertValueToUnit(downstream.getCurrentValue(), downstream.unit, targetUnit);
const upstreamAvg = this._convertValueToUnit(upstream.getAverage(), upstream.unit, targetUnit);
const downstreamAvg = this._convertValueToUnit(downstream.getAverage(), downstream.unit, targetUnit);
return { return {
value: downstreamValue - upstreamValue, value: aVal - bVal,
avgDiff: downstreamAvg - upstreamAvg, avgDiff: aAvg - bAvg,
unit: targetUnit unit: targetUnit,
from,
to,
}; };
} }
// Helper methods // Helper methods
_ensureChainIsValid() { _ensureChainIsValid() {
@@ -345,6 +456,8 @@ class MeasurementContainer {
this._currentType = null; this._currentType = null;
this._currentVariant = null; this._currentVariant = null;
this._currentPosition = null; this._currentPosition = null;
this._currentDistance = null;
this._unit = null;
} }
// Helper method for value conversion // Helper method for value conversion
@@ -414,11 +527,11 @@ class MeasurementContainer {
_convertPositionStr2Num(positionString) { _convertPositionStr2Num(positionString) {
switch(positionString) { switch(positionString) {
case "atEquipment": case POSITIONS.AT_EQUIPMENT:
return 0; return 0;
case "upstream": case POSITIONS.UPSTREAM:
return Number.POSITIVE_INFINITY; return Number.POSITIVE_INFINITY;
case "downstream": case POSITIONS.DOWNSTREAM:
return Number.NEGATIVE_INFINITY; return Number.NEGATIVE_INFINITY;
default: default:
@@ -432,11 +545,11 @@ class MeasurementContainer {
_convertPositionNum2Str(positionValue) { _convertPositionNum2Str(positionValue) {
switch (positionValue) { switch (positionValue) {
case 0: case 0:
return "atEquipment"; return POSITIONS.AT_EQUIPMENT;
case (positionValue < 0): case (positionValue < 0):
return "upstream"; return POSITIONS.UPSTREAM;
case (positionValue > 0): case (positionValue > 0):
return "downstream"; return POSITIONS.DOWNSTREAM;
default: default:
console.log(`Invalid position provided: ${positionValue}`); console.log(`Invalid position provided: ${positionValue}`);
} }

View File

@@ -1,4 +1,7 @@
const { MeasurementContainer } = require('./index'); const { MeasurementContainer } = require('./index');
const { POSITIONS } = require('../constants/positions');
const measurements = new MeasurementContainer();
console.log('=== MEASUREMENT CONTAINER EXAMPLES ===\n'); console.log('=== MEASUREMENT CONTAINER EXAMPLES ===\n');
console.log('This guide shows how to use the MeasurementContainer for storing,'); console.log('This guide shows how to use the MeasurementContainer for storing,');
@@ -27,7 +30,7 @@ console.log('\nSetting pressure values with distances:');
basicContainer basicContainer
.type('pressure') .type('pressure')
.variant('measured') .variant('measured')
.position('upstream') .position(POSITIONS.UPSTREAM)
.distance(1.5) .distance(1.5)
.value(100) .value(100)
.unit('psi'); .unit('psi');
@@ -35,7 +38,7 @@ basicContainer
basicContainer basicContainer
.type('pressure') .type('pressure')
.variant('measured') .variant('measured')
.position('downstream') .position(POSITIONS.DOWNSTREAM)
.distance(5.2) .distance(5.2)
.value(95) .value(95)
.unit('psi'); .unit('psi');
@@ -44,7 +47,7 @@ basicContainer
basicContainer basicContainer
.type('pressure') .type('pressure')
.variant('measured') .variant('measured')
.position('downstream') .position(POSITIONS.DOWNSTREAM)
.value(90); // distance 5.2 is automatically reused .value(90); // distance 5.2 is automatically reused
console.log('✅ Basic setup complete\n'); console.log('✅ Basic setup complete\n');
@@ -53,7 +56,7 @@ console.log('✅ Basic setup complete\n');
const upstreamPressure = basicContainer const upstreamPressure = basicContainer
.type('pressure') .type('pressure')
.variant('measured') .variant('measured')
.position('upstream') .position(POSITIONS.UPSTREAM)
.get(); .get();
console.log(`Retrieved upstream pressure: ${upstreamPressure.getCurrentValue()} ${upstreamPressure.unit}`); console.log(`Retrieved upstream pressure: ${upstreamPressure.getCurrentValue()} ${upstreamPressure.unit}`);
@@ -83,14 +86,15 @@ console.log('Adding pressure with auto-conversion:');
autoContainer autoContainer
.type('pressure') .type('pressure')
.variant('measured') .variant('measured')
.position('upstream') .position(POSITIONS.UPSTREAM)
.distance(0.5) .distance(0.5)
.value(1.5, Date.now(), 'bar'); // Input: 1.5 bar → Auto-stored as ~21.76 psi .value(1.5, Date.now(), 'bar'); // Input: 1.5 bar → Auto-stored as ~21.76 psi
const converted = autoContainer const converted = autoContainer
.type('pressure') .type('pressure')
.variant('measured') .variant('measured')
.position('upstream') .position(POSITIONS.UPSTREAM)
.get(); .get();
console.log(`Stored as: ${converted.getCurrentValue()} ${converted.unit} (distance=${converted.distance}m)`); console.log(`Stored as: ${converted.getCurrentValue()} ${converted.unit} (distance=${converted.distance}m)`);
@@ -104,14 +108,14 @@ console.log('--- Example 3: Unit Conversion on Retrieval ---');
autoContainer autoContainer
.type('flow') .type('flow')
.variant('predicted') .variant('predicted')
.position('upstream') .position(POSITIONS.UPSTREAM)
.distance(2.4) .distance(2.4)
.value(100, Date.now(), 'l/min'); .value(100, Date.now(), 'l/min');
const flowMeasurement = autoContainer const flowMeasurement = autoContainer
.type('flow') .type('flow')
.variant('predicted') .variant('predicted')
.position('upstream') .position(POSITIONS.UPSTREAM)
.get(); .get();
console.log(`Flow in l/min: ${flowMeasurement.getCurrentValue('l/min')}`); console.log(`Flow in l/min: ${flowMeasurement.getCurrentValue('l/min')}`);
@@ -152,13 +156,13 @@ console.log('--- Example 5: Basic Value Retrieval ---');
const upstreamVal = basicContainer const upstreamVal = basicContainer
.type('pressure') .type('pressure')
.variant('measured') .variant('measured')
.position('upstream') .position(POSITIONS.UPSTREAM)
.getCurrentValue(); .getCurrentValue();
const upstreamData = basicContainer const upstreamData = basicContainer
.type('pressure') .type('pressure')
.variant('measured') .variant('measured')
.position('upstream') .position(POSITIONS.UPSTREAM)
.get(); .get();
console.log(`Upstream: ${upstreamVal} ${upstreamData.unit} at ${upstreamData.distance}m`); console.log(`Upstream: ${upstreamVal} ${upstreamData.unit} at ${upstreamData.distance}m`);
@@ -166,15 +170,34 @@ console.log(`Upstream: ${upstreamVal} ${upstreamData.unit} at ${upstreamData.dis
const downstreamVal = basicContainer const downstreamVal = basicContainer
.type('pressure') .type('pressure')
.variant('measured') .variant('measured')
.position('downstream') .position(POSITIONS.DOWNSTREAM)
.getCurrentValue(); .getCurrentValue();
const downstreamData = basicContainer const downstreamData = basicContainer
.type('pressure') .type('pressure')
.variant('measured') .variant('measured')
.position('downstream') .position(POSITIONS.DOWNSTREAM)
.get(); .get();
//check wether a serie exists
const hasSeries = measurements // eslint-disable-line no-unused-vars
.type("flow")
.variant("measured")
.exists(); // true if any position exists
const hasUpstreamValues = measurements // eslint-disable-line no-unused-vars
.type("flow")
.variant("measured")
.exists({ position: POSITIONS.UPSTREAM, requireValues: true });
// Passing everything explicitly
const hasPercent = measurements.exists({ // eslint-disable-line no-unused-vars
type: "volume",
variant: "percent",
position: POSITIONS.AT_EQUIPMENT,
});
console.log(`Downstream: ${downstreamVal} ${downstreamData.unit} at ${downstreamData.distance}m\n`); console.log(`Downstream: ${downstreamVal} ${downstreamData.unit} at ${downstreamData.distance}m\n`);
// ==================================== // ====================================
@@ -185,7 +208,7 @@ console.log('--- Example 6: Calculations & Statistics ---');
basicContainer basicContainer
.type('flow') .type('flow')
.variant('predicted') .variant('predicted')
.position('upstream') .position(POSITIONS.UPSTREAM)
.distance(3.0) .distance(3.0)
.value(200) .value(200)
.unit('gpm'); .unit('gpm');
@@ -193,7 +216,7 @@ basicContainer
basicContainer basicContainer
.type('flow') .type('flow')
.variant('predicted') .variant('predicted')
.position('downstream') .position(POSITIONS.DOWNSTREAM)
.distance(8.5) .distance(8.5)
.value(195) .value(195)
.unit('gpm'); .unit('gpm');
@@ -201,7 +224,7 @@ basicContainer
const flowAvg = basicContainer const flowAvg = basicContainer
.type('flow') .type('flow')
.variant('predicted') .variant('predicted')
.position('upstream') .position(POSITIONS.UPSTREAM)
.getAverage(); .getAverage();
console.log(`Average upstream flow: ${flowAvg.toFixed(1)} gpm`); console.log(`Average upstream flow: ${flowAvg.toFixed(1)} gpm`);
@@ -213,6 +236,10 @@ const pressureDiff = basicContainer
console.log(`Pressure difference: ${pressureDiff.value} ${pressureDiff.unit}\n`); console.log(`Pressure difference: ${pressureDiff.value} ${pressureDiff.unit}\n`);
//reversable difference
const deltaP = basicContainer.type("pressure").variant("measured").difference(); // eslint-disable-line no-unused-vars -- defaults to downstream - upstream
const netFlow = basicContainer.type("flow").variant("measured").difference({ from: POSITIONS.UPSTREAM, to: POSITIONS.DOWNSTREAM }); // eslint-disable-line no-unused-vars
// ==================================== // ====================================
// ADVANCED STATISTICS & HISTORY // ADVANCED STATISTICS & HISTORY
// ==================================== // ====================================
@@ -221,7 +248,7 @@ console.log('--- Example 7: Advanced Statistics & History ---');
basicContainer basicContainer
.type('flow') .type('flow')
.variant('measured') .variant('measured')
.position('upstream') .position(POSITIONS.UPSTREAM)
.distance(3.0) .distance(3.0)
.value(210) .value(210)
.value(215) .value(215)
@@ -233,7 +260,7 @@ basicContainer
const stats = basicContainer const stats = basicContainer
.type('flow') .type('flow')
.variant('measured') .variant('measured')
.position('upstream'); .position(POSITIONS.UPSTREAM);
const statsData = stats.get(); const statsData = stats.get();
@@ -248,6 +275,28 @@ const allValues = stats.getAllValues();
console.log(` Samples: ${allValues.values.length}`); console.log(` Samples: ${allValues.values.length}`);
console.log(` History: [${allValues.values.join(', ')}]\n`); console.log(` History: [${allValues.values.join(', ')}]\n`);
console.log('--- Lagged sample comparison ---');
const latest = stats.getCurrentValue(); // existing helper
const prevSample = stats.getLaggedValue(1); // new helper
const prevPrevSample = stats.getLaggedValue(2); // optional
if (prevSample) {
const delta = latest - prevSample.value;
console.log(
`Current vs previous: ${latest} ${statsData.unit} (t=${stats.get().getLatestTimestamp()}) vs ` +
`${prevSample.value} ${prevSample.unit} (t=${prevSample.timestamp})`
);
console.log(`Δ = ${delta.toFixed(2)} ${statsData.unit}`);
}
if (prevPrevSample) {
console.log(
`Previous vs 2-steps-back timestamps: ${new Date(prevSample.timestamp).toISOString()} vs ` +
`${new Date(prevPrevSample.timestamp).toISOString()}`
);
}
// ==================================== // ====================================
// DYNAMIC UNIT MANAGEMENT // DYNAMIC UNIT MANAGEMENT
// ==================================== // ====================================
@@ -299,8 +348,11 @@ basicContainer.getTypes().forEach(type => {
} }
}); });
console.log('\n✅ All examples complete!\n'); console.log('\n✅ All examples complete!\n');
// ==================================== // ====================================
// BEST PRACTICES // BEST PRACTICES
// ==================================== // ====================================

View File

@@ -1,5 +1,5 @@
const AssetMenu = require('./asset.js'); const AssetMenu = require('./asset.js');
const { TagcodeApp, DynamicAssetMenu } = require('./tagcodeApp.js'); // TagcodeApp and DynamicAssetMenu available via ./tagcodeApp.js
const LoggerMenu = require('./logger.js'); const LoggerMenu = require('./logger.js');
const PhysicalPositionMenu = require('./physicalPosition.js'); const PhysicalPositionMenu = require('./physicalPosition.js');

View File

@@ -87,3 +87,5 @@ dataStream.forEach((value, index) => {
console.log("\nFinal detector cluster states:", JSON.stringify(detector.clusters, null, 2)); console.log("\nFinal detector cluster states:", JSON.stringify(detector.clusters, null, 2));
*/ */
module.exports = DynamicClusterDeviation;

View File

@@ -88,7 +88,7 @@ class Interpolation {
array_values(obj) { array_values(obj) {
const new_array = []; const new_array = [];
for (let i in obj) { for (let i in obj) {
if (obj.hasOwnProperty(i)) { if (Object.prototype.hasOwnProperty.call(obj, i)) {
new_array.push(obj[i]); new_array.push(obj[i]);
} }
} }
@@ -101,6 +101,7 @@ class Interpolation {
} else if (type == "monotone_cubic_spline") { } else if (type == "monotone_cubic_spline") {
this.monotonic_cubic_spline(); this.monotonic_cubic_spline();
} else if (type == "linear") { } else if (type == "linear") {
/* intentionally empty */
} else { } else {
this.error = 1000; this.error = 1000;
} }
@@ -230,7 +231,6 @@ class Interpolation {
let xdata = this.input_xdata; let xdata = this.input_xdata;
let ydata = this.input_ydata; let ydata = this.input_ydata;
let interpolationtype = this.interpolationtype;
let tension = this.tension; let tension = this.tension;
let n = ydata.length; let n = ydata.length;
@@ -266,6 +266,7 @@ class Interpolation {
let k = 0; let k = 0;
if (xpoint < xdata[0] || xpoint > xdata[n - 1]) { if (xpoint < xdata[0] || xpoint > xdata[n - 1]) {
/* intentionally empty */
} }
while (k < n - 1 && xpoint > xdata[k + 1] && !(xpoint < xdata[0] || xpoint > xdata[n - 1])) { while (k < n - 1 && xpoint > xdata[k + 1] && !(xpoint < xdata[0] || xpoint > xdata[n - 1])) {

View File

@@ -161,6 +161,11 @@ class Predict {
//find index of y peak //find index of y peak
const { peak , peakIndex } = this.getLocalPeak(curve.y); const { peak , peakIndex } = this.getLocalPeak(curve.y);
// Guard against invalid peakIndex (e.g. empty array returns -1)
if (peakIndex < 0 || peakIndex >= curve.x.length) {
return { yPeak: null, x: null, xProcent: null };
}
// scale the x value to procentual value // scale the x value to procentual value
const yPeak = peak; const yPeak = peak;
const x = curve.x[peakIndex]; const x = curve.x[peakIndex];
@@ -350,6 +355,7 @@ class Predict {
} }
buildAllFxyCurves(curve) { buildAllFxyCurves(curve) {
let globalMinY = Infinity; let globalMinY = Infinity;
let globalMaxY = -Infinity; let globalMaxY = -Infinity;

View File

@@ -49,15 +49,17 @@ class movementManager {
try { try {
// Execute the movement logic based on the mode // Execute the movement logic based on the mode
switch (this.movementMode) { switch (this.movementMode) {
case "staticspeed": case "staticspeed": {
const movelinFeedback = await this.moveLinear(targetPosition,signal); const movelinFeedback = await this.moveLinear(targetPosition,signal);
this.logger.info(`Linear move: ${movelinFeedback} `); this.logger.info(`Linear move: ${movelinFeedback} `);
break; break;
}
case "dynspeed": case "dynspeed": {
const moveDynFeedback = await this.moveEaseInOut(targetPosition,signal); const moveDynFeedback = await this.moveEaseInOut(targetPosition,signal);
this.logger.info(`Dynamic move : ${moveDynFeedback}`); this.logger.info(`Dynamic move : ${moveDynFeedback}`);
break; break;
}
default: default:
throw new Error(`Unsupported movement mode: ${this.movementMode}`); throw new Error(`Unsupported movement mode: ${this.movementMode}`);
@@ -214,7 +216,6 @@ class movementManager {
return reject(new Error("Movement aborted")); return reject(new Error("Movement aborted"));
} }
const direction = targetPosition > this.currentPosition ? 1 : -1;
const totalDistance = Math.abs(targetPosition - this.currentPosition); const totalDistance = Math.abs(targetPosition - this.currentPosition);
const startPosition = this.currentPosition; const startPosition = this.currentPosition;
this.speed = Math.min(Math.max(this.speed, 0), 1); this.speed = Math.min(Math.max(this.speed, 0), 1);

View File

@@ -0,0 +1,360 @@
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);
});
});
});

217
test/configManager.test.js Normal file
View File

@@ -0,0 +1,217 @@
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');
});
});
});

View File

@@ -0,0 +1,336 @@
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');
});
});
});

69
test/outputUtils.test.js Normal file
View File

@@ -0,0 +1,69 @@
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');
});
});

View File

@@ -0,0 +1,554 @@
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();
});
});
});