Compare commits
55 Commits
2fb73e6713
...
dev-Rene
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27a6d3c709 | ||
|
|
c60aa40666 | ||
|
|
1cfb36f604 | ||
|
|
105a3082ab | ||
|
|
cde331246c | ||
|
|
15c33d650b | ||
|
|
a536c6ed5e | ||
|
|
266a6ed4a3 | ||
|
|
37796c3e3b | ||
|
|
067017f2ea | ||
|
|
52f1cf73b4 | ||
|
|
a81733c492 | ||
|
|
555d4d865b | ||
|
|
db85100c4d | ||
|
|
b884faf402 | ||
|
|
2c43d28f76 | ||
|
|
858189d6da | ||
|
|
ec42ebcb25 | ||
|
|
d52a1827e3 | ||
|
|
f4629e5fcc | ||
|
|
dafe4c5336 | ||
|
|
5439d5111a | ||
|
|
1e5ef47a4d | ||
|
|
2b87c67876 | ||
|
|
0db90c0e4b | ||
|
|
1e07093101 | ||
|
|
f2c9134b64 | ||
|
|
5df3881375 | ||
|
|
ce25ee930a | ||
|
|
6be3bf92ef | ||
|
|
efe4a5f97d | ||
|
|
e5c98b7d30 | ||
|
|
4a489acd89 | ||
|
|
a293e0286a | ||
|
|
98cd44d3ae | ||
|
|
44adfdece6 | ||
|
|
9ada6e2acd | ||
| 012b8a7ff6 | |||
|
|
9610e7138d | ||
|
|
d5d078413c | ||
|
|
17662ef7cb | ||
|
|
9d8da15d0e | ||
| d503cf5dc9 | |||
|
|
48a227d519 | ||
|
|
f653a1e98c | ||
|
|
1725c5b0e9 | ||
|
|
d7cb8e1072 | ||
| 9b7a8ae2c8 | |||
|
|
dc50432ee8 | ||
|
|
c99d24e4c6 | ||
|
|
f9d1348fd0 | ||
|
|
428c611ec6 | ||
|
|
cffbd51d92 | ||
|
|
3886277616 | ||
|
|
83018fabe0 |
@@ -66,6 +66,33 @@
|
||||
"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,13 @@
|
||||
{
|
||||
"id": "hidrostal-pump-001",
|
||||
"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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
838
datasets/assetData/curves/hidrostal-C5-D03R-SHN1.json
Normal file
838
datasets/assetData/curves/hidrostal-C5-D03R-SHN1.json
Normal 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
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,11 @@ class AssetLoader {
|
||||
*/
|
||||
loadAsset(datasetType, assetId) {
|
||||
//const cacheKey = `${datasetType}/${assetId}`;
|
||||
const cacheKey = `${assetId}`;
|
||||
const normalizedAssetId = String(assetId || '').trim();
|
||||
if (!normalizedAssetId) {
|
||||
return null;
|
||||
}
|
||||
const cacheKey = normalizedAssetId.toLowerCase();
|
||||
|
||||
|
||||
// Check cache first
|
||||
@@ -34,11 +38,11 @@ class AssetLoader {
|
||||
}
|
||||
|
||||
try {
|
||||
const filePath = path.join(this.baseDir, `${assetId}.json`);
|
||||
const filePath = this._resolveAssetPath(normalizedAssetId);
|
||||
|
||||
// Check if file exists
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.warn(`Asset not found: ${filePath}`);
|
||||
if (!filePath || !fs.existsSync(filePath)) {
|
||||
console.warn(`Asset not found for id '${normalizedAssetId}' in ${this.baseDir}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -56,6 +60,21 @@ class AssetLoader {
|
||||
}
|
||||
}
|
||||
|
||||
_resolveAssetPath(assetId) {
|
||||
const exactPath = path.join(this.baseDir, `${assetId}.json`);
|
||||
if (fs.existsSync(exactPath)) {
|
||||
return exactPath;
|
||||
}
|
||||
|
||||
const target = `${assetId}.json`.toLowerCase();
|
||||
const files = fs.readdirSync(this.baseDir);
|
||||
const matched = files.find((file) => file.toLowerCase() === target);
|
||||
if (!matched) {
|
||||
return null;
|
||||
}
|
||||
return path.join(this.baseDir, matched);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available assets in a dataset
|
||||
* @param {string} datasetType - The dataset folder name
|
||||
@@ -121,4 +140,4 @@ console.log('Available curves:', availableCurves);
|
||||
const { AssetLoader } = require('./index.js');
|
||||
const customLoader = new AssetLoader();
|
||||
const data = customLoader.loadCurve('hidrostal-H05K-S03R');
|
||||
*/
|
||||
*/
|
||||
|
||||
89
datasets/assetData/index.js
Normal file
89
datasets/assetData/index.js
Normal file
@@ -0,0 +1,89 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
class AssetCategoryManager {
|
||||
constructor(relPath = '.') {
|
||||
this.assetDir = path.resolve(__dirname, relPath);
|
||||
this.cache = new Map();
|
||||
}
|
||||
|
||||
getCategory(softwareType) {
|
||||
if (!softwareType) {
|
||||
throw new Error('softwareType is required');
|
||||
}
|
||||
|
||||
if (this.cache.has(softwareType)) {
|
||||
return this.cache.get(softwareType);
|
||||
}
|
||||
|
||||
const filePath = path.resolve(this.assetDir, `${softwareType}.json`);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new Error(`Asset data '${softwareType}' not found in ${this.assetDir}`);
|
||||
}
|
||||
|
||||
const raw = fs.readFileSync(filePath, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
this.cache.set(softwareType, parsed);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
hasCategory(softwareType) {
|
||||
const filePath = path.resolve(this.assetDir, `${softwareType}.json`);
|
||||
return fs.existsSync(filePath);
|
||||
}
|
||||
|
||||
listCategories({ withMeta = false } = {}) {
|
||||
const files = fs.readdirSync(this.assetDir, { withFileTypes: true });
|
||||
|
||||
return files
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.isFile() &&
|
||||
entry.name.endsWith('.json') &&
|
||||
entry.name !== 'index.json' &&
|
||||
entry.name !== 'assetData.json'
|
||||
)
|
||||
.map((entry) => path.basename(entry.name, '.json'))
|
||||
.map((name) => {
|
||||
if (!withMeta) {
|
||||
return name;
|
||||
}
|
||||
|
||||
const data = this.getCategory(name);
|
||||
return {
|
||||
softwareType: data.softwareType || name,
|
||||
label: data.label || name,
|
||||
file: `${name}.json`
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
searchCategories(query) {
|
||||
const term = (query || '').trim().toLowerCase();
|
||||
if (!term) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.listCategories({ withMeta: true }).filter(
|
||||
({ softwareType, label }) =>
|
||||
softwareType.toLowerCase().includes(term) ||
|
||||
label.toLowerCase().includes(term)
|
||||
);
|
||||
}
|
||||
|
||||
clearCache() {
|
||||
this.cache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
const assetCategoryManager = new AssetCategoryManager();
|
||||
|
||||
module.exports = {
|
||||
AssetCategoryManager,
|
||||
assetCategoryManager,
|
||||
getCategory: (softwareType) => assetCategoryManager.getCategory(softwareType),
|
||||
listCategories: (options) => assetCategoryManager.listCategories(options),
|
||||
searchCategories: (query) => assetCategoryManager.searchCategories(query),
|
||||
hasCategory: (softwareType) => assetCategoryManager.hasCategory(softwareType),
|
||||
clearCache: () => assetCategoryManager.clearCache()
|
||||
};
|
||||
21
datasets/assetData/machine.json
Normal file
21
datasets/assetData/machine.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"id": "machine",
|
||||
"label": "machine",
|
||||
"softwareType": "machine",
|
||||
"suppliers": [
|
||||
{
|
||||
"id": "hidrostal",
|
||||
"name": "Hidrostal",
|
||||
"types": [
|
||||
{
|
||||
"id": "pump-centrifugal",
|
||||
"name": "Centrifugal",
|
||||
"models": [
|
||||
{ "id": "hidrostal-H05K-S03R", "name": "hidrostal-H05K-S03R", "units": ["l/s","m3/h"] },
|
||||
{ "id": "hidrostal-C5-D03R-SHN1", "name": "hidrostal-C5-D03R-SHN1", "units": ["l/s"] }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
115
datasets/assetData/measurement.json
Normal file
115
datasets/assetData/measurement.json
Normal file
@@ -0,0 +1,115 @@
|
||||
{
|
||||
"id": "sensor",
|
||||
"label": "Sensor",
|
||||
"softwareType": "measurement",
|
||||
"suppliers": [
|
||||
{
|
||||
"id": "vega",
|
||||
"name": "Vega",
|
||||
"types": [
|
||||
{
|
||||
"id": "temperature",
|
||||
"name": "Temperature",
|
||||
"models": [
|
||||
{ "id": "vega-temp-10", "name": "VegaTemp 10", "units": ["degC", "degF"], "product_model_id": 1001, "product_model_uuid": "vega-temp-10" },
|
||||
{ "id": "vega-temp-20", "name": "VegaTemp 20", "units": ["degC", "degF"], "product_model_id": 1002, "product_model_uuid": "vega-temp-20" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "pressure",
|
||||
"name": "Pressure",
|
||||
"models": [
|
||||
{ "id": "vega-pressure-10", "name": "VegaPressure 10", "units": ["bar", "mbar", "psi"], "product_model_id": 1003, "product_model_uuid": "vega-pressure-10" },
|
||||
{ "id": "vega-pressure-20", "name": "VegaPressure 20", "units": ["bar", "mbar", "psi"], "product_model_id": 1004, "product_model_uuid": "vega-pressure-20" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "flow",
|
||||
"name": "Flow",
|
||||
"models": [
|
||||
{ "id": "vega-flow-10", "name": "VegaFlow 10", "units": ["m3/h", "gpm", "l/min"], "product_model_id": 1005, "product_model_uuid": "vega-flow-10" },
|
||||
{ "id": "vega-flow-20", "name": "VegaFlow 20", "units": ["m3/h", "gpm", "l/min"], "product_model_id": 1006, "product_model_uuid": "vega-flow-20" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "level",
|
||||
"name": "Level",
|
||||
"models": [
|
||||
{ "id": "vega-level-10", "name": "VegaLevel 10", "units": ["m", "ft", "mm"], "product_model_id": 1007, "product_model_uuid": "vega-level-10" },
|
||||
{ "id": "vega-level-20", "name": "VegaLevel 20", "units": ["m", "ft", "mm"], "product_model_id": 1008, "product_model_uuid": "vega-level-20" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "oxygen",
|
||||
"name": "Quantity (oxygen)",
|
||||
"models": [
|
||||
{ "id": "vega-oxy-10", "name": "VegaOxySense 10", "units": ["g/m3", "mol/m3"], "product_model_id": 1009, "product_model_uuid": "vega-oxy-10" }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "Endress+Hauser",
|
||||
"name": "Endress+Hauser",
|
||||
"types": [
|
||||
{
|
||||
"id": "flow",
|
||||
"name": "Flow",
|
||||
"models": [
|
||||
{ "id": "Promag-W400", "name": "Promag W400", "units": ["m3/h", "l/s", "gpm"] },
|
||||
{ "id": "Promag-W300", "name": "Promag W300", "units": ["m3/h", "l/s", "gpm"] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "pressure",
|
||||
"name": "Pressure",
|
||||
"models": [
|
||||
{ "id": "Cerabar-PMC51", "name": "Cerabar PMC51", "units": ["mbar", "bar", "psi"] },
|
||||
{ "id": "Cerabar-PMC71", "name": "Cerabar PMC71", "units": ["mbar", "bar", "psi"] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "level",
|
||||
"name": "Level",
|
||||
"models": [
|
||||
{ "id": "Levelflex-FMP50", "name": "Levelflex FMP50", "units": ["m", "mm", "ft"] }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "Hach",
|
||||
"name": "Hach",
|
||||
"types": [
|
||||
{
|
||||
"id": "dissolved-oxygen",
|
||||
"name": "Dissolved Oxygen",
|
||||
"models": [
|
||||
{ "id": "LDO2", "name": "LDO2", "units": ["mg/L", "ppm"] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ammonium",
|
||||
"name": "Ammonium",
|
||||
"models": [
|
||||
{ "id": "Amtax-sc", "name": "Amtax sc", "units": ["mg/L"] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "nitrate",
|
||||
"name": "Nitrate",
|
||||
"models": [
|
||||
{ "id": "Nitratax-sc", "name": "Nitratax sc", "units": ["mg/L"] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "tss",
|
||||
"name": "TSS (Suspended Solids)",
|
||||
"models": [
|
||||
{ "id": "Solitax-sc", "name": "Solitax sc", "units": ["mg/L", "g/L"] }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
16
datasets/assetData/modelData/ECDV.json
Normal file
16
datasets/assetData/modelData/ECDV.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"1.204": {
|
||||
"125": {
|
||||
"x": [0,10,20,30,40,50,60,70,80,90,100],
|
||||
"y": [0,18,50,95,150,216,337,564,882,1398,1870]
|
||||
},
|
||||
"150": {
|
||||
"x": [0,10,20,30,40,50,60,70,80,90,100],
|
||||
"y": [0,25,73,138,217,314,490,818,1281,2029,2715]
|
||||
},
|
||||
"400": {
|
||||
"x": [0,10,20,30,40,50,60,70,80,90,100],
|
||||
"y": [0,155,443,839,1322,1911,2982,4980,7795,12349,16524]
|
||||
}
|
||||
}
|
||||
}
|
||||
838
datasets/assetData/modelData/hidrostal-C5-D03R-SHN1.json
Normal file
838
datasets/assetData/modelData/hidrostal-C5-D03R-SHN1.json
Normal 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
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
1062
datasets/assetData/modelData/hidrostal-H05K-S03R.json
Normal file
1062
datasets/assetData/modelData/hidrostal-H05K-S03R.json
Normal file
File diff suppressed because it is too large
Load Diff
124
datasets/assetData/modelData/index.js
Normal file
124
datasets/assetData/modelData/index.js
Normal file
@@ -0,0 +1,124 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
class AssetLoader {
|
||||
constructor() {
|
||||
this.relPath = './'
|
||||
this.baseDir = path.resolve(__dirname, this.relPath);
|
||||
this.cache = new Map(); // Cache loaded JSON files for better performance
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a specific curve by type
|
||||
* @param {string} curveType - The curve identifier (e.g., 'hidrostal-H05K-S03R')
|
||||
* @returns {Object|null} The curve data object or null if not found
|
||||
*/
|
||||
loadModel(modelType) {
|
||||
return this.loadAsset('models', modelType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load any asset from a specific dataset folder
|
||||
* @param {string} datasetType - The dataset folder name (e.g., 'curves', 'assetData')
|
||||
* @param {string} assetId - The specific asset identifier
|
||||
* @returns {Object|null} The asset data object or null if not found
|
||||
*/
|
||||
loadAsset(datasetType, assetId) {
|
||||
//const cacheKey = `${datasetType}/${assetId}`;
|
||||
const cacheKey = `${assetId}`;
|
||||
|
||||
|
||||
// Check cache first
|
||||
if (this.cache.has(cacheKey)) {
|
||||
return this.cache.get(cacheKey);
|
||||
}
|
||||
|
||||
try {
|
||||
const filePath = path.join(this.baseDir, `${assetId}.json`);
|
||||
|
||||
// Check if file exists
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.warn(`Asset not found: ${filePath}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Load and parse JSON
|
||||
const rawData = fs.readFileSync(filePath, 'utf8');
|
||||
const assetData = JSON.parse(rawData);
|
||||
|
||||
// Cache the result
|
||||
this.cache.set(cacheKey, assetData);
|
||||
|
||||
return assetData;
|
||||
} catch (error) {
|
||||
console.error(`Error loading asset ${cacheKey}:`, error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available assets in a dataset
|
||||
* @param {string} datasetType - The dataset folder name
|
||||
* @returns {string[]} Array of available asset IDs
|
||||
*/
|
||||
getAvailableAssets(datasetType) {
|
||||
try {
|
||||
const datasetPath = path.join(this.baseDir, datasetType);
|
||||
|
||||
if (!fs.existsSync(datasetPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return fs.readdirSync(datasetPath)
|
||||
.filter(file => file.endsWith('.json'))
|
||||
.map(file => file.replace('.json', ''));
|
||||
} catch (error) {
|
||||
console.error(`Error reading dataset ${datasetType}:`, error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the cache (useful for development/testing)
|
||||
*/
|
||||
clearCache() {
|
||||
this.cache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// Create and export a singleton instance
|
||||
const assetLoader = new AssetLoader();
|
||||
|
||||
module.exports = {
|
||||
AssetLoader,
|
||||
assetLoader,
|
||||
// Convenience methods for backward compatibility
|
||||
loadModel: (modelType) => assetLoader.loadModel(modelType),
|
||||
loadAsset: (datasetType, assetId) => assetLoader.loadAsset(datasetType, assetId),
|
||||
getAvailableAssets: (datasetType) => assetLoader.getAvailableAssets(datasetType)
|
||||
};
|
||||
|
||||
/*
|
||||
// Example usage in your scripts
|
||||
const loader = new AssetLoader();
|
||||
|
||||
// Load a specific curve
|
||||
const curve = loader.loadModel('hidrostal-H05K-S03R');
|
||||
if (curve) {
|
||||
console.log('Model loaded:', curve);
|
||||
} else {
|
||||
console.log('Model not found');
|
||||
}
|
||||
/*
|
||||
// Load any asset from any dataset
|
||||
const someAsset = loadAsset('assetData', 'some-asset-id');
|
||||
|
||||
// Get list of available models
|
||||
const availableCurves = getAvailableAssets('curves');
|
||||
console.log('Available curves:', availableCurves);
|
||||
|
||||
// Using the class directly for more control
|
||||
const { AssetLoader } = require('./index.js');
|
||||
const customLoader = new AssetLoader();
|
||||
const data = customLoader.loadCurve('hidrostal-H05K-S03R');
|
||||
*/
|
||||
716
datasets/assetData/monsterSamples.json
Normal file
716
datasets/assetData/monsterSamples.json
Normal file
@@ -0,0 +1,716 @@
|
||||
{
|
||||
"samples": [
|
||||
{
|
||||
"code": "106100",
|
||||
"description": "Baarle Nassau influent totaal"
|
||||
},
|
||||
{
|
||||
"code": "106100C",
|
||||
"description": "RWZI Baarle Nassau influent - Monstername influent COVID-19"
|
||||
},
|
||||
{
|
||||
"code": "106120",
|
||||
"description": "Baarle Nassau inhoud beluchtingsruimte"
|
||||
},
|
||||
{
|
||||
"code": "106150",
|
||||
"description": "Baarle Nassau effluent"
|
||||
},
|
||||
{
|
||||
"code": "106209",
|
||||
"description": "Baarle Nassau slibafvoer voorindikker"
|
||||
},
|
||||
{
|
||||
"code": "106400",
|
||||
"description": "Baarle Nassau slibafvoer slibbufferput"
|
||||
},
|
||||
{
|
||||
"code": "109100",
|
||||
"description": "RWZI Chaam influent totaal"
|
||||
},
|
||||
{
|
||||
"code": "109100C",
|
||||
"description": "RWZI Chaam influent - Monstername influent COVID-19"
|
||||
},
|
||||
{
|
||||
"code": "109120",
|
||||
"description": "RWZI Chaam inhoud beluchtingstank"
|
||||
},
|
||||
{
|
||||
"code": "109150",
|
||||
"description": "RWZI Chaam effluent"
|
||||
},
|
||||
{
|
||||
"code": "109153",
|
||||
"description": "RWZI Chaam afloop cascade"
|
||||
},
|
||||
{
|
||||
"code": "109400",
|
||||
"description": "Chaam slib afvoer slibbufferput"
|
||||
},
|
||||
{
|
||||
"code": "112004",
|
||||
"description": "RWZI Dongemond diverse onderzoeken"
|
||||
},
|
||||
{
|
||||
"code": "112062",
|
||||
"description": "RWZI Dongemond RUWE(geleverde) PE zeefbandpers"
|
||||
},
|
||||
{
|
||||
"code": "112100",
|
||||
"description": "RWZI Dongemond influent totaal"
|
||||
},
|
||||
{
|
||||
"code": "112100C",
|
||||
"description": "RWZI Dongemond influent - Monstername influent COVID-19"
|
||||
},
|
||||
{
|
||||
"code": "112110",
|
||||
"description": "RWZI Dongemond afloop voorbezinktank"
|
||||
},
|
||||
{
|
||||
"code": "112121",
|
||||
"description": "RWZI Dongemond inhoud beluchtingstank 1"
|
||||
},
|
||||
{
|
||||
"code": "112122",
|
||||
"description": "RWZI Dongemond inhoud beluchtingstank 2"
|
||||
},
|
||||
{
|
||||
"code": "112123",
|
||||
"description": "RWZI Dongemond inhoud beluchtingstank 3"
|
||||
},
|
||||
{
|
||||
"code": "112124",
|
||||
"description": "RWZI Dongemond inhoud beluchtingstank 4"
|
||||
},
|
||||
{
|
||||
"code": "112150",
|
||||
"description": "RWZI Dongemond effluent"
|
||||
},
|
||||
{
|
||||
"code": "112203",
|
||||
"description": "RWZI Dongemond inhoud container zandvanger"
|
||||
},
|
||||
{
|
||||
"code": "112206",
|
||||
"description": "RWZI Dongemond ingedikt primair slib"
|
||||
},
|
||||
{
|
||||
"code": "112211",
|
||||
"description": "RWZI Dongemond ingedikt secundair slib"
|
||||
},
|
||||
{
|
||||
"code": "112231",
|
||||
"description": "RWZI Dongemond afvoer bandindikker"
|
||||
},
|
||||
{
|
||||
"code": "112244",
|
||||
"description": "RWZI Dongemond inhoud gistingstank"
|
||||
},
|
||||
{
|
||||
"code": "112287",
|
||||
"description": "RWZI Dongemond waterafvoer zeefbandpers totaal"
|
||||
},
|
||||
{
|
||||
"code": "112425",
|
||||
"description": "RWZI Dongemond afvoer slibkoek silo"
|
||||
},
|
||||
{
|
||||
"code": "112569",
|
||||
"description": "RWZI Dongemond Al2(SO4)3"
|
||||
},
|
||||
{
|
||||
"code": "115100",
|
||||
"description": "RWZI Kaatsheuvel influent totaal"
|
||||
},
|
||||
{
|
||||
"code": "115100C",
|
||||
"description": "RWZI Kaatsheuvel influent - Monstername influent COVID-19"
|
||||
},
|
||||
{
|
||||
"code": "115120",
|
||||
"description": "RWZI Kaatsheuvel inhoud beluchtingsruimte"
|
||||
},
|
||||
{
|
||||
"code": "115150",
|
||||
"description": "RWZI Kaatsheuvel effluent"
|
||||
},
|
||||
{
|
||||
"code": "115155",
|
||||
"description": "RWZI Kaatsheuvel toevoer zandfilter"
|
||||
},
|
||||
{
|
||||
"code": "115156",
|
||||
"description": "RWZI Kaatsheuvel afvoer zandfilter"
|
||||
},
|
||||
{
|
||||
"code": "115157",
|
||||
"description": "RWZI Kaatsheuvel afvoer waswater zandfilter"
|
||||
},
|
||||
{
|
||||
"code": "115166",
|
||||
"description": "RWZI Kaatsheuvel Voor UV filter"
|
||||
},
|
||||
{
|
||||
"code": "115167",
|
||||
"description": "RWZI Kaatsheuvel Na UV filter"
|
||||
},
|
||||
{
|
||||
"code": "115203",
|
||||
"description": "RWZI Kaatsheuvel inhoud container zandvanger"
|
||||
},
|
||||
{
|
||||
"code": "115209",
|
||||
"description": "RWZI Kaatsheuvel slibafvoer voorindikker"
|
||||
},
|
||||
{
|
||||
"code": "115400",
|
||||
"description": "RWZI Kaatsheuvel slibafvoer slibbufferput"
|
||||
},
|
||||
{
|
||||
"code": "116100",
|
||||
"description": "RWZI Lage-Zwaluwe influent totaal"
|
||||
},
|
||||
{
|
||||
"code": "116100C",
|
||||
"description": "RWZI Lage-Zwaluwe influent - Monstername influent COVID-19"
|
||||
},
|
||||
{
|
||||
"code": "116120",
|
||||
"description": "RWZI Lage-Zwaluwe inhoud beluchtingsruimte"
|
||||
},
|
||||
{
|
||||
"code": "116150",
|
||||
"description": "RWZI Lage-Zwaluwe effluent"
|
||||
},
|
||||
{
|
||||
"code": "116400",
|
||||
"description": "RWZI Lage-Zwaluwe slibafvoer slibbufferput"
|
||||
},
|
||||
{
|
||||
"code": "121100",
|
||||
"description": "RWZI Riel influent totaal"
|
||||
},
|
||||
{
|
||||
"code": "121100C",
|
||||
"description": "RWZI Riel influent - Monstername influent COVID-19"
|
||||
},
|
||||
{
|
||||
"code": "121120",
|
||||
"description": "RWZI Riel inhoud beluchtingruimte"
|
||||
},
|
||||
{
|
||||
"code": "121150",
|
||||
"description": "RWZI Riel effluent"
|
||||
},
|
||||
{
|
||||
"code": "121203",
|
||||
"description": "RWZI Riel inhoud container zandvanger"
|
||||
},
|
||||
{
|
||||
"code": "121400",
|
||||
"description": "RWZI Riel slibafvoer slibbufferput"
|
||||
},
|
||||
{
|
||||
"code": "124089",
|
||||
"description": "RWZI Rijen aanvoer kolkenzuigermateriaal"
|
||||
},
|
||||
{
|
||||
"code": "124100",
|
||||
"description": "RWZI Rijen influent totaal"
|
||||
},
|
||||
{
|
||||
"code": "124100C",
|
||||
"description": "RWZI Rijen influent - Monstername influent COVID-19"
|
||||
},
|
||||
{
|
||||
"code": "124110",
|
||||
"description": "RWZI Rijen afloop voorbezinktank"
|
||||
},
|
||||
{
|
||||
"code": "124120",
|
||||
"description": "RWZI Rijen inhoud beluchtingsruimte"
|
||||
},
|
||||
{
|
||||
"code": "124150",
|
||||
"description": "RWZI Rijen effluent"
|
||||
},
|
||||
{
|
||||
"code": "124151",
|
||||
"description": "RWZI Rijen effluent voor legionella"
|
||||
},
|
||||
{
|
||||
"code": "124203",
|
||||
"description": "RWZI Rijen inhoud container zandvanger"
|
||||
},
|
||||
{
|
||||
"code": "124206",
|
||||
"description": "RWZI Rijen ingedikt primair slib"
|
||||
},
|
||||
{
|
||||
"code": "124211",
|
||||
"description": "RWZI Rijen ingedikt secundair slib"
|
||||
},
|
||||
{
|
||||
"code": "124350",
|
||||
"description": "RWZI Rijen Toevoer bandindikker"
|
||||
},
|
||||
{
|
||||
"code": "124351",
|
||||
"description": "RWZI Rijen Afvoer bandindikker"
|
||||
},
|
||||
{
|
||||
"code": "124352",
|
||||
"description": "RWZI Rijen waterafvoer bandindikker"
|
||||
},
|
||||
{
|
||||
"code": "124400",
|
||||
"description": "RWZI Rijen slibafvoer"
|
||||
},
|
||||
{
|
||||
"code": "124540",
|
||||
"description": "RWZI Rijen RUWE(geleverde) PE bandindikker"
|
||||
},
|
||||
{
|
||||
"code": "127100",
|
||||
"description": "RWZI Waalwijk influent totaal"
|
||||
},
|
||||
{
|
||||
"code": "127100C",
|
||||
"description": "RWZI Waalwijk influent - Monstername influent COVID-19"
|
||||
},
|
||||
{
|
||||
"code": "127110",
|
||||
"description": "RWZI Waalwijk afloop VBT"
|
||||
},
|
||||
{
|
||||
"code": "127121",
|
||||
"description": "RWZI Waalwijk inhoud beluchtingsruimte 1"
|
||||
},
|
||||
{
|
||||
"code": "127122",
|
||||
"description": "RWZI Waalwijk inhoud beluchtingsruimte 2"
|
||||
},
|
||||
{
|
||||
"code": "127150",
|
||||
"description": "RWZI Waalwijk effluent"
|
||||
},
|
||||
{
|
||||
"code": "127203",
|
||||
"description": "RWZI Waalwijk inhoud container zandvanger"
|
||||
},
|
||||
{
|
||||
"code": "127206",
|
||||
"description": "RWZI Waalwijk ingedikt primair slib"
|
||||
},
|
||||
{
|
||||
"code": "127211",
|
||||
"description": "RWZI Waalwijk ingedikt secundair slib"
|
||||
},
|
||||
{
|
||||
"code": "127244",
|
||||
"description": "RWZI Waalwijk inhoud gistingstank"
|
||||
},
|
||||
{
|
||||
"code": "127450",
|
||||
"description": "RWZI Waalwijk slibafvoer indiklagune"
|
||||
},
|
||||
{
|
||||
"code": "131100",
|
||||
"description": "RWZI Waspik industrie & dorp influent totaal"
|
||||
},
|
||||
{
|
||||
"code": "131100C",
|
||||
"description": "RWZI Waspik influent - Monstername influent COVID-19"
|
||||
},
|
||||
{
|
||||
"code": "131120",
|
||||
"description": "RWZI Waspik inhoud beluchtingsruimte"
|
||||
},
|
||||
{
|
||||
"code": "131150",
|
||||
"description": "RWZI Waspik effluent"
|
||||
},
|
||||
{
|
||||
"code": "131400",
|
||||
"description": "RWZI Waspik slibafvoer slibbufferput"
|
||||
},
|
||||
{
|
||||
"code": "131581",
|
||||
"description": "Waspik Levering Aluminiumchloride 9%"
|
||||
},
|
||||
{
|
||||
"code": "142062",
|
||||
"description": "RWZI Nieuwveer RUWE(geleverde) PE zeefbandpers"
|
||||
},
|
||||
{
|
||||
"code": "142078",
|
||||
"description": "RWZI Nieuwveer Cloetta suikerwater"
|
||||
},
|
||||
{
|
||||
"code": "142089",
|
||||
"description": "RWZI Nieuwveer aanvoer kolkenzuigermateriaal"
|
||||
},
|
||||
{
|
||||
"code": "142105",
|
||||
"description": "RWZI Nieuwveer afloop influentvijzels"
|
||||
},
|
||||
{
|
||||
"code": "142105C",
|
||||
"description": "RWZI Nieuwveer afloop influentvijzels - Monstername influent COVID-19"
|
||||
},
|
||||
{
|
||||
"code": "142110",
|
||||
"description": "RWZI Nieuwveer afloop TBT"
|
||||
},
|
||||
{
|
||||
"code": "142121",
|
||||
"description": "RWZI Nieuwveer inhoud beluchtingsruimte 1"
|
||||
},
|
||||
{
|
||||
"code": "142122",
|
||||
"description": "RWZI Nieuwveer inhoud beluchtingsruimte 2"
|
||||
},
|
||||
{
|
||||
"code": "142123",
|
||||
"description": "RWZI Nieuwveer inhoud beluchtingsruimte 3"
|
||||
},
|
||||
{
|
||||
"code": "142124",
|
||||
"description": "RWZI Nieuwveer inhoud beluchtingsruimte 4"
|
||||
},
|
||||
{
|
||||
"code": "142150",
|
||||
"description": "RWZI Nieuwveer effluent"
|
||||
},
|
||||
{
|
||||
"code": "142174",
|
||||
"description": "RWZI Nieuwveer secundair spuislib"
|
||||
},
|
||||
{
|
||||
"code": "142203",
|
||||
"description": "RWZI Nieuwveer inhoud container zandvanger"
|
||||
},
|
||||
{
|
||||
"code": "142301",
|
||||
"description": "RWZI Nieuwveer slibafvoer Bandindikker 1"
|
||||
},
|
||||
{
|
||||
"code": "142302",
|
||||
"description": "RWZI Nieuwveer slibafvoer Bandindikker 2"
|
||||
},
|
||||
{
|
||||
"code": "142303",
|
||||
"description": "RWZI Nieuwveer slibafvoer Bandindikker 3"
|
||||
},
|
||||
{
|
||||
"code": "142310",
|
||||
"description": "RWZI Nieuwveer monitor slibafvoer ESOMT"
|
||||
},
|
||||
{
|
||||
"code": "142311",
|
||||
"description": "RWZI Nieuwveer afloop Gisting"
|
||||
},
|
||||
{
|
||||
"code": "142325",
|
||||
"description": "RWZI Nieuwveer Influent DEMON"
|
||||
},
|
||||
{
|
||||
"code": "142326",
|
||||
"description": "RWZI Nieuwveer Inhoud DEMON"
|
||||
},
|
||||
{
|
||||
"code": "142327",
|
||||
"description": "RWZI Nieuwveer Effluent DEMON"
|
||||
},
|
||||
{
|
||||
"code": "142332",
|
||||
"description": "RWZI Nieuwveer retourwater slibverwerking"
|
||||
},
|
||||
{
|
||||
"code": "142425",
|
||||
"description": "RWZI Nieuwveer afvoer slibkoek silo"
|
||||
},
|
||||
{
|
||||
"code": "142571",
|
||||
"description": "RWZI Nieuwveer ijzersulfaat levering totaal"
|
||||
},
|
||||
{
|
||||
"code": "144007",
|
||||
"description": "Bouvigne Toevoer helofytenfilter"
|
||||
},
|
||||
{
|
||||
"code": "144008",
|
||||
"description": "Bouvigne Afvoer helofytenfilter"
|
||||
},
|
||||
{
|
||||
"code": "144061",
|
||||
"description": "144061 (toevoer verticale helofytenfilters)"
|
||||
},
|
||||
{
|
||||
"code": "144062",
|
||||
"description": "144062 (afvoer verticale helofytenfilters)"
|
||||
},
|
||||
{
|
||||
"code": "144063",
|
||||
"description": "144063 (afvoer horizontale helofytenfilters)"
|
||||
},
|
||||
{
|
||||
"code": "144064",
|
||||
"description": "144064 (kwaliteit voorberging)"
|
||||
},
|
||||
{
|
||||
"code": "160061",
|
||||
"description": "RWZI Bath RUWE(geleverde) PE bandindikker"
|
||||
},
|
||||
{
|
||||
"code": "160062",
|
||||
"description": "RWZI Bath RUWE(geleverde) PE zeefbandpers"
|
||||
},
|
||||
{
|
||||
"code": "160100",
|
||||
"description": "Bath influent totaal"
|
||||
},
|
||||
{
|
||||
"code": "160100C",
|
||||
"description": "RWZI Bath influent - Monstername influent COVID-19"
|
||||
},
|
||||
{
|
||||
"code": "160110",
|
||||
"description": "Bath Afloop Voorbezinktank West (1 en 3)"
|
||||
},
|
||||
{
|
||||
"code": "160112",
|
||||
"description": "Bath Afloop Voorbezinktank Oost (2 en 4)"
|
||||
},
|
||||
{
|
||||
"code": "160121",
|
||||
"description": "Bath inhoud beluchtingsruimte 1, sectie 4"
|
||||
},
|
||||
{
|
||||
"code": "160122",
|
||||
"description": "Bath inhoud beluchtingsruimte 2, sectie 4"
|
||||
},
|
||||
{
|
||||
"code": "160123",
|
||||
"description": "Bath inhoud beluchtingsruimte 3, sectie 4"
|
||||
},
|
||||
{
|
||||
"code": "160124",
|
||||
"description": "Bath inhoud beluchtingsruimte 4, sectie 4"
|
||||
},
|
||||
{
|
||||
"code": "160125",
|
||||
"description": "Bath inhoud beluchtingsruimte 5, sectie 4"
|
||||
},
|
||||
{
|
||||
"code": "160126",
|
||||
"description": "Bath inhoud beluchtingsruimte 6, sectie 4"
|
||||
},
|
||||
{
|
||||
"code": "160127",
|
||||
"description": "Bath inhoud beluchtingsruimte 7, sectie 4"
|
||||
},
|
||||
{
|
||||
"code": "160128",
|
||||
"description": "Bath inhoud beluchtingsruimte 8, sectie 4"
|
||||
},
|
||||
{
|
||||
"code": "160129",
|
||||
"description": "Bath inhoud beluchtingsruimte 9, sectie 4"
|
||||
},
|
||||
{
|
||||
"code": "160130",
|
||||
"description": "Bath inhoud beluchtingsruimte 10, sectie 4"
|
||||
},
|
||||
{
|
||||
"code": "160150",
|
||||
"description": "Bath effluent"
|
||||
},
|
||||
{
|
||||
"code": "160206",
|
||||
"description": "Bath ingedikt primair slib"
|
||||
},
|
||||
{
|
||||
"code": "160245",
|
||||
"description": "Bath inhoud gistingstank 1 ZB"
|
||||
},
|
||||
{
|
||||
"code": "160246",
|
||||
"description": "Bath inhoud gistingstank 2 ZB"
|
||||
},
|
||||
{
|
||||
"code": "160415",
|
||||
"description": "Bath 160415 Ingedikt Sec.slib BI 1-4 (Buffer)"
|
||||
},
|
||||
{
|
||||
"code": "160425",
|
||||
"description": "Bath afvoer slibkoek silo"
|
||||
},
|
||||
{
|
||||
"code": "169100",
|
||||
"description": "RWZI Dinteloord influent totaal"
|
||||
},
|
||||
{
|
||||
"code": "169100C",
|
||||
"description": "RWZI Dinteloord influent - Monstername influent COVID-19"
|
||||
},
|
||||
{
|
||||
"code": "169120",
|
||||
"description": "RWZI Dinteloord inhoud beluchtingsruimte"
|
||||
},
|
||||
{
|
||||
"code": "169150",
|
||||
"description": "RWZI Dinteloord effluent"
|
||||
},
|
||||
{
|
||||
"code": "169209",
|
||||
"description": "RWZI Dinteloord slibafvoer voorindikker"
|
||||
},
|
||||
{
|
||||
"code": "169400",
|
||||
"description": "RWZI Dinteloord slibafvoer slibbufferput"
|
||||
},
|
||||
{
|
||||
"code": "169700",
|
||||
"description": "RWZI Dinteloord Peilbuis ref 01"
|
||||
},
|
||||
{
|
||||
"code": "169705",
|
||||
"description": "RWZI Dinteloord Peilbuis ref 02"
|
||||
},
|
||||
{
|
||||
"code": "169710",
|
||||
"description": "RWZI Dinteloord Peilbuis 03"
|
||||
},
|
||||
{
|
||||
"code": "169715",
|
||||
"description": "RWZI Dinteloord Peilbuis 04"
|
||||
},
|
||||
{
|
||||
"code": "169720",
|
||||
"description": "RWZI Dinteloord Peilbuis 05"
|
||||
},
|
||||
{
|
||||
"code": "172100",
|
||||
"description": "RWZI Halsteren influent"
|
||||
},
|
||||
{
|
||||
"code": "172100C",
|
||||
"description": "RWZI Halsteren influent - Monstername influent COVID-19"
|
||||
},
|
||||
{
|
||||
"code": "172120",
|
||||
"description": "RWZI Halsteren inhoud beluchtingsruimte"
|
||||
},
|
||||
{
|
||||
"code": "172150",
|
||||
"description": "RWZI Halsteren effluent"
|
||||
},
|
||||
{
|
||||
"code": "172209",
|
||||
"description": "RWZI Halsteren slibafvoer voorindikker"
|
||||
},
|
||||
{
|
||||
"code": "172400",
|
||||
"description": "RWZI Halsteren slibafvoer slibbufferput"
|
||||
},
|
||||
{
|
||||
"code": "181100",
|
||||
"description": "RWZI Nieuw-Vossemeer influent totaal"
|
||||
},
|
||||
{
|
||||
"code": "181100C",
|
||||
"description": "RWZI Nieuw-Vossemeer influent - Monstername influent COVID-19"
|
||||
},
|
||||
{
|
||||
"code": "181120",
|
||||
"description": "RWZI Nieuw-Vossemeer inhoud beluchtingsruimte"
|
||||
},
|
||||
{
|
||||
"code": "181150",
|
||||
"description": "RWZI Nieuw-Vossemeer Effluent steekmonster"
|
||||
},
|
||||
{
|
||||
"code": "181156",
|
||||
"description": "RWZI Nieuw-Vossemeer Effluent waterharmonica steekmonster"
|
||||
},
|
||||
{
|
||||
"code": "181400",
|
||||
"description": "Nieuw Vossemeer slibafvoer slibbufferput"
|
||||
},
|
||||
{
|
||||
"code": "184100",
|
||||
"description": "RWZI Ossendrecht influent totaal"
|
||||
},
|
||||
{
|
||||
"code": "184100C",
|
||||
"description": "RWZI Ossendrecht influent - Monstername influent COVID-19"
|
||||
},
|
||||
{
|
||||
"code": "184120",
|
||||
"description": "RWZI Ossendrecht inhoud beluchtingsruimte"
|
||||
},
|
||||
{
|
||||
"code": "184150",
|
||||
"description": "RWZI Ossendrecht effluent"
|
||||
},
|
||||
{
|
||||
"code": "184460",
|
||||
"description": "RWZI Ossendrecht afvoer slibpersleiding naar AWP"
|
||||
},
|
||||
{
|
||||
"code": "191100",
|
||||
"description": "RWZI Putte influent totaal"
|
||||
},
|
||||
{
|
||||
"code": "191100C",
|
||||
"description": "RWZI Putte influent - Monstername influent COVID-19"
|
||||
},
|
||||
{
|
||||
"code": "191120",
|
||||
"description": "RWZI Putte inhoud beluchtingsruimte"
|
||||
},
|
||||
{
|
||||
"code": "191150",
|
||||
"description": "RWZI Putte effluent"
|
||||
},
|
||||
{
|
||||
"code": "191460",
|
||||
"description": "RWZI Putte afvoer slibpersleiding naar AWP"
|
||||
},
|
||||
{
|
||||
"code": "196100",
|
||||
"description": "RWZI Willemstad influent totaal"
|
||||
},
|
||||
{
|
||||
"code": "196100C",
|
||||
"description": "RWZI Willemstad influent - Monstername influent COVID-19"
|
||||
},
|
||||
{
|
||||
"code": "196120",
|
||||
"description": "RWZI Willemstad inhoud beluchtingsruimte"
|
||||
},
|
||||
{
|
||||
"code": "196150",
|
||||
"description": "RWZI Willemstad effluent"
|
||||
},
|
||||
{
|
||||
"code": "196400",
|
||||
"description": "RWZI Willemstad slibafvoer slibbufferput"
|
||||
},
|
||||
{
|
||||
"code": "303203",
|
||||
"description": "Persstation Bergen op Zoom inh. container zandvang"
|
||||
},
|
||||
{
|
||||
"code": "312203",
|
||||
"description": "AWP persstation Roosendaal inh. container zandvang"
|
||||
},
|
||||
{
|
||||
"code": "WSBD Toeslag Weekendbemonsteri",
|
||||
"description": "WSBD Toeslag Weekendbemonsteringen"
|
||||
}
|
||||
]
|
||||
}
|
||||
1794
datasets/assetData/specs/monster/index.json
Normal file
1794
datasets/assetData/specs/monster/index.json
Normal file
File diff suppressed because it is too large
Load Diff
27
datasets/assetData/valve.json
Normal file
27
datasets/assetData/valve.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"id": "valve",
|
||||
"label": "valve",
|
||||
"softwareType": "valve",
|
||||
"suppliers": [
|
||||
{
|
||||
"id": "binder",
|
||||
"name": "Binder Engineering",
|
||||
"types": [
|
||||
{
|
||||
"id": "valve-gate",
|
||||
"name": "Gate",
|
||||
"models": [
|
||||
{ "id": "binder-valve-001", "name": "ECDV", "units": ["m3/h", "gpm", "l/min"] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "valve-jet",
|
||||
"name": "Jet",
|
||||
"models": [
|
||||
{ "id": "binder-valve-002", "name": "JCV", "units": ["m3/h", "gpm", "l/min"] }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
1
datasets/get_all_assets.php
Normal file
1
datasets/get_all_assets.php
Normal 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
@@ -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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
39
index.js
39
index.js
@@ -8,29 +8,37 @@
|
||||
*/
|
||||
|
||||
// Core helper modules
|
||||
const outputUtils = require('./src/helper/outputUtils.js');
|
||||
const logger = require('./src/helper/logger.js');
|
||||
const validation = require('./src/helper/validationUtils.js');
|
||||
const configUtils = require('./src/helper/configUtils.js');
|
||||
const assertions = require('./src/helper/assertionUtils.js')
|
||||
const helper = require('./src/helper/index.js');
|
||||
const {
|
||||
outputUtils,
|
||||
logger,
|
||||
validation,
|
||||
configUtils,
|
||||
assertions,
|
||||
childRegistrationUtils,
|
||||
gravity,
|
||||
} = helper;
|
||||
const coolprop = require('./src/coolprop-node/src/index.js');
|
||||
const assetApiConfig = require('./src/configs/assetApiConfig.js');
|
||||
|
||||
// Domain-specific modules
|
||||
const { MeasurementContainer } = require('./src/measurements/index.js');
|
||||
const configManager = require('./src/configs/index.js');
|
||||
const nrmse = require('./src/nrmse/errorMetrics.js');
|
||||
const state = require('./src/state/state.js');
|
||||
const { nrmse } = require('./src/nrmse/index.js');
|
||||
const { state } = require('./src/state/index.js');
|
||||
const convert = require('./src/convert/index.js');
|
||||
const MenuManager = require('./src/menu/index.js');
|
||||
const predict = require('./src/predict/predict_class.js');
|
||||
const interpolation = require('./src/predict/interpolation.js');
|
||||
const childRegistrationUtils = require('./src/helper/childRegistrationUtils.js');
|
||||
const { loadCurve } = require('./datasets/assetData/curves/index.js');
|
||||
const { predict, interpolation } = require('./src/predict/index.js');
|
||||
const { PIDController, CascadePIDController, createPidController, createCascadePidController } = require('./src/pid/index.js');
|
||||
const { loadCurve } = require('./datasets/assetData/curves/index.js'); //deprecated replace with load model data
|
||||
const { loadModel } = require('./datasets/assetData/modelData/index.js');
|
||||
|
||||
// Export everything
|
||||
module.exports = {
|
||||
predict,
|
||||
interpolation,
|
||||
configManager,
|
||||
assetApiConfig,
|
||||
outputUtils,
|
||||
configUtils,
|
||||
logger,
|
||||
@@ -39,8 +47,15 @@ module.exports = {
|
||||
MeasurementContainer,
|
||||
nrmse,
|
||||
state,
|
||||
coolprop,
|
||||
convert,
|
||||
MenuManager,
|
||||
PIDController,
|
||||
CascadePIDController,
|
||||
createPidController,
|
||||
createCascadePidController,
|
||||
childRegistrationUtils,
|
||||
loadCurve
|
||||
loadCurve, //deprecated replace with loadModel
|
||||
loadModel,
|
||||
gravity
|
||||
};
|
||||
|
||||
12
package.json
12
package.json
@@ -9,11 +9,17 @@
|
||||
"./menuUtils": "./src/helper/menuUtils.js",
|
||||
"./mathUtils": "./src/helper/mathUtils.js",
|
||||
"./assetUtils": "./src/helper/assetUtils.js",
|
||||
"./outputUtils": "./src/helper/outputUtils.js"
|
||||
"./outputUtils": "./src/helper/outputUtils.js",
|
||||
"./helper": "./src/helper/index.js",
|
||||
"./state": "./src/state/index.js",
|
||||
"./predict": "./src/predict/index.js",
|
||||
"./pid": "./src/pid/index.js",
|
||||
"./nrmse": "./src/nrmse/index.js",
|
||||
"./outliers": "./src/outliers/index.js"
|
||||
},
|
||||
|
||||
"scripts": {
|
||||
"test": "node test.js"
|
||||
"test": "node --test test/*.test.js src/nrmse/errorMetric.test.js"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -26,4 +32,4 @@
|
||||
],
|
||||
"author": "Rene de Ren",
|
||||
"license": "SEE LICENSE"
|
||||
}
|
||||
}
|
||||
|
||||
16
src/configs/assetApiConfig.js
Normal file
16
src/configs/assetApiConfig.js
Normal file
@@ -0,0 +1,16 @@
|
||||
const BASE_URL = 'http://localhost:8000';
|
||||
const AUTHORIZATION = '4a49332a-fc3e-11f0-bf0a-9457f8d645d9';
|
||||
const CSRF_TOKEN = 'dcWLY6luSVuQu4mIlKNCGlk3i9VzG9n3p2pxihcm';
|
||||
|
||||
module.exports = {
|
||||
baseUrl: BASE_URL,
|
||||
registerPath: '/assets/store',
|
||||
updatePath: (tag) => `/assets/${encodeURIComponent(tag)}/edit`,
|
||||
updateMethod: 'POST',
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
Authorization: AUTHORIZATION,
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': CSRF_TOKEN
|
||||
}
|
||||
};
|
||||
@@ -47,30 +47,30 @@ class ConfigManager {
|
||||
return fs.existsSync(configPath);
|
||||
}
|
||||
|
||||
createEndpoint(nodeName) {
|
||||
try {
|
||||
// Load the config for this node
|
||||
const config = this.getConfig(nodeName);
|
||||
|
||||
// Convert config to JSON
|
||||
const configJSON = JSON.stringify(config, null, 2);
|
||||
createEndpoint(nodeName) {
|
||||
try {
|
||||
// Load the config for this node
|
||||
const config = this.getConfig(nodeName);
|
||||
|
||||
// Assemble the complete script
|
||||
return `
|
||||
// Create the namespace structure
|
||||
window.EVOLV = window.EVOLV || {};
|
||||
window.EVOLV.nodes = window.EVOLV.nodes || {};
|
||||
window.EVOLV.nodes.${nodeName} = window.EVOLV.nodes.${nodeName} || {};
|
||||
// Convert config to JSON
|
||||
const configJSON = JSON.stringify(config, null, 2);
|
||||
|
||||
// Inject the pre-loaded config data directly into the namespace
|
||||
window.EVOLV.nodes.${nodeName}.config = ${configJSON};
|
||||
// Assemble the complete script
|
||||
return `
|
||||
// Create the namespace structure
|
||||
window.EVOLV = window.EVOLV || {};
|
||||
window.EVOLV.nodes = window.EVOLV.nodes || {};
|
||||
window.EVOLV.nodes.${nodeName} = window.EVOLV.nodes.${nodeName} || {};
|
||||
|
||||
console.log('${nodeName} config loaded and endpoint created');
|
||||
`;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to create endpoint for '${nodeName}': ${error.message}`);
|
||||
}
|
||||
// Inject the pre-loaded config data directly into the namespace
|
||||
window.EVOLV.nodes.${nodeName}.config = ${configJSON};
|
||||
|
||||
console.log('${nodeName} config loaded and endpoint created');
|
||||
`;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to create endpoint for '${nodeName}': ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ConfigManager;
|
||||
@@ -58,10 +58,10 @@
|
||||
},
|
||||
"functionality": {
|
||||
"softwareType": {
|
||||
"default": "machineGroup",
|
||||
"rules": {
|
||||
"type": "string",
|
||||
"description": "Logical name identifying the software type."
|
||||
"default": "machinegroup",
|
||||
"rules": {
|
||||
"type": "string",
|
||||
"description": "Logical name identifying the software type."
|
||||
}
|
||||
},
|
||||
"role": {
|
||||
|
||||
@@ -117,6 +117,14 @@
|
||||
"description": "Asset tag code which is a unique identifier for this asset. May be null if not assigned."
|
||||
}
|
||||
},
|
||||
"tagNumber": {
|
||||
"default": null,
|
||||
"rules": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "Asset tag number assigned by the asset registry. May be null if not assigned."
|
||||
}
|
||||
},
|
||||
"geoLocation": {
|
||||
"default": {
|
||||
"x": 0,
|
||||
@@ -166,6 +174,10 @@
|
||||
{
|
||||
"value": "sensor",
|
||||
"description": "A device that detects or measures a physical property and responds to it (e.g. temperature sensor)."
|
||||
},
|
||||
{
|
||||
"value": "measurement",
|
||||
"description": "Measurement software category used by the asset menu for this node."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -208,6 +220,52 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"assetRegistration": {
|
||||
"default": {
|
||||
"profileId": 1,
|
||||
"locationId": 1,
|
||||
"processId": 1,
|
||||
"status": "actief",
|
||||
"childAssets": []
|
||||
},
|
||||
"rules": {
|
||||
"type": "object",
|
||||
"schema": {
|
||||
"profileId": {
|
||||
"default": 1,
|
||||
"rules": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"locationId": {
|
||||
"default": 1,
|
||||
"rules": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"processId": {
|
||||
"default": 1,
|
||||
"rules": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"default": "actief",
|
||||
"rules": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"childAssets": {
|
||||
"default": [],
|
||||
"rules": {
|
||||
"type": "array",
|
||||
"itemType": "string",
|
||||
"minLength": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"scaling": {
|
||||
"enabled": {
|
||||
"default": false,
|
||||
|
||||
256
src/configs/monster.json
Normal file
256
src/configs/monster.json
Normal file
@@ -0,0 +1,256 @@
|
||||
{
|
||||
"general": {
|
||||
"name": {
|
||||
"default": "Monster Configuration",
|
||||
"rules": {
|
||||
"type": "string",
|
||||
"description": "A human-readable name or label for this configuration."
|
||||
}
|
||||
},
|
||||
"id": {
|
||||
"default": null,
|
||||
"rules": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "A unique identifier for this configuration. If not provided, defaults to null."
|
||||
}
|
||||
},
|
||||
"unit": {
|
||||
"default": "unitless",
|
||||
"rules": {
|
||||
"type": "string",
|
||||
"description": "The unit for this configuration (e.g., 'meters', 'seconds', 'unitless')."
|
||||
}
|
||||
},
|
||||
"logging": {
|
||||
"logLevel": {
|
||||
"default": "info",
|
||||
"rules": {
|
||||
"type": "enum",
|
||||
"values": [
|
||||
{
|
||||
"value": "debug",
|
||||
"description": "Log messages are printed for debugging purposes."
|
||||
},
|
||||
{
|
||||
"value": "info",
|
||||
"description": "Informational messages are printed."
|
||||
},
|
||||
{
|
||||
"value": "warn",
|
||||
"description": "Warning messages are printed."
|
||||
},
|
||||
{
|
||||
"value": "error",
|
||||
"description": "Error messages are printed."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"enabled": {
|
||||
"default": true,
|
||||
"rules": {
|
||||
"type": "boolean",
|
||||
"description": "Indicates whether logging is active. If true, log messages will be generated."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"functionality": {
|
||||
"softwareType": {
|
||||
"default": "monster",
|
||||
"rules": {
|
||||
"type": "string",
|
||||
"description": "Specified software type for this configuration."
|
||||
}
|
||||
},
|
||||
"role": {
|
||||
"default": "samplingCabinet",
|
||||
"rules": {
|
||||
"type": "string",
|
||||
"description": "Indicates the role this configuration plays (e.g., sensor, controller, etc.)."
|
||||
}
|
||||
}
|
||||
},
|
||||
"asset": {
|
||||
"uuid": {
|
||||
"default": null,
|
||||
"rules": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "Asset tag number which is a universally unique identifier for this asset. May be null if not assigned."
|
||||
}
|
||||
},
|
||||
"geoLocation": {
|
||||
"default": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0
|
||||
},
|
||||
"rules": {
|
||||
"type": "object",
|
||||
"description": "An object representing the asset's physical coordinates or location.",
|
||||
"schema": {
|
||||
"x": {
|
||||
"default": 0,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"description": "X coordinate of the asset's location."
|
||||
}
|
||||
},
|
||||
"y": {
|
||||
"default": 0,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"description": "Y coordinate of the asset's location."
|
||||
}
|
||||
},
|
||||
"z": {
|
||||
"default": 0,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"description": "Z coordinate of the asset's location."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"supplier": {
|
||||
"default": "Unknown",
|
||||
"rules": {
|
||||
"type": "string",
|
||||
"description": "The supplier or manufacturer of the asset."
|
||||
}
|
||||
},
|
||||
"type": {
|
||||
"default": "sensor",
|
||||
"rules": {
|
||||
"type": "enum",
|
||||
"values": [
|
||||
{
|
||||
"value": "sensor",
|
||||
"description": "A device that detects or measures a physical property and responds to it (e.g. temperature sensor)."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"subType": {
|
||||
"default": "pressure",
|
||||
"rules": {
|
||||
"type": "string",
|
||||
"description": "A more specific classification within 'type'. For example, 'pressure' for a pressure sensor."
|
||||
}
|
||||
},
|
||||
"model": {
|
||||
"default": "Unknown",
|
||||
"rules": {
|
||||
"type": "string",
|
||||
"description": "A user-defined or manufacturer-defined model identifier for the asset."
|
||||
}
|
||||
},
|
||||
"emptyWeightBucket": {
|
||||
"default": 3,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"description": "The weight of the empty bucket in kilograms."
|
||||
}
|
||||
}
|
||||
},
|
||||
"constraints": {
|
||||
"samplingtime": {
|
||||
"default": 0,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"description": "The time interval between sampling events (in seconds) if not using a flow meter."
|
||||
}
|
||||
},
|
||||
"samplingperiod": {
|
||||
"default": 24,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"description": "The fixed period in hours in which a composite sample is collected."
|
||||
}
|
||||
},
|
||||
"minVolume": {
|
||||
"default": 5,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"min": 5,
|
||||
"description": "The minimum volume in liters."
|
||||
}
|
||||
},
|
||||
"maxWeight": {
|
||||
"default": 23,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"max": 23,
|
||||
"description": "The maximum weight in kilograms."
|
||||
}
|
||||
},
|
||||
"subSampleVolume": {
|
||||
"default": 50,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"min": 50,
|
||||
"max": 50,
|
||||
"description": "The volume of each sub-sample in milliliters."
|
||||
}
|
||||
},
|
||||
"storageTemperature": {
|
||||
"default": {
|
||||
"min": 1,
|
||||
"max": 5
|
||||
},
|
||||
"rules": {
|
||||
"type": "object",
|
||||
"description": "Acceptable storage temperature range for samples in degrees Celsius.",
|
||||
"schema": {
|
||||
"min": {
|
||||
"default": 1,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"min": 1,
|
||||
"description": "Minimum acceptable storage temperature in degrees Celsius."
|
||||
}
|
||||
},
|
||||
"max": {
|
||||
"default": 5,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"max": 5,
|
||||
"description": "Maximum acceptable storage temperature in degrees Celsius."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"flowmeter": {
|
||||
"default": true,
|
||||
"rules": {
|
||||
"type": "boolean",
|
||||
"description": "Indicates whether a flow meter is used for proportional sampling."
|
||||
}
|
||||
},
|
||||
"closedSystem": {
|
||||
"default": false,
|
||||
"rules": {
|
||||
"type": "boolean",
|
||||
"description": "Indicates if the sampling system is closed (true) or open (false)."
|
||||
}
|
||||
},
|
||||
"intakeSpeed": {
|
||||
"default": 0.3,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"description": "Minimum intake speed in meters per second."
|
||||
}
|
||||
},
|
||||
"intakeDiameter": {
|
||||
"default": 12,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"description": "Minimum inner diameter of the intake tubing in millimeters."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
893
src/configs/pumpingStation.json
Normal file
893
src/configs/pumpingStation.json
Normal file
@@ -0,0 +1,893 @@
|
||||
{
|
||||
"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."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"distance": {
|
||||
"default": null,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"nullable": true,
|
||||
"description": "Optional distance to parent asset for registration metadata."
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"minHeightBasedOn": {
|
||||
"default": "outlet",
|
||||
"rules": {
|
||||
"type": "enum",
|
||||
"values": [
|
||||
{
|
||||
"value": "inlet",
|
||||
"description": "Minimum height is based on inlet elevation."
|
||||
},
|
||||
{
|
||||
"value": "outlet",
|
||||
"description": "Minimum height is based on outlet elevation."
|
||||
}
|
||||
],
|
||||
"description": "Basis for minimum height check: inlet or outlet."
|
||||
}
|
||||
},
|
||||
"basinBottomRef": {
|
||||
"default": 0,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"description": "Absolute elevation reference of basin bottom."
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"mode": {
|
||||
"default": "levelbased",
|
||||
"rules": {
|
||||
"type": "string",
|
||||
"values": [
|
||||
{
|
||||
"value": "levelbased",
|
||||
"description": "Lead and lag pumps are controlled by basin level thresholds."
|
||||
},
|
||||
{
|
||||
"value": "pressureBased",
|
||||
"description": "Pumps target a discharge pressure setpoint."
|
||||
},
|
||||
{
|
||||
"value": "flowBased",
|
||||
"description": "Pumps modulate to match measured inflow or downstream demand."
|
||||
},
|
||||
{
|
||||
"value": "percentageBased",
|
||||
"description": "Pumps operate to maintain basin volume at a target percentage."
|
||||
},
|
||||
{
|
||||
"value":"powerBased",
|
||||
"description": "Pumps are controlled based on power consumption.For example, to limit peak power usage or operate within netcongestion limits."
|
||||
},
|
||||
{
|
||||
"value": "hybrid",
|
||||
"description": "Combines multiple control strategies for optimized operation."
|
||||
},
|
||||
{
|
||||
"value": "manual",
|
||||
"description": "Pumps are operated manually or by an external controller."
|
||||
}
|
||||
],
|
||||
"description": "Primary control philosophy for pump actuation."
|
||||
}
|
||||
},
|
||||
"allowedModes": {
|
||||
"default": [
|
||||
"levelbased",
|
||||
"pressurebased",
|
||||
"flowbased",
|
||||
"percentagebased",
|
||||
"powerbased",
|
||||
"manual"
|
||||
],
|
||||
"rules": {
|
||||
"type": "set",
|
||||
"itemType": "string",
|
||||
"description": "List of control modes that the station is permitted to operate in."
|
||||
}
|
||||
},
|
||||
"levelbased": {
|
||||
"startLevel": {
|
||||
"default": 1,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"min": 0,
|
||||
"description": "start of pump / group when level reaches this in meters starting from bottom."
|
||||
}
|
||||
},
|
||||
"stopLevel": {
|
||||
"default": 1,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"min": 0,
|
||||
"description": "stop of pump / group when level reaches this in meters starting from bottom"
|
||||
}
|
||||
},
|
||||
"minFlowLevel": {
|
||||
"default": 1,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"min": 0,
|
||||
"description": "min level to scale the flow lineair"
|
||||
}
|
||||
},
|
||||
"maxFlowLevel": {
|
||||
"default": 4,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"min": 0,
|
||||
"description": "max level to scale the flow lineair"
|
||||
}
|
||||
}
|
||||
},
|
||||
"pressureBased": {
|
||||
"pressureSetpoint": {
|
||||
"default": 1000,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"min": 0,
|
||||
"max": 5000,
|
||||
"description": "Target discharge pressure when operating in pressure control (kPa)."
|
||||
}
|
||||
}
|
||||
},
|
||||
"flowBased": {
|
||||
"flowSetpoint": {
|
||||
"default": 0,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"min": 0,
|
||||
"description": "Target outflow setpoint used by flow-based control (m3/h)."
|
||||
}
|
||||
},
|
||||
"flowDeadband": {
|
||||
"default": 0,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"min": 0,
|
||||
"description": "Allowed deadband around the outflow setpoint before corrective actions are taken (m3/h)."
|
||||
}
|
||||
},
|
||||
"pid": {
|
||||
"default": {},
|
||||
"rules": {
|
||||
"type": "object",
|
||||
"schema": {
|
||||
"kp": {
|
||||
"default": 1.5,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"description": "Proportional gain for flow-based PID control."
|
||||
}
|
||||
},
|
||||
"ki": {
|
||||
"default": 0.05,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"description": "Integral gain for flow-based PID control."
|
||||
}
|
||||
},
|
||||
"kd": {
|
||||
"default": 0.01,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"description": "Derivative gain for flow-based PID control."
|
||||
}
|
||||
},
|
||||
"derivativeFilter": {
|
||||
"default": 0.2,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"min": 0,
|
||||
"max": 1,
|
||||
"description": "Derivative filter coefficient (0..1)."
|
||||
}
|
||||
},
|
||||
"rateUp": {
|
||||
"default": 30,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"min": 0,
|
||||
"description": "Maximum controller output increase rate (%/s)."
|
||||
}
|
||||
},
|
||||
"rateDown": {
|
||||
"default": 40,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"min": 0,
|
||||
"description": "Maximum controller output decrease rate (%/s)."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"equalizationTargetPercent": {
|
||||
"default": 60,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"min": 0,
|
||||
"max": 100,
|
||||
"description": "Target fill percentage of the basin when operating in equalization mode."
|
||||
}
|
||||
},
|
||||
"flowBalanceTolerance": {
|
||||
"default": 5,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"min": 0,
|
||||
"description": "Allowable error between inflow and outflow before adjustments are triggered (m3/h)."
|
||||
}
|
||||
}
|
||||
},
|
||||
"percentageBased": {
|
||||
"targetVolumePercent": {
|
||||
"default": 50,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"min": 0,
|
||||
"max": 100,
|
||||
"description": "Target basin volume percentage to maintain during percentage-based control."
|
||||
}
|
||||
},
|
||||
"tolerancePercent": {
|
||||
"default": 5,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"min": 0,
|
||||
"description": "Acceptable deviation from the target volume percentage before corrective action is taken."
|
||||
}
|
||||
}
|
||||
},
|
||||
"powerBased": {
|
||||
"maxPowerKW": {
|
||||
"default": 50,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"min": 0,
|
||||
"description": "Maximum allowable power consumption for the pumping station (kW)."
|
||||
}
|
||||
},
|
||||
"powerControlMode": {
|
||||
"default": "limit",
|
||||
"rules": {
|
||||
"type": "enum",
|
||||
"values": [
|
||||
{
|
||||
"value": "limit",
|
||||
"description": "Limit pump operation to stay below the max power threshold."
|
||||
},
|
||||
{
|
||||
"value": "optimize",
|
||||
"description": "Optimize pump scheduling to minimize power usage while meeting flow demands."
|
||||
}
|
||||
],
|
||||
"description": "Defines how power constraints are managed during operation."
|
||||
}
|
||||
}
|
||||
},
|
||||
"manualOverrideTimeoutMinutes": {
|
||||
"default": 30,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"min": 0,
|
||||
"description": "Duration after which a manual override expires automatically (minutes)."
|
||||
}
|
||||
}
|
||||
},
|
||||
"safety": {
|
||||
"enableDryRunProtection": {
|
||||
"default": true,
|
||||
"rules": {
|
||||
"type": "boolean",
|
||||
"description": "If true, pumps will be prevented from running if basin volume is too low."
|
||||
}
|
||||
},
|
||||
"dryRunThresholdPercent": {
|
||||
"default": 2,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"min": 0,
|
||||
"max": 100,
|
||||
"description": "Volume percentage below which dry run protection activates."
|
||||
}
|
||||
},
|
||||
"dryRunDebounceSeconds": {
|
||||
"default": 30,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"min": 0,
|
||||
"description": "Time the low-volume condition must persist before dry-run protection engages (seconds)."
|
||||
}
|
||||
},
|
||||
"enableOverfillProtection": {
|
||||
"default": true,
|
||||
"rules": {
|
||||
"type": "boolean",
|
||||
"description": "If true, high level alarms and shutdowns will be enforced to prevent overfilling."
|
||||
}
|
||||
},
|
||||
"overfillThresholdPercent": {
|
||||
"default": 98,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"min": 0,
|
||||
"max": 100,
|
||||
"description": "Volume percentage above which overfill protection activates."
|
||||
}
|
||||
},
|
||||
"overfillDebounceSeconds": {
|
||||
"default": 30,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"min": 0,
|
||||
"description": "Time the high-volume condition must persist before overfill protection engages (seconds)."
|
||||
}
|
||||
},
|
||||
"timeleftToFullOrEmptyThresholdSeconds": {
|
||||
"default": 0,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"min": 0,
|
||||
"description": "Time threshold (seconds) used to predict imminent full or empty conditions."
|
||||
}
|
||||
}
|
||||
},
|
||||
"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)."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@
|
||||
}
|
||||
},
|
||||
"unit": {
|
||||
"default": "m3/h",
|
||||
"default": "l/s",
|
||||
"rules": {
|
||||
"type": "string",
|
||||
"description": "The default measurement unit for this configuration (e.g., 'meters', 'seconds', 'unitless')."
|
||||
@@ -110,6 +110,14 @@
|
||||
"description": "Asset tag code which is a unique identifier for this asset. May be null if not assigned."
|
||||
}
|
||||
},
|
||||
"tagNumber": {
|
||||
"default": null,
|
||||
"rules": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "Optional asset tag number for legacy integrations."
|
||||
}
|
||||
},
|
||||
"geoLocation": {
|
||||
"default": {},
|
||||
"rules": {
|
||||
@@ -175,6 +183,47 @@
|
||||
"description": "The unit of measurement for this asset (e.g., 'meters', 'seconds', 'unitless')."
|
||||
}
|
||||
},
|
||||
"curveUnits": {
|
||||
"default": {
|
||||
"pressure": "mbar",
|
||||
"flow": "m3/h",
|
||||
"power": "kW",
|
||||
"control": "%"
|
||||
},
|
||||
"rules": {
|
||||
"type": "object",
|
||||
"schema": {
|
||||
"pressure": {
|
||||
"default": "mbar",
|
||||
"rules": {
|
||||
"type": "string",
|
||||
"description": "Pressure unit used on the machine curve dimension axis."
|
||||
}
|
||||
},
|
||||
"flow": {
|
||||
"default": "m3/h",
|
||||
"rules": {
|
||||
"type": "string",
|
||||
"description": "Flow unit used in the machine curve output (nq.y)."
|
||||
}
|
||||
},
|
||||
"power": {
|
||||
"default": "kW",
|
||||
"rules": {
|
||||
"type": "string",
|
||||
"description": "Power unit used in the machine curve output (np.y)."
|
||||
}
|
||||
},
|
||||
"control": {
|
||||
"default": "%",
|
||||
"rules": {
|
||||
"type": "string",
|
||||
"description": "Control axis unit used in the curve x-dimension."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"accuracy": {
|
||||
"default": null,
|
||||
"rules": {
|
||||
@@ -245,10 +294,6 @@
|
||||
{
|
||||
"value": "fysicalControl",
|
||||
"description": "Controlled via physical buttons or switches; ignores external automated commands."
|
||||
},
|
||||
{
|
||||
"value": "maintenance",
|
||||
"description": "No active control from auto, virtual, or fysical sources."
|
||||
}
|
||||
],
|
||||
"description": "The operational mode of the machine."
|
||||
@@ -260,7 +305,14 @@
|
||||
"type": "object",
|
||||
"schema":{
|
||||
"auto": {
|
||||
"default": ["statusCheck", "execMovement", "execSequence", "emergencyStop"],
|
||||
"default": [
|
||||
"statuscheck",
|
||||
"execmovement",
|
||||
"execsequence",
|
||||
"flowmovement",
|
||||
"emergencystop",
|
||||
"entermaintenance"
|
||||
],
|
||||
"rules": {
|
||||
"type": "set",
|
||||
"itemType": "string",
|
||||
@@ -268,7 +320,14 @@
|
||||
}
|
||||
},
|
||||
"virtualControl": {
|
||||
"default": ["statusCheck", "execMovement", "execSequence", "emergencyStop"],
|
||||
"default": [
|
||||
"statuscheck",
|
||||
"execmovement",
|
||||
"flowmovement",
|
||||
"execsequence",
|
||||
"emergencystop",
|
||||
"exitmaintenance"
|
||||
],
|
||||
"rules": {
|
||||
"type": "set",
|
||||
"itemType": "string",
|
||||
@@ -276,25 +335,22 @@
|
||||
}
|
||||
},
|
||||
"fysicalControl": {
|
||||
"default": ["statusCheck", "emergencyStop"],
|
||||
"default": [
|
||||
"statuscheck",
|
||||
"emergencystop",
|
||||
"entermaintenance",
|
||||
"exitmaintenance"
|
||||
],
|
||||
"rules": {
|
||||
"type": "set",
|
||||
"itemType": "string",
|
||||
"description": "Actions allowed in fysicalControl mode."
|
||||
}
|
||||
},
|
||||
"maintenance": {
|
||||
"default": ["statusCheck"],
|
||||
"rules": {
|
||||
"type": "set",
|
||||
"itemType": "string",
|
||||
"description": "Actions allowed in maintenance mode."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Information about valid command sources recognized by the machine."
|
||||
}
|
||||
},
|
||||
},
|
||||
"allowedSources":{
|
||||
"default": {},
|
||||
"rules": {
|
||||
@@ -386,6 +442,22 @@
|
||||
"itemType": "string",
|
||||
"description": "Sequence of states for booting up the machine."
|
||||
}
|
||||
},
|
||||
"entermaintenance":{
|
||||
"default": ["stopping","coolingdown","idle","maintenance"],
|
||||
"rules": {
|
||||
"type": "set",
|
||||
"itemType": "string",
|
||||
"description": "Sequence of states if the machine is running to put it in maintenance state"
|
||||
}
|
||||
},
|
||||
"exitmaintenance":{
|
||||
"default": ["off","idle"],
|
||||
"rules": {
|
||||
"type": "set",
|
||||
"itemType": "string",
|
||||
"description": "Sequence of states if the machine is running to put it in maintenance state"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -412,6 +484,14 @@
|
||||
],
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
2
src/coolprop-node/.gitattributes
vendored
Normal file
2
src/coolprop-node/.gitattributes
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
||||
21
src/coolprop-node/LICENSE
Normal file
21
src/coolprop-node/LICENSE
Normal 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
253
src/coolprop-node/README.md
Normal 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
|
||||
|
||||
|
||||
|
||||
|
||||
80
src/coolprop-node/benchmark.js
Normal file
80
src/coolprop-node/benchmark.js
Normal 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);
|
||||
});
|
||||
1
src/coolprop-node/coolprop/coolprop.js
Normal file
1
src/coolprop-node/coolprop/coolprop.js
Normal file
File diff suppressed because one or more lines are too long
BIN
src/coolprop-node/coolprop/coolprop.wasm
Normal file
BIN
src/coolprop-node/coolprop/coolprop.wasm
Normal file
Binary file not shown.
31
src/coolprop-node/package.json
Normal file
31
src/coolprop-node/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
92
src/coolprop-node/src/cp.js
Normal file
92
src/coolprop-node/src/cp.js
Normal 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);
|
||||
}
|
||||
};
|
||||
487
src/coolprop-node/src/index.js
Normal file
487
src/coolprop-node/src/index.js
Normal file
@@ -0,0 +1,487 @@
|
||||
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;
|
||||
this.PropsSI = this._propsSI.bind(this);
|
||||
|
||||
|
||||
// 🔹 Wastewater correction options (defaults)
|
||||
this._ww = {
|
||||
enabled: true,
|
||||
tss_g_per_L: 3.5, // default MLSS / TSS
|
||||
density_k: 2e-4, // +0.02% per g/L
|
||||
viscosity_k: 0.07, // +7% per g/L (clamped)
|
||||
viscosity_max_gpl: 4 // cap effect at 4 g/L
|
||||
};
|
||||
|
||||
this._initPromise = null;
|
||||
this._autoInit({ refrigerant: 'Water' });
|
||||
|
||||
}
|
||||
|
||||
_isWastewaterFluid(fluidRaw) {
|
||||
if (!fluidRaw) return false;
|
||||
const token = String(fluidRaw).trim().toLowerCase();
|
||||
return token === 'wastewater' || token.startsWith('wastewater:');
|
||||
}
|
||||
|
||||
_parseWastewaterFluid(fluidRaw) {
|
||||
if (!this._isWastewaterFluid(fluidRaw)) return null;
|
||||
const ww = { ...this._ww };
|
||||
const [, tail] = String(fluidRaw).split(':');
|
||||
if (tail) {
|
||||
tail.split(',').forEach(pair => {
|
||||
const [key, value] = pair.split('=').map(s => s.trim().toLowerCase());
|
||||
if (key === 'tss' && !Number.isNaN(Number(value))) {
|
||||
ww.tss_g_per_L = Number(value);
|
||||
}
|
||||
});
|
||||
}
|
||||
return ww;
|
||||
}
|
||||
|
||||
_applyWastewaterCorrection(outputKey, baseValue, ww) {
|
||||
if (!Number.isFinite(baseValue) || !ww || !ww.enabled) return baseValue;
|
||||
switch (outputKey.toUpperCase()) {
|
||||
case 'D': // density
|
||||
return baseValue * (1 + ww.density_k * ww.tss_g_per_L);
|
||||
case 'V': // viscosity
|
||||
const effTss = Math.min(ww.tss_g_per_L, ww.viscosity_max_gpl);
|
||||
return baseValue * (1 + ww.viscosity_k * effTss);
|
||||
default:
|
||||
return baseValue;
|
||||
}
|
||||
}
|
||||
|
||||
// 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 };
|
||||
}
|
||||
}
|
||||
|
||||
_autoInit(defaults) {
|
||||
if (!this._initPromise) {
|
||||
this._initPromise = this.init(defaults);
|
||||
}
|
||||
return this._initPromise;
|
||||
}
|
||||
|
||||
_propsSI(outputKey, inKey1, inVal1, inKey2, inVal2, fluidRaw) {
|
||||
if (!this.initialized) {
|
||||
// Start init if no one else asked yet
|
||||
this._autoInit({ refrigerant: this.defaultRefrigerant || 'Water' });
|
||||
throw new Error('CoolProp is still warming up, retry PropsSI in a moment');
|
||||
}
|
||||
const ww = this._parseWastewaterFluid(fluidRaw);
|
||||
const fluid = ww ? 'Water' : (this.customRefString || fluidRaw);
|
||||
const baseValue = coolprop.PropsSI(outputKey, inKey1, inVal1, inKey2, inVal2, fluid);
|
||||
return ww ? this._applyWastewaterCorrection(outputKey, baseValue, ww) : baseValue;
|
||||
}
|
||||
|
||||
//Access to coolprop
|
||||
async getPropsSI() {
|
||||
await this._ensureInit({ refrigerant: this.defaultRefrigerant || 'Water' });
|
||||
return this.PropsSI;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = new CoolPropWrapper();
|
||||
308
src/coolprop-node/src/refData.js
Normal file
308
src/coolprop-node/src/refData.js
Normal 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;
|
||||
94
src/coolprop-node/test/R448a.test.js
Normal file
94
src/coolprop-node/test/R448a.test.js
Normal 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
|
||||
});
|
||||
});
|
||||
94
src/coolprop-node/test/R449a.test.js
Normal file
94
src/coolprop-node/test/R449a.test.js
Normal 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
|
||||
});
|
||||
});
|
||||
97
src/coolprop-node/test/R507.test.js
Normal file
97
src/coolprop-node/test/R507.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
55
src/coolprop-node/test/R744.C..test.js
Normal file
55
src/coolprop-node/test/R744.C..test.js
Normal 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
|
||||
});
|
||||
});
|
||||
55
src/coolprop-node/test/R744.F.test.js
Normal file
55
src/coolprop-node/test/R744.F.test.js
Normal 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
|
||||
});
|
||||
});
|
||||
296
src/coolprop-node/test/nodeprop.test.js
Normal file
296
src/coolprop-node/test/nodeprop.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
58
src/coolprop-node/test/pressure-conversions.test.js
Normal file
58
src/coolprop-node/test/pressure-conversions.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
50
src/coolprop-node/test/propsSI.test.js
Normal file
50
src/coolprop-node/test/propsSI.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
128
src/coolprop-node/test/temperature-conversions.test.js
Normal file
128
src/coolprop-node/test/temperature-conversions.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -16,7 +16,7 @@ class Assertions {
|
||||
assertNoNaN(arr, label = "array") {
|
||||
if (Array.isArray(arr)) {
|
||||
for (const el of arr) {
|
||||
assertNoNaN(el, label);
|
||||
this.assertNoNaN(el, label);
|
||||
}
|
||||
} else {
|
||||
if (Number.isNaN(arr)) {
|
||||
@@ -26,4 +26,4 @@ class Assertions {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Assertions;
|
||||
module.exports = Assertions;
|
||||
|
||||
@@ -1,3 +1,244 @@
|
||||
export function getAssetVariables() {
|
||||
const http = require('node:http');
|
||||
const https = require('node:https');
|
||||
const { URL } = require('node:url');
|
||||
const { assetCategoryManager } = require('../../datasets/assetData');
|
||||
|
||||
}
|
||||
function toNumber(value, fallback = 1) {
|
||||
const result = Number(value);
|
||||
return Number.isFinite(result) && result > 0 ? result : fallback;
|
||||
}
|
||||
|
||||
function toArray(value = []) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.filter((item) => typeof item !== 'undefined' && item !== null);
|
||||
}
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
return [value.trim()];
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return [value];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function findModelMetadata(selection = {}) {
|
||||
if (!selection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const categoryKey = selection.softwareType || 'measurement';
|
||||
if (!assetCategoryManager.hasCategory(categoryKey)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const suppliers = assetCategoryManager.getCategory(categoryKey).suppliers || [];
|
||||
const supplierMatch = (entry, value) => {
|
||||
if (!entry || !value) return false;
|
||||
const key = value.toString().toLowerCase();
|
||||
return (
|
||||
(entry.id && entry.id.toLowerCase() === key) ||
|
||||
(entry.name && entry.name.toLowerCase() === key)
|
||||
);
|
||||
};
|
||||
|
||||
const supplier = suppliers.find((item) => supplierMatch(item, selection.supplier));
|
||||
const types = supplier?.types || [];
|
||||
const type = types.find((item) => supplierMatch(item, selection.assetType));
|
||||
const models = type?.models || [];
|
||||
const model = models.find((item) => supplierMatch(item, selection.model));
|
||||
|
||||
return model || null;
|
||||
}
|
||||
|
||||
function buildAssetPayload({ assetSelection = {}, registrationDefaults = {} }) {
|
||||
const defaults = {
|
||||
profileId: 1,
|
||||
locationId: 1,
|
||||
processId: 1,
|
||||
status: 'actief',
|
||||
childAssets: [],
|
||||
...registrationDefaults
|
||||
};
|
||||
|
||||
const metadata = assetSelection.modelMetadata || findModelMetadata(assetSelection) || {};
|
||||
const rawName = assetSelection.assetName || assetSelection.name || assetSelection.assetType || assetSelection.model;
|
||||
const assetName = (rawName || 'Measurement asset').toString();
|
||||
const assetDescription = (assetSelection.assetDescription || assetSelection.description || assetName).toString();
|
||||
|
||||
const modelId = metadata.product_model_id ?? metadata.id ?? assetSelection.modelId ?? assetSelection.model ?? null;
|
||||
|
||||
const payload = {
|
||||
profile_id: toNumber(defaults.profileId, 1),
|
||||
location_id: toNumber(defaults.locationId, 1),
|
||||
process_id: toNumber(defaults.processId, 1),
|
||||
asset_name: assetName,
|
||||
asset_description: assetDescription,
|
||||
asset_status: (assetSelection.assetStatus || defaults.status || 'actief').toString(),
|
||||
product_model_id: modelId,
|
||||
product_model_uuid: metadata.product_model_uuid || metadata.uuid || null,
|
||||
child_assets: toArray(defaults.childAssets)
|
||||
};
|
||||
|
||||
const validation = [];
|
||||
const missing = [];
|
||||
const tooLong = [];
|
||||
const invalid = [];
|
||||
|
||||
if (!payload.asset_name) {
|
||||
missing.push('asset_name');
|
||||
} else if (payload.asset_name.length > 100) {
|
||||
tooLong.push('asset_name');
|
||||
}
|
||||
|
||||
if (!payload.asset_status) {
|
||||
missing.push('asset_status');
|
||||
} else if (payload.asset_status.length > 20) {
|
||||
tooLong.push('asset_status');
|
||||
}
|
||||
|
||||
if (!Number.isInteger(payload.location_id)) {
|
||||
invalid.push('location_id');
|
||||
}
|
||||
if (!Number.isInteger(payload.process_id)) {
|
||||
invalid.push('process_id');
|
||||
}
|
||||
if (!Number.isInteger(payload.profile_id)) {
|
||||
invalid.push('profile_id');
|
||||
}
|
||||
|
||||
if (!Number.isInteger(payload.product_model_id)) {
|
||||
invalid.push('product_model_id');
|
||||
}
|
||||
|
||||
if (!Array.isArray(payload.child_assets)) {
|
||||
invalid.push('child_assets');
|
||||
}
|
||||
|
||||
if (missing.length) {
|
||||
validation.push(`missing: ${missing.join(', ')}`);
|
||||
}
|
||||
if (tooLong.length) {
|
||||
validation.push(`too long: ${tooLong.join(', ')}`);
|
||||
}
|
||||
if (invalid.length) {
|
||||
validation.push(`invalid type: ${invalid.join(', ')}`);
|
||||
}
|
||||
|
||||
if (validation.length) {
|
||||
console.warn('[assetUtils] payload validation', validation.join(' | '));
|
||||
} else {
|
||||
console.info('[assetUtils] payload validation ok');
|
||||
}
|
||||
|
||||
const tagNumber = typeof assetSelection.tagNumber === 'string' && assetSelection.tagNumber.trim()
|
||||
? assetSelection.tagNumber.trim()
|
||||
: null;
|
||||
|
||||
return {
|
||||
payload,
|
||||
tagNumber,
|
||||
isUpdate: Boolean(tagNumber)
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeHeaders(headers = {}, body = '') {
|
||||
const normalized = { ...headers };
|
||||
if (!Object.prototype.hasOwnProperty.call(normalized, 'Content-Length')) {
|
||||
normalized['Content-Length'] = Buffer.byteLength(body);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function prepareUrl(baseUrl = '', path = '') {
|
||||
const trimmedBase = (baseUrl || '').replace(/\/+$/g, '').replace(/\\/g, '/');
|
||||
const trimmedPath = path.startsWith('/') ? path : `/${path}`;
|
||||
if (!trimmedBase) {
|
||||
return trimmedPath;
|
||||
}
|
||||
return `${trimmedBase}${trimmedPath}`;
|
||||
}
|
||||
|
||||
function sendHttpRequest(url, method, headers = {}, body = '') {
|
||||
const parsedUrl = new URL(url, 'http://localhost');
|
||||
const agent = parsedUrl.protocol === 'https:' ? https : http;
|
||||
const requestOptions = {
|
||||
method,
|
||||
hostname: parsedUrl.hostname,
|
||||
port: parsedUrl.port || (parsedUrl.protocol === 'https:' ? 443 : 80),
|
||||
path: `${parsedUrl.pathname}${parsedUrl.search}`,
|
||||
headers: normalizeHeaders(headers, body)
|
||||
};
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = agent.request(requestOptions, (res) => {
|
||||
let raw = '';
|
||||
res.setEncoding('utf8');
|
||||
res.on('data', (chunk) => { raw += chunk; });
|
||||
res.on('end', () => resolve({ status: res.statusCode, body: raw }));
|
||||
});
|
||||
|
||||
req.on('error', reject);
|
||||
if (body) {
|
||||
req.write(body);
|
||||
}
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
function parseApiResponse(raw, status) {
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return {
|
||||
success: parsed.success === true,
|
||||
data: parsed.data || null,
|
||||
message: parsed.message || (status >= 400 ? `HTTP ${status}` : 'Result returned')
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
data: raw,
|
||||
message: `Unable to decode asset API response: ${error.message}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function syncAsset({ assetSelection = {}, registrationDefaults = {}, apiConfig = {}, nodeContext = {} }) {
|
||||
const { payload, tagNumber, isUpdate } = buildAssetPayload({ assetSelection, registrationDefaults });
|
||||
if (!apiConfig || !apiConfig.baseUrl) {
|
||||
const message = 'Asset API configuration is missing';
|
||||
console.warn('[assetUtils] ' + message, { nodeContext });
|
||||
return { success: false, data: null, message };
|
||||
}
|
||||
|
||||
const path = isUpdate && tagNumber && typeof apiConfig.updatePath === 'function'
|
||||
? apiConfig.updatePath(tagNumber)
|
||||
: apiConfig.registerPath;
|
||||
const url = prepareUrl(apiConfig.baseUrl, path);
|
||||
const method = isUpdate ? (apiConfig.updateMethod || 'PUT') : 'POST';
|
||||
const headers = apiConfig.headers || {};
|
||||
|
||||
console.info('[assetUtils] Sending asset update', { nodeContext, method, url });
|
||||
|
||||
try {
|
||||
const response = await sendHttpRequest(url, method, headers, JSON.stringify(payload));
|
||||
const parsed = parseApiResponse(response.body, response.status);
|
||||
return {
|
||||
success: parsed.success,
|
||||
data: parsed.data,
|
||||
message: parsed.message
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[assetUtils] Asset API request failed', error, { nodeContext });
|
||||
return {
|
||||
success: false,
|
||||
data: null,
|
||||
message: `Asset API request error: ${error.message}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
syncAsset,
|
||||
buildAssetPayload,
|
||||
findModelMetadata
|
||||
};
|
||||
@@ -6,13 +6,27 @@ class ChildRegistrationUtils {
|
||||
}
|
||||
|
||||
async registerChild(child, positionVsParent, distance) {
|
||||
const { softwareType } = child.config.functionality;
|
||||
const { name, id } = child.config.general;
|
||||
if (!child || typeof child !== 'object') {
|
||||
this.logger?.warn('registerChild skipped: invalid child payload');
|
||||
return false;
|
||||
}
|
||||
if (!child.config?.functionality || !child.config?.general) {
|
||||
this.logger?.warn('registerChild skipped: missing child config/functionality/general');
|
||||
return false;
|
||||
}
|
||||
|
||||
const softwareType = child.config.functionality.softwareType;
|
||||
const name = child.config.general.name || child.config.general.id || 'unknown';
|
||||
const id = child.config.general.id || name;
|
||||
|
||||
this.logger.debug(`Registering child: ${name} (${id}) as ${softwareType} at ${positionVsParent}`);
|
||||
|
||||
// Enhanced child setup
|
||||
child.parent = this.mainClass;
|
||||
// Enhanced child setup - multiple parents
|
||||
if (Array.isArray(child.parent)) {
|
||||
child.parent.push(this.mainClass);
|
||||
} else {
|
||||
child.parent = [this.mainClass];
|
||||
}
|
||||
child.positionVsParent = positionVsParent;
|
||||
|
||||
// Enhanced measurement container with rich context
|
||||
@@ -39,19 +53,21 @@ class ChildRegistrationUtils {
|
||||
}
|
||||
|
||||
this.logger.info(`✅ Child ${name} registered successfully`);
|
||||
return true;
|
||||
}
|
||||
|
||||
_storeChild(child, softwareType) {
|
||||
// Maintain your existing structure
|
||||
if (!this.mainClass.child) this.mainClass.child = {};
|
||||
if (!this.mainClass.child[softwareType]) this.mainClass.child[softwareType] = {};
|
||||
const typeKey = softwareType || 'unknown';
|
||||
if (!this.mainClass.child[typeKey]) this.mainClass.child[typeKey] = {};
|
||||
|
||||
const { category = "sensor" } = child.config.asset || {};
|
||||
if (!this.mainClass.child[softwareType][category]) {
|
||||
this.mainClass.child[softwareType][category] = [];
|
||||
if (!this.mainClass.child[typeKey][category]) {
|
||||
this.mainClass.child[typeKey][category] = [];
|
||||
}
|
||||
|
||||
this.mainClass.child[softwareType][category].push(child);
|
||||
this.mainClass.child[typeKey][category].push(child);
|
||||
}
|
||||
|
||||
// NEW: Utility methods for parent to use
|
||||
@@ -91,4 +107,4 @@ class ChildRegistrationUtils {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ChildRegistrationUtils;
|
||||
module.exports = ChildRegistrationUtils;
|
||||
|
||||
@@ -39,8 +39,8 @@ const Logger = require("./logger");
|
||||
|
||||
class ConfigUtils {
|
||||
constructor(defaultConfig, IloggerEnabled , IloggerLevel) {
|
||||
const loggerEnabled = IloggerEnabled || true;
|
||||
const loggerLevel = IloggerLevel || "warn";
|
||||
const loggerEnabled = IloggerEnabled ?? true;
|
||||
const loggerLevel = IloggerLevel ?? "warn";
|
||||
this.logger = new Logger(loggerEnabled, loggerLevel, 'ConfigUtils');
|
||||
this.defaultConfig = defaultConfig;
|
||||
this.validationUtils = new ValidationUtils(loggerEnabled, loggerLevel);
|
||||
@@ -73,17 +73,25 @@ class ConfigUtils {
|
||||
return updatedConfig;
|
||||
}
|
||||
|
||||
_isPlainObject(value) {
|
||||
return Object.prototype.toString.call(value) === '[object Object]';
|
||||
}
|
||||
|
||||
// loop through objects and merge them obj1 will be updated with obj2 values
|
||||
mergeObjects(obj1, obj2) {
|
||||
for (let key in obj2) {
|
||||
if (obj2.hasOwnProperty(key)) {
|
||||
if (typeof obj2[key] === 'object') {
|
||||
if (!obj1[key]) {
|
||||
const nextValue = obj2[key];
|
||||
|
||||
if (Array.isArray(nextValue)) {
|
||||
obj1[key] = [...nextValue];
|
||||
} else if (this._isPlainObject(nextValue)) {
|
||||
if (!this._isPlainObject(obj1[key])) {
|
||||
obj1[key] = {};
|
||||
}
|
||||
this.mergeObjects(obj1[key], obj2[key]);
|
||||
this.mergeObjects(obj1[key], nextValue);
|
||||
} else {
|
||||
obj1[key] = obj2[key];
|
||||
obj1[key] = nextValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,13 +18,102 @@ class EndpointUtils {
|
||||
* @param {string} nodeName the name of the node (used in the URL)
|
||||
* @param {object} customHelpers additional helper functions to inject
|
||||
*/
|
||||
createMenuUtilsEndpoint(RED, nodeName, customHelpers = {}) {
|
||||
RED.httpAdmin.get(`/${nodeName}/resources/menuUtils.js`, (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);
|
||||
createMenuUtilsEndpoint(RED, nodeName, customHelpers = {}, options = {}) {
|
||||
const basePath = `/${nodeName}/resources`;
|
||||
|
||||
RED.httpAdmin.get(`${basePath}/menuUtilsData.json`, (req, res) => {
|
||||
res.json(this.generateMenuUtilsData(nodeName, customHelpers, options));
|
||||
});
|
||||
|
||||
RED.httpAdmin.get(`${basePath}/menuUtils.legacy.js`, (req, res) => {
|
||||
res.set('Content-Type', 'application/javascript');
|
||||
res.send(this.generateLegacyMenuUtilsCode(nodeName, customHelpers));
|
||||
});
|
||||
|
||||
RED.httpAdmin.get(`${basePath}/menuUtils.js`, (req, res) => {
|
||||
res.set('Content-Type', 'application/javascript');
|
||||
res.send(this.generateMenuUtilsBootstrap(nodeName));
|
||||
});
|
||||
}
|
||||
|
||||
generateMenuUtilsData(nodeName, customHelpers = {}, options = {}) {
|
||||
const defaultHelpers = {
|
||||
validateRequired: `function(value) {
|
||||
return value != null && value.toString().trim() !== '';
|
||||
}`,
|
||||
formatDisplayValue: `function(value, unit) {
|
||||
return \`${'${'}value} ${'${'}unit || ''}\`.trim();
|
||||
}`,
|
||||
validateScaling: `function(min, max) {
|
||||
return !isNaN(min) && !isNaN(max) && Number(min) < Number(max);
|
||||
}`,
|
||||
validateUnit: `function(unit) {
|
||||
return typeof unit === 'string' && unit.trim() !== '';
|
||||
}`,
|
||||
};
|
||||
|
||||
return {
|
||||
nodeName,
|
||||
helpers: { ...defaultHelpers, ...customHelpers },
|
||||
options: {
|
||||
autoLoadLegacy: options.autoLoadLegacy !== false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
generateMenuUtilsBootstrap(nodeName) {
|
||||
return `
|
||||
// Stable bootstrap for EVOLV menu utils (${nodeName})
|
||||
(function() {
|
||||
const nodeName = ${JSON.stringify(nodeName)};
|
||||
const basePath = '/' + nodeName + '/resources';
|
||||
|
||||
window.EVOLV = window.EVOLV || {};
|
||||
window.EVOLV.nodes = window.EVOLV.nodes || {};
|
||||
window.EVOLV.nodes[nodeName] = window.EVOLV.nodes[nodeName] || {};
|
||||
window.EVOLV.nodes[nodeName].utils = window.EVOLV.nodes[nodeName].utils || {};
|
||||
|
||||
function parseHelper(fnBody) {
|
||||
try {
|
||||
return (new Function('return (' + fnBody + ')'))();
|
||||
} catch (error) {
|
||||
console.error('[menuUtils] helper parse failed:', error);
|
||||
return function() { return null; };
|
||||
}
|
||||
}
|
||||
|
||||
function loadLegacyIfNeeded(autoLoadLegacy) {
|
||||
if (!autoLoadLegacy || typeof window.MenuUtils === 'function') return Promise.resolve();
|
||||
return new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = basePath + '/menuUtils.legacy.js';
|
||||
script.onload = resolve;
|
||||
script.onerror = reject;
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
fetch(basePath + '/menuUtilsData.json')
|
||||
.then(function(res) { return res.json(); })
|
||||
.then(function(payload) {
|
||||
const helperFns = {};
|
||||
Object.entries(payload.helpers || {}).forEach(function(entry) {
|
||||
helperFns[entry[0]] = parseHelper(entry[1]);
|
||||
});
|
||||
|
||||
window.EVOLV.nodes[nodeName].utils.helpers = helperFns;
|
||||
return loadLegacyIfNeeded(payload.options && payload.options.autoLoadLegacy);
|
||||
})
|
||||
.then(function() {
|
||||
if (typeof window.MenuUtils === 'function' && !window.EVOLV.nodes[nodeName].utils.menuUtils) {
|
||||
window.EVOLV.nodes[nodeName].utils.menuUtils = new window.MenuUtils();
|
||||
}
|
||||
})
|
||||
.catch(function(error) {
|
||||
console.error('[menuUtils] bootstrap failed for ' + nodeName, error);
|
||||
});
|
||||
})();
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -33,7 +122,7 @@ class EndpointUtils {
|
||||
* @param {object} customHelpers map of name: functionString pairs
|
||||
* @returns {string} a JS snippet to run in the browser
|
||||
*/
|
||||
generateMenuUtilsCode(nodeName, customHelpers = {}) {
|
||||
generateLegacyMenuUtilsCode(nodeName, customHelpers = {}) {
|
||||
// Default helper implementations to expose alongside MenuUtils
|
||||
const defaultHelpers = {
|
||||
validateRequired: `function(value) {
|
||||
@@ -101,6 +190,11 @@ ${helpersCode}
|
||||
console.log('Loaded EVOLV.nodes.${nodeName}.utils.menuUtils');
|
||||
`;
|
||||
}
|
||||
|
||||
// Backward-compatible alias.
|
||||
generateMenuUtilsCode(nodeName, customHelpers = {}) {
|
||||
return this.generateLegacyMenuUtilsCode(nodeName, customHelpers);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = EndpointUtils;
|
||||
|
||||
90
src/helper/gravity.js
Normal file
90
src/helper/gravity.js
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Gravity calculations based on WGS-84 ellipsoid model.
|
||||
* Author: Rene de Ren (Waterschap Brabantse Delta)
|
||||
* License: EUPL-1.2
|
||||
*/
|
||||
|
||||
class Gravity {
|
||||
constructor() {
|
||||
// Standard (conventional) gravity at 45° latitude, sea level
|
||||
this.g0 = 9.80665; // m/s²
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns standard gravity (constant)
|
||||
* @returns {number} gravity in m/s²
|
||||
*/
|
||||
getStandardGravity() {
|
||||
return this.g0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes local gravity based on latitude and elevation.
|
||||
* Formula: WGS-84 normal gravity (Somigliana)
|
||||
* @param {number} latitudeDeg Latitude in degrees (−90 → +90)
|
||||
* @param {number} elevationM Elevation above sea level [m]
|
||||
* @returns {number} gravity in m/s²
|
||||
*/
|
||||
getLocalGravity(latitudeDeg, elevationM = 0) {
|
||||
const phi = (latitudeDeg * Math.PI) / 180;
|
||||
const sinPhi = Math.sin(phi);
|
||||
const sin2 = sinPhi * sinPhi;
|
||||
const sin2_2phi = Math.sin(2 * phi) ** 2;
|
||||
|
||||
// WGS-84 normal gravity on the ellipsoid
|
||||
const gSurface =
|
||||
9.780327 * (1 + 0.0053024 * sin2 - 0.0000058 * sin2_2phi);
|
||||
|
||||
// Free-air correction for elevation (~ −3.086×10⁻⁶ m/s² per m)
|
||||
const gLocal = gSurface - 3.086e-6 * elevationM;
|
||||
return gLocal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates hydrostatic pressure difference (ΔP = ρ g h)
|
||||
* @param {number} density Fluid density [kg/m³]
|
||||
* @param {number} heightM Height difference [m]
|
||||
* @param {number} latitudeDeg Latitude (for local g)
|
||||
* @param {number} elevationM Elevation (for local g)
|
||||
* @returns {number} Pressure difference [Pa]
|
||||
*/
|
||||
pressureHead(density, heightM, latitudeDeg = 45, elevationM = 0) {
|
||||
const g = this.getLocalGravity(latitudeDeg, elevationM);
|
||||
return density * g * heightM;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates weight force (F = m g)
|
||||
* @param {number} massKg Mass [kg]
|
||||
* @param {number} latitudeDeg Latitude (for local g)
|
||||
* @param {number} elevationM Elevation (for local g)
|
||||
* @returns {number} Force [N]
|
||||
*/
|
||||
weightForce(massKg, latitudeDeg = 45, elevationM = 0) {
|
||||
const g = this.getLocalGravity(latitudeDeg, elevationM);
|
||||
return massKg * g;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new Gravity();
|
||||
|
||||
|
||||
/*
|
||||
const gravity = gravity;
|
||||
|
||||
// Standard gravity
|
||||
console.log('g₀ =', gravity.getStandardGravity(), 'm/s²');
|
||||
|
||||
// Local gravity (Breda ≈ 51.6° N, 3 m elevation)
|
||||
console.log('g @ Breda =', gravity.getLocalGravity(51.6, 3).toFixed(6), 'm/s²');
|
||||
|
||||
// Head pressure for 5 m water column at Breda
|
||||
console.log(
|
||||
'ΔP =',
|
||||
gravity.pressureHead(1000, 5, 51.6, 3).toFixed(1),
|
||||
'Pa'
|
||||
);
|
||||
|
||||
// Weight of 1 kg mass at Breda
|
||||
console.log('Weight =', gravity.weightForce(1, 51.6, 3).toFixed(6), 'N');
|
||||
*/
|
||||
25
src/helper/index.js
Normal file
25
src/helper/index.js
Normal file
@@ -0,0 +1,25 @@
|
||||
const assertions = require('./assertionUtils.js');
|
||||
const assetUtils = require('./assetUtils.js');
|
||||
const childRegistrationUtils = require('./childRegistrationUtils.js');
|
||||
const configUtils = require('./configUtils.js');
|
||||
const endpointUtils = require('./endpointUtils.js');
|
||||
const gravity = require('./gravity.js');
|
||||
const logger = require('./logger.js');
|
||||
const menuUtils = require('./menuUtils.js');
|
||||
const nodeTemplates = require('./nodeTemplates.js');
|
||||
const outputUtils = require('./outputUtils.js');
|
||||
const validation = require('./validationUtils.js');
|
||||
|
||||
module.exports = {
|
||||
assertions,
|
||||
assetUtils,
|
||||
childRegistrationUtils,
|
||||
configUtils,
|
||||
endpointUtils,
|
||||
gravity,
|
||||
logger,
|
||||
menuUtils,
|
||||
nodeTemplates,
|
||||
outputUtils,
|
||||
validation,
|
||||
};
|
||||
@@ -44,7 +44,7 @@ class Logger {
|
||||
if (this.levels.includes(level)) {
|
||||
this.logLevel = level;
|
||||
} else {
|
||||
console.error(`[ERROR ${nameModule}]: Invalid log level: ${level}`);
|
||||
console.error(`[ERROR] -> ${this.nameModule}: Invalid log level: ${level}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,4 +54,4 @@ class Logger {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Logger;
|
||||
module.exports = Logger;
|
||||
|
||||
@@ -180,7 +180,6 @@ async apiCall(node) {
|
||||
// Only add tagCode to URL if it exists
|
||||
if (tagCode) {
|
||||
apiUrl += `&asset_tag_number=${tagCode}`;
|
||||
console.log('hello there');
|
||||
}
|
||||
|
||||
assetregisterAPI += apiUrl;
|
||||
@@ -461,10 +460,6 @@ populateModels(
|
||||
// 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"]);
|
||||
*/
|
||||
});
|
||||
|
||||
})
|
||||
@@ -485,17 +480,26 @@ generateHtml(htmlElement, options, savedValue) {
|
||||
}
|
||||
}
|
||||
|
||||
createMenuUtilsEndpoint(RED, nodeName, customHelpers = {}) {
|
||||
RED.httpAdmin.get(`/${nodeName}/resources/menuUtils.js`, function(req, res) {
|
||||
console.log(`Serving menuUtils.js for ${nodeName} node`);
|
||||
createMenuUtilsEndpoint(RED, nodeName, customHelpers = {}, options = {}) {
|
||||
const basePath = `/${nodeName}/resources`;
|
||||
|
||||
RED.httpAdmin.get(`${basePath}/menuUtilsData.json`, function(req, res) {
|
||||
res.json(this.generateMenuUtilsData(nodeName, customHelpers, options));
|
||||
}.bind(this));
|
||||
|
||||
RED.httpAdmin.get(`${basePath}/menuUtils.legacy.js`, function(req, res) {
|
||||
res.set('Content-Type', 'application/javascript');
|
||||
|
||||
const browserCode = this.generateMenuUtilsCode(nodeName, customHelpers);
|
||||
const browserCode = this.generateLegacyMenuUtilsCode(nodeName, customHelpers);
|
||||
res.send(browserCode);
|
||||
}.bind(this));
|
||||
|
||||
RED.httpAdmin.get(`${basePath}/menuUtils.js`, function(req, res) {
|
||||
res.set('Content-Type', 'application/javascript');
|
||||
res.send(this.generateMenuUtilsBootstrap(nodeName));
|
||||
}.bind(this));
|
||||
}
|
||||
|
||||
generateMenuUtilsCode(nodeName, customHelpers = {}) {
|
||||
generateMenuUtilsData(nodeName, customHelpers = {}, options = {}) {
|
||||
const defaultHelpers = {
|
||||
validateRequired: `function(value) {
|
||||
return value && value.toString().trim() !== '';
|
||||
@@ -505,7 +509,71 @@ generateMenuUtilsCode(nodeName, customHelpers = {}) {
|
||||
}`
|
||||
};
|
||||
|
||||
const allHelpers = { ...defaultHelpers, ...customHelpers };
|
||||
return {
|
||||
nodeName,
|
||||
helpers: { ...defaultHelpers, ...customHelpers },
|
||||
options: {
|
||||
autoLoadLegacy: options.autoLoadLegacy !== false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
generateMenuUtilsBootstrap(nodeName) {
|
||||
return `
|
||||
// Stable bootstrap for EVOLV menu utils (${nodeName})
|
||||
(function() {
|
||||
const nodeName = ${JSON.stringify(nodeName)};
|
||||
const basePath = '/' + nodeName + '/resources';
|
||||
|
||||
window.EVOLV = window.EVOLV || {};
|
||||
window.EVOLV.nodes = window.EVOLV.nodes || {};
|
||||
window.EVOLV.nodes[nodeName] = window.EVOLV.nodes[nodeName] || {};
|
||||
window.EVOLV.nodes[nodeName].utils = window.EVOLV.nodes[nodeName].utils || {};
|
||||
|
||||
function parseHelper(fnBody) {
|
||||
try {
|
||||
return (new Function('return (' + fnBody + ')'))();
|
||||
} catch (error) {
|
||||
console.error('[menuUtils] helper parse failed:', error);
|
||||
return function() { return null; };
|
||||
}
|
||||
}
|
||||
|
||||
function loadLegacyIfNeeded(autoLoadLegacy) {
|
||||
if (!autoLoadLegacy || typeof window.MenuUtils === 'function') return Promise.resolve();
|
||||
return new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = basePath + '/menuUtils.legacy.js';
|
||||
script.onload = resolve;
|
||||
script.onerror = reject;
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
fetch(basePath + '/menuUtilsData.json')
|
||||
.then(function(res) { return res.json(); })
|
||||
.then(function(payload) {
|
||||
const helperFns = {};
|
||||
Object.entries(payload.helpers || {}).forEach(function(entry) {
|
||||
helperFns[entry[0]] = parseHelper(entry[1]);
|
||||
});
|
||||
window.EVOLV.nodes[nodeName].utils.helpers = helperFns;
|
||||
return loadLegacyIfNeeded(payload.options && payload.options.autoLoadLegacy);
|
||||
})
|
||||
.then(function() {
|
||||
if (typeof window.MenuUtils === 'function' && !window.EVOLV.nodes[nodeName].utils.menuUtils) {
|
||||
window.EVOLV.nodes[nodeName].utils.menuUtils = new window.MenuUtils();
|
||||
}
|
||||
})
|
||||
.catch(function(error) {
|
||||
console.error('[menuUtils] bootstrap failed for ' + nodeName, error);
|
||||
});
|
||||
})();
|
||||
`;
|
||||
}
|
||||
|
||||
generateLegacyMenuUtilsCode(nodeName, customHelpers = {}) {
|
||||
const allHelpers = { ...this.generateMenuUtilsData(nodeName).helpers, ...customHelpers };
|
||||
|
||||
const helpersCode = Object.entries(allHelpers)
|
||||
.map(([name, func]) => ` ${name}: ${func}`)
|
||||
@@ -538,6 +606,11 @@ ${helpersCode}
|
||||
`;
|
||||
}
|
||||
|
||||
// Backward-compatible alias
|
||||
generateMenuUtilsCode(nodeName, customHelpers = {}) {
|
||||
return this.generateLegacyMenuUtilsCode(nodeName, customHelpers);
|
||||
}
|
||||
|
||||
module.exports = MenuUtils;
|
||||
}
|
||||
|
||||
module.exports = MenuUtils;
|
||||
|
||||
@@ -180,7 +180,6 @@ async apiCall(node) {
|
||||
// Only add tagCode to URL if it exists
|
||||
if (tagCode) {
|
||||
apiUrl += `&asset_tag_number=${tagCode}`;
|
||||
console.log('hello there');
|
||||
}
|
||||
|
||||
assetregisterAPI += apiUrl;
|
||||
@@ -461,10 +460,7 @@ populateModels(
|
||||
// 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"]);
|
||||
*/
|
||||
|
||||
});
|
||||
|
||||
})
|
||||
|
||||
@@ -53,4 +53,4 @@ const nodeTemplates = {
|
||||
// …add more node “templates” here…
|
||||
};
|
||||
|
||||
export default nodeTemplates;
|
||||
module.exports = nodeTemplates;
|
||||
|
||||
@@ -7,6 +7,9 @@ class OutputUtils {
|
||||
}
|
||||
|
||||
checkForChanges(output, format) {
|
||||
if (!output || typeof output !== 'object') {
|
||||
return {};
|
||||
}
|
||||
const changedFields = {};
|
||||
for (const key in output) {
|
||||
if (output.hasOwnProperty(key) && output[key] !== this.output[format][key]) {
|
||||
@@ -54,17 +57,17 @@ class OutputUtils {
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log('Unknown format in output utils');
|
||||
break;
|
||||
return null;
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
influxDBFormat(changedFields, config , flatTags) {
|
||||
// Create the measurement and topic using softwareType and name config.functionality.softwareType + .
|
||||
const measurement = config.general.name;
|
||||
const measurement = `${config.functionality?.softwareType}_${config.general?.id}`;
|
||||
const payload = {
|
||||
measurement: measurement,
|
||||
fields: changedFields,
|
||||
@@ -104,24 +107,23 @@ class OutputUtils {
|
||||
return {
|
||||
// general properties
|
||||
id: config.general?.id,
|
||||
name: config.general?.name,
|
||||
unit: config.general?.unit,
|
||||
// functionality properties
|
||||
softwareType: config.functionality?.softwareType,
|
||||
role: config.functionality?.role,
|
||||
// asset properties (exclude machineCurve)
|
||||
uuid: config.asset?.uuid,
|
||||
tagcode: config.asset?.tagcode,
|
||||
geoLocation: config.asset?.geoLocation,
|
||||
supplier: config.asset?.supplier,
|
||||
category: config.asset?.category,
|
||||
type: config.asset?.type,
|
||||
subType: config.asset?.subType,
|
||||
model: config.asset?.model,
|
||||
unit: config.general?.unit,
|
||||
};
|
||||
}
|
||||
|
||||
processFormat(changedFields,config) {
|
||||
// Create the measurement and topic using softwareType and name config.functionality.softwareType + .
|
||||
const measurement = config.general.name;
|
||||
const measurement = `${config.functionality?.softwareType}_${config.general?.id}`;
|
||||
const payload = changedFields;
|
||||
const topic = measurement;
|
||||
const msg = { topic: topic, payload: payload };
|
||||
|
||||
@@ -36,11 +36,24 @@ const Logger = require("./logger");
|
||||
|
||||
class ValidationUtils {
|
||||
constructor(IloggerEnabled, IloggerLevel) {
|
||||
const loggerEnabled = IloggerEnabled || true;
|
||||
const loggerLevel = IloggerLevel || "warn";
|
||||
const loggerEnabled = IloggerEnabled ?? true;
|
||||
const loggerLevel = IloggerLevel ?? "warn";
|
||||
this.logger = new Logger(loggerEnabled, loggerLevel, 'ValidationUtils');
|
||||
this._onceLogCache = new Set();
|
||||
}
|
||||
|
||||
_logOnce(level, onceKey, message) {
|
||||
if (onceKey && this._onceLogCache.has(onceKey)) {
|
||||
return;
|
||||
}
|
||||
if (onceKey) {
|
||||
this._onceLogCache.add(onceKey);
|
||||
}
|
||||
if (typeof this.logger?.[level] === "function") {
|
||||
this.logger[level](message);
|
||||
}
|
||||
}
|
||||
|
||||
constrain(value, min, max) {
|
||||
if (typeof value !== "number") {
|
||||
this.logger?.warn(`Value '${value}' is not a number. Defaulting to ${min}.`);
|
||||
@@ -96,7 +109,7 @@ class ValidationUtils {
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
this.logger.info(`There is no value provided for ${name}.${key}. Using default value.`);
|
||||
this.logger.debug(`No value provided for ${name}.${key}. Using default value.`);
|
||||
configValue = fieldSchema.default;
|
||||
}
|
||||
//continue;
|
||||
@@ -191,7 +204,7 @@ class ValidationUtils {
|
||||
continue;
|
||||
}
|
||||
|
||||
if("default" in v){
|
||||
if(v && typeof v === "object" && "default" in v){
|
||||
//put the default value in the object
|
||||
newObj[k] = v.default;
|
||||
continue;
|
||||
@@ -390,19 +403,52 @@ class ValidationUtils {
|
||||
return fieldSchema.default;
|
||||
}
|
||||
|
||||
const keyString = `${name}.${key}`;
|
||||
const normalizeMode = rules.normalize || this._resolveStringNormalizeMode(keyString);
|
||||
const preserveCase = normalizeMode !== "lowercase";
|
||||
|
||||
// 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()}`);
|
||||
if (!preserveCase && newConfigValue !== newConfigValue.toLowerCase()) {
|
||||
this._logOnce(
|
||||
"info",
|
||||
`normalize-lowercase:${keyString}`,
|
||||
`${name}.${key} normalized to lowercase: ${newConfigValue} -> ${newConfigValue.toLowerCase()}`
|
||||
);
|
||||
newConfigValue = newConfigValue.toLowerCase();
|
||||
}
|
||||
|
||||
return newConfigValue;
|
||||
}
|
||||
|
||||
_isUnitLikeField(path) {
|
||||
const normalized = String(path || "").toLowerCase();
|
||||
if (!normalized) return false;
|
||||
return /(^|\.)([a-z0-9]*unit|units)(\.|$)/.test(normalized)
|
||||
|| normalized.includes(".curveunits.");
|
||||
}
|
||||
|
||||
_resolveStringNormalizeMode(path) {
|
||||
const normalized = String(path || "").toLowerCase();
|
||||
if (!normalized) return "none";
|
||||
|
||||
if (this._isUnitLikeField(normalized)) return "none";
|
||||
if (normalized.endsWith(".name")) return "none";
|
||||
if (normalized.endsWith(".model")) return "none";
|
||||
if (normalized.endsWith(".supplier")) return "none";
|
||||
if (normalized.endsWith(".role")) return "none";
|
||||
if (normalized.endsWith(".description")) return "none";
|
||||
|
||||
if (normalized.endsWith(".softwaretype")) return "lowercase";
|
||||
if (normalized.endsWith(".type")) return "lowercase";
|
||||
if (normalized.endsWith(".category")) return "lowercase";
|
||||
|
||||
return "lowercase";
|
||||
}
|
||||
|
||||
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.`);
|
||||
this.logger.debug(`${name}.${key} is not a Set. Converting to one using default value.`);
|
||||
return new Set(fieldSchema.default);
|
||||
}
|
||||
|
||||
@@ -426,9 +472,10 @@ class ValidationUtils {
|
||||
.slice(0, rules.maxLength || Infinity);
|
||||
|
||||
// 4. Check if the filtered array meets the minimum length.
|
||||
if (validatedArray.length < (rules.minLength || 1)) {
|
||||
const minLength = Number.isInteger(rules.minLength) ? rules.minLength : 0;
|
||||
if (validatedArray.length < minLength) {
|
||||
this.logger.warn(
|
||||
`${name}.${key} contains fewer items than allowed (${rules.minLength}). Using default value.`
|
||||
`${name}.${key} contains fewer items than allowed (${minLength}). Using default value.`
|
||||
);
|
||||
return new Set(fieldSchema.default);
|
||||
}
|
||||
@@ -439,7 +486,7 @@ class ValidationUtils {
|
||||
|
||||
validateArray(configValue, rules, fieldSchema, name, key) {
|
||||
if (!Array.isArray(configValue)) {
|
||||
this.logger.info(`${name}.${key} is not an array. Using default value.`);
|
||||
this.logger.debug(`${name}.${key} is not an array. Using default value.`);
|
||||
return fieldSchema.default;
|
||||
}
|
||||
|
||||
@@ -460,9 +507,10 @@ class ValidationUtils {
|
||||
})
|
||||
.slice(0, rules.maxLength || Infinity);
|
||||
|
||||
if (validatedArray.length < (rules.minLength || 1)) {
|
||||
const minLength = Number.isInteger(rules.minLength) ? rules.minLength : 0;
|
||||
if (validatedArray.length < minLength) {
|
||||
this.logger.warn(
|
||||
`${name}.${key} contains fewer items than allowed (${rules.minLength}). Using default value.`
|
||||
`${name}.${key} contains fewer items than allowed (${minLength}). Using default value.`
|
||||
);
|
||||
return fieldSchema.default;
|
||||
}
|
||||
@@ -496,6 +544,11 @@ class ValidationUtils {
|
||||
return fieldSchema.default;
|
||||
}
|
||||
|
||||
if (typeof configValue !== "string") {
|
||||
this.logger.warn(`${name}.${key} is not a valid enum string. Using default value.`);
|
||||
return fieldSchema.default;
|
||||
}
|
||||
|
||||
const validValues = rules.values.map(e => e.value.toLowerCase());
|
||||
|
||||
//remove caps
|
||||
|
||||
@@ -67,6 +67,25 @@ class Measurement {
|
||||
if (this.values.length === 0) return null;
|
||||
return this.values[this.values.length - 1];
|
||||
}
|
||||
|
||||
getLaggedValue(lag){
|
||||
if (lag < 0) throw new Error('lag must be >= 0');
|
||||
const index = this.values.length - 1 - lag;
|
||||
if (index < 0) return null;
|
||||
return this.values[index];
|
||||
}
|
||||
|
||||
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() {
|
||||
if (this.values.length === 0) return null;
|
||||
@@ -96,7 +115,7 @@ class Measurement {
|
||||
|
||||
// Create a new measurement that is the difference between two positions
|
||||
static createDifference(upstreamMeasurement, downstreamMeasurement) {
|
||||
console.log('hello:');
|
||||
|
||||
if (upstreamMeasurement.type !== downstreamMeasurement.type ||
|
||||
upstreamMeasurement.variant !== downstreamMeasurement.variant) {
|
||||
throw new Error('Cannot calculate difference between different measurement types or variants');
|
||||
@@ -161,7 +180,7 @@ class Measurement {
|
||||
|
||||
try {
|
||||
const convertedValues = this.values.map(value =>
|
||||
convertModule.convert(value).from(this.unit).to(targetUnit)
|
||||
convertModule(value).from(this.unit).to(targetUnit)
|
||||
);
|
||||
|
||||
const newMeasurement = new Measurement(
|
||||
|
||||
@@ -4,18 +4,20 @@ const convertModule = require('../convert/index');
|
||||
|
||||
class MeasurementContainer {
|
||||
constructor(options = {},logger) {
|
||||
this.logger = logger || null;
|
||||
this.emitter = new EventEmitter();
|
||||
this.measurements = {};
|
||||
this.windowSize = options.windowSize || 10; // Default window size
|
||||
|
||||
// For chaining context
|
||||
this._currentChildId = null;
|
||||
this._currentType = null;
|
||||
this._currentVariant = null;
|
||||
this._currentPosition = null;
|
||||
this._currentDistance = null;
|
||||
this._unit = null;
|
||||
|
||||
// Default units for each measurement type
|
||||
// Default units for each measurement type (ingress/preferred)
|
||||
this.defaultUnits = {
|
||||
pressure: 'mbar',
|
||||
flow: 'm3/h',
|
||||
@@ -25,10 +27,48 @@ class MeasurementContainer {
|
||||
length: 'm',
|
||||
...options.defaultUnits // Allow override
|
||||
};
|
||||
|
||||
// Canonical storage unit map (single conversion anchor per measurement type)
|
||||
this.canonicalUnits = {
|
||||
pressure: 'Pa',
|
||||
atmPressure: 'Pa',
|
||||
flow: 'm3/s',
|
||||
power: 'W',
|
||||
hydraulicPower: 'W',
|
||||
temperature: 'K',
|
||||
volume: 'm3',
|
||||
length: 'm',
|
||||
mass: 'kg',
|
||||
energy: 'J',
|
||||
...options.canonicalUnits,
|
||||
};
|
||||
|
||||
// Auto-conversion settings
|
||||
this.autoConvert = options.autoConvert !== false; // Default to true
|
||||
this.preferredUnits = options.preferredUnits || {}; // Per-measurement overrides
|
||||
this.storeCanonical = options.storeCanonical === true;
|
||||
this.strictUnitValidation = options.strictUnitValidation === true;
|
||||
this.throwOnInvalidUnit = options.throwOnInvalidUnit === true;
|
||||
this.requireUnitForTypes = new Set(
|
||||
(options.requireUnitForTypes || []).map((t) => String(t).trim().toLowerCase())
|
||||
);
|
||||
|
||||
// Map EVOLV measurement types to convert-module measure families
|
||||
this.measureMap = {
|
||||
pressure: 'pressure',
|
||||
atmpressure: 'pressure',
|
||||
flow: 'volumeFlowRate',
|
||||
power: 'power',
|
||||
hydraulicpower: 'power',
|
||||
reactivepower: 'reactivePower',
|
||||
apparentpower: 'apparentPower',
|
||||
temperature: 'temperature',
|
||||
volume: 'volume',
|
||||
length: 'length',
|
||||
mass: 'mass',
|
||||
energy: 'energy',
|
||||
reactiveenergy: 'reactiveEnergy',
|
||||
};
|
||||
|
||||
// For chaining context
|
||||
this._currentType = null;
|
||||
@@ -49,6 +89,11 @@ class MeasurementContainer {
|
||||
return this;
|
||||
}
|
||||
|
||||
child(childId) {
|
||||
this._currentChildId = childId || 'default';
|
||||
return this;
|
||||
}
|
||||
|
||||
setChildName(childName) {
|
||||
this.childName = childName;
|
||||
return this;
|
||||
@@ -65,6 +110,11 @@ class MeasurementContainer {
|
||||
return this;
|
||||
}
|
||||
|
||||
setCanonicalUnit(measurementType, unit) {
|
||||
this.canonicalUnits[measurementType] = unit;
|
||||
return this;
|
||||
}
|
||||
|
||||
// Get the target unit for a measurement type
|
||||
_getTargetUnit(measurementType) {
|
||||
return this.preferredUnits[measurementType] ||
|
||||
@@ -72,29 +122,115 @@ class MeasurementContainer {
|
||||
null;
|
||||
}
|
||||
|
||||
_getCanonicalUnit(measurementType) {
|
||||
return this.canonicalUnits[measurementType] || null;
|
||||
}
|
||||
|
||||
_normalizeType(measurementType) {
|
||||
return String(measurementType || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
_describeUnit(unit) {
|
||||
if (typeof unit !== 'string' || unit.trim() === '') return null;
|
||||
try {
|
||||
return convertModule().describe(unit.trim());
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
isUnitCompatible(measurementType, unit) {
|
||||
const desc = this._describeUnit(unit);
|
||||
if (!desc) return false;
|
||||
const normalizedType = this._normalizeType(measurementType);
|
||||
const expectedMeasure = this.measureMap[normalizedType];
|
||||
if (!expectedMeasure) return true;
|
||||
return desc.measure === expectedMeasure;
|
||||
}
|
||||
|
||||
_handleUnitViolation(message) {
|
||||
if (this.throwOnInvalidUnit) {
|
||||
throw new Error(message);
|
||||
}
|
||||
if (this.logger) {
|
||||
this.logger.warn(message);
|
||||
}
|
||||
}
|
||||
|
||||
_resolveUnitPolicy(measurementType, sourceUnit = null) {
|
||||
const normalizedType = this._normalizeType(measurementType);
|
||||
const rawSourceUnit = typeof sourceUnit === 'string' && sourceUnit.trim()
|
||||
? sourceUnit.trim()
|
||||
: null;
|
||||
const fallbackIngressUnit = this._getTargetUnit(measurementType);
|
||||
const canonicalUnit = this._getCanonicalUnit(measurementType);
|
||||
const resolvedSourceUnit = rawSourceUnit || fallbackIngressUnit || canonicalUnit || null;
|
||||
|
||||
if (this.requireUnitForTypes.has(normalizedType) && !rawSourceUnit) {
|
||||
this._handleUnitViolation(`Missing source unit for required measurement type '${measurementType}'.`);
|
||||
return { valid: false };
|
||||
}
|
||||
|
||||
if (resolvedSourceUnit && !this.isUnitCompatible(measurementType, resolvedSourceUnit)) {
|
||||
this._handleUnitViolation(`Incompatible or unknown source unit '${resolvedSourceUnit}' for measurement type '${measurementType}'.`);
|
||||
return { valid: false };
|
||||
}
|
||||
|
||||
const resolvedStorageUnit = this.storeCanonical
|
||||
? (canonicalUnit || fallbackIngressUnit || resolvedSourceUnit)
|
||||
: (fallbackIngressUnit || canonicalUnit || resolvedSourceUnit);
|
||||
|
||||
if (resolvedStorageUnit && !this.isUnitCompatible(measurementType, resolvedStorageUnit)) {
|
||||
this._handleUnitViolation(`Incompatible storage unit '${resolvedStorageUnit}' for measurement type '${measurementType}'.`);
|
||||
return { valid: false };
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
sourceUnit: resolvedSourceUnit,
|
||||
storageUnit: resolvedStorageUnit || null,
|
||||
strictValidation: this.strictUnitValidation,
|
||||
};
|
||||
}
|
||||
|
||||
getUnit(type) {
|
||||
if (!type) return null;
|
||||
if (this.preferredUnits && this.preferredUnits[type]) return this.preferredUnits[type];
|
||||
if (this.defaultUnits && this.defaultUnits[type]) return this.defaultUnits[type];
|
||||
return null;
|
||||
}
|
||||
|
||||
// Chainable methods
|
||||
type(typeName) {
|
||||
this._currentType = typeName;
|
||||
this._currentVariant = null;
|
||||
this._currentPosition = null;
|
||||
this._currentChildId = null;
|
||||
return this;
|
||||
}
|
||||
|
||||
variant(variantName) {
|
||||
if (!this._currentType) {
|
||||
throw new Error('Type must be specified before variant');
|
||||
if (this.logger) {
|
||||
this.logger.warn('variant() ignored: type must be specified before variant');
|
||||
}
|
||||
return this;
|
||||
}
|
||||
this._currentVariant = variantName;
|
||||
this._currentPosition = null;
|
||||
this._currentChildId = null;
|
||||
return this;
|
||||
}
|
||||
|
||||
position(positionValue) {
|
||||
if (!this._currentVariant) {
|
||||
throw new Error('Variant must be specified before position');
|
||||
if (this.logger) {
|
||||
this.logger.warn('position() ignored: variant must be specified before position');
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
this._currentPosition = positionValue;
|
||||
this._currentPosition = positionValue.toString().toLowerCase();
|
||||
|
||||
return this;
|
||||
}
|
||||
@@ -113,34 +249,40 @@ class MeasurementContainer {
|
||||
// ENHANCED: Update your existing value method
|
||||
value(val, timestamp = Date.now(), sourceUnit = null) {
|
||||
if (!this._ensureChainIsValid()) return this;
|
||||
|
||||
|
||||
const unitPolicy = this._resolveUnitPolicy(this._currentType, sourceUnit);
|
||||
if (!unitPolicy.valid) return this;
|
||||
|
||||
const measurement = this._getOrCreateMeasurement();
|
||||
const targetUnit = this._getTargetUnit(this._currentType);
|
||||
|
||||
const targetUnit = unitPolicy.storageUnit;
|
||||
|
||||
let convertedValue = val;
|
||||
let finalUnit = sourceUnit || targetUnit;
|
||||
let finalUnit = targetUnit || unitPolicy.sourceUnit;
|
||||
|
||||
// Auto-convert if enabled and units are specified
|
||||
if (this.autoConvert && sourceUnit && targetUnit && sourceUnit !== targetUnit) {
|
||||
if (this.autoConvert && unitPolicy.sourceUnit && targetUnit && unitPolicy.sourceUnit !== targetUnit) {
|
||||
try {
|
||||
convertedValue = convertModule(val).from(sourceUnit).to(targetUnit);
|
||||
convertedValue = convertModule(val).from(unitPolicy.sourceUnit).to(targetUnit);
|
||||
finalUnit = targetUnit;
|
||||
|
||||
if (this.logger) {
|
||||
this.logger.debug(`Auto-converted ${val} ${sourceUnit} to ${convertedValue} ${targetUnit}`);
|
||||
this.logger.debug(`Auto-converted ${val} ${unitPolicy.sourceUnit} to ${convertedValue} ${targetUnit}`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.logger) {
|
||||
this.logger.warn(`Auto-conversion failed from ${sourceUnit} to ${targetUnit}: ${error.message}`);
|
||||
const message = `Auto-conversion failed from ${unitPolicy.sourceUnit} to ${targetUnit}: ${error.message}`;
|
||||
if (this.strictUnitValidation) {
|
||||
this._handleUnitViolation(message);
|
||||
return this;
|
||||
}
|
||||
if (this.logger) this.logger.warn(message);
|
||||
convertedValue = val;
|
||||
finalUnit = sourceUnit;
|
||||
finalUnit = unitPolicy.sourceUnit;
|
||||
}
|
||||
}
|
||||
|
||||
measurement.setValue(convertedValue, timestamp);
|
||||
|
||||
if (finalUnit && !measurement.unit) {
|
||||
if (finalUnit) {
|
||||
measurement.setUnit(finalUnit);
|
||||
}
|
||||
|
||||
@@ -149,7 +291,7 @@ class MeasurementContainer {
|
||||
value: convertedValue,
|
||||
originalValue: val,
|
||||
unit: finalUnit,
|
||||
sourceUnit: sourceUnit,
|
||||
sourceUnit: unitPolicy.sourceUnit,
|
||||
timestamp,
|
||||
position: this._currentPosition,
|
||||
distance: this._currentDistance,
|
||||
@@ -163,11 +305,53 @@ class MeasurementContainer {
|
||||
|
||||
// Emit the exact event your parent expects
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
if (!this._ensureChainIsValid()) return this;
|
||||
@@ -179,36 +363,47 @@ class MeasurementContainer {
|
||||
}
|
||||
|
||||
// Terminal operations - get data out
|
||||
get() {
|
||||
if (!this._ensureChainIsValid()) return null;
|
||||
return this._getOrCreateMeasurement();
|
||||
}
|
||||
get() {
|
||||
if (!this._ensureChainIsValid()) return null;
|
||||
const variantBucket = this.measurements[this._currentType]?.[this._currentVariant];
|
||||
if (!variantBucket) return null;
|
||||
const posBucket = variantBucket[this._currentPosition];
|
||||
if (!posBucket) return null;
|
||||
|
||||
// Legacy single measurement
|
||||
if (posBucket?.getCurrentValue) return posBucket;
|
||||
|
||||
// Child-aware: pick requested child, otherwise fall back to default, otherwise first available
|
||||
if (posBucket && typeof posBucket === 'object') {
|
||||
const requestedKey = this._currentChildId || this.childId;
|
||||
const keys = Object.keys(posBucket);
|
||||
if (!keys.length) return null;
|
||||
const measurement =
|
||||
(requestedKey && posBucket[requestedKey]) ||
|
||||
posBucket.default ||
|
||||
posBucket[keys[0]];
|
||||
return measurement || null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
getCurrentValue(requestedUnit = null) {
|
||||
const measurement = this.get();
|
||||
if (!measurement) return null;
|
||||
|
||||
const value = measurement.getCurrentValue();
|
||||
if (value === null) return null;
|
||||
|
||||
// Return as-is if no unit conversion requested
|
||||
if (!requestedUnit) {
|
||||
if (!requestedUnit || !measurement.unit || requestedUnit === measurement.unit) {
|
||||
return value;
|
||||
}
|
||||
|
||||
// Convert if needed
|
||||
if (measurement.unit && requestedUnit !== measurement.unit) {
|
||||
try {
|
||||
return convertModule(value).from(measurement.unit).to(requestedUnit);
|
||||
} catch (error) {
|
||||
if (this.logger) {
|
||||
this.logger.error(`Unit conversion failed: ${error.message}`);
|
||||
}
|
||||
return value; // Return original value if conversion fails
|
||||
}
|
||||
try {
|
||||
return convertModule(value).from(measurement.unit).to(requestedUnit);
|
||||
} catch (error) {
|
||||
if (this.logger) this.logger.error(`Unit conversion failed: ${error.message}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
getAverage(requestedUnit = null) {
|
||||
@@ -247,36 +442,152 @@ class MeasurementContainer {
|
||||
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(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 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;
|
||||
}
|
||||
|
||||
sum(type, variant, positions = [], targetUnit = null) {
|
||||
const bucket = this.measurements?.[type]?.[variant];
|
||||
if (!bucket) return 0;
|
||||
return positions
|
||||
.map((pos) => {
|
||||
const posBucket = bucket[pos];
|
||||
if (!posBucket) return 0;
|
||||
return Object.values(posBucket)
|
||||
.map((m) => {
|
||||
if (!m?.getCurrentValue) return 0;
|
||||
const val = m.getCurrentValue();
|
||||
if (val == null) return 0;
|
||||
const fromUnit = m.unit || targetUnit;
|
||||
if (!targetUnit || !fromUnit || fromUnit === targetUnit) return val;
|
||||
try { return convertModule(val).from(fromUnit).to(targetUnit); } catch { return val; }
|
||||
})
|
||||
.reduce((acc, v) => acc + (Number.isFinite(v) ? v : 0), 0);
|
||||
})
|
||||
.reduce((acc, v) => acc + v, 0);
|
||||
}
|
||||
|
||||
getFlattenedOutput(options = {}) {
|
||||
const requestedUnits = options.requestedUnits || (options.usePreferredUnits ? this.preferredUnits : null);
|
||||
const out = {};
|
||||
Object.entries(this.measurements).forEach(([type, variants]) => {
|
||||
Object.entries(variants).forEach(([variant, positions]) => {
|
||||
Object.entries(positions).forEach(([position, entry]) => {
|
||||
// Legacy single series
|
||||
if (entry?.getCurrentValue) {
|
||||
out[`${type}.${variant}.${position}`] = this._resolveOutputValue(type, entry, requestedUnits);
|
||||
return;
|
||||
}
|
||||
// Child-bucketed series
|
||||
if (entry && typeof entry === 'object') {
|
||||
Object.entries(entry).forEach(([childId, m]) => {
|
||||
if (m?.getCurrentValue) {
|
||||
out[`${type}.${variant}.${position}.${childId}`] = this._resolveOutputValue(type, m, requestedUnits);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
// Difference calculations between positions
|
||||
difference(requestedUnit = null) {
|
||||
|
||||
difference({ from = "downstream", to = "upstream", unit: requestedUnit } = {}) {
|
||||
if (!this._currentType || !this._currentVariant) {
|
||||
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) {
|
||||
if (this.logger) {
|
||||
this.logger.warn('difference() ignored: type and variant must be specified');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get target unit for conversion
|
||||
const targetUnit = requestedUnit || upstream.unit || downstream.unit;
|
||||
|
||||
// Get values in the same unit
|
||||
const upstreamValue = this._convertValueToUnit(upstream.getCurrentValue(), upstream.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 {
|
||||
value: downstreamValue - upstreamValue,
|
||||
avgDiff: downstreamAvg - upstreamAvg,
|
||||
unit: targetUnit
|
||||
const get = pos => {
|
||||
const bucket = this.measurements?.[this._currentType]?.[this._currentVariant]?.[pos];
|
||||
if (!bucket) return null;
|
||||
// child-aware bucket: pick current childId/default or first available
|
||||
if (bucket && typeof bucket === 'object' && !bucket.getCurrentValue) {
|
||||
const childKey = this._currentChildId || this.childId || Object.keys(bucket)[0];
|
||||
return bucket?.[childKey] || null;
|
||||
}
|
||||
// legacy single measurement
|
||||
return bucket;
|
||||
};
|
||||
|
||||
const a = get(from);
|
||||
const b = get(to);
|
||||
if (!a || !b || !a.values || !b.values || a.values.length === 0 || b.values.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const targetUnit = requestedUnit || a.unit || b.unit;
|
||||
const aVal = this._convertValueToUnit(a.getCurrentValue(), a.unit, targetUnit);
|
||||
const bVal = this._convertValueToUnit(b.getCurrentValue(), b.unit, targetUnit);
|
||||
|
||||
const aAvg = this._convertValueToUnit(a.getAverage(), a.unit, targetUnit);
|
||||
const bAvg = this._convertValueToUnit(b.getAverage(), b.unit, targetUnit);
|
||||
|
||||
return { value: aVal - bVal, avgDiff: aAvg - bAvg, unit: targetUnit, from, to };
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
@@ -300,18 +611,26 @@ class MeasurementContainer {
|
||||
this.measurements[this._currentType][this._currentVariant] = {};
|
||||
}
|
||||
|
||||
if (!this.measurements[this._currentType][this._currentVariant][this._currentPosition]) {
|
||||
this.measurements[this._currentType][this._currentVariant][this._currentPosition] =
|
||||
new MeasurementBuilder()
|
||||
.setType(this._currentType)
|
||||
.setVariant(this._currentVariant)
|
||||
.setPosition(this._currentPosition)
|
||||
.setWindowSize(this.windowSize)
|
||||
.setDistance(this._currentDistance)
|
||||
.build();
|
||||
const positionKey = this._currentPosition;
|
||||
const childKey = this._currentChildId || this.childId || 'default';
|
||||
|
||||
if (!this.measurements[this._currentType][this._currentVariant][positionKey]) {
|
||||
this.measurements[this._currentType][this._currentVariant][positionKey] = {};
|
||||
}
|
||||
|
||||
return this.measurements[this._currentType][this._currentVariant][this._currentPosition];
|
||||
|
||||
const bucket = this.measurements[this._currentType][this._currentVariant][positionKey];
|
||||
|
||||
if (!bucket[childKey]) {
|
||||
bucket[childKey] = new MeasurementBuilder()
|
||||
.setType(this._currentType)
|
||||
.setVariant(this._currentVariant)
|
||||
.setPosition(positionKey)
|
||||
.setWindowSize(this.windowSize)
|
||||
.setDistance(this._currentDistance)
|
||||
.build();
|
||||
}
|
||||
|
||||
return bucket[childKey];
|
||||
}
|
||||
|
||||
// Additional utility methods
|
||||
@@ -321,15 +640,33 @@ class MeasurementContainer {
|
||||
|
||||
getVariants() {
|
||||
if (!this._currentType) {
|
||||
throw new Error('Type must be specified before listing variants');
|
||||
if (this.logger) {
|
||||
this.logger.warn('getVariants() ignored: type must be specified first');
|
||||
}
|
||||
return [];
|
||||
}
|
||||
return this.measurements[this._currentType] ?
|
||||
Object.keys(this.measurements[this._currentType]) : [];
|
||||
}
|
||||
|
||||
_resolveOutputValue(type, measurement, requestedUnits = null) {
|
||||
const value = measurement.getCurrentValue();
|
||||
if (!requestedUnits || value === null || typeof value === 'undefined') {
|
||||
return value;
|
||||
}
|
||||
const targetUnit = requestedUnits[type];
|
||||
if (!targetUnit) {
|
||||
return value;
|
||||
}
|
||||
return this._convertValueToUnit(value, measurement.unit, targetUnit);
|
||||
}
|
||||
|
||||
getPositions() {
|
||||
if (!this._currentType || !this._currentVariant) {
|
||||
throw new Error('Type and variant must be specified before listing positions');
|
||||
if (this.logger) {
|
||||
this.logger.warn('getPositions() ignored: type and variant must be specified first');
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!this.measurements[this._currentType] ||
|
||||
@@ -349,7 +686,7 @@ class MeasurementContainer {
|
||||
|
||||
// Helper method for value conversion
|
||||
_convertValueToUnit(value, fromUnit, toUnit) {
|
||||
if (!value || !fromUnit || !toUnit || fromUnit === toUnit) {
|
||||
if ((value === null || typeof value === 'undefined') || !fromUnit || !toUnit || fromUnit === toUnit) {
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -368,19 +705,7 @@ class MeasurementContainer {
|
||||
const type = measurementType || this._currentType;
|
||||
if (!type) return [];
|
||||
|
||||
// Map measurement types to convert module measures
|
||||
const measureMap = {
|
||||
pressure: 'pressure',
|
||||
flow: 'volumeFlowRate',
|
||||
power: 'power',
|
||||
temperature: 'temperature',
|
||||
volume: 'volume',
|
||||
length: 'length',
|
||||
mass: 'mass',
|
||||
energy: 'energy'
|
||||
};
|
||||
|
||||
const convertMeasure = measureMap[type];
|
||||
const convertMeasure = this.measureMap[this._normalizeType(type)];
|
||||
if (!convertMeasure) return [];
|
||||
|
||||
try {
|
||||
@@ -430,16 +755,19 @@ class MeasurementContainer {
|
||||
}
|
||||
|
||||
_convertPositionNum2Str(positionValue) {
|
||||
switch (positionValue) {
|
||||
case 0:
|
||||
if (positionValue === 0) {
|
||||
return "atEquipment";
|
||||
case (positionValue < 0):
|
||||
return "upstream";
|
||||
case (positionValue > 0):
|
||||
return "downstream";
|
||||
default:
|
||||
console.log(`Invalid position provided: ${positionValue}`);
|
||||
}
|
||||
if (positionValue < 0) {
|
||||
return "upstream";
|
||||
}
|
||||
if (positionValue > 0) {
|
||||
return "downstream";
|
||||
}
|
||||
if (this.logger) {
|
||||
this.logger.warn(`Invalid position provided: ${positionValue}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -87,6 +87,7 @@ autoContainer
|
||||
.distance(0.5)
|
||||
.value(1.5, Date.now(), 'bar'); // Input: 1.5 bar → Auto-stored as ~21.76 psi
|
||||
|
||||
|
||||
const converted = autoContainer
|
||||
.type('pressure')
|
||||
.variant('measured')
|
||||
@@ -175,6 +176,25 @@ const downstreamData = basicContainer
|
||||
.position('downstream')
|
||||
.get();
|
||||
|
||||
//check wether a serie exists
|
||||
const hasSeries = basicContainer
|
||||
.type("flow")
|
||||
.variant("measured")
|
||||
.exists(); // true if any position exists
|
||||
|
||||
const hasUpstreamValues = basicContainer
|
||||
.type("flow")
|
||||
.variant("measured")
|
||||
.exists({ position: "upstream", requireValues: true });
|
||||
|
||||
// Passing everything explicitly
|
||||
const hasPercent = basicContainer.exists({
|
||||
type: "volume",
|
||||
variant: "percent",
|
||||
position: "atEquipment",
|
||||
});
|
||||
|
||||
|
||||
console.log(`Downstream: ${downstreamVal} ${downstreamData.unit} at ${downstreamData.distance}m\n`);
|
||||
|
||||
// ====================================
|
||||
@@ -213,6 +233,10 @@ const pressureDiff = basicContainer
|
||||
|
||||
console.log(`Pressure difference: ${pressureDiff.value} ${pressureDiff.unit}\n`);
|
||||
|
||||
//reversable difference
|
||||
const deltaP = basicContainer.type("pressure").variant("measured").difference(); // defaults to downstream - upstream
|
||||
const netFlow = basicContainer.type("flow").variant("measured").difference({ from: "upstream", to: "downstream" });
|
||||
|
||||
// ====================================
|
||||
// ADVANCED STATISTICS & HISTORY
|
||||
// ====================================
|
||||
@@ -248,6 +272,28 @@ const allValues = stats.getAllValues();
|
||||
console.log(` Samples: ${allValues.values.length}`);
|
||||
console.log(` History: [${allValues.values.join(', ')}]\n`);
|
||||
|
||||
console.log('--- Lagged sample comparison ---');
|
||||
|
||||
const latestSample = stats.getLaggedSample(0); // newest sample object
|
||||
const prevSample = stats.getLaggedSample(1);
|
||||
const prevPrevSample = stats.getLaggedSample(2);
|
||||
|
||||
if (prevSample) {
|
||||
const delta = (latestSample?.value ?? 0) - (prevSample.value ?? 0);
|
||||
console.log(
|
||||
`Current vs previous: ${latestSample?.value} ${statsData.unit} (t=${latestSample?.timestamp}) 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
|
||||
// ====================================
|
||||
@@ -299,8 +345,73 @@ basicContainer.getTypes().forEach(type => {
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// --- Child Aggregation -----------------------------------------------------
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ====================================
|
||||
// AGGREGATION WITH CHILD SERIES (sum)
|
||||
// ====================================
|
||||
console.log();
|
||||
console.log('--- Example X: Aggregation with sum() and child series ---');
|
||||
|
||||
// Container where flow is stored internally in m3/h
|
||||
const aggContainer = new MeasurementContainer({
|
||||
windowSize: 10,
|
||||
defaultUnits: {
|
||||
flow: 'm3/h',
|
||||
},
|
||||
});
|
||||
|
||||
// Two pumps both feeding the same inlet position
|
||||
aggContainer
|
||||
.child('pumpA')
|
||||
.type('flow')
|
||||
.variant('measured')
|
||||
.position('inlet')
|
||||
.value(10, Date.now(), 'm3/h'); // 10 m3/h
|
||||
|
||||
aggContainer
|
||||
.child('pumpB')
|
||||
.type('flow')
|
||||
.variant('measured')
|
||||
.position('inlet')
|
||||
.value(15, Date.now(), 'm3/h'); // 15 m3/h
|
||||
|
||||
// Another position, e.g. outlet, also with two pumps
|
||||
aggContainer
|
||||
.child('pumpA')
|
||||
.type('flow')
|
||||
.variant('measured')
|
||||
.position('outlet')
|
||||
.value(8, Date.now(), 'm3/h'); // 8 m3/h
|
||||
|
||||
aggContainer
|
||||
.child('pumpB')
|
||||
.type('flow')
|
||||
.variant('measured')
|
||||
.position('outlet')
|
||||
.value(11, Date.now(), 'm3/h'); // 11 m3/h
|
||||
|
||||
|
||||
// 1) Sum only inlet position (children pumpA + pumpB)
|
||||
const inletTotal = aggContainer.sum('flow', 'measured', ['inlet']);
|
||||
console.log(`Total inlet flow: ${inletTotal} m3/h (expected 25 m3/h)`);
|
||||
|
||||
// 2) Sum inlet + outlet positions together
|
||||
const totalAll = aggContainer.sum('flow', 'measured', ['inlet', 'outlet']);
|
||||
console.log(`Total inlet+outlet flow: ${totalAll} m3/h (expected 44 m3/h)`);
|
||||
|
||||
// 3) Same sum but explicitly ask for a target unit (e.g. l/s)
|
||||
// This will use convertModule(...) internally.
|
||||
// If conversion is not supported, it will fall back to the raw value.
|
||||
const totalAllLps = aggContainer.sum('flow', 'measured', ['inlet', 'outlet'], 'l/s');
|
||||
console.log(`Total inlet+outlet flow in l/s: ${totalAllLps} l/s (converted from m3/h)\n`);
|
||||
|
||||
|
||||
console.log('\n✅ All examples complete!\n');
|
||||
|
||||
|
||||
// ====================================
|
||||
// BEST PRACTICES
|
||||
// ====================================
|
||||
|
||||
41
src/menu/aquonSamples.js
Normal file
41
src/menu/aquonSamples.js
Normal file
@@ -0,0 +1,41 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
class AquonSamplesMenu {
|
||||
constructor(relPath = '../../datasets/assetData') {
|
||||
this.baseDir = path.resolve(__dirname, relPath);
|
||||
this.samplePath = path.resolve(this.baseDir, 'monsterSamples.json');
|
||||
this.specPath = path.resolve(this.baseDir, 'specs/monster/index.json');
|
||||
this.cache = new Map();
|
||||
}
|
||||
|
||||
_loadJSON(filePath, cacheKey) {
|
||||
if (this.cache.has(cacheKey)) {
|
||||
return this.cache.get(cacheKey);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new Error(`Aquon dataset not found: ${filePath}`);
|
||||
}
|
||||
|
||||
const raw = fs.readFileSync(filePath, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
this.cache.set(cacheKey, parsed);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
getAllMenuData() {
|
||||
const samples = this._loadJSON(this.samplePath, 'samples');
|
||||
const specs = this._loadJSON(this.specPath, 'specs');
|
||||
|
||||
return {
|
||||
samples: samples.samples || [],
|
||||
specs: {
|
||||
defaults: specs.defaults || {},
|
||||
bySample: specs.bySample || {}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = AquonSamplesMenu;
|
||||
@@ -1,62 +1,89 @@
|
||||
// asset.js
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { assetCategoryManager } = require('../../datasets/assetData');
|
||||
const assetApiConfig = require('../configs/assetApiConfig.js');
|
||||
|
||||
class AssetMenu {
|
||||
/** Define path where to find data of assets in constructor for now */
|
||||
constructor(relPath = '../../datasets/assetData') {
|
||||
this.baseDir = path.resolve(__dirname, relPath);
|
||||
this.assetData = this._loadJSON('assetData');
|
||||
constructor({ manager = assetCategoryManager, softwareType = null } = {}) {
|
||||
this.manager = manager;
|
||||
this.softwareType = softwareType;
|
||||
this.categories = this.manager
|
||||
.listCategories({ withMeta: true })
|
||||
.reduce((map, meta) => {
|
||||
map[meta.softwareType] = this.manager.getCategory(meta.softwareType);
|
||||
return map;
|
||||
}, {});
|
||||
}
|
||||
|
||||
_loadJSON(...segments) {
|
||||
const filePath = path.resolve(this.baseDir, ...segments) + '.json';
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to load ${filePath}: ${err.message}`);
|
||||
normalizeCategory(key) {
|
||||
const category = this.categories[key];
|
||||
if (!category) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ADD THIS METHOD
|
||||
* Compiles all menu data from the file system into a single nested object.
|
||||
* This is run once on the server to pre-load everything.
|
||||
* @returns {object} A comprehensive object with all menu options.
|
||||
*/
|
||||
getAllMenuData() {
|
||||
// load the raw JSON once
|
||||
const data = this._loadJSON('assetData');
|
||||
const allData = {};
|
||||
|
||||
data.suppliers.forEach(sup => {
|
||||
allData[sup.name] = {};
|
||||
sup.categories.forEach(cat => {
|
||||
allData[sup.name][cat.name] = {};
|
||||
cat.types.forEach(type => {
|
||||
// here: store the full array of model objects, not just names
|
||||
allData[sup.name][cat.name][type.name] = type.models;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return allData;
|
||||
return {
|
||||
...category,
|
||||
label: category.label || category.softwareType || key,
|
||||
suppliers: (category.suppliers || []).map((supplier) => ({
|
||||
...supplier,
|
||||
id: supplier.id || supplier.name,
|
||||
types: (supplier.types || []).map((type) => ({
|
||||
...type,
|
||||
id: type.id || type.name,
|
||||
models: (type.models || []).map((model) => ({
|
||||
...model,
|
||||
id: model.id || model.name,
|
||||
units: model.units || []
|
||||
}))
|
||||
}))
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the static initEditor function to a string that can be served to the client
|
||||
* @param {string} nodeName - The name of the node type
|
||||
* @returns {string} JavaScript code as a string
|
||||
*/
|
||||
getClientInitCode(nodeName) {
|
||||
// step 1: get the two helper strings
|
||||
const htmlCode = this.getHtmlInjectionCode(nodeName);
|
||||
const dataCode = this.getDataInjectionCode(nodeName);
|
||||
const eventsCode = this.getEventInjectionCode(nodeName);
|
||||
const saveCode = this.getSaveInjectionCode(nodeName);
|
||||
resolveCategoryForNode(nodeName) {
|
||||
const keys = Object.keys(this.categories);
|
||||
if (keys.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (this.softwareType && this.categories[this.softwareType]) {
|
||||
return this.softwareType;
|
||||
}
|
||||
|
||||
return `
|
||||
if (nodeName) {
|
||||
const normalized = typeof nodeName === 'string' ? nodeName.toLowerCase() : nodeName;
|
||||
if (normalized && this.categories[normalized]) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
return keys[0];
|
||||
}
|
||||
|
||||
getAllMenuData(nodeName) {
|
||||
const categoryKey = this.resolveCategoryForNode(nodeName);
|
||||
const selectedCategories = {};
|
||||
|
||||
if (categoryKey && this.categories[categoryKey]) {
|
||||
selectedCategories[categoryKey] = this.normalizeCategory(categoryKey);
|
||||
}
|
||||
|
||||
return {
|
||||
categories: selectedCategories,
|
||||
defaultCategory: categoryKey,
|
||||
apiConfig: {
|
||||
url: `${assetApiConfig.baseUrl}/apis/products/PLC/integration/`,
|
||||
headers: { ...assetApiConfig.headers }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
getClientInitCode(nodeName) {
|
||||
const htmlCode = this.getHtmlInjectionCode(nodeName);
|
||||
const dataCode = this.getDataInjectionCode(nodeName);
|
||||
const eventsCode = this.getEventInjectionCode(nodeName);
|
||||
const syncCode = this.getSyncInjectionCode(nodeName);
|
||||
const saveCode = this.getSaveInjectionCode(nodeName);
|
||||
|
||||
return `
|
||||
// --- AssetMenu for ${nodeName} ---
|
||||
window.EVOLV.nodes.${nodeName}.assetMenu =
|
||||
window.EVOLV.nodes.${nodeName}.assetMenu || {};
|
||||
@@ -64,105 +91,462 @@ getClientInitCode(nodeName) {
|
||||
${htmlCode}
|
||||
${dataCode}
|
||||
${eventsCode}
|
||||
${syncCode}
|
||||
${saveCode}
|
||||
|
||||
// wire it all up when the editor loads
|
||||
window.EVOLV.nodes.${nodeName}.assetMenu.initEditor = function(node) {
|
||||
// ------------------ BELOW sequence is important! -------------------------------
|
||||
console.log('Initializing asset properties for ${nodeName}…');
|
||||
console.log('Initializing asset properties for ${nodeName}');
|
||||
this.injectHtml();
|
||||
// load the data and wire up events
|
||||
// this will populate the fields and set up the event listeners
|
||||
this.wireEvents(node);
|
||||
// this will load the initial data into the fields
|
||||
// this is important to ensure the fields are populated correctly
|
||||
this.loadData(node);
|
||||
this.loadData(node).catch((error) =>
|
||||
console.error('Asset menu load failed:', error)
|
||||
);
|
||||
};
|
||||
`;
|
||||
|
||||
}
|
||||
|
||||
getDataInjectionCode(nodeName) {
|
||||
return `
|
||||
// Asset Data loader for ${nodeName}
|
||||
window.EVOLV.nodes.${nodeName}.assetMenu.loadData = function(node) {
|
||||
const data = window.EVOLV.nodes.${nodeName}.menuData.asset;
|
||||
const elems = {
|
||||
supplier: document.getElementById('node-input-supplier'),
|
||||
category: document.getElementById('node-input-category'),
|
||||
type: document.getElementById('node-input-assetType'),
|
||||
model: document.getElementById('node-input-model'),
|
||||
unit: document.getElementById('node-input-unit')
|
||||
};
|
||||
function populate(el, opts, sel) {
|
||||
const old = el.value;
|
||||
el.innerHTML = '<option value="">Select…</option>';
|
||||
(opts||[]).forEach(o=>{
|
||||
const opt = document.createElement('option');
|
||||
opt.value = o; opt.textContent = o;
|
||||
el.appendChild(opt);
|
||||
});
|
||||
el.value = sel||"";
|
||||
if(el.value!==old) el.dispatchEvent(new Event('change'));
|
||||
}
|
||||
// initial population
|
||||
populate(elems.supplier, Object.keys(data), node.supplier);
|
||||
};
|
||||
`
|
||||
}
|
||||
|
||||
getEventInjectionCode(nodeName) {
|
||||
getDataInjectionCode(nodeName) {
|
||||
return `
|
||||
// Asset Event wiring for ${nodeName}
|
||||
// Asset data loader for ${nodeName}
|
||||
window.EVOLV.nodes.${nodeName}.assetMenu.loadData = async function(node) {
|
||||
const menuAsset = window.EVOLV.nodes.${nodeName}.menuData.asset || {};
|
||||
const categories = menuAsset.categories || {};
|
||||
const defaultCategory = menuAsset.defaultCategory || Object.keys(categories)[0] || null;
|
||||
const apiConfig = menuAsset.apiConfig || {};
|
||||
const elems = {
|
||||
supplier: document.getElementById('node-input-supplier'),
|
||||
type: document.getElementById('node-input-assetType'),
|
||||
model: document.getElementById('node-input-model'),
|
||||
unit: document.getElementById('node-input-unit')
|
||||
};
|
||||
|
||||
function resolveCategoryKey() {
|
||||
if (node.softwareType && categories[node.softwareType]) {
|
||||
return node.softwareType;
|
||||
}
|
||||
if (node.category && categories[node.category]) {
|
||||
return node.category;
|
||||
}
|
||||
return defaultCategory;
|
||||
}
|
||||
|
||||
function normalizeModel(model = {}) {
|
||||
return {
|
||||
id: model.id ?? model.name,
|
||||
name: model.name,
|
||||
units: model.units || []
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeType(type = {}) {
|
||||
return {
|
||||
id: type.id || type.name,
|
||||
name: type.name,
|
||||
models: Array.isArray(type.models)
|
||||
? type.models.map(normalizeModel)
|
||||
: []
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSupplier(supplier = {}) {
|
||||
const types = (supplier.categories || []).reduce((acc, category) => {
|
||||
const categoryTypes = Array.isArray(category.types)
|
||||
? category.types.map(normalizeType)
|
||||
: [];
|
||||
return acc.concat(categoryTypes);
|
||||
}, []);
|
||||
return {
|
||||
id: supplier.id || supplier.name,
|
||||
name: supplier.name,
|
||||
types
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeApiCategory(key, label, suppliers = []) {
|
||||
const normalizedSuppliers = suppliers
|
||||
.map(normalizeSupplier)
|
||||
.filter((supplier) => supplier.types && supplier.types.length);
|
||||
if (!normalizedSuppliers.length) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
softwareType: key,
|
||||
label: label || key,
|
||||
suppliers: normalizedSuppliers
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchCategoryFromApi(key) {
|
||||
if (!apiConfig.url || !key) {
|
||||
return null;
|
||||
}
|
||||
const response = await fetch(apiConfig.url, {
|
||||
headers: apiConfig.headers || {}
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Asset API request failed: ' + response.status);
|
||||
}
|
||||
const payload = await response.json();
|
||||
if (!payload || payload.success === false || !Array.isArray(payload.data)) {
|
||||
throw new Error(payload?.message || 'Unexpected asset API response');
|
||||
}
|
||||
return normalizeApiCategory(key, node.softwareType || key, payload.data);
|
||||
}
|
||||
|
||||
function populate(selectEl, items = [], selectedValue, mapFn, placeholderText = 'Select...') {
|
||||
const previous = selectEl.value;
|
||||
const mapper = typeof mapFn === 'function'
|
||||
? mapFn
|
||||
: (value) => ({ value, label: value });
|
||||
|
||||
selectEl.innerHTML = '';
|
||||
|
||||
const placeholder = document.createElement('option');
|
||||
placeholder.value = '';
|
||||
placeholder.textContent = placeholderText;
|
||||
placeholder.disabled = true;
|
||||
placeholder.selected = true;
|
||||
selectEl.appendChild(placeholder);
|
||||
|
||||
items.forEach((item) => {
|
||||
const option = mapper(item);
|
||||
if (!option || typeof option.value === 'undefined') {
|
||||
return;
|
||||
}
|
||||
const opt = document.createElement('option');
|
||||
opt.value = option.value;
|
||||
opt.textContent = option.label;
|
||||
selectEl.appendChild(opt);
|
||||
});
|
||||
|
||||
if (selectedValue) {
|
||||
selectEl.value = selectedValue;
|
||||
if (!selectEl.value) {
|
||||
selectEl.value = '';
|
||||
}
|
||||
} else {
|
||||
selectEl.value = '';
|
||||
}
|
||||
if (selectEl.value !== previous) {
|
||||
selectEl.dispatchEvent(new Event('change'));
|
||||
}
|
||||
}
|
||||
|
||||
const categoryKey = resolveCategoryKey();
|
||||
const resolvedCategoryKey = categoryKey || defaultCategory;
|
||||
let activeCategory = resolvedCategoryKey ? categories[resolvedCategoryKey] : null;
|
||||
|
||||
if (resolvedCategoryKey) {
|
||||
node.category = resolvedCategoryKey;
|
||||
}
|
||||
|
||||
try {
|
||||
const apiCategory = await fetchCategoryFromApi(resolvedCategoryKey);
|
||||
if (apiCategory) {
|
||||
categories[resolvedCategoryKey] = apiCategory;
|
||||
activeCategory = apiCategory;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[AssetMenu] API lookup failed for ${nodeName}, using local asset data', error);
|
||||
}
|
||||
|
||||
const suppliers = activeCategory ? activeCategory.suppliers : [];
|
||||
populate(
|
||||
elems.supplier,
|
||||
suppliers,
|
||||
node.supplier,
|
||||
(supplier) => ({ value: supplier.id || supplier.name, label: supplier.name }),
|
||||
suppliers.length ? 'Select...' : 'No suppliers available'
|
||||
);
|
||||
|
||||
const activeSupplier = suppliers.find(
|
||||
(supplier) => String(supplier.id || supplier.name) === String(node.supplier)
|
||||
);
|
||||
const types = activeSupplier ? activeSupplier.types : [];
|
||||
populate(
|
||||
elems.type,
|
||||
types,
|
||||
node.assetType,
|
||||
(type) => ({ value: type.id || type.name, label: type.name }),
|
||||
activeSupplier ? 'Select...' : 'Awaiting Supplier Selection'
|
||||
);
|
||||
|
||||
const activeType = types.find(
|
||||
(type) => String(type.id || type.name) === String(node.assetType)
|
||||
);
|
||||
const models = activeType ? activeType.models : [];
|
||||
populate(
|
||||
elems.model,
|
||||
models,
|
||||
node.model,
|
||||
(model) => ({ value: model.id || model.name, label: model.name }),
|
||||
activeType ? 'Select...' : 'Awaiting Type Selection'
|
||||
);
|
||||
|
||||
const activeModel = models.find(
|
||||
(model) => String(model.id || model.name) === String(node.model)
|
||||
);
|
||||
if (activeModel) {
|
||||
node.modelMetadata = activeModel;
|
||||
node.modelName = activeModel.name;
|
||||
}
|
||||
populate(
|
||||
elems.unit,
|
||||
activeModel ? activeModel.units || [] : [],
|
||||
node.unit,
|
||||
(unit) => ({ value: unit, label: unit }),
|
||||
activeModel ? 'Select...' : activeType ? 'Awaiting Model Selection' : 'Awaiting Type Selection'
|
||||
);
|
||||
this.setAssetTagNumber(node, node.assetTagNumber || '');
|
||||
};
|
||||
`;
|
||||
}
|
||||
|
||||
getEventInjectionCode(nodeName) {
|
||||
return `
|
||||
// Asset event wiring for ${nodeName}
|
||||
window.EVOLV.nodes.${nodeName}.assetMenu.wireEvents = function(node) {
|
||||
const data = window.EVOLV.nodes.${nodeName}.menuData.asset;
|
||||
const menuAsset = window.EVOLV.nodes.${nodeName}.menuData.asset || {};
|
||||
const categories = menuAsset.categories || {};
|
||||
const defaultCategory = menuAsset.defaultCategory || Object.keys(categories)[0] || null;
|
||||
const elems = {
|
||||
supplier: document.getElementById('node-input-supplier'),
|
||||
category: document.getElementById('node-input-category'),
|
||||
type: document.getElementById('node-input-assetType'),
|
||||
model: document.getElementById('node-input-model'),
|
||||
unit: document.getElementById('node-input-unit')
|
||||
type: document.getElementById('node-input-assetType'),
|
||||
model: document.getElementById('node-input-model'),
|
||||
unit: document.getElementById('node-input-unit')
|
||||
};
|
||||
function populate(el, opts, sel) {
|
||||
const old = el.value;
|
||||
el.innerHTML = '<option value="">Select…</option>';
|
||||
(opts||[]).forEach(o=>{
|
||||
|
||||
function populate(selectEl, items = [], selectedValue, mapFn, placeholderText = 'Select...') {
|
||||
const previous = selectEl.value;
|
||||
const mapper = typeof mapFn === 'function'
|
||||
? mapFn
|
||||
: (value) => ({ value, label: value });
|
||||
|
||||
selectEl.innerHTML = '';
|
||||
|
||||
const placeholder = document.createElement('option');
|
||||
placeholder.value = '';
|
||||
placeholder.textContent = placeholderText;
|
||||
placeholder.disabled = true;
|
||||
placeholder.selected = true;
|
||||
selectEl.appendChild(placeholder);
|
||||
|
||||
items.forEach((item) => {
|
||||
const option = mapper(item);
|
||||
if (!option || typeof option.value === 'undefined') {
|
||||
return;
|
||||
}
|
||||
const opt = document.createElement('option');
|
||||
opt.value = o; opt.textContent = o;
|
||||
el.appendChild(opt);
|
||||
opt.value = option.value;
|
||||
opt.textContent = option.label;
|
||||
selectEl.appendChild(opt);
|
||||
});
|
||||
el.value = sel||"";
|
||||
if(el.value!==old) el.dispatchEvent(new Event('change'));
|
||||
|
||||
if (selectedValue) {
|
||||
selectEl.value = selectedValue;
|
||||
if (!selectEl.value) {
|
||||
selectEl.value = '';
|
||||
}
|
||||
} else {
|
||||
selectEl.value = '';
|
||||
}
|
||||
if (selectEl.value !== previous) {
|
||||
selectEl.dispatchEvent(new Event('change'));
|
||||
}
|
||||
}
|
||||
elems.supplier.addEventListener('change', ()=>{
|
||||
populate(elems.category,
|
||||
elems.supplier.value? Object.keys(data[elems.supplier.value]||{}) : [],
|
||||
node.category);
|
||||
|
||||
const resolveCategoryKey = () => {
|
||||
if (node.softwareType && categories[node.softwareType]) {
|
||||
return node.softwareType;
|
||||
}
|
||||
if (node.category && categories[node.category]) {
|
||||
return node.category;
|
||||
}
|
||||
return defaultCategory;
|
||||
};
|
||||
|
||||
const getActiveCategory = () => {
|
||||
const key = resolveCategoryKey();
|
||||
return key ? categories[key] : null;
|
||||
};
|
||||
|
||||
node.category = resolveCategoryKey();
|
||||
|
||||
elems.supplier.addEventListener('change', () => {
|
||||
const category = getActiveCategory();
|
||||
const supplier = category
|
||||
? category.suppliers.find(
|
||||
(item) => String(item.id || item.name) === String(elems.supplier.value)
|
||||
)
|
||||
: null;
|
||||
const types = supplier ? supplier.types : [];
|
||||
populate(
|
||||
elems.type,
|
||||
types,
|
||||
node.assetType,
|
||||
(type) => ({ value: type.id || type.name, label: type.name }),
|
||||
supplier ? 'Select...' : 'Awaiting Supplier Selection'
|
||||
);
|
||||
node.modelMetadata = null;
|
||||
populate(elems.model, [], '', undefined, 'Awaiting Type Selection');
|
||||
populate(elems.unit, [], '', undefined, 'Awaiting Type Selection');
|
||||
});
|
||||
elems.category.addEventListener('change', ()=>{
|
||||
const s=elems.supplier.value, c=elems.category.value;
|
||||
populate(elems.type,
|
||||
(s&&c)? Object.keys(data[s][c]||{}) : [],
|
||||
node.assetType);
|
||||
|
||||
elems.type.addEventListener('change', () => {
|
||||
const category = getActiveCategory();
|
||||
const supplier = category
|
||||
? category.suppliers.find(
|
||||
(item) => String(item.id || item.name) === String(elems.supplier.value)
|
||||
)
|
||||
: null;
|
||||
const type = supplier
|
||||
? supplier.types.find(
|
||||
(item) => String(item.id || item.name) === String(elems.type.value)
|
||||
)
|
||||
: null;
|
||||
const models = type ? type.models : [];
|
||||
populate(
|
||||
elems.model,
|
||||
models,
|
||||
node.model,
|
||||
(model) => ({ value: model.id || model.name, label: model.name }),
|
||||
type ? 'Select...' : 'Awaiting Type Selection'
|
||||
);
|
||||
node.modelMetadata = null;
|
||||
populate(
|
||||
elems.unit,
|
||||
[],
|
||||
'',
|
||||
undefined,
|
||||
type ? 'Awaiting Model Selection' : 'Awaiting Type Selection'
|
||||
);
|
||||
});
|
||||
elems.type.addEventListener('change', ()=>{
|
||||
const s=elems.supplier.value, c=elems.category.value, t=elems.type.value;
|
||||
const md = (s&&c&&t)? data[s][c][t]||[] : [];
|
||||
populate(elems.model, md.map(m=>m.name), node.model);
|
||||
});
|
||||
elems.model.addEventListener('change', ()=>{
|
||||
const s=elems.supplier.value, c=elems.category.value, t=elems.type.value, m=elems.model.value;
|
||||
const md = (s&&c&&t)? data[s][c][t]||[] : [];
|
||||
const entry = md.find(x=>x.name===m);
|
||||
populate(elems.unit, entry? entry.units : [], node.unit);
|
||||
|
||||
elems.model.addEventListener('change', () => {
|
||||
const category = getActiveCategory();
|
||||
const supplier = category
|
||||
? category.suppliers.find(
|
||||
(item) => String(item.id || item.name) === String(elems.supplier.value)
|
||||
)
|
||||
: null;
|
||||
const type = supplier
|
||||
? supplier.types.find(
|
||||
(item) => String(item.id || item.name) === String(elems.type.value)
|
||||
)
|
||||
: null;
|
||||
const model = type
|
||||
? type.models.find(
|
||||
(item) => String(item.id || item.name) === String(elems.model.value)
|
||||
)
|
||||
: null;
|
||||
node.modelMetadata = model;
|
||||
node.modelName = model ? model.name : '';
|
||||
populate(
|
||||
elems.unit,
|
||||
model ? model.units || [] : [],
|
||||
node.unit,
|
||||
(unit) => ({ value: unit, label: unit }),
|
||||
model ? 'Select...' : type ? 'Awaiting Model Selection' : 'Awaiting Type Selection'
|
||||
);
|
||||
});
|
||||
};
|
||||
`
|
||||
`;
|
||||
}
|
||||
|
||||
getSyncInjectionCode(nodeName) {
|
||||
return `
|
||||
// Asset synchronization helpers for ${nodeName}
|
||||
window.EVOLV.nodes.${nodeName}.assetMenu.setAssetTagNumber = function(node, tag) {
|
||||
const normalized = tag ? tag.toString() : '';
|
||||
const input = document.getElementById('node-input-assetTagNumber');
|
||||
const hint = document.getElementById('node-input-assetTagNumber-hint');
|
||||
console.info('[AssetMenu] tag number update', {
|
||||
nodeId: node && node.id ? node.id : null,
|
||||
tag: normalized
|
||||
});
|
||||
if (input) {
|
||||
input.value = normalized;
|
||||
}
|
||||
if (hint) {
|
||||
hint.textContent = normalized ? 'Assigned tag ' + normalized : 'Not registered yet';
|
||||
}
|
||||
if (node) {
|
||||
node.assetTagNumber = normalized;
|
||||
}
|
||||
};
|
||||
|
||||
window.EVOLV.nodes.${nodeName}.assetMenu.buildSyncRequest = function(node) {
|
||||
const tagInput = document.getElementById('node-input-assetTagNumber');
|
||||
const candidateTag = tagInput && tagInput.value ? tagInput.value.trim() : '';
|
||||
const fallbackTag = node && node.assetTagNumber ? node.assetTagNumber : '';
|
||||
const registrationDefaults =
|
||||
(window.EVOLV.nodes.${nodeName}.config && window.EVOLV.nodes.${nodeName}.config.assetRegistration && window.EVOLV.nodes.${nodeName}.config.assetRegistration.default) || {};
|
||||
const displayName = node && node.name ? node.name : node && node.id ? node.id : '${nodeName}';
|
||||
console.info('[AssetMenu] build sync payload', {
|
||||
nodeId: node && node.id ? node.id : null,
|
||||
candidateTag,
|
||||
fallbackTag,
|
||||
status: registrationDefaults.status || 'actief'
|
||||
});
|
||||
return {
|
||||
asset: {
|
||||
tagNumber: candidateTag || fallbackTag,
|
||||
supplier: node && node.supplier ? node.supplier : '',
|
||||
assetType: node && node.assetType ? node.assetType : '',
|
||||
model: node && node.model ? node.model : '',
|
||||
unit: node && node.unit ? node.unit : '',
|
||||
assetName: displayName,
|
||||
assetDescription: displayName,
|
||||
assetStatus: registrationDefaults.status || 'actief',
|
||||
modelMetadata: node && node.modelMetadata ? node.modelMetadata : null
|
||||
},
|
||||
nodeId: node && node.id ? node.id : null,
|
||||
nodeName: node && node.type ? node.type : '${nodeName}'
|
||||
};
|
||||
};
|
||||
|
||||
window.EVOLV.nodes.${nodeName}.assetMenu.syncAsset = function(node) {
|
||||
const payload = this.buildSyncRequest(node);
|
||||
const redSettings = window.RED && window.RED.settings;
|
||||
const adminRoot = redSettings ? redSettings.httpAdminRoot : '';
|
||||
const trimmedRoot = adminRoot && adminRoot.endsWith('/') ? adminRoot.slice(0, -1) : adminRoot || '';
|
||||
const prefix = trimmedRoot || '';
|
||||
const endpoint = (prefix || '') + '/${nodeName}/asset-reg';
|
||||
console.info('[AssetMenu] sync request', {
|
||||
endpoint,
|
||||
nodeId: node && node.id ? node.id : null,
|
||||
tagNumber: payload && payload.asset ? payload.asset.tagNumber : null
|
||||
});
|
||||
fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
.then((res) =>
|
||||
res.json().catch((err) => {
|
||||
console.warn('[AssetMenu] asset sync response is not JSON', err);
|
||||
return { success: false, message: err.message || 'Invalid API response' };
|
||||
})
|
||||
)
|
||||
.then((result) => {
|
||||
console.info('[AssetMenu] sync response', result);
|
||||
if (result && result.success) {
|
||||
const newTag = (result.data && result.data.asset_tag_number) || payload.asset.tagNumber || '';
|
||||
this.setAssetTagNumber(node, newTag);
|
||||
if (window.RED && typeof window.RED.notify === 'function') {
|
||||
window.RED.notify('Asset synced: ' + (newTag || 'no tag'), 'info');
|
||||
}
|
||||
} else {
|
||||
console.warn('[AssetMenu] asset sync failed', result && result.message);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('[AssetMenu] asset sync error', error);
|
||||
});
|
||||
};
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate HTML template for asset fields
|
||||
*/
|
||||
getHtmlTemplate() {
|
||||
return `
|
||||
<!-- Asset Properties -->
|
||||
@@ -172,10 +556,6 @@ getEventInjectionCode(nodeName) {
|
||||
<label for="node-input-supplier"><i class="fa fa-industry"></i> Supplier</label>
|
||||
<select id="node-input-supplier" style="width:70%;"></select>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-category"><i class="fa fa-sitemap"></i> Category</label>
|
||||
<select id="node-input-category" style="width:70%;"></select>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-assetType"><i class="fa fa-puzzle-piece"></i> Type</label>
|
||||
<select id="node-input-assetType" style="width:70%;"></select>
|
||||
@@ -188,16 +568,20 @@ getEventInjectionCode(nodeName) {
|
||||
<label for="node-input-unit"><i class="fa fa-balance-scale"></i> Unit</label>
|
||||
<select id="node-input-unit" style="width:70%;"></select>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-assetTagNumber"><i class="fa fa-hashtag"></i> Asset Tag</label>
|
||||
<input type="text" id="node-input-assetTagNumber" readonly style="width:70%;" />
|
||||
<div class="form-tips" id="node-input-assetTagNumber-hint">Not registered yet</div>
|
||||
</div>
|
||||
<hr />
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client-side HTML injection code
|
||||
*/
|
||||
getHtmlInjectionCode(nodeName) {
|
||||
const htmlTemplate = this.getHtmlTemplate().replace(/`/g, '\\`').replace(/\$/g, '\\$');
|
||||
|
||||
const htmlTemplate = this.getHtmlTemplate()
|
||||
.replace(/`/g, '\\`')
|
||||
.replace(/\$/g, '\\$');
|
||||
|
||||
return `
|
||||
// Asset HTML injection for ${nodeName}
|
||||
window.EVOLV.nodes.${nodeName}.assetMenu.injectHtml = function() {
|
||||
@@ -210,33 +594,60 @@ getEventInjectionCode(nodeName) {
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the JS that injects the saveEditor function
|
||||
*/
|
||||
getSaveInjectionCode(nodeName) {
|
||||
return `
|
||||
// Asset Save injection for ${nodeName}
|
||||
// Asset save handler for ${nodeName}
|
||||
window.EVOLV.nodes.${nodeName}.assetMenu.saveEditor = function(node) {
|
||||
console.log('Saving asset properties for ${nodeName}…');
|
||||
const fields = ['supplier','category','assetType','model','unit'];
|
||||
const errors = [];
|
||||
fields.forEach(f => {
|
||||
const el = document.getElementById(\`node-input-\${f}\`);
|
||||
node[f] = el ? el.value : '';
|
||||
});
|
||||
if (node.assetType && !node.unit) errors.push('Unit must be set when type is specified.');
|
||||
if (!node.unit) errors.push('Unit is required.');
|
||||
errors.forEach(e=>RED.notify(e,'error'));
|
||||
|
||||
// --- DEBUG: show exactly what was saved ---
|
||||
const saved = fields.reduce((o,f) => { o[f] = node[f]; return o; }, {});
|
||||
console.log('→ assetMenu.saveEditor result:', saved);
|
||||
console.log('Saving asset properties for ${nodeName}');
|
||||
const menuAsset = window.EVOLV.nodes.${nodeName}.menuData.asset || {};
|
||||
const categories = menuAsset.categories || {};
|
||||
const defaultCategory = menuAsset.defaultCategory || Object.keys(categories)[0] || null;
|
||||
const resolveCategoryKey = () => {
|
||||
if (node.softwareType && categories[node.softwareType]) {
|
||||
return node.softwareType;
|
||||
}
|
||||
if (node.category && categories[node.category]) {
|
||||
return node.category;
|
||||
}
|
||||
return defaultCategory || '';
|
||||
};
|
||||
|
||||
return errors.length===0;
|
||||
node.category = resolveCategoryKey();
|
||||
|
||||
const fields = ['supplier', 'assetType', 'model', 'unit', 'assetTagNumber'];
|
||||
const errors = [];
|
||||
|
||||
fields.forEach((field) => {
|
||||
const el = document.getElementById(\`node-input-\${field}\`);
|
||||
node[field] = el ? el.value : '';
|
||||
});
|
||||
|
||||
if (node.assetType && !node.unit) {
|
||||
errors.push('Unit must be set when a type is specified.');
|
||||
}
|
||||
if (!node.unit) {
|
||||
errors.push('Unit is required.');
|
||||
}
|
||||
|
||||
errors.forEach((msg) => RED.notify(msg, 'error'));
|
||||
|
||||
const saved = fields.reduce((acc, field) => {
|
||||
acc[field] = node[field];
|
||||
return acc;
|
||||
}, {});
|
||||
if (node.modelMetadata && typeof node.modelMetadata.id !== 'undefined') {
|
||||
saved.modelId = node.modelMetadata.id;
|
||||
}
|
||||
console.log('[AssetMenu] save result:', saved);
|
||||
|
||||
if (errors.length === 0 && this.syncAsset) {
|
||||
this.syncAsset(node);
|
||||
}
|
||||
|
||||
return errors.length === 0;
|
||||
};
|
||||
`;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = AssetMenu;
|
||||
|
||||
243
src/menu/asset_DEPRECATED.js
Normal file
243
src/menu/asset_DEPRECATED.js
Normal file
@@ -0,0 +1,243 @@
|
||||
// asset.js
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
|
||||
class AssetMenu {
|
||||
/** Define path where to find data of assets in constructor for now */
|
||||
constructor(relPath = '../../datasets/assetData') {
|
||||
this.baseDir = path.resolve(__dirname, relPath);
|
||||
this.assetData = this._loadJSON('assetData');
|
||||
}
|
||||
|
||||
_loadJSON(...segments) {
|
||||
const filePath = path.resolve(this.baseDir, ...segments) + '.json';
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to load ${filePath}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ADD THIS METHOD
|
||||
* Compiles all menu data from the file system into a single nested object.
|
||||
* This is run once on the server to pre-load everything.
|
||||
* @returns {object} A comprehensive object with all menu options.
|
||||
*/
|
||||
getAllMenuData() {
|
||||
// load the raw JSON once
|
||||
const data = this._loadJSON('assetData');
|
||||
const allData = {};
|
||||
|
||||
data.suppliers.forEach(sup => {
|
||||
allData[sup.name] = {};
|
||||
sup.categories.forEach(cat => {
|
||||
allData[sup.name][cat.name] = {};
|
||||
cat.types.forEach(type => {
|
||||
// here: store the full array of model objects, not just names
|
||||
allData[sup.name][cat.name][type.name] = type.models;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return allData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the static initEditor function to a string that can be served to the client
|
||||
* @param {string} nodeName - The name of the node type
|
||||
* @returns {string} JavaScript code as a string
|
||||
*/
|
||||
getClientInitCode(nodeName) {
|
||||
// step 1: get the two helper strings
|
||||
const htmlCode = this.getHtmlInjectionCode(nodeName);
|
||||
const dataCode = this.getDataInjectionCode(nodeName);
|
||||
const eventsCode = this.getEventInjectionCode(nodeName);
|
||||
const saveCode = this.getSaveInjectionCode(nodeName);
|
||||
|
||||
|
||||
return `
|
||||
// --- AssetMenu for ${nodeName} ---
|
||||
window.EVOLV.nodes.${nodeName}.assetMenu =
|
||||
window.EVOLV.nodes.${nodeName}.assetMenu || {};
|
||||
|
||||
${htmlCode}
|
||||
${dataCode}
|
||||
${eventsCode}
|
||||
${saveCode}
|
||||
|
||||
// wire it all up when the editor loads
|
||||
window.EVOLV.nodes.${nodeName}.assetMenu.initEditor = function(node) {
|
||||
// ------------------ BELOW sequence is important! -------------------------------
|
||||
console.log('Initializing asset properties for ${nodeName}…');
|
||||
this.injectHtml();
|
||||
// load the data and wire up events
|
||||
// this will populate the fields and set up the event listeners
|
||||
this.wireEvents(node);
|
||||
// this will load the initial data into the fields
|
||||
// this is important to ensure the fields are populated correctly
|
||||
this.loadData(node);
|
||||
};
|
||||
`;
|
||||
|
||||
}
|
||||
|
||||
getDataInjectionCode(nodeName) {
|
||||
return `
|
||||
// Asset Data loader for ${nodeName}
|
||||
window.EVOLV.nodes.${nodeName}.assetMenu.loadData = function(node) {
|
||||
const data = window.EVOLV.nodes.${nodeName}.menuData.asset;
|
||||
const elems = {
|
||||
supplier: document.getElementById('node-input-supplier'),
|
||||
category: document.getElementById('node-input-category'),
|
||||
type: document.getElementById('node-input-assetType'),
|
||||
model: document.getElementById('node-input-model'),
|
||||
unit: document.getElementById('node-input-unit')
|
||||
};
|
||||
function populate(el, opts, sel) {
|
||||
const old = el.value;
|
||||
el.innerHTML = '<option value="">Select…</option>';
|
||||
(opts||[]).forEach(o=>{
|
||||
const opt = document.createElement('option');
|
||||
opt.value = o; opt.textContent = o;
|
||||
el.appendChild(opt);
|
||||
});
|
||||
el.value = sel||"";
|
||||
if(el.value!==old) el.dispatchEvent(new Event('change'));
|
||||
}
|
||||
// initial population
|
||||
populate(elems.supplier, Object.keys(data), node.supplier);
|
||||
};
|
||||
`
|
||||
}
|
||||
|
||||
getEventInjectionCode(nodeName) {
|
||||
return `
|
||||
// Asset Event wiring for ${nodeName}
|
||||
window.EVOLV.nodes.${nodeName}.assetMenu.wireEvents = function(node) {
|
||||
const data = window.EVOLV.nodes.${nodeName}.menuData.asset;
|
||||
const elems = {
|
||||
supplier: document.getElementById('node-input-supplier'),
|
||||
category: document.getElementById('node-input-category'),
|
||||
type: document.getElementById('node-input-assetType'),
|
||||
model: document.getElementById('node-input-model'),
|
||||
unit: document.getElementById('node-input-unit')
|
||||
};
|
||||
function populate(el, opts, sel) {
|
||||
const old = el.value;
|
||||
el.innerHTML = '<option value="">Select…</option>';
|
||||
(opts||[]).forEach(o=>{
|
||||
const opt = document.createElement('option');
|
||||
opt.value = o; opt.textContent = o;
|
||||
el.appendChild(opt);
|
||||
});
|
||||
el.value = sel||"";
|
||||
if(el.value!==old) el.dispatchEvent(new Event('change'));
|
||||
}
|
||||
elems.supplier.addEventListener('change', ()=>{
|
||||
populate(elems.category,
|
||||
elems.supplier.value? Object.keys(data[elems.supplier.value]||{}) : [],
|
||||
node.category);
|
||||
});
|
||||
elems.category.addEventListener('change', ()=>{
|
||||
const s=elems.supplier.value, c=elems.category.value;
|
||||
populate(elems.type,
|
||||
(s&&c)? Object.keys(data[s][c]||{}) : [],
|
||||
node.assetType);
|
||||
});
|
||||
elems.type.addEventListener('change', ()=>{
|
||||
const s=elems.supplier.value, c=elems.category.value, t=elems.type.value;
|
||||
const md = (s&&c&&t)? data[s][c][t]||[] : [];
|
||||
populate(elems.model, md.map(m=>m.name), node.model);
|
||||
});
|
||||
elems.model.addEventListener('change', ()=>{
|
||||
const s=elems.supplier.value, c=elems.category.value, t=elems.type.value, m=elems.model.value;
|
||||
const md = (s&&c&&t)? data[s][c][t]||[] : [];
|
||||
const entry = md.find(x=>x.name===m);
|
||||
populate(elems.unit, entry? entry.units : [], node.unit);
|
||||
});
|
||||
};
|
||||
`
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate HTML template for asset fields
|
||||
*/
|
||||
getHtmlTemplate() {
|
||||
return `
|
||||
<!-- Asset Properties -->
|
||||
<hr />
|
||||
<h3>Asset selection</h3>
|
||||
<div class="form-row">
|
||||
<label for="node-input-supplier"><i class="fa fa-industry"></i> Supplier</label>
|
||||
<select id="node-input-supplier" style="width:70%;"></select>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-category"><i class="fa fa-sitemap"></i> Category</label>
|
||||
<select id="node-input-category" style="width:70%;"></select>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-assetType"><i class="fa fa-puzzle-piece"></i> Type</label>
|
||||
<select id="node-input-assetType" style="width:70%;"></select>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-model"><i class="fa fa-wrench"></i> Model</label>
|
||||
<select id="node-input-model" style="width:70%;"></select>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-unit"><i class="fa fa-balance-scale"></i> Unit</label>
|
||||
<select id="node-input-unit" style="width:70%;"></select>
|
||||
</div>
|
||||
<hr />
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client-side HTML injection code
|
||||
*/
|
||||
getHtmlInjectionCode(nodeName) {
|
||||
const htmlTemplate = this.getHtmlTemplate().replace(/`/g, '\\`').replace(/\$/g, '\\$');
|
||||
|
||||
return `
|
||||
// Asset HTML injection for ${nodeName}
|
||||
window.EVOLV.nodes.${nodeName}.assetMenu.injectHtml = function() {
|
||||
const placeholder = document.getElementById('asset-fields-placeholder');
|
||||
if (placeholder && !placeholder.hasChildNodes()) {
|
||||
placeholder.innerHTML = \`${htmlTemplate}\`;
|
||||
console.log('Asset HTML injected successfully');
|
||||
}
|
||||
};
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the JS that injects the saveEditor function
|
||||
*/
|
||||
getSaveInjectionCode(nodeName) {
|
||||
return `
|
||||
// Asset Save injection for ${nodeName}
|
||||
window.EVOLV.nodes.${nodeName}.assetMenu.saveEditor = function(node) {
|
||||
console.log('Saving asset properties for ${nodeName}…');
|
||||
const fields = ['supplier','category','assetType','model','unit'];
|
||||
const errors = [];
|
||||
fields.forEach(f => {
|
||||
const el = document.getElementById(\`node-input-\${f}\`);
|
||||
node[f] = el ? el.value : '';
|
||||
});
|
||||
if (node.assetType && !node.unit) errors.push('Unit must be set when type is specified.');
|
||||
if (!node.unit) errors.push('Unit is required.');
|
||||
errors.forEach(e=>RED.notify(e,'error'));
|
||||
|
||||
// --- DEBUG: show exactly what was saved ---
|
||||
const saved = fields.reduce((o,f) => { o[f] = node[f]; return o; }, {});
|
||||
console.log('→ assetMenu.saveEditor result:', saved);
|
||||
|
||||
return errors.length===0;
|
||||
};
|
||||
`;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = AssetMenu;
|
||||
@@ -2,16 +2,22 @@ const AssetMenu = require('./asset.js');
|
||||
const { TagcodeApp, DynamicAssetMenu } = require('./tagcodeApp.js');
|
||||
const LoggerMenu = require('./logger.js');
|
||||
const PhysicalPositionMenu = require('./physicalPosition.js');
|
||||
const AquonSamplesMenu = require('./aquonSamples.js');
|
||||
const ConfigManager = require('../configs');
|
||||
|
||||
class MenuManager {
|
||||
|
||||
constructor() {
|
||||
this.registeredMenus = new Map();
|
||||
this.configManager = new ConfigManager('../configs');
|
||||
// Register factory functions
|
||||
this.registerMenu('asset', () => new AssetMenu()); // static menu to be replaced by dynamic one but later
|
||||
this.registerMenu('asset', (nodeName) => new AssetMenu({
|
||||
softwareType: this._getSoftwareType(nodeName)
|
||||
})); // static menu to be replaced by dynamic one but later
|
||||
//this.registerMenu('asset', (nodeName) => new DynamicAssetMenu(nodeName, new TagcodeApp()));
|
||||
this.registerMenu('logger', () => new LoggerMenu());
|
||||
this.registerMenu('position', () => new PhysicalPositionMenu());
|
||||
this.registerMenu('aquon', () => new AquonSamplesMenu());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -23,6 +29,34 @@ class MenuManager {
|
||||
this.registeredMenus.set(menuType, menuFactory);
|
||||
}
|
||||
|
||||
_getSoftwareType(nodeName) {
|
||||
if (!nodeName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const config = this.configManager.getConfig(nodeName);
|
||||
const softwareType = config?.functionality?.softwareType;
|
||||
|
||||
if (typeof softwareType === 'string' && softwareType.trim()) {
|
||||
return softwareType;
|
||||
}
|
||||
if (
|
||||
softwareType &&
|
||||
typeof softwareType === 'object' &&
|
||||
typeof softwareType.default === 'string' &&
|
||||
softwareType.default.trim()
|
||||
) {
|
||||
return softwareType.default;
|
||||
}
|
||||
|
||||
return nodeName;
|
||||
} catch (error) {
|
||||
console.warn(`Unable to determine softwareType for ${nodeName}: ${error.message}`);
|
||||
return nodeName;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a complete endpoint script with data and initialization functions
|
||||
* @param {string} nodeName - The name of the node type
|
||||
@@ -54,7 +88,7 @@ class MenuManager {
|
||||
try {
|
||||
const handler = instantiatedMenus.get(menuType);
|
||||
if (handler && typeof handler.getAllMenuData === 'function') {
|
||||
menuData[menuType] = handler.getAllMenuData();
|
||||
menuData[menuType] = handler.getAllMenuData(nodeName);
|
||||
} else {
|
||||
// Provide default empty data if method doesn't exist
|
||||
menuData[menuType] = {};
|
||||
@@ -172,4 +206,4 @@ class MenuManager {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = MenuManager;
|
||||
module.exports = MenuManager;
|
||||
|
||||
@@ -6,9 +6,9 @@ class PhysicalPositionMenu {
|
||||
return {
|
||||
positionGroups: [
|
||||
{ group: 'Positional', options: [
|
||||
{ value: 'upstream', label: '← Upstream', icon: '←'},
|
||||
{ value: 'upstream', label: '→ Upstream', icon: '→'}, //flow is then typically left to right
|
||||
{ value: 'atEquipment', label: '⊥ in place' , icon: '⊥' },
|
||||
{ value: 'downstream', label: '→ Downstream' , icon: '→' }
|
||||
{ value: 'downstream', label: '← Downstream' , icon: '←' }
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
@@ -1,125 +1,126 @@
|
||||
//load local dependencies
|
||||
const EventEmitter = require('events');
|
||||
|
||||
//load all config modules
|
||||
const defaultConfig = require('./nrmseConfig.json');
|
||||
const ConfigUtils = require('../helper/configUtils');
|
||||
|
||||
class ErrorMetrics {
|
||||
constructor(config = {}, logger) {
|
||||
|
||||
this.emitter = new EventEmitter(); // Own EventEmitter
|
||||
this.emitter = new EventEmitter();
|
||||
this.configUtils = new ConfigUtils(defaultConfig);
|
||||
this.config = this.configUtils.initConfig(config);
|
||||
|
||||
// Init after config is set
|
||||
this.logger = logger;
|
||||
|
||||
// For long-term NRMSD accumulation
|
||||
this.metricState = new Map();
|
||||
this.legacyMetricId = 'default';
|
||||
|
||||
// Backward-compatible fields retained for existing callers/tests.
|
||||
this.cumNRMSD = 0;
|
||||
this.cumCount = 0;
|
||||
}
|
||||
|
||||
//INCLUDE timestamps in the next update OLIFANT
|
||||
meanSquaredError(predicted, measured) {
|
||||
if (predicted.length !== measured.length) {
|
||||
this.logger.error("Comparing MSE Arrays must have the same length.");
|
||||
registerMetric(metricId, profile = {}) {
|
||||
const key = String(metricId || this.legacyMetricId);
|
||||
const state = this._ensureMetricState(key);
|
||||
state.profile = { ...state.profile, ...profile };
|
||||
return state.profile;
|
||||
}
|
||||
|
||||
resetMetric(metricId = this.legacyMetricId) {
|
||||
this.metricState.delete(String(metricId));
|
||||
if (metricId === this.legacyMetricId) {
|
||||
this.cumNRMSD = 0;
|
||||
this.cumCount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
getMetricState(metricId = this.legacyMetricId) {
|
||||
return this.metricState.get(String(metricId)) || null;
|
||||
}
|
||||
|
||||
meanSquaredError(predicted, measured, options = {}) {
|
||||
const { p, m } = this._validateSeries(predicted, measured, options);
|
||||
let sumSqError = 0;
|
||||
for (let i = 0; i < p.length; i += 1) {
|
||||
const err = p[i] - m[i];
|
||||
sumSqError += err * err;
|
||||
}
|
||||
return sumSqError / p.length;
|
||||
}
|
||||
|
||||
rootMeanSquaredError(predicted, measured, options = {}) {
|
||||
return Math.sqrt(this.meanSquaredError(predicted, measured, options));
|
||||
}
|
||||
|
||||
normalizedRootMeanSquaredError(predicted, measured, processMin, processMax, options = {}) {
|
||||
const range = Number(processMax) - Number(processMin);
|
||||
if (!Number.isFinite(range) || range <= 0) {
|
||||
this._failOrLog(
|
||||
`Invalid process range: processMax (${processMax}) must be greater than processMin (${processMin}).`,
|
||||
options
|
||||
);
|
||||
return NaN;
|
||||
}
|
||||
const rmse = this.rootMeanSquaredError(predicted, measured, options);
|
||||
return rmse / range;
|
||||
}
|
||||
|
||||
normalizeUsingRealtime(predicted, measured, options = {}) {
|
||||
const { p, m } = this._validateSeries(predicted, measured, options);
|
||||
const realtimeMin = Math.min(Math.min(...p), Math.min(...m));
|
||||
const realtimeMax = Math.max(Math.max(...p), Math.max(...m));
|
||||
const range = realtimeMax - realtimeMin;
|
||||
if (!Number.isFinite(range) || range <= 0) {
|
||||
throw new Error('Invalid process range: processMax must be greater than processMin.');
|
||||
}
|
||||
const rmse = this.rootMeanSquaredError(p, m, options);
|
||||
return rmse / range;
|
||||
}
|
||||
|
||||
longTermNRMSD(input, metricId = this.legacyMetricId, options = {}) {
|
||||
const metricKey = String(metricId || this.legacyMetricId);
|
||||
const state = this._ensureMetricState(metricKey);
|
||||
const profile = this._resolveProfile(metricKey, options);
|
||||
const value = Number(input);
|
||||
if (!Number.isFinite(value)) {
|
||||
this._failOrLog(`longTermNRMSD input must be finite. Received: ${input}`, options);
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
let sumSqError = 0;
|
||||
for (let i = 0; i < predicted.length; i++) {
|
||||
const err = predicted[i] - measured[i];
|
||||
sumSqError += err * err;
|
||||
}
|
||||
return sumSqError / predicted.length;
|
||||
}
|
||||
|
||||
rootMeanSquaredError(predicted, measured) {
|
||||
return Math.sqrt(this.meanSquaredError(predicted, measured));
|
||||
}
|
||||
|
||||
normalizedRootMeanSquaredError(predicted, measured, processMin, processMax) {
|
||||
const range = processMax - processMin;
|
||||
if (range <= 0) {
|
||||
this.logger.error("Invalid process range: processMax must be greater than processMin.");
|
||||
}
|
||||
const rmse = this.rootMeanSquaredError(predicted, measured);
|
||||
return rmse / range;
|
||||
}
|
||||
|
||||
longTermNRMSD(input) {
|
||||
|
||||
const storedNRMSD = this.cumNRMSD;
|
||||
const storedCount = this.cumCount;
|
||||
const newCount = storedCount + 1;
|
||||
|
||||
// Update cumulative values
|
||||
this.cumCount = newCount;
|
||||
|
||||
// Calculate new running average
|
||||
if (storedCount === 0) {
|
||||
this.cumNRMSD = input; // First value
|
||||
} else {
|
||||
// Running average formula: newAvg = oldAvg + (newValue - oldAvg) / newCount
|
||||
this.cumNRMSD = storedNRMSD + (input - storedNRMSD) / newCount;
|
||||
// Keep backward compatibility if callers manipulate cumCount/cumNRMSD directly.
|
||||
if (metricKey === this.legacyMetricId && (state.sampleCount !== this.cumCount || state.longTermEwma !== this.cumNRMSD)) {
|
||||
state.sampleCount = Number(this.cumCount) || 0;
|
||||
state.longTermEwma = Number(this.cumNRMSD) || 0;
|
||||
}
|
||||
|
||||
if(newCount >= 100) {
|
||||
// Return the current NRMSD value, not just the contribution from this sample
|
||||
return this.cumNRMSD;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
state.sampleCount += 1;
|
||||
const alpha = profile.ewmaAlpha;
|
||||
state.longTermEwma = state.sampleCount === 1 ? value : (alpha * value) + ((1 - alpha) * state.longTermEwma);
|
||||
|
||||
normalizeUsingRealtime(predicted, measured) {
|
||||
const realtimeMin = Math.min(Math.min(...predicted), Math.min(...measured));
|
||||
const realtimeMax = Math.max(Math.max(...predicted), Math.max(...measured));
|
||||
const range = realtimeMax - realtimeMin;
|
||||
if (range <= 0) {
|
||||
throw new Error("Invalid process range: processMax must be greater than processMin.");
|
||||
if (metricKey === this.legacyMetricId) {
|
||||
this.cumCount = state.sampleCount;
|
||||
this.cumNRMSD = state.longTermEwma;
|
||||
}
|
||||
const rmse = this.rootMeanSquaredError(predicted, measured);
|
||||
return rmse / range;
|
||||
|
||||
if (state.sampleCount < profile.minSamplesForLongTerm) {
|
||||
return 0;
|
||||
}
|
||||
return state.longTermEwma;
|
||||
}
|
||||
|
||||
detectImmediateDrift(nrmse) {
|
||||
let ImmDrift = {};
|
||||
this.logger.debug(`checking immediate drift with thresholds : ${this.config.thresholds.NRMSE_HIGH} ${this.config.thresholds.NRMSE_MEDIUM} ${this.config.thresholds.NRMSE_LOW}`);
|
||||
switch (true) {
|
||||
case( nrmse > this.config.thresholds.NRMSE_HIGH ) :
|
||||
ImmDrift = {level : 3 , feedback : "High immediate drift detected"};
|
||||
break;
|
||||
case( nrmse > this.config.thresholds.NRMSE_MEDIUM ) :
|
||||
ImmDrift = {level : 2 , feedback : "Medium immediate drift detected"};
|
||||
break;
|
||||
case(nrmse > this.config.thresholds.NRMSE_LOW ):
|
||||
ImmDrift = {level : 1 , feedback : "Low immediate drift detected"};
|
||||
break;
|
||||
default:
|
||||
ImmDrift = {level : 0 , feedback : "No drift detected"};
|
||||
}
|
||||
return ImmDrift;
|
||||
const thresholds = this.config.thresholds;
|
||||
if (nrmse > thresholds.NRMSE_HIGH) return { level: 3, feedback: 'High immediate drift detected' };
|
||||
if (nrmse > thresholds.NRMSE_MEDIUM) return { level: 2, feedback: 'Medium immediate drift detected' };
|
||||
if (nrmse > thresholds.NRMSE_LOW) return { level: 1, feedback: 'Low immediate drift detected' };
|
||||
return { level: 0, feedback: 'No drift detected' };
|
||||
}
|
||||
|
||||
detectLongTermDrift(longTermNRMSD) {
|
||||
let LongTermDrift = {};
|
||||
this.logger.debug(`checking longterm drift with thresholds : ${this.config.thresholds.LONG_TERM_HIGH} ${this.config.thresholds.LONG_TERM_MEDIUM} ${this.config.thresholds.LONG_TERM_LOW}`);
|
||||
switch (true) {
|
||||
case(Math.abs(longTermNRMSD) > this.config.thresholds.LONG_TERM_HIGH) :
|
||||
LongTermDrift = {level : 3 , feedback : "High long-term drift detected"};
|
||||
break;
|
||||
case (Math.abs(longTermNRMSD) > this.config.thresholds.LONG_TERM_MEDIUM) :
|
||||
LongTermDrift = {level : 2 , feedback : "Medium long-term drift detected"};
|
||||
break;
|
||||
case ( Math.abs(longTermNRMSD) > this.config.thresholds.LONG_TERM_LOW ) :
|
||||
LongTermDrift = {level : 1 , feedback : "Low long-term drift detected"};
|
||||
break;
|
||||
default:
|
||||
LongTermDrift = {level : 0 , feedback : "No drift detected"};
|
||||
}
|
||||
return LongTermDrift;
|
||||
const thresholds = this.config.thresholds;
|
||||
const absValue = Math.abs(longTermNRMSD);
|
||||
if (absValue > thresholds.LONG_TERM_HIGH) return { level: 3, feedback: 'High long-term drift detected' };
|
||||
if (absValue > thresholds.LONG_TERM_MEDIUM) return { level: 2, feedback: 'Medium long-term drift detected' };
|
||||
if (absValue > thresholds.LONG_TERM_LOW) return { level: 1, feedback: 'Low long-term drift detected' };
|
||||
return { level: 0, feedback: 'No drift detected' };
|
||||
}
|
||||
|
||||
detectDrift(nrmse, longTermNRMSD) {
|
||||
@@ -128,27 +129,272 @@ class ErrorMetrics {
|
||||
return { ImmDrift, LongTermDrift };
|
||||
}
|
||||
|
||||
// asses the drift
|
||||
assessDrift(predicted, measured, processMin, processMax) {
|
||||
// Compute NRMSE and check for immediate drift
|
||||
const nrmse = this.normalizedRootMeanSquaredError(predicted, measured, processMin, processMax);
|
||||
this.logger.debug(`NRMSE: ${nrmse}`);
|
||||
// cmopute long-term NRMSD and add result to cumalitve NRMSD
|
||||
const longTermNRMSD = this.longTermNRMSD(nrmse);
|
||||
// return the drift
|
||||
// Return the drift assessment object
|
||||
assessDrift(predicted, measured, processMin, processMax, options = {}) {
|
||||
const metricKey = String(options.metricId || this.legacyMetricId);
|
||||
const profile = this._resolveProfile(metricKey, options);
|
||||
const strict = this._resolveStrict(options, profile);
|
||||
|
||||
const aligned = this._alignSeriesByTimestamp(predicted, measured, options, profile);
|
||||
if (!aligned.valid) {
|
||||
if (strict) {
|
||||
throw new Error(aligned.reason);
|
||||
}
|
||||
return this._invalidAssessment(metricKey, aligned.reason);
|
||||
}
|
||||
|
||||
const nrmse = this.normalizedRootMeanSquaredError(
|
||||
aligned.predicted,
|
||||
aligned.measured,
|
||||
processMin,
|
||||
processMax,
|
||||
{ ...options, strictValidation: strict }
|
||||
);
|
||||
if (!Number.isFinite(nrmse)) {
|
||||
if (strict) {
|
||||
throw new Error('NRMSE calculation returned a non-finite value.');
|
||||
}
|
||||
return this._invalidAssessment(metricKey, 'non_finite_nrmse');
|
||||
}
|
||||
|
||||
const longTermNRMSD = this.longTermNRMSD(nrmse, metricKey, { ...options, strictValidation: strict });
|
||||
const driftAssessment = this.detectDrift(nrmse, longTermNRMSD);
|
||||
return {
|
||||
const state = this._ensureMetricState(metricKey);
|
||||
state.lastResult = {
|
||||
nrmse,
|
||||
longTermNRMSD,
|
||||
immediateLevel: driftAssessment.ImmDrift.level,
|
||||
immediateFeedback: driftAssessment.ImmDrift.feedback,
|
||||
longTermLevel: driftAssessment.LongTermDrift.level,
|
||||
longTermFeedback: driftAssessment.LongTermDrift.feedback
|
||||
longTermFeedback: driftAssessment.LongTermDrift.feedback,
|
||||
valid: true,
|
||||
metricId: metricKey,
|
||||
sampleCount: state.sampleCount,
|
||||
longTermReady: state.sampleCount >= profile.minSamplesForLongTerm,
|
||||
flags: [],
|
||||
};
|
||||
return state.lastResult;
|
||||
}
|
||||
|
||||
assessPoint(metricId, predictedValue, measuredValue, options = {}) {
|
||||
const metricKey = String(metricId || this.legacyMetricId);
|
||||
const profile = this._resolveProfile(metricKey, options);
|
||||
const state = this._ensureMetricState(metricKey);
|
||||
const strict = this._resolveStrict(options, profile);
|
||||
|
||||
const p = Number(predictedValue);
|
||||
const m = Number(measuredValue);
|
||||
if (!Number.isFinite(p) || !Number.isFinite(m)) {
|
||||
const reason = `assessPoint requires finite numbers. predicted=${predictedValue}, measured=${measuredValue}`;
|
||||
if (strict) {
|
||||
throw new Error(reason);
|
||||
}
|
||||
return this._invalidAssessment(metricKey, reason);
|
||||
}
|
||||
|
||||
const predictedTimestamp = Number(options.predictedTimestamp ?? options.timestamp ?? Date.now());
|
||||
const measuredTimestamp = Number(options.measuredTimestamp ?? options.timestamp ?? Date.now());
|
||||
const delta = Math.abs(predictedTimestamp - measuredTimestamp);
|
||||
if (delta > profile.alignmentToleranceMs) {
|
||||
const reason = `Sample timestamp delta (${delta} ms) exceeds tolerance (${profile.alignmentToleranceMs} ms)`;
|
||||
if (strict) {
|
||||
throw new Error(reason);
|
||||
}
|
||||
return this._invalidAssessment(metricKey, reason);
|
||||
}
|
||||
|
||||
state.predicted.push(p);
|
||||
state.measured.push(m);
|
||||
state.predictedTimestamps.push(predictedTimestamp);
|
||||
state.measuredTimestamps.push(measuredTimestamp);
|
||||
|
||||
while (state.predicted.length > profile.windowSize) state.predicted.shift();
|
||||
while (state.measured.length > profile.windowSize) state.measured.shift();
|
||||
while (state.predictedTimestamps.length > profile.windowSize) state.predictedTimestamps.shift();
|
||||
while (state.measuredTimestamps.length > profile.windowSize) state.measuredTimestamps.shift();
|
||||
|
||||
if (state.predicted.length < 2 || state.measured.length < 2) {
|
||||
return this._invalidAssessment(metricKey, 'insufficient_samples');
|
||||
}
|
||||
|
||||
let processMin = Number(options.processMin);
|
||||
let processMax = Number(options.processMax);
|
||||
if (!Number.isFinite(processMin) || !Number.isFinite(processMax) || processMax <= processMin) {
|
||||
processMin = Math.min(...state.predicted, ...state.measured);
|
||||
processMax = Math.max(...state.predicted, ...state.measured);
|
||||
if (!Number.isFinite(processMin) || !Number.isFinite(processMax) || processMax <= processMin) {
|
||||
processMin = 0;
|
||||
processMax = 1;
|
||||
}
|
||||
}
|
||||
|
||||
return this.assessDrift(state.predicted, state.measured, processMin, processMax, {
|
||||
...options,
|
||||
metricId: metricKey,
|
||||
strictValidation: strict,
|
||||
predictedTimestamps: state.predictedTimestamps,
|
||||
measuredTimestamps: state.measuredTimestamps,
|
||||
});
|
||||
}
|
||||
|
||||
_ensureMetricState(metricId) {
|
||||
const key = String(metricId || this.legacyMetricId);
|
||||
if (!this.metricState.has(key)) {
|
||||
this.metricState.set(key, {
|
||||
predicted: [],
|
||||
measured: [],
|
||||
predictedTimestamps: [],
|
||||
measuredTimestamps: [],
|
||||
sampleCount: 0,
|
||||
longTermEwma: 0,
|
||||
profile: {},
|
||||
lastResult: null,
|
||||
});
|
||||
}
|
||||
return this.metricState.get(key);
|
||||
}
|
||||
|
||||
_resolveProfile(metricId, options = {}) {
|
||||
const state = this._ensureMetricState(metricId);
|
||||
const base = this.config.processing || {};
|
||||
return {
|
||||
windowSize: Number(options.windowSize ?? state.profile.windowSize ?? base.windowSize ?? 50),
|
||||
minSamplesForLongTerm: Number(options.minSamplesForLongTerm ?? state.profile.minSamplesForLongTerm ?? base.minSamplesForLongTerm ?? 100),
|
||||
ewmaAlpha: Number(options.ewmaAlpha ?? state.profile.ewmaAlpha ?? base.ewmaAlpha ?? 0.1),
|
||||
alignmentToleranceMs: Number(options.alignmentToleranceMs ?? state.profile.alignmentToleranceMs ?? base.alignmentToleranceMs ?? 2000),
|
||||
strictValidation: Boolean(options.strictValidation ?? state.profile.strictValidation ?? base.strictValidation ?? true),
|
||||
};
|
||||
}
|
||||
|
||||
_resolveStrict(options = {}, profile = null) {
|
||||
if (Object.prototype.hasOwnProperty.call(options, 'strictValidation')) {
|
||||
return Boolean(options.strictValidation);
|
||||
}
|
||||
if (profile && Object.prototype.hasOwnProperty.call(profile, 'strictValidation')) {
|
||||
return Boolean(profile.strictValidation);
|
||||
}
|
||||
return Boolean(this.config.processing?.strictValidation ?? true);
|
||||
}
|
||||
|
||||
_validateSeries(predicted, measured, options = {}) {
|
||||
if (!Array.isArray(predicted) || !Array.isArray(measured)) {
|
||||
this._failOrLog('predicted and measured must be arrays.', options);
|
||||
return { p: [], m: [] };
|
||||
}
|
||||
if (!predicted.length || !measured.length) {
|
||||
this._failOrLog('predicted and measured arrays must not be empty.', options);
|
||||
return { p: [], m: [] };
|
||||
}
|
||||
if (predicted.length !== measured.length) {
|
||||
this._failOrLog('predicted and measured arrays must have the same length.', options);
|
||||
return { p: [], m: [] };
|
||||
}
|
||||
|
||||
const p = predicted.map(Number);
|
||||
const m = measured.map(Number);
|
||||
const hasBad = p.some((v) => !Number.isFinite(v)) || m.some((v) => !Number.isFinite(v));
|
||||
if (hasBad) {
|
||||
this._failOrLog('predicted and measured arrays must contain finite numeric values.', options);
|
||||
return { p: [], m: [] };
|
||||
}
|
||||
return { p, m };
|
||||
}
|
||||
|
||||
_alignSeriesByTimestamp(predicted, measured, options = {}, profile = null) {
|
||||
const strict = this._resolveStrict(options, profile);
|
||||
const tolerance = Number(options.alignmentToleranceMs ?? profile?.alignmentToleranceMs ?? 2000);
|
||||
const predictedTimestamps = Array.isArray(options.predictedTimestamps) ? options.predictedTimestamps.map(Number) : null;
|
||||
const measuredTimestamps = Array.isArray(options.measuredTimestamps) ? options.measuredTimestamps.map(Number) : null;
|
||||
|
||||
if (!predictedTimestamps || !measuredTimestamps) {
|
||||
if (!Array.isArray(predicted) || !Array.isArray(measured)) {
|
||||
return { valid: false, reason: 'predicted and measured must be arrays.' };
|
||||
}
|
||||
if (predicted.length !== measured.length) {
|
||||
const reason = `Series length mismatch without timestamps: predicted=${predicted.length}, measured=${measured.length}`;
|
||||
if (strict) return { valid: false, reason };
|
||||
const n = Math.min(predicted.length, measured.length);
|
||||
if (n < 2) return { valid: false, reason };
|
||||
return {
|
||||
valid: true,
|
||||
predicted: predicted.slice(-n).map(Number),
|
||||
measured: measured.slice(-n).map(Number),
|
||||
flags: ['length_mismatch_realigned'],
|
||||
};
|
||||
}
|
||||
try {
|
||||
const { p, m } = this._validateSeries(predicted, measured, { ...options, strictValidation: true });
|
||||
return { valid: true, predicted: p, measured: m, flags: [] };
|
||||
} catch (error) {
|
||||
return { valid: false, reason: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
if (!Array.isArray(predicted) || !Array.isArray(measured)) {
|
||||
return { valid: false, reason: 'predicted and measured must be arrays.' };
|
||||
}
|
||||
if (predicted.length !== predictedTimestamps.length || measured.length !== measuredTimestamps.length) {
|
||||
return { valid: false, reason: 'timestamp arrays must match value-array lengths.' };
|
||||
}
|
||||
|
||||
const predictedSamples = predicted
|
||||
.map((v, i) => ({ value: Number(v), ts: predictedTimestamps[i] }))
|
||||
.filter((s) => Number.isFinite(s.value) && Number.isFinite(s.ts))
|
||||
.sort((a, b) => a.ts - b.ts);
|
||||
const measuredSamples = measured
|
||||
.map((v, i) => ({ value: Number(v), ts: measuredTimestamps[i] }))
|
||||
.filter((s) => Number.isFinite(s.value) && Number.isFinite(s.ts))
|
||||
.sort((a, b) => a.ts - b.ts);
|
||||
|
||||
const alignedPredicted = [];
|
||||
const alignedMeasured = [];
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
while (i < predictedSamples.length && j < measuredSamples.length) {
|
||||
const p = predictedSamples[i];
|
||||
const m = measuredSamples[j];
|
||||
const delta = p.ts - m.ts;
|
||||
if (Math.abs(delta) <= tolerance) {
|
||||
alignedPredicted.push(p.value);
|
||||
alignedMeasured.push(m.value);
|
||||
i += 1;
|
||||
j += 1;
|
||||
} else if (delta < 0) {
|
||||
i += 1;
|
||||
} else {
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (alignedPredicted.length < 2 || alignedMeasured.length < 2) {
|
||||
return { valid: false, reason: 'insufficient aligned samples after timestamp matching.' };
|
||||
}
|
||||
|
||||
return { valid: true, predicted: alignedPredicted, measured: alignedMeasured, flags: [] };
|
||||
}
|
||||
|
||||
_invalidAssessment(metricId, reason) {
|
||||
return {
|
||||
nrmse: NaN,
|
||||
longTermNRMSD: 0,
|
||||
immediateLevel: 0,
|
||||
immediateFeedback: 'Drift assessment unavailable',
|
||||
longTermLevel: 0,
|
||||
longTermFeedback: 'Drift assessment unavailable',
|
||||
valid: false,
|
||||
metricId: String(metricId || this.legacyMetricId),
|
||||
sampleCount: this._ensureMetricState(metricId).sampleCount,
|
||||
longTermReady: false,
|
||||
flags: [reason],
|
||||
};
|
||||
}
|
||||
|
||||
_failOrLog(message, options = {}) {
|
||||
const strict = this._resolveStrict(options);
|
||||
if (strict) {
|
||||
throw new Error(message);
|
||||
}
|
||||
this.logger?.warn?.(message);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ErrorMetrics;
|
||||
|
||||
7
src/nrmse/index.js
Normal file
7
src/nrmse/index.js
Normal file
@@ -0,0 +1,7 @@
|
||||
const nrmse = require('./errorMetrics.js');
|
||||
const nrmseConfig = require('./nrmseConfig.json');
|
||||
|
||||
module.exports = {
|
||||
nrmse,
|
||||
nrmseConfig,
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"general": {
|
||||
"name": {
|
||||
"default": "ErrorMetrics",
|
||||
"default": "errormetrics",
|
||||
"rules": {
|
||||
"type": "string",
|
||||
"description": "A human-readable name for the configuration."
|
||||
@@ -58,7 +58,7 @@
|
||||
},
|
||||
"functionality": {
|
||||
"softwareType": {
|
||||
"default": "errorMetrics",
|
||||
"default": "errormetrics",
|
||||
"rules": {
|
||||
"type": "string",
|
||||
"description": "Logical name identifying the software type."
|
||||
@@ -134,5 +134,47 @@
|
||||
"description": "High threshold for long-term normalized root mean squared deviation."
|
||||
}
|
||||
}
|
||||
},
|
||||
"processing": {
|
||||
"windowSize": {
|
||||
"default": 50,
|
||||
"rules": {
|
||||
"type": "integer",
|
||||
"min": 2,
|
||||
"description": "Rolling sample window size used for drift evaluation."
|
||||
}
|
||||
},
|
||||
"minSamplesForLongTerm": {
|
||||
"default": 100,
|
||||
"rules": {
|
||||
"type": "integer",
|
||||
"min": 1,
|
||||
"description": "Minimum sample count before long-term drift is considered mature."
|
||||
}
|
||||
},
|
||||
"ewmaAlpha": {
|
||||
"default": 0.1,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"min": 0.001,
|
||||
"max": 1,
|
||||
"description": "EWMA smoothing factor for long-term drift trend."
|
||||
}
|
||||
},
|
||||
"alignmentToleranceMs": {
|
||||
"default": 2000,
|
||||
"rules": {
|
||||
"type": "integer",
|
||||
"min": 0,
|
||||
"description": "Maximum timestamp delta allowed between predicted and measured sample pairs."
|
||||
}
|
||||
},
|
||||
"strictValidation": {
|
||||
"default": true,
|
||||
"rules": {
|
||||
"type": "boolean",
|
||||
"description": "When true, invalid inputs raise errors instead of producing silent outputs."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
5
src/outliers/index.js
Normal file
5
src/outliers/index.js
Normal file
@@ -0,0 +1,5 @@
|
||||
const outlierDetection = require('./outlierDetection.js');
|
||||
|
||||
module.exports = {
|
||||
outlierDetection,
|
||||
};
|
||||
@@ -61,6 +61,8 @@ class DynamicClusterDeviation {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = DynamicClusterDeviation;
|
||||
|
||||
// Rolling window simulation with outlier detection
|
||||
/*
|
||||
const detector = new DynamicClusterDeviation();
|
||||
@@ -86,4 +88,4 @@ dataStream.forEach((value, index) => {
|
||||
});
|
||||
|
||||
console.log("\nFinal detector cluster states:", JSON.stringify(detector.clusters, null, 2));
|
||||
*/
|
||||
*/
|
||||
|
||||
663
src/pid/PIDController.js
Normal file
663
src/pid/PIDController.js
Normal file
@@ -0,0 +1,663 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Production-focused discrete PID controller with modern control features:
|
||||
* - auto/manual and bumpless transfer
|
||||
* - freeze/unfreeze (hold output while optionally tracking process)
|
||||
* - derivative filtering and derivative-on-measurement/error
|
||||
* - anti-windup (clamp or back-calculation)
|
||||
* - output and integral limits
|
||||
* - output rate limiting
|
||||
* - deadband
|
||||
* - gain scheduling (array/function)
|
||||
* - feedforward and dynamic tunings at runtime
|
||||
*/
|
||||
class PIDController {
|
||||
constructor(options = {}) {
|
||||
const {
|
||||
kp = 1,
|
||||
ki = 0,
|
||||
kd = 0,
|
||||
sampleTime = 1000,
|
||||
derivativeFilter = 0.15,
|
||||
outputMin = Number.NEGATIVE_INFINITY,
|
||||
outputMax = Number.POSITIVE_INFINITY,
|
||||
integralMin = null,
|
||||
integralMax = null,
|
||||
derivativeOnMeasurement = true,
|
||||
setpointWeight = 1,
|
||||
derivativeWeight = 0,
|
||||
deadband = 0,
|
||||
outputRateLimitUp = Number.POSITIVE_INFINITY,
|
||||
outputRateLimitDown = Number.POSITIVE_INFINITY,
|
||||
antiWindupMode = 'clamp',
|
||||
backCalculationGain = 0,
|
||||
gainSchedule = null,
|
||||
autoMode = true,
|
||||
trackOnManual = true,
|
||||
frozen = false,
|
||||
freezeTrackMeasurement = true,
|
||||
freezeTrackError = false,
|
||||
} = options;
|
||||
|
||||
this.kp = 0;
|
||||
this.ki = 0;
|
||||
this.kd = 0;
|
||||
|
||||
this.setTunings({ kp, ki, kd });
|
||||
this.setSampleTime(sampleTime);
|
||||
this.setOutputLimits(outputMin, outputMax);
|
||||
this.setIntegralLimits(integralMin, integralMax);
|
||||
this.setDerivativeFilter(derivativeFilter);
|
||||
this.setSetpointWeights({ beta: setpointWeight, gamma: derivativeWeight });
|
||||
this.setDeadband(deadband);
|
||||
this.setOutputRateLimits(outputRateLimitUp, outputRateLimitDown);
|
||||
this.setAntiWindup({ mode: antiWindupMode, backCalculationGain });
|
||||
this.setGainSchedule(gainSchedule);
|
||||
|
||||
this.derivativeOnMeasurement = Boolean(derivativeOnMeasurement);
|
||||
this.autoMode = Boolean(autoMode);
|
||||
this.trackOnManual = Boolean(trackOnManual);
|
||||
|
||||
this.frozen = Boolean(frozen);
|
||||
this.freezeTrackMeasurement = Boolean(freezeTrackMeasurement);
|
||||
this.freezeTrackError = Boolean(freezeTrackError);
|
||||
|
||||
this.reset();
|
||||
}
|
||||
|
||||
setTunings({ kp = this.kp, ki = this.ki, kd = this.kd } = {}) {
|
||||
[kp, ki, kd].forEach((gain, index) => {
|
||||
if (!Number.isFinite(gain)) {
|
||||
const label = ['kp', 'ki', 'kd'][index];
|
||||
throw new TypeError(`${label} must be a finite number`);
|
||||
}
|
||||
});
|
||||
|
||||
this.kp = kp;
|
||||
this.ki = ki;
|
||||
this.kd = kd;
|
||||
return this;
|
||||
}
|
||||
|
||||
setSampleTime(sampleTimeMs = this.sampleTime) {
|
||||
if (!Number.isFinite(sampleTimeMs) || sampleTimeMs <= 0) {
|
||||
throw new RangeError('sampleTime must be a positive number of milliseconds');
|
||||
}
|
||||
|
||||
this.sampleTime = sampleTimeMs;
|
||||
return this;
|
||||
}
|
||||
|
||||
setOutputLimits(min = this.outputMin, max = this.outputMax) {
|
||||
if (!Number.isFinite(min) && min !== Number.NEGATIVE_INFINITY) {
|
||||
throw new TypeError('outputMin must be finite or -Infinity');
|
||||
}
|
||||
if (!Number.isFinite(max) && max !== Number.POSITIVE_INFINITY) {
|
||||
throw new TypeError('outputMax must be finite or Infinity');
|
||||
}
|
||||
if (min >= max) {
|
||||
throw new RangeError('outputMin must be smaller than outputMax');
|
||||
}
|
||||
|
||||
this.outputMin = min;
|
||||
this.outputMax = max;
|
||||
this.lastOutput = this._clamp(this.lastOutput ?? 0, this.outputMin, this.outputMax);
|
||||
return this;
|
||||
}
|
||||
|
||||
setIntegralLimits(min = this.integralMin ?? null, max = this.integralMax ?? null) {
|
||||
if (min !== null && !Number.isFinite(min)) {
|
||||
throw new TypeError('integralMin must be null or a finite number');
|
||||
}
|
||||
if (max !== null && !Number.isFinite(max)) {
|
||||
throw new TypeError('integralMax must be null or a finite number');
|
||||
}
|
||||
if (min !== null && max !== null && min > max) {
|
||||
throw new RangeError('integralMin must be smaller than integralMax');
|
||||
}
|
||||
|
||||
this.integralMin = min;
|
||||
this.integralMax = max;
|
||||
this.integral = this._applyIntegralLimits(this.integral ?? 0);
|
||||
return this;
|
||||
}
|
||||
|
||||
setDerivativeFilter(value = this.derivativeFilter ?? 0) {
|
||||
if (!Number.isFinite(value) || value < 0 || value > 1) {
|
||||
throw new RangeError('derivativeFilter must be between 0 and 1');
|
||||
}
|
||||
|
||||
this.derivativeFilter = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
setSetpointWeights({ beta = this.setpointWeight ?? 1, gamma = this.derivativeWeight ?? 0 } = {}) {
|
||||
if (!Number.isFinite(beta) || !Number.isFinite(gamma)) {
|
||||
throw new TypeError('setpoint and derivative weights must be finite numbers');
|
||||
}
|
||||
|
||||
this.setpointWeight = beta;
|
||||
this.derivativeWeight = gamma;
|
||||
return this;
|
||||
}
|
||||
|
||||
setDeadband(value = this.deadband ?? 0) {
|
||||
if (!Number.isFinite(value) || value < 0) {
|
||||
throw new RangeError('deadband must be a non-negative finite number');
|
||||
}
|
||||
|
||||
this.deadband = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
setOutputRateLimits(up = this.outputRateLimitUp, down = this.outputRateLimitDown) {
|
||||
if (!Number.isFinite(up) && up !== Number.POSITIVE_INFINITY) {
|
||||
throw new TypeError('outputRateLimitUp must be finite or Infinity');
|
||||
}
|
||||
if (!Number.isFinite(down) && down !== Number.POSITIVE_INFINITY) {
|
||||
throw new TypeError('outputRateLimitDown must be finite or Infinity');
|
||||
}
|
||||
if (up <= 0 || down <= 0) {
|
||||
throw new RangeError('output rate limits must be positive values');
|
||||
}
|
||||
|
||||
this.outputRateLimitUp = up;
|
||||
this.outputRateLimitDown = down;
|
||||
return this;
|
||||
}
|
||||
|
||||
setAntiWindup({ mode = this.antiWindupMode ?? 'clamp', backCalculationGain = this.backCalculationGain ?? 0 } = {}) {
|
||||
const normalized = String(mode || 'clamp').trim().toLowerCase();
|
||||
if (normalized !== 'clamp' && normalized !== 'backcalc') {
|
||||
throw new RangeError('anti windup mode must be "clamp" or "backcalc"');
|
||||
}
|
||||
if (!Number.isFinite(backCalculationGain) || backCalculationGain < 0) {
|
||||
throw new RangeError('backCalculationGain must be a non-negative finite number');
|
||||
}
|
||||
|
||||
this.antiWindupMode = normalized;
|
||||
this.backCalculationGain = backCalculationGain;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gain schedule options:
|
||||
* - null: disabled
|
||||
* - function(input, state) => { kp, ki, kd }
|
||||
* - array: [{ min, max, kp, ki, kd }, ...]
|
||||
*/
|
||||
setGainSchedule(schedule = null) {
|
||||
if (schedule == null) {
|
||||
this.gainSchedule = null;
|
||||
return this;
|
||||
}
|
||||
|
||||
if (typeof schedule === 'function') {
|
||||
this.gainSchedule = schedule;
|
||||
return this;
|
||||
}
|
||||
|
||||
if (!Array.isArray(schedule)) {
|
||||
throw new TypeError('gainSchedule must be null, a function, or an array');
|
||||
}
|
||||
|
||||
schedule.forEach((entry, index) => {
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
throw new TypeError(`gainSchedule[${index}] must be an object`);
|
||||
}
|
||||
const { min = Number.NEGATIVE_INFINITY, max = Number.POSITIVE_INFINITY, kp, ki, kd } = entry;
|
||||
if (!Number.isFinite(min) && min !== Number.NEGATIVE_INFINITY) {
|
||||
throw new TypeError(`gainSchedule[${index}].min must be finite or -Infinity`);
|
||||
}
|
||||
if (!Number.isFinite(max) && max !== Number.POSITIVE_INFINITY) {
|
||||
throw new TypeError(`gainSchedule[${index}].max must be finite or Infinity`);
|
||||
}
|
||||
if (min >= max) {
|
||||
throw new RangeError(`gainSchedule[${index}] min must be smaller than max`);
|
||||
}
|
||||
[kp, ki, kd].forEach((value, gainIndex) => {
|
||||
const label = ['kp', 'ki', 'kd'][gainIndex];
|
||||
if (!Number.isFinite(value)) {
|
||||
throw new TypeError(`gainSchedule[${index}].${label} must be finite`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
this.gainSchedule = schedule;
|
||||
return this;
|
||||
}
|
||||
|
||||
setMode(mode, options = {}) {
|
||||
if (mode !== 'automatic' && mode !== 'manual') {
|
||||
throw new Error('mode must be either "automatic" or "manual"');
|
||||
}
|
||||
|
||||
const nextAuto = mode === 'automatic';
|
||||
const previousAuto = this.autoMode;
|
||||
this.autoMode = nextAuto;
|
||||
|
||||
if (options && Number.isFinite(options.manualOutput)) {
|
||||
this.setManualOutput(options.manualOutput);
|
||||
}
|
||||
|
||||
if (!previousAuto && nextAuto) {
|
||||
this._initializeForAuto(options);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
freeze(options = {}) {
|
||||
this.frozen = true;
|
||||
this.freezeTrackMeasurement = options.trackMeasurement !== false;
|
||||
this.freezeTrackError = Boolean(options.trackError);
|
||||
|
||||
if (Number.isFinite(options.output)) {
|
||||
this.setManualOutput(options.output);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
unfreeze() {
|
||||
this.frozen = false;
|
||||
return this;
|
||||
}
|
||||
|
||||
isFrozen() {
|
||||
return this.frozen;
|
||||
}
|
||||
|
||||
setManualOutput(value) {
|
||||
this._assertNumeric('manual output', value);
|
||||
this.lastOutput = this._clamp(value, this.outputMin, this.outputMax);
|
||||
return this.lastOutput;
|
||||
}
|
||||
|
||||
reset(state = {}) {
|
||||
const {
|
||||
integral = 0,
|
||||
lastOutput = 0,
|
||||
timestamp = null,
|
||||
prevMeasurement = null,
|
||||
prevError = null,
|
||||
prevDerivativeInput = null,
|
||||
derivativeState = 0,
|
||||
} = state;
|
||||
|
||||
this.integral = this._applyIntegralLimits(Number.isFinite(integral) ? integral : 0);
|
||||
this.prevError = Number.isFinite(prevError) ? prevError : null;
|
||||
this.prevMeasurement = Number.isFinite(prevMeasurement) ? prevMeasurement : null;
|
||||
this.prevDerivativeInput = Number.isFinite(prevDerivativeInput) ? prevDerivativeInput : null;
|
||||
this.lastOutput = this._clamp(
|
||||
Number.isFinite(lastOutput) ? lastOutput : 0,
|
||||
this.outputMin ?? Number.NEGATIVE_INFINITY,
|
||||
this.outputMax ?? Number.POSITIVE_INFINITY
|
||||
);
|
||||
this.lastTimestamp = Number.isFinite(timestamp) ? timestamp : null;
|
||||
this.derivativeState = Number.isFinite(derivativeState) ? derivativeState : 0;
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
update(setpoint, measurement, timestamp = Date.now(), options = {}) {
|
||||
if (timestamp && typeof timestamp === 'object' && options && Object.keys(options).length === 0) {
|
||||
options = timestamp;
|
||||
timestamp = Date.now();
|
||||
}
|
||||
|
||||
this._assertNumeric('setpoint', setpoint);
|
||||
this._assertNumeric('measurement', measurement);
|
||||
this._assertNumeric('timestamp', timestamp);
|
||||
|
||||
const opts = options || {};
|
||||
|
||||
if (opts.tunings && typeof opts.tunings === 'object') {
|
||||
this.setTunings(opts.tunings);
|
||||
}
|
||||
|
||||
if (Number.isFinite(opts.gainInput)) {
|
||||
this._applyGainSchedule(opts.gainInput, { setpoint, measurement, timestamp });
|
||||
}
|
||||
|
||||
if (typeof opts.setMode === 'string') {
|
||||
this.setMode(opts.setMode, opts);
|
||||
}
|
||||
|
||||
if (opts.freeze === true) this.freeze(opts);
|
||||
if (opts.unfreeze === true) this.unfreeze();
|
||||
|
||||
if (Number.isFinite(opts.manualOutput)) {
|
||||
this.setManualOutput(opts.manualOutput);
|
||||
}
|
||||
|
||||
const feedForward = Number.isFinite(opts.feedForward) ? opts.feedForward : 0;
|
||||
const force = Boolean(opts.force);
|
||||
|
||||
const error = setpoint - measurement;
|
||||
|
||||
if (!this.autoMode) {
|
||||
if (this.trackOnManual) {
|
||||
this._trackProcessState(setpoint, measurement, error, timestamp);
|
||||
}
|
||||
return this.lastOutput;
|
||||
}
|
||||
|
||||
if (this.frozen) {
|
||||
if (this.freezeTrackMeasurement || this.freezeTrackError) {
|
||||
this._trackProcessState(setpoint, measurement, error, timestamp, {
|
||||
trackMeasurement: this.freezeTrackMeasurement,
|
||||
trackError: this.freezeTrackError,
|
||||
});
|
||||
}
|
||||
return this.lastOutput;
|
||||
}
|
||||
|
||||
if (!force && this.lastTimestamp !== null && (timestamp - this.lastTimestamp) < this.sampleTime) {
|
||||
return this.lastOutput;
|
||||
}
|
||||
|
||||
const elapsedMs = this.lastTimestamp === null ? this.sampleTime : (timestamp - this.lastTimestamp);
|
||||
const dtSeconds = Math.max(elapsedMs / 1000, Number.EPSILON);
|
||||
|
||||
const inDeadband = Math.abs(error) <= this.deadband;
|
||||
if (inDeadband) {
|
||||
this.prevError = error;
|
||||
this.prevMeasurement = measurement;
|
||||
this.prevDerivativeInput = this.derivativeOnMeasurement
|
||||
? measurement
|
||||
: ((this.derivativeWeight * setpoint) - measurement);
|
||||
this.lastTimestamp = timestamp;
|
||||
return this.lastOutput;
|
||||
}
|
||||
|
||||
const effectiveError = error;
|
||||
|
||||
const pInput = (this.setpointWeight * setpoint) - measurement;
|
||||
const pTerm = this.kp * pInput;
|
||||
|
||||
const derivativeRaw = this._computeDerivative({ setpoint, measurement, error, dtSeconds });
|
||||
this.derivativeState = this.derivativeFilter === 0
|
||||
? derivativeRaw
|
||||
: this.derivativeState + (derivativeRaw - this.derivativeState) * (1 - this.derivativeFilter);
|
||||
|
||||
const dTerm = this.kd * this.derivativeState;
|
||||
|
||||
const nextIntegral = this._applyIntegralLimits(this.integral + (effectiveError * dtSeconds));
|
||||
let unclampedOutput = pTerm + (this.ki * nextIntegral) + dTerm + feedForward;
|
||||
let clampedOutput = this._clamp(unclampedOutput, this.outputMin, this.outputMax);
|
||||
|
||||
if (this.antiWindupMode === 'backcalc' && this.ki !== 0 && this.backCalculationGain > 0) {
|
||||
const correctedIntegral = nextIntegral + ((clampedOutput - unclampedOutput) * this.backCalculationGain * dtSeconds);
|
||||
this.integral = this._applyIntegralLimits(correctedIntegral);
|
||||
} else {
|
||||
const saturatingHigh = clampedOutput >= this.outputMax && effectiveError > 0;
|
||||
const saturatingLow = clampedOutput <= this.outputMin && effectiveError < 0;
|
||||
this.integral = (saturatingHigh || saturatingLow) ? this.integral : nextIntegral;
|
||||
}
|
||||
|
||||
let output = pTerm + (this.ki * this.integral) + dTerm + feedForward;
|
||||
output = this._clamp(output, this.outputMin, this.outputMax);
|
||||
|
||||
if (this.lastTimestamp !== null) {
|
||||
output = this._applyRateLimit(output, this.lastOutput, dtSeconds);
|
||||
}
|
||||
|
||||
if (Number.isFinite(opts.trackingOutput)) {
|
||||
this._trackIntegralToOutput(opts.trackingOutput, { pTerm, dTerm, feedForward });
|
||||
output = this._clamp(opts.trackingOutput, this.outputMin, this.outputMax);
|
||||
}
|
||||
|
||||
this.lastOutput = output;
|
||||
this.prevError = error;
|
||||
this.prevMeasurement = measurement;
|
||||
this.prevDerivativeInput = this.derivativeOnMeasurement
|
||||
? measurement
|
||||
: ((this.derivativeWeight * setpoint) - measurement);
|
||||
this.lastTimestamp = timestamp;
|
||||
|
||||
return this.lastOutput;
|
||||
}
|
||||
|
||||
getState() {
|
||||
return {
|
||||
kp: this.kp,
|
||||
ki: this.ki,
|
||||
kd: this.kd,
|
||||
sampleTime: this.sampleTime,
|
||||
outputLimits: { min: this.outputMin, max: this.outputMax },
|
||||
integralLimits: { min: this.integralMin, max: this.integralMax },
|
||||
derivativeFilter: this.derivativeFilter,
|
||||
derivativeOnMeasurement: this.derivativeOnMeasurement,
|
||||
setpointWeight: this.setpointWeight,
|
||||
derivativeWeight: this.derivativeWeight,
|
||||
deadband: this.deadband,
|
||||
outputRateLimits: { up: this.outputRateLimitUp, down: this.outputRateLimitDown },
|
||||
antiWindupMode: this.antiWindupMode,
|
||||
backCalculationGain: this.backCalculationGain,
|
||||
autoMode: this.autoMode,
|
||||
frozen: this.frozen,
|
||||
integral: this.integral,
|
||||
derivativeState: this.derivativeState,
|
||||
lastOutput: this.lastOutput,
|
||||
lastTimestamp: this.lastTimestamp,
|
||||
};
|
||||
}
|
||||
|
||||
getLastOutput() {
|
||||
return this.lastOutput;
|
||||
}
|
||||
|
||||
_initializeForAuto(options = {}) {
|
||||
const setpoint = Number.isFinite(options.setpoint) ? options.setpoint : null;
|
||||
const measurement = Number.isFinite(options.measurement) ? options.measurement : null;
|
||||
const timestamp = Number.isFinite(options.timestamp) ? options.timestamp : Date.now();
|
||||
|
||||
if (measurement !== null) {
|
||||
this.prevMeasurement = measurement;
|
||||
}
|
||||
if (setpoint !== null && measurement !== null) {
|
||||
this.prevError = setpoint - measurement;
|
||||
this.prevDerivativeInput = this.derivativeOnMeasurement
|
||||
? measurement
|
||||
: ((this.derivativeWeight * setpoint) - measurement);
|
||||
}
|
||||
|
||||
this.lastTimestamp = timestamp;
|
||||
|
||||
if (this.ki !== 0 && setpoint !== null && measurement !== null) {
|
||||
const pTerm = this.kp * ((this.setpointWeight * setpoint) - measurement);
|
||||
const dTerm = this.kd * this.derivativeState;
|
||||
const trackedIntegral = (this.lastOutput - pTerm - dTerm) / this.ki;
|
||||
this.integral = this._applyIntegralLimits(Number.isFinite(trackedIntegral) ? trackedIntegral : this.integral);
|
||||
}
|
||||
}
|
||||
|
||||
_trackProcessState(setpoint, measurement, error, timestamp, tracking = {}) {
|
||||
const trackMeasurement = tracking.trackMeasurement !== false;
|
||||
const trackError = Boolean(tracking.trackError);
|
||||
|
||||
if (trackMeasurement) {
|
||||
this.prevMeasurement = measurement;
|
||||
this.prevDerivativeInput = this.derivativeOnMeasurement
|
||||
? measurement
|
||||
: ((this.derivativeWeight * setpoint) - measurement);
|
||||
}
|
||||
|
||||
if (trackError) {
|
||||
this.prevError = error;
|
||||
}
|
||||
|
||||
this.lastTimestamp = timestamp;
|
||||
}
|
||||
|
||||
_trackIntegralToOutput(trackingOutput, terms) {
|
||||
if (this.ki === 0) return;
|
||||
const { pTerm, dTerm, feedForward } = terms;
|
||||
const targetIntegral = (trackingOutput - pTerm - dTerm - feedForward) / this.ki;
|
||||
if (Number.isFinite(targetIntegral)) {
|
||||
this.integral = this._applyIntegralLimits(targetIntegral);
|
||||
}
|
||||
}
|
||||
|
||||
_applyGainSchedule(input, state) {
|
||||
if (!this.gainSchedule) return;
|
||||
|
||||
if (typeof this.gainSchedule === 'function') {
|
||||
const tunings = this.gainSchedule(input, this.getState(), state);
|
||||
if (tunings && typeof tunings === 'object') {
|
||||
this.setTunings(tunings);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const matched = this.gainSchedule.find((entry) => input >= entry.min && input < entry.max);
|
||||
if (matched) {
|
||||
this.setTunings({ kp: matched.kp, ki: matched.ki, kd: matched.kd });
|
||||
}
|
||||
}
|
||||
|
||||
_computeDerivative({ setpoint, measurement, error, dtSeconds }) {
|
||||
if (!(dtSeconds > 0) || !Number.isFinite(dtSeconds)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (this.derivativeOnMeasurement) {
|
||||
if (this.prevMeasurement === null) return 0;
|
||||
return -(measurement - this.prevMeasurement) / dtSeconds;
|
||||
}
|
||||
|
||||
const derivativeInput = (this.derivativeWeight * setpoint) - measurement;
|
||||
if (this.prevDerivativeInput === null) return 0;
|
||||
const derivativeFromInput = (derivativeInput - this.prevDerivativeInput) / dtSeconds;
|
||||
|
||||
if (Number.isFinite(derivativeFromInput)) {
|
||||
return derivativeFromInput;
|
||||
}
|
||||
|
||||
if (this.prevError === null) return 0;
|
||||
return (error - this.prevError) / dtSeconds;
|
||||
}
|
||||
|
||||
_applyRateLimit(nextOutput, previousOutput, dtSeconds) {
|
||||
const maxRise = Number.isFinite(this.outputRateLimitUp)
|
||||
? this.outputRateLimitUp * dtSeconds
|
||||
: Number.POSITIVE_INFINITY;
|
||||
const maxFall = Number.isFinite(this.outputRateLimitDown)
|
||||
? this.outputRateLimitDown * dtSeconds
|
||||
: Number.POSITIVE_INFINITY;
|
||||
|
||||
const lower = previousOutput - maxFall;
|
||||
const upper = previousOutput + maxRise;
|
||||
return this._clamp(nextOutput, lower, upper);
|
||||
}
|
||||
|
||||
_applyIntegralLimits(value) {
|
||||
if (!Number.isFinite(value)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let result = value;
|
||||
if (this.integralMin !== null && result < this.integralMin) {
|
||||
result = this.integralMin;
|
||||
}
|
||||
if (this.integralMax !== null && result > this.integralMax) {
|
||||
result = this.integralMax;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
_assertNumeric(label, value) {
|
||||
if (!Number.isFinite(value)) {
|
||||
throw new TypeError(`${label} must be a finite number`);
|
||||
}
|
||||
}
|
||||
|
||||
_clamp(value, min, max) {
|
||||
if (value < min) return min;
|
||||
if (value > max) return max;
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cascade PID utility:
|
||||
* - primary PID controls the outer variable
|
||||
* - primary output becomes setpoint for secondary PID
|
||||
*/
|
||||
class CascadePIDController {
|
||||
constructor(options = {}) {
|
||||
const {
|
||||
primary = {},
|
||||
secondary = {},
|
||||
} = options;
|
||||
|
||||
this.primary = primary instanceof PIDController ? primary : new PIDController(primary);
|
||||
this.secondary = secondary instanceof PIDController ? secondary : new PIDController(secondary);
|
||||
}
|
||||
|
||||
update({
|
||||
setpoint,
|
||||
primaryMeasurement,
|
||||
secondaryMeasurement,
|
||||
timestamp = Date.now(),
|
||||
primaryOptions = {},
|
||||
secondaryOptions = {},
|
||||
} = {}) {
|
||||
if (!Number.isFinite(setpoint)) {
|
||||
throw new TypeError('setpoint must be a finite number');
|
||||
}
|
||||
if (!Number.isFinite(primaryMeasurement)) {
|
||||
throw new TypeError('primaryMeasurement must be a finite number');
|
||||
}
|
||||
if (!Number.isFinite(secondaryMeasurement)) {
|
||||
throw new TypeError('secondaryMeasurement must be a finite number');
|
||||
}
|
||||
|
||||
const secondarySetpoint = this.primary.update(setpoint, primaryMeasurement, timestamp, primaryOptions);
|
||||
const controlOutput = this.secondary.update(secondarySetpoint, secondaryMeasurement, timestamp, secondaryOptions);
|
||||
|
||||
return {
|
||||
primaryOutput: secondarySetpoint,
|
||||
secondaryOutput: controlOutput,
|
||||
state: this.getState(),
|
||||
};
|
||||
}
|
||||
|
||||
setMode(mode, options = {}) {
|
||||
this.primary.setMode(mode, options.primary || options);
|
||||
this.secondary.setMode(mode, options.secondary || options);
|
||||
return this;
|
||||
}
|
||||
|
||||
freeze(options = {}) {
|
||||
this.primary.freeze(options.primary || options);
|
||||
this.secondary.freeze(options.secondary || options);
|
||||
return this;
|
||||
}
|
||||
|
||||
unfreeze() {
|
||||
this.primary.unfreeze();
|
||||
this.secondary.unfreeze();
|
||||
return this;
|
||||
}
|
||||
|
||||
reset(state = {}) {
|
||||
this.primary.reset(state.primary || {});
|
||||
this.secondary.reset(state.secondary || {});
|
||||
return this;
|
||||
}
|
||||
|
||||
getState() {
|
||||
return {
|
||||
primary: this.primary.getState(),
|
||||
secondary: this.secondary.getState(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PIDController,
|
||||
CascadePIDController,
|
||||
};
|
||||
87
src/pid/examples.js
Normal file
87
src/pid/examples.js
Normal file
@@ -0,0 +1,87 @@
|
||||
const { PIDController } = require('./index');
|
||||
|
||||
console.log('=== PID CONTROLLER EXAMPLES ===\n');
|
||||
console.log('This guide shows how to instantiate, tune, and operate the PID helper.\n');
|
||||
|
||||
// ====================================
|
||||
// EXAMPLE 1: FLOW CONTROL LOOP
|
||||
// ====================================
|
||||
console.log('--- Example 1: Pump speed control ---');
|
||||
|
||||
const pumpController = new PIDController({
|
||||
kp: 1.1,
|
||||
ki: 0.35,
|
||||
kd: 0.08,
|
||||
sampleTime: 250, // ms
|
||||
outputMin: 0,
|
||||
outputMax: 100,
|
||||
derivativeFilter: 0.2
|
||||
});
|
||||
|
||||
const pumpSetpoint = 75; // desired flow percentage
|
||||
let pumpFlow = 20;
|
||||
const pumpStart = Date.now();
|
||||
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
const timestamp = pumpStart + (i + 1) * pumpController.sampleTime;
|
||||
const controlSignal = pumpController.update(pumpSetpoint, pumpFlow, timestamp);
|
||||
|
||||
// Simple first-order plant approximation
|
||||
pumpFlow += (controlSignal - pumpFlow) * 0.12;
|
||||
pumpFlow -= (pumpFlow - pumpSetpoint) * 0.05; // disturbance rejection
|
||||
|
||||
console.log(
|
||||
`Cycle ${i + 1}: output=${controlSignal.toFixed(2)}% | flow=${pumpFlow.toFixed(2)}%`
|
||||
);
|
||||
}
|
||||
|
||||
console.log('Pump loop state:', pumpController.getState(), '\n');
|
||||
|
||||
// ====================================
|
||||
// EXAMPLE 2: TANK LEVEL WITH MANUAL/AUTO
|
||||
// ====================================
|
||||
console.log('--- Example 2: Tank level handover ---');
|
||||
|
||||
const tankController = new PIDController({
|
||||
kp: 2.0,
|
||||
ki: 0.5,
|
||||
kd: 0.25,
|
||||
sampleTime: 400,
|
||||
derivativeFilter: 0.25,
|
||||
outputMin: 0,
|
||||
outputMax: 1
|
||||
}).setIntegralLimits(-0.3, 0.3);
|
||||
|
||||
tankController.setMode('manual');
|
||||
tankController.setManualOutput(0.4);
|
||||
console.log(`Manual output locked at ${tankController.getLastOutput().toFixed(2)}\n`);
|
||||
|
||||
tankController.setMode('automatic');
|
||||
|
||||
let level = 0.2;
|
||||
const levelSetpoint = 0.8;
|
||||
const tankStart = Date.now();
|
||||
|
||||
for (let step = 0; step < 8; step += 1) {
|
||||
const timestamp = tankStart + (step + 1) * tankController.sampleTime;
|
||||
const output = tankController.update(levelSetpoint, level, timestamp);
|
||||
|
||||
// Integrating process with slight disturbance
|
||||
level += (output - 0.5) * 0.18;
|
||||
level += 0.02; // inflow bump
|
||||
level = Math.max(0, Math.min(1, level));
|
||||
|
||||
console.log(
|
||||
`Cycle ${step + 1}: output=${output.toFixed(3)} | level=${level.toFixed(3)}`
|
||||
);
|
||||
}
|
||||
|
||||
console.log('\nBest practice tips:');
|
||||
console.log(' - Call update() on a fixed interval (sampleTime).');
|
||||
console.log(' - Clamp output and integral to avoid windup.');
|
||||
console.log(' - Use setMode("manual") during maintenance or bump-less transfer.');
|
||||
|
||||
module.exports = {
|
||||
pumpController,
|
||||
tankController
|
||||
};
|
||||
14
src/pid/index.js
Normal file
14
src/pid/index.js
Normal file
@@ -0,0 +1,14 @@
|
||||
const { PIDController, CascadePIDController } = require('./PIDController');
|
||||
|
||||
/**
|
||||
* Convenience factories.
|
||||
*/
|
||||
const createPidController = (options) => new PIDController(options);
|
||||
const createCascadePidController = (options) => new CascadePIDController(options);
|
||||
|
||||
module.exports = {
|
||||
PIDController,
|
||||
CascadePIDController,
|
||||
createPidController,
|
||||
createCascadePidController,
|
||||
};
|
||||
9
src/predict/index.js
Normal file
9
src/predict/index.js
Normal file
@@ -0,0 +1,9 @@
|
||||
const predict = require('./predict_class.js');
|
||||
const interpolation = require('./interpolation.js');
|
||||
const predictConfig = require('./predictConfig.json');
|
||||
|
||||
module.exports = {
|
||||
predict,
|
||||
interpolation,
|
||||
predictConfig,
|
||||
};
|
||||
@@ -350,6 +350,7 @@ class Predict {
|
||||
}
|
||||
|
||||
buildAllFxyCurves(curve) {
|
||||
|
||||
let globalMinY = Infinity;
|
||||
let globalMaxY = -Infinity;
|
||||
|
||||
|
||||
11
src/state/index.js
Normal file
11
src/state/index.js
Normal file
@@ -0,0 +1,11 @@
|
||||
const state = require('./state.js');
|
||||
const stateManager = require('./stateManager.js');
|
||||
const movementManager = require('./movementManager.js');
|
||||
const stateConfig = require('./stateConfig.json');
|
||||
|
||||
module.exports = {
|
||||
state,
|
||||
stateManager,
|
||||
movementManager,
|
||||
stateConfig,
|
||||
};
|
||||
@@ -13,12 +13,12 @@ class movementManager {
|
||||
|
||||
this.speed = speed;
|
||||
this.maxSpeed = maxSpeed;
|
||||
console.log(`MovementManager: Initial speed=${this.speed}, maxSpeed=${maxSpeed}`);
|
||||
this.interval = interval;
|
||||
this.timeleft = 0; // timeleft of current movement
|
||||
|
||||
this.logger = logger;
|
||||
this.movementMode = config.movement.mode;
|
||||
this.logger?.debug?.(`MovementManager initialized: speed=${this.speed}, maxSpeed=${this.maxSpeed}`);
|
||||
}
|
||||
|
||||
getCurrentPosition() {
|
||||
@@ -81,11 +81,8 @@ class movementManager {
|
||||
const direction = targetPosition > this.currentPosition ? 1 : -1;
|
||||
const distance = Math.abs(targetPosition - this.currentPosition);
|
||||
|
||||
// Speed is a fraction [0,1] of full-range per second
|
||||
this.speed = Math.min(Math.max(this.speed, 0), 1);
|
||||
const fullRange = this.maxPosition - this.minPosition;
|
||||
const velocity = this.speed * fullRange; // units per second
|
||||
if (velocity === 0) {
|
||||
const velocity = this.getVelocity(); // units per second
|
||||
if (velocity <= 0) {
|
||||
return reject(new Error("Movement aborted: zero speed"));
|
||||
}
|
||||
|
||||
@@ -154,11 +151,11 @@ class movementManager {
|
||||
const direction = targetPosition > this.currentPosition ? 1 : -1;
|
||||
const distance = Math.abs(targetPosition - this.currentPosition);
|
||||
|
||||
// Ensure speed is a percentage [0, 1]
|
||||
this.speed = Math.min(Math.max(this.speed, 0), 1);
|
||||
|
||||
// Calculate duration based on percentage of distance per second
|
||||
const duration = 1 / this.speed; // 1 second for 100% of the distance
|
||||
const velocity = this.getVelocity();
|
||||
if (velocity <= 0) {
|
||||
return reject(new Error("Movement aborted: zero speed"));
|
||||
}
|
||||
const duration = distance / velocity;
|
||||
|
||||
this.timeleft = duration; //set this so other classes can use it
|
||||
this.logger.debug(
|
||||
@@ -217,13 +214,16 @@ class movementManager {
|
||||
const direction = targetPosition > this.currentPosition ? 1 : -1;
|
||||
const totalDistance = Math.abs(targetPosition - this.currentPosition);
|
||||
const startPosition = this.currentPosition;
|
||||
this.speed = Math.min(Math.max(this.speed, 0), 1);
|
||||
const velocity = this.getVelocity();
|
||||
if (velocity <= 0) {
|
||||
return reject(new Error("Movement aborted: zero speed"));
|
||||
}
|
||||
|
||||
const easeFunction = (t) =>
|
||||
t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
||||
|
||||
let elapsedTime = 0;
|
||||
const duration = totalDistance / this.speed;
|
||||
const duration = totalDistance / velocity;
|
||||
this.timeleft = duration;
|
||||
const interval = this.interval;
|
||||
|
||||
@@ -273,6 +273,20 @@ class movementManager {
|
||||
constrain(value) {
|
||||
return Math.min(Math.max(value, this.minPosition), this.maxPosition);
|
||||
}
|
||||
|
||||
getNormalizedSpeed() {
|
||||
const rawSpeed = Number.isFinite(this.speed) ? this.speed : 0;
|
||||
const clampedSpeed = Math.max(0, rawSpeed);
|
||||
const hasMax = Number.isFinite(this.maxSpeed) && this.maxSpeed > 0;
|
||||
const effectiveSpeed = hasMax ? Math.min(clampedSpeed, this.maxSpeed) : clampedSpeed;
|
||||
return effectiveSpeed / 100; // convert %/s -> fraction of range per second
|
||||
}
|
||||
|
||||
getVelocity() {
|
||||
const normalizedSpeed = this.getNormalizedSpeed();
|
||||
const fullRange = this.maxPosition - this.minPosition;
|
||||
return normalizedSpeed * fullRange;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = movementManager;
|
||||
|
||||
@@ -52,7 +52,11 @@ class state{
|
||||
return this.stateManager.getRunTimeHours();
|
||||
}
|
||||
|
||||
getMaintenanceTimeHours(){
|
||||
return this.stateManager.getMaintenanceTimeHours();
|
||||
}
|
||||
|
||||
|
||||
async moveTo(targetPosition) {
|
||||
|
||||
// Check for invalid conditions and throw errors
|
||||
|
||||
@@ -127,7 +127,7 @@
|
||||
}
|
||||
},
|
||||
"maxSpeed": {
|
||||
"default": 10,
|
||||
"default": 1000,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"description": "Maximum speed setting."
|
||||
@@ -205,6 +205,10 @@
|
||||
{
|
||||
"value": "off",
|
||||
"description": "Machine is off."
|
||||
},
|
||||
{
|
||||
"value": "maintenance",
|
||||
"description": "Machine locked for inspection or repair; automatic control disabled."
|
||||
}
|
||||
],
|
||||
"description": "Current state of the machine."
|
||||
@@ -216,7 +220,7 @@
|
||||
"type": "object",
|
||||
"schema": {
|
||||
"idle": {
|
||||
"default": ["starting", "off","emergencystop"],
|
||||
"default": ["starting", "off","emergencystop","maintenance"],
|
||||
"rules":{
|
||||
"type": "set",
|
||||
"itemType": "string",
|
||||
@@ -280,7 +284,7 @@
|
||||
}
|
||||
},
|
||||
"off": {
|
||||
"default": ["idle","emergencystop"],
|
||||
"default": ["idle","emergencystop","maintenance"],
|
||||
"rules":{
|
||||
"type": "set",
|
||||
"itemType": "string",
|
||||
@@ -288,12 +292,20 @@
|
||||
}
|
||||
},
|
||||
"emergencystop": {
|
||||
"default": ["idle","off"],
|
||||
"default": ["idle","off","maintenance"],
|
||||
"rules":{
|
||||
"type": "set",
|
||||
"itemType": "string",
|
||||
"description": "Allowed transitions from emergency stop state."
|
||||
}
|
||||
},
|
||||
"maintenance": {
|
||||
"default": ["maintenance","idle","off"],
|
||||
"rules":{
|
||||
"type": "set",
|
||||
"itemType": "string",
|
||||
"description": "Allowed transitions for maintenance mode"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Allowed transitions between states."
|
||||
|
||||
@@ -48,10 +48,14 @@ class stateManager {
|
||||
// Define valid transitions (can be extended dynamically if needed)
|
||||
this.validTransitions = config.state.allowedTransitions;
|
||||
|
||||
// NEW: Initialize runtime tracking
|
||||
//runtime tracking
|
||||
this.runTimeHours = 0; // cumulative runtime in hours
|
||||
this.runTimeStart = null; // timestamp when active state began
|
||||
|
||||
//maintenance tracking
|
||||
this.maintenanceTimeStart = null; //timestamp when active state began
|
||||
this.maintenanceTimeHours = 0; //cumulative
|
||||
|
||||
// Define active states (runtime counts only in these states)
|
||||
this.activeStates = config.state.activeStates;
|
||||
}
|
||||
@@ -59,7 +63,7 @@ class stateManager {
|
||||
getCurrentState() {
|
||||
return this.currentState;
|
||||
}
|
||||
|
||||
|
||||
transitionTo(newState,signal) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal && signal.aborted) {
|
||||
@@ -73,8 +77,9 @@ class stateManager {
|
||||
); //go back early and reject promise
|
||||
}
|
||||
|
||||
// NEW: Handle runtime tracking based on active states
|
||||
//Time tracking based on active states
|
||||
this.handleRuntimeTracking(newState);
|
||||
this.handleMaintenancetimeTracking(newState);
|
||||
|
||||
const transitionDuration = this.transitionTimes[this.currentState] || 0; // Default to 0 if no transition time
|
||||
this.logger.debug(
|
||||
@@ -100,7 +105,7 @@ class stateManager {
|
||||
}
|
||||
|
||||
handleRuntimeTracking(newState) {
|
||||
// NEW: Handle runtime tracking based on active states
|
||||
//Handle runtime tracking based on active states
|
||||
const wasActive = this.activeStates.has(this.currentState);
|
||||
const willBeActive = this.activeStates.has(newState);
|
||||
if (wasActive && !willBeActive && this.runTimeStart) {
|
||||
@@ -120,6 +125,28 @@ class stateManager {
|
||||
}
|
||||
}
|
||||
|
||||
handleMaintenancetimeTracking(newState) {
|
||||
//is this maintenance time ?
|
||||
const wasActive = (this.currentState == "maintenance"? true:false);
|
||||
const willBeActive = ( newState == "maintenance" ? true:false );
|
||||
|
||||
if (wasActive && this.maintenanceTimeStart) {
|
||||
// stop runtime timer and accumulate elapsed time
|
||||
const elapsed = (Date.now() - this.maintenanceTimeStart) / 3600000; // hours
|
||||
this.maintenanceTimeHours += elapsed;
|
||||
this.maintenanceTimeStart = null;
|
||||
this.logger.debug(
|
||||
`Maintenance timer stopped; elapsed=${elapsed.toFixed(
|
||||
3
|
||||
)}h, total=${this.maintenanceTimeHours.toFixed(3)}h.`
|
||||
);
|
||||
} else if (willBeActive && !this.runTimeStart) {
|
||||
// starting new runtime
|
||||
this.maintenanceTimeStart = Date.now();
|
||||
this.logger.debug("Runtime timer started.");
|
||||
}
|
||||
}
|
||||
|
||||
isValidTransition(newState) {
|
||||
this.logger.debug(
|
||||
`Check 1 Transition valid ? From ${
|
||||
@@ -150,7 +177,6 @@ class stateManager {
|
||||
return this.descriptions[state] || "No description available.";
|
||||
}
|
||||
|
||||
// NEW: Getter to retrieve current cumulative runtime (active time) in hours.
|
||||
getRunTimeHours() {
|
||||
// If currently active add the ongoing duration.
|
||||
let currentElapsed = 0;
|
||||
@@ -159,6 +185,15 @@ class stateManager {
|
||||
}
|
||||
return this.runTimeHours + currentElapsed;
|
||||
}
|
||||
|
||||
getMaintenanceTimeHours() {
|
||||
// If currently active add the ongoing duration.
|
||||
let currentElapsed = 0;
|
||||
if (this.maintenanceTimeStart) {
|
||||
currentElapsed = (Date.now() - this.maintenanceTimeStart) / 3600000;
|
||||
}
|
||||
return this.maintenanceTimeHours + currentElapsed;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = stateManager;
|
||||
|
||||
50
test/00-barrel-contract.test.js
Normal file
50
test/00-barrel-contract.test.js
Normal file
@@ -0,0 +1,50 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const barrel = require('../index.js');
|
||||
|
||||
test('barrel exports expected public members', () => {
|
||||
const expected = [
|
||||
'predict',
|
||||
'interpolation',
|
||||
'configManager',
|
||||
'assetApiConfig',
|
||||
'outputUtils',
|
||||
'configUtils',
|
||||
'logger',
|
||||
'validation',
|
||||
'assertions',
|
||||
'MeasurementContainer',
|
||||
'nrmse',
|
||||
'state',
|
||||
'coolprop',
|
||||
'convert',
|
||||
'MenuManager',
|
||||
'PIDController',
|
||||
'CascadePIDController',
|
||||
'createPidController',
|
||||
'createCascadePidController',
|
||||
'childRegistrationUtils',
|
||||
'loadCurve',
|
||||
'loadModel',
|
||||
'gravity',
|
||||
];
|
||||
|
||||
for (const key of expected) {
|
||||
assert.ok(key in barrel, `missing export: ${key}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('barrel types are callable where expected', () => {
|
||||
assert.equal(typeof barrel.logger, 'function');
|
||||
assert.equal(typeof barrel.validation, 'function');
|
||||
assert.equal(typeof barrel.configUtils, 'function');
|
||||
assert.equal(typeof barrel.outputUtils, 'function');
|
||||
assert.equal(typeof barrel.MeasurementContainer, 'function');
|
||||
assert.equal(typeof barrel.convert, 'function');
|
||||
assert.equal(typeof barrel.PIDController, 'function');
|
||||
assert.equal(typeof barrel.CascadePIDController, 'function');
|
||||
assert.equal(typeof barrel.createPidController, 'function');
|
||||
assert.equal(typeof barrel.createCascadePidController, 'function');
|
||||
assert.equal(typeof barrel.gravity.getStandardGravity, 'function');
|
||||
});
|
||||
14
test/assertions.test.js
Normal file
14
test/assertions.test.js
Normal file
@@ -0,0 +1,14 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const Assertions = require('../src/helper/assertionUtils.js');
|
||||
|
||||
test('assertNoNaN does not throw for valid nested arrays', () => {
|
||||
const assertions = new Assertions();
|
||||
assert.doesNotThrow(() => assertions.assertNoNaN([1, [2, 3, [4]]]));
|
||||
});
|
||||
|
||||
test('assertNoNaN throws when NaN exists in nested arrays', () => {
|
||||
const assertions = new Assertions();
|
||||
assert.throws(() => assertions.assertNoNaN([1, [2, NaN]]), /NaN detected/);
|
||||
});
|
||||
55
test/child-registration-utils.test.js
Normal file
55
test/child-registration-utils.test.js
Normal file
@@ -0,0 +1,55 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const ChildRegistrationUtils = require('../src/helper/childRegistrationUtils.js');
|
||||
|
||||
function makeMainClass() {
|
||||
return {
|
||||
logger: {
|
||||
debug() {},
|
||||
info() {},
|
||||
warn() {},
|
||||
error() {},
|
||||
},
|
||||
child: {},
|
||||
registerChildCalls: [],
|
||||
registerChild(child, softwareType) {
|
||||
this.registerChildCalls.push({ child, softwareType });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('registerChild wires parent, measurement context, and storage', async () => {
|
||||
const mainClass = makeMainClass();
|
||||
const utils = new ChildRegistrationUtils(mainClass);
|
||||
|
||||
const measurementContext = {
|
||||
childId: null,
|
||||
childName: null,
|
||||
parentRef: null,
|
||||
setChildId(v) { this.childId = v; },
|
||||
setChildName(v) { this.childName = v; },
|
||||
setParentRef(v) { this.parentRef = v; },
|
||||
};
|
||||
|
||||
const child = {
|
||||
config: {
|
||||
functionality: { softwareType: 'measurement' },
|
||||
general: { name: 'PT1', id: 'child-1' },
|
||||
asset: { category: 'sensor' },
|
||||
},
|
||||
measurements: measurementContext,
|
||||
};
|
||||
|
||||
await utils.registerChild(child, 'upstream');
|
||||
|
||||
assert.deepEqual(child.parent, [mainClass]);
|
||||
assert.equal(child.positionVsParent, 'upstream');
|
||||
assert.equal(measurementContext.childId, 'child-1');
|
||||
assert.equal(measurementContext.childName, 'PT1');
|
||||
assert.equal(measurementContext.parentRef, mainClass);
|
||||
|
||||
assert.equal(mainClass.child.measurement.sensor.length, 1);
|
||||
assert.equal(utils.getChildById('child-1'), child);
|
||||
assert.equal(mainClass.registerChildCalls.length, 1);
|
||||
});
|
||||
33
test/config-manager.test.js
Normal file
33
test/config-manager.test.js
Normal file
@@ -0,0 +1,33 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const ConfigManager = require('../src/configs/index.js');
|
||||
|
||||
test('can read known config and report existence', () => {
|
||||
const manager = new ConfigManager('.');
|
||||
assert.equal(manager.hasConfig('measurement'), true);
|
||||
|
||||
const config = manager.getConfig('measurement');
|
||||
assert.ok(config.functionality);
|
||||
assert.ok(config.functionality.softwareType);
|
||||
});
|
||||
|
||||
test('getAvailableConfigs includes known names', () => {
|
||||
const manager = new ConfigManager('.');
|
||||
const configs = manager.getAvailableConfigs();
|
||||
assert.ok(configs.includes('measurement'));
|
||||
assert.ok(configs.includes('rotatingMachine'));
|
||||
});
|
||||
|
||||
test('createEndpoint creates executable JS payload shell', () => {
|
||||
const manager = new ConfigManager('.');
|
||||
const script = manager.createEndpoint('measurement');
|
||||
|
||||
assert.match(script, /window\.EVOLV\.nodes\.measurement/);
|
||||
assert.match(script, /config loaded and endpoint created/);
|
||||
});
|
||||
|
||||
test('getConfig throws on missing config', () => {
|
||||
const manager = new ConfigManager('.');
|
||||
assert.throws(() => manager.getConfig('definitely-not-real'), /Failed to load config/);
|
||||
});
|
||||
51
test/config-utils.test.js
Normal file
51
test/config-utils.test.js
Normal file
@@ -0,0 +1,51 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const ConfigUtils = require('../src/helper/configUtils.js');
|
||||
|
||||
const defaultConfig = {
|
||||
functionality: {
|
||||
softwareType: {
|
||||
default: 'measurement',
|
||||
rules: { type: 'string' },
|
||||
},
|
||||
},
|
||||
general: {
|
||||
logging: {
|
||||
enabled: { default: true, rules: { type: 'boolean' } },
|
||||
logLevel: {
|
||||
default: 'info',
|
||||
rules: {
|
||||
type: 'enum',
|
||||
values: [{ value: 'debug' }, { value: 'info' }, { value: 'warn' }, { value: 'error' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
name: { default: 'default-name', rules: { type: 'string' } },
|
||||
},
|
||||
scaling: {
|
||||
absMin: { default: 0, rules: { type: 'number' } },
|
||||
absMax: { default: 100, rules: { type: 'number' } },
|
||||
},
|
||||
};
|
||||
|
||||
test('initConfig applies defaults', () => {
|
||||
const cfg = new ConfigUtils(defaultConfig, false, 'error');
|
||||
const result = cfg.initConfig({});
|
||||
assert.equal(result.general.name, 'default-name');
|
||||
assert.equal(result.scaling.absMax, 100);
|
||||
});
|
||||
|
||||
test('updateConfig merges nested overrides and revalidates', () => {
|
||||
const cfg = new ConfigUtils(defaultConfig, false, 'error');
|
||||
const base = cfg.initConfig({ general: { name: 'sensor-a' } });
|
||||
const updated = cfg.updateConfig(base, { scaling: { absMax: 150 } });
|
||||
|
||||
assert.equal(updated.general.name, 'sensor-a');
|
||||
assert.equal(updated.scaling.absMax, 150);
|
||||
});
|
||||
|
||||
test('constructor respects explicit logger disabled flag', () => {
|
||||
const cfg = new ConfigUtils(defaultConfig, false, 'error');
|
||||
assert.equal(cfg.logger.logging, false);
|
||||
});
|
||||
13
test/curve-loader.test.js
Normal file
13
test/curve-loader.test.js
Normal file
@@ -0,0 +1,13 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { loadCurve } = require('../index.js');
|
||||
|
||||
test('loadCurve resolves curve ids case-insensitively', () => {
|
||||
const canonical = loadCurve('hidrostal-H05K-S03R');
|
||||
const lowercase = loadCurve('hidrostal-h05k-s03r');
|
||||
|
||||
assert.ok(canonical);
|
||||
assert.ok(lowercase);
|
||||
assert.strictEqual(canonical, lowercase);
|
||||
});
|
||||
26
test/endpoint-utils.test.js
Normal file
26
test/endpoint-utils.test.js
Normal file
@@ -0,0 +1,26 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const EndpointUtils = require('../src/helper/endpointUtils.js');
|
||||
|
||||
test('generateMenuUtilsData returns helpers and compatibility options', () => {
|
||||
const endpointUtils = new EndpointUtils();
|
||||
const data = endpointUtils.generateMenuUtilsData('measurement', {
|
||||
customCheck: 'function(value) { return !!value; }',
|
||||
});
|
||||
|
||||
assert.equal(data.nodeName, 'measurement');
|
||||
assert.equal(typeof data.helpers.validateRequired, 'string');
|
||||
assert.equal(typeof data.helpers.customCheck, 'string');
|
||||
assert.equal(data.options.autoLoadLegacy, true);
|
||||
});
|
||||
|
||||
test('generateMenuUtilsBootstrap points to data and legacy endpoints', () => {
|
||||
const endpointUtils = new EndpointUtils();
|
||||
const script = endpointUtils.generateMenuUtilsBootstrap('measurement');
|
||||
|
||||
assert.match(script, /menuUtilsData\.json/);
|
||||
assert.match(script, /menuUtils\.legacy\.js/);
|
||||
assert.match(script, /window\.EVOLV\.nodes/);
|
||||
});
|
||||
|
||||
21
test/gravity.test.js
Normal file
21
test/gravity.test.js
Normal file
@@ -0,0 +1,21 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const gravity = require('../src/helper/gravity.js');
|
||||
|
||||
test('standard gravity constant is available', () => {
|
||||
assert.ok(Math.abs(gravity.getStandardGravity() - 9.80665) < 1e-9);
|
||||
});
|
||||
|
||||
test('local gravity decreases with elevation', () => {
|
||||
const seaLevel = gravity.getLocalGravity(45, 0);
|
||||
const high = gravity.getLocalGravity(45, 1000);
|
||||
assert.ok(high < seaLevel);
|
||||
});
|
||||
|
||||
test('pressureHead and weightForce use local gravity', () => {
|
||||
const dp = gravity.pressureHead(1000, 5, 45, 0);
|
||||
const force = gravity.weightForce(2, 45, 0);
|
||||
assert.ok(dp > 0);
|
||||
assert.ok(force > 0);
|
||||
});
|
||||
24
test/helpers.js
Normal file
24
test/helpers.js
Normal file
@@ -0,0 +1,24 @@
|
||||
const path = require('node:path');
|
||||
|
||||
function makeLogger() {
|
||||
return {
|
||||
debug() {},
|
||||
info() {},
|
||||
warn() {},
|
||||
error() {},
|
||||
};
|
||||
}
|
||||
|
||||
function near(actual, expected, epsilon = 1e-6) {
|
||||
return Math.abs(actual - expected) <= epsilon;
|
||||
}
|
||||
|
||||
function fixturePath(...segments) {
|
||||
return path.join(__dirname, ...segments);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
makeLogger,
|
||||
near,
|
||||
fixturePath,
|
||||
};
|
||||
65
test/logger.test.js
Normal file
65
test/logger.test.js
Normal file
@@ -0,0 +1,65 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const Logger = require('../src/helper/logger.js');
|
||||
|
||||
function withPatchedConsole(fn) {
|
||||
const original = {
|
||||
debug: console.debug,
|
||||
info: console.info,
|
||||
warn: console.warn,
|
||||
error: console.error,
|
||||
};
|
||||
|
||||
const calls = [];
|
||||
console.debug = (...args) => calls.push(['debug', ...args]);
|
||||
console.info = (...args) => calls.push(['info', ...args]);
|
||||
console.warn = (...args) => calls.push(['warn', ...args]);
|
||||
console.error = (...args) => calls.push(['error', ...args]);
|
||||
|
||||
try {
|
||||
fn(calls);
|
||||
} finally {
|
||||
console.debug = original.debug;
|
||||
console.info = original.info;
|
||||
console.warn = original.warn;
|
||||
console.error = original.error;
|
||||
}
|
||||
}
|
||||
|
||||
test('respects log level threshold', () => {
|
||||
withPatchedConsole((calls) => {
|
||||
const logger = new Logger(true, 'warn', 'T');
|
||||
logger.debug('a');
|
||||
logger.info('b');
|
||||
logger.warn('c');
|
||||
logger.error('d');
|
||||
|
||||
const levels = calls.map((c) => c[0]);
|
||||
assert.deepEqual(levels, ['warn', 'error']);
|
||||
});
|
||||
});
|
||||
|
||||
test('toggleLogging disables output', () => {
|
||||
withPatchedConsole((calls) => {
|
||||
const logger = new Logger(true, 'debug', 'T');
|
||||
logger.toggleLogging();
|
||||
logger.debug('x');
|
||||
logger.error('y');
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
test('setLogLevel updates to valid level', () => {
|
||||
const logger = new Logger(true, 'debug', 'T');
|
||||
logger.setLogLevel('error');
|
||||
assert.equal(logger.logLevel, 'error');
|
||||
});
|
||||
|
||||
test('setLogLevel with invalid value should not throw', () => {
|
||||
withPatchedConsole(() => {
|
||||
const logger = new Logger(true, 'debug', 'T');
|
||||
assert.doesNotThrow(() => logger.setLogLevel('invalid-level'));
|
||||
assert.equal(logger.logLevel, 'debug');
|
||||
});
|
||||
});
|
||||
29
test/measurement-builder.test.js
Normal file
29
test/measurement-builder.test.js
Normal file
@@ -0,0 +1,29 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const MeasurementBuilder = require('../src/measurements/MeasurementBuilder.js');
|
||||
|
||||
test('builder requires mandatory fields', () => {
|
||||
assert.throws(() => new MeasurementBuilder().build(), /Measurement type is required/);
|
||||
assert.throws(() => new MeasurementBuilder().setType('flow').build(), /Measurement variant is required/);
|
||||
assert.throws(
|
||||
() => new MeasurementBuilder().setType('flow').setVariant('measured').build(),
|
||||
/Measurement position is required/
|
||||
);
|
||||
});
|
||||
|
||||
test('builder creates measurement with provided config', () => {
|
||||
const measurement = new MeasurementBuilder()
|
||||
.setType('flow')
|
||||
.setVariant('measured')
|
||||
.setPosition('upstream')
|
||||
.setWindowSize(25)
|
||||
.setDistance(3.2)
|
||||
.build();
|
||||
|
||||
assert.equal(measurement.type, 'flow');
|
||||
assert.equal(measurement.variant, 'measured');
|
||||
assert.equal(measurement.position, 'upstream');
|
||||
assert.equal(measurement.windowSize, 25);
|
||||
assert.equal(measurement.distance, 3.2);
|
||||
});
|
||||
97
test/measurement-container-core.test.js
Normal file
97
test/measurement-container-core.test.js
Normal file
@@ -0,0 +1,97 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const MeasurementContainer = require('../src/measurements/MeasurementContainer.js');
|
||||
|
||||
function makeContainer() {
|
||||
return new MeasurementContainer({
|
||||
windowSize: 10,
|
||||
defaultUnits: {
|
||||
flow: 'm3/h',
|
||||
pressure: 'mbar',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
test('stores and retrieves measurements via chain API', () => {
|
||||
const c = makeContainer();
|
||||
c.type('flow').variant('measured').position('upstream').value(100, 1, 'm3/h');
|
||||
|
||||
assert.equal(c.type('flow').variant('measured').position('upstream').getCurrentValue(), 100);
|
||||
assert.equal(c.type('flow').variant('measured').position('upstream').exists(), true);
|
||||
});
|
||||
|
||||
test('distance(null) auto-derives from position mapping', () => {
|
||||
const c = makeContainer();
|
||||
c.type('pressure').variant('measured').position('upstream').distance(null).value(5, 1, 'mbar');
|
||||
|
||||
const m = c.type('pressure').variant('measured').position('upstream').get();
|
||||
assert.equal(m.distance, Number.POSITIVE_INFINITY);
|
||||
});
|
||||
|
||||
test('getLaggedSample with requested unit converts sample value', () => {
|
||||
const c = makeContainer();
|
||||
c.type('flow').variant('measured').position('upstream').value(3.6, 1, 'm3/h');
|
||||
c.type('flow').variant('measured').position('upstream').value(7.2, 2, 'm3/h');
|
||||
|
||||
const previous = c.type('flow').variant('measured').position('upstream').getLaggedSample(1, 'm3/s');
|
||||
assert.ok(previous);
|
||||
assert.equal(previous.unit, 'm3/s');
|
||||
assert.ok(Math.abs(previous.value - 0.001) < 1e-8);
|
||||
});
|
||||
|
||||
test('difference computes current and average delta between positions', () => {
|
||||
const c = makeContainer();
|
||||
c.type('pressure').variant('measured').position('downstream').value(120, 1, 'mbar');
|
||||
c.type('pressure').variant('measured').position('downstream').value(130, 2, 'mbar');
|
||||
c.type('pressure').variant('measured').position('upstream').value(100, 1, 'mbar');
|
||||
c.type('pressure').variant('measured').position('upstream').value(110, 2, 'mbar');
|
||||
|
||||
const diff = c.type('pressure').variant('measured').difference();
|
||||
assert.equal(diff.value, 20);
|
||||
assert.equal(diff.avgDiff, 20);
|
||||
assert.equal(diff.unit, 'mbar');
|
||||
});
|
||||
|
||||
test('_convertPositionNum2Str maps signs to labels', () => {
|
||||
const c = makeContainer();
|
||||
assert.equal(c._convertPositionNum2Str(0), 'atEquipment');
|
||||
assert.equal(c._convertPositionNum2Str(1), 'downstream');
|
||||
assert.equal(c._convertPositionNum2Str(-1), 'upstream');
|
||||
});
|
||||
|
||||
test('storeCanonical stores anchor unit internally and can emit preferred output units', () => {
|
||||
const c = new MeasurementContainer({
|
||||
windowSize: 10,
|
||||
autoConvert: true,
|
||||
defaultUnits: { flow: 'm3/h' },
|
||||
preferredUnits: { flow: 'm3/h' },
|
||||
canonicalUnits: { flow: 'm3/s' },
|
||||
storeCanonical: true,
|
||||
});
|
||||
|
||||
c.type('flow').variant('measured').position('upstream').value(3.6, 1, 'm3/h');
|
||||
|
||||
const internal = c.type('flow').variant('measured').position('upstream').getCurrentValue();
|
||||
assert.ok(Math.abs(internal - 0.001) < 1e-9);
|
||||
|
||||
const flat = c.getFlattenedOutput({ requestedUnits: { flow: 'm3/h' } });
|
||||
assert.ok(Math.abs(flat['flow.measured.upstream.default'] - 3.6) < 1e-9);
|
||||
});
|
||||
|
||||
test('strict unit validation rejects missing required unit and incompatible units', () => {
|
||||
const c = new MeasurementContainer({
|
||||
windowSize: 10,
|
||||
strictUnitValidation: true,
|
||||
throwOnInvalidUnit: true,
|
||||
requireUnitForTypes: ['flow'],
|
||||
});
|
||||
|
||||
assert.throws(() => {
|
||||
c.type('flow').variant('measured').position('upstream').value(10, 1);
|
||||
}, /Missing source unit/i);
|
||||
|
||||
assert.throws(() => {
|
||||
c.type('flow').variant('measured').position('upstream').value(10, 1, 'mbar');
|
||||
}, /Incompatible|unknown source unit/i);
|
||||
});
|
||||
49
test/measurement.test.js
Normal file
49
test/measurement.test.js
Normal file
@@ -0,0 +1,49 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const Measurement = require('../src/measurements/Measurement.js');
|
||||
const { near } = require('./helpers.js');
|
||||
|
||||
test('maintains rolling window and exposes stats', () => {
|
||||
const m = new Measurement('flow', 'measured', 'upstream', 3);
|
||||
m.setValue(10, 1).setValue(20, 2).setValue(30, 3).setValue(40, 4);
|
||||
|
||||
assert.deepEqual(m.getAllValues().values, [20, 30, 40]);
|
||||
assert.deepEqual(m.getAllValues().timestamps, [2, 3, 4]);
|
||||
assert.equal(m.getCurrentValue(), 40);
|
||||
assert.equal(m.getAverage(), 30);
|
||||
assert.equal(m.getMin(), 20);
|
||||
assert.equal(m.getMax(), 40);
|
||||
});
|
||||
|
||||
test('lag semantics: lag=1 is previous sample', () => {
|
||||
const m = new Measurement('flow', 'measured', 'upstream', 5);
|
||||
m.setValue(10, 100).setValue(20, 200).setValue(30, 300);
|
||||
|
||||
assert.equal(m.getLaggedSample(0).value, 30);
|
||||
assert.equal(m.getLaggedSample(1).value, 20);
|
||||
assert.equal(m.getLaggedValue(1), 20);
|
||||
});
|
||||
|
||||
test('convertTo converts values to target unit', () => {
|
||||
const m = new Measurement('flow', 'measured', 'upstream', 5);
|
||||
m.setUnit('m3/h');
|
||||
m.setValue(3.6, 1);
|
||||
|
||||
const converted = m.convertTo('m3/s');
|
||||
assert.ok(near(converted.getCurrentValue(), 0.001, 1e-8));
|
||||
assert.equal(converted.unit, 'm3/s');
|
||||
assert.equal(converted.getLatestTimestamp(), 1);
|
||||
});
|
||||
|
||||
test('createDifference aligns timestamps and subtracts downstream from upstream', () => {
|
||||
const up = new Measurement('pressure', 'measured', 'upstream', 10).setUnit('mbar');
|
||||
const down = new Measurement('pressure', 'measured', 'downstream', 10).setUnit('mbar');
|
||||
|
||||
up.setValue(120, 1).setValue(140, 2);
|
||||
down.setValue(100, 2).setValue(95, 3);
|
||||
|
||||
const diff = Measurement.createDifference(up, down);
|
||||
assert.deepEqual(diff.getAllValues().timestamps, [2]);
|
||||
assert.deepEqual(diff.getAllValues().values, [40]);
|
||||
});
|
||||
20
test/menu-manager.test.js
Normal file
20
test/menu-manager.test.js
Normal file
@@ -0,0 +1,20 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const MenuManager = require('../src/menu/index.js');
|
||||
|
||||
test('createEndpoint returns script including initEditor and menuData', () => {
|
||||
const manager = new MenuManager();
|
||||
const script = manager.createEndpoint('measurement', ['asset', 'logger', 'position']);
|
||||
|
||||
assert.match(script, /window\.EVOLV\.nodes\.measurement\.initEditor/);
|
||||
assert.match(script, /window\.EVOLV\.nodes\.measurement\.menuData/);
|
||||
});
|
||||
|
||||
test('_getSoftwareType resolves to string identifier', () => {
|
||||
const manager = new MenuManager();
|
||||
const softwareType = manager._getSoftwareType('measurement');
|
||||
|
||||
assert.equal(typeof softwareType, 'string');
|
||||
assert.equal(softwareType, 'measurement');
|
||||
});
|
||||
56
test/nrmse.test.js
Normal file
56
test/nrmse.test.js
Normal file
@@ -0,0 +1,56 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const ErrorMetrics = require('../src/nrmse/errorMetrics.js');
|
||||
const { makeLogger } = require('./helpers.js');
|
||||
|
||||
test('MSE and RMSE calculations are correct', () => {
|
||||
const m = new ErrorMetrics({}, makeLogger());
|
||||
const predicted = [1, 2, 3];
|
||||
const measured = [1, 3, 5];
|
||||
|
||||
assert.ok(Math.abs(m.meanSquaredError(predicted, measured) - 5 / 3) < 1e-9);
|
||||
assert.ok(Math.abs(m.rootMeanSquaredError(predicted, measured) - Math.sqrt(5 / 3)) < 1e-9);
|
||||
});
|
||||
|
||||
test('MSE throws for mismatched series lengths in strict mode', () => {
|
||||
const m = new ErrorMetrics({}, makeLogger());
|
||||
assert.throws(() => m.meanSquaredError([1, 2], [1]), /same length/);
|
||||
});
|
||||
|
||||
test('normalizeUsingRealtime throws when range is zero', () => {
|
||||
const m = new ErrorMetrics({}, makeLogger());
|
||||
assert.throws(() => m.normalizeUsingRealtime([1, 1, 1], [1, 1, 1]), /Invalid process range/);
|
||||
});
|
||||
|
||||
test('longTermNRMSD returns 0 before 100 samples and value after', () => {
|
||||
const m = new ErrorMetrics({}, makeLogger());
|
||||
for (let i = 0; i < 99; i++) {
|
||||
assert.equal(m.longTermNRMSD(0.1), 0);
|
||||
}
|
||||
assert.notEqual(m.longTermNRMSD(0.2), 0);
|
||||
});
|
||||
|
||||
test('assessDrift returns expected result envelope', () => {
|
||||
const m = new ErrorMetrics({}, makeLogger());
|
||||
const out = m.assessDrift([100, 101, 102], [99, 100, 103], 90, 110);
|
||||
|
||||
assert.equal(typeof out.nrmse, 'number');
|
||||
assert.equal(typeof out.longTermNRMSD, 'number');
|
||||
assert.ok('immediateLevel' in out);
|
||||
assert.ok('longTermLevel' in out);
|
||||
});
|
||||
|
||||
test('assessPoint keeps per-metric state and returns metric id', () => {
|
||||
const m = new ErrorMetrics({}, makeLogger());
|
||||
m.registerMetric('flow', { windowSize: 5, minSamplesForLongTerm: 3, strictValidation: true });
|
||||
|
||||
m.assessPoint('flow', 100, 99, { processMin: 0, processMax: 200, timestamp: Date.now() - 2000 });
|
||||
m.assessPoint('flow', 101, 100, { processMin: 0, processMax: 200, timestamp: Date.now() - 1000 });
|
||||
const out = m.assessPoint('flow', 102, 101, { processMin: 0, processMax: 200, timestamp: Date.now() });
|
||||
|
||||
assert.equal(out.metricId, 'flow');
|
||||
assert.equal(out.valid, true);
|
||||
assert.equal(typeof out.nrmse, 'number');
|
||||
assert.equal(typeof out.sampleCount, 'number');
|
||||
});
|
||||
42
test/output-utils.test.js
Normal file
42
test/output-utils.test.js
Normal file
@@ -0,0 +1,42 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const OutputUtils = require('../src/helper/outputUtils.js');
|
||||
|
||||
const config = {
|
||||
functionality: { softwareType: 'measurement', role: 'sensor' },
|
||||
general: { id: 'abc', unit: 'mbar' },
|
||||
asset: {
|
||||
uuid: 'u1',
|
||||
tagcode: 't1',
|
||||
geoLocation: { lat: 51.6, lon: 4.7 },
|
||||
category: 'measurement',
|
||||
type: 'pressure',
|
||||
model: 'M1',
|
||||
},
|
||||
};
|
||||
|
||||
test('process format emits message with changed fields only', () => {
|
||||
const out = new OutputUtils();
|
||||
|
||||
const first = out.formatMsg({ a: 1, b: 2 }, config, 'process');
|
||||
assert.equal(first.topic, 'measurement_abc');
|
||||
assert.deepEqual(first.payload, { a: 1, b: 2 });
|
||||
|
||||
const second = out.formatMsg({ a: 1, b: 2 }, config, 'process');
|
||||
assert.equal(second, null);
|
||||
|
||||
const third = out.formatMsg({ a: 1, b: 3, c: { x: 1 } }, config, 'process');
|
||||
assert.deepEqual(third.payload, { b: 3, c: JSON.stringify({ x: 1 }) });
|
||||
});
|
||||
|
||||
test('influx format flattens tags and stringifies tag values', () => {
|
||||
const out = new OutputUtils();
|
||||
const msg = out.formatMsg({ value: 10 }, config, 'influxdb');
|
||||
|
||||
assert.equal(msg.topic, 'measurement_abc');
|
||||
assert.equal(msg.payload.measurement, 'measurement_abc');
|
||||
assert.equal(msg.payload.tags.geoLocation_lat, '51.6');
|
||||
assert.equal(msg.payload.tags.geoLocation_lon, '4.7');
|
||||
assert.ok(msg.payload.timestamp instanceof Date);
|
||||
});
|
||||
105
test/pid-controller.test.js
Normal file
105
test/pid-controller.test.js
Normal file
@@ -0,0 +1,105 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { PIDController, CascadePIDController } = require('../src/pid/index.js');
|
||||
|
||||
test('pid supports freeze/unfreeze with held output', () => {
|
||||
const pid = new PIDController({
|
||||
kp: 2,
|
||||
ki: 0.5,
|
||||
kd: 0.1,
|
||||
sampleTime: 100,
|
||||
outputMin: 0,
|
||||
outputMax: 100,
|
||||
});
|
||||
|
||||
const t0 = Date.now();
|
||||
const first = pid.update(10, 2, t0 + 100);
|
||||
pid.freeze({ output: first, trackMeasurement: true });
|
||||
const frozen = pid.update(10, 4, t0 + 200);
|
||||
assert.equal(frozen, first);
|
||||
|
||||
pid.unfreeze();
|
||||
const resumed = pid.update(10, 4, t0 + 300);
|
||||
assert.equal(Number.isFinite(resumed), true);
|
||||
});
|
||||
|
||||
test('pid supports dynamic tunings and gain scheduling', () => {
|
||||
const pid = new PIDController({
|
||||
kp: 1,
|
||||
ki: 0,
|
||||
kd: 0,
|
||||
sampleTime: 100,
|
||||
outputMin: -100,
|
||||
outputMax: 100,
|
||||
gainSchedule: [
|
||||
{ min: Number.NEGATIVE_INFINITY, max: 5, kp: 1, ki: 0, kd: 0 },
|
||||
{ min: 5, max: Number.POSITIVE_INFINITY, kp: 3, ki: 0, kd: 0 },
|
||||
],
|
||||
});
|
||||
|
||||
const t0 = Date.now();
|
||||
const low = pid.update(10, 9, t0 + 100, { gainInput: 4 });
|
||||
const high = pid.update(10, 9, t0 + 200, { gainInput: 6 });
|
||||
|
||||
assert.equal(high > low, true);
|
||||
|
||||
const tuned = pid.update(10, 9, t0 + 300, { tunings: { kp: 10, ki: 0, kd: 0 } });
|
||||
assert.equal(tuned > high, true);
|
||||
});
|
||||
|
||||
test('pid applies deadband and output rate limits', () => {
|
||||
const pid = new PIDController({
|
||||
kp: 10,
|
||||
ki: 0,
|
||||
kd: 0,
|
||||
deadband: 0.5,
|
||||
sampleTime: 100,
|
||||
outputMin: 0,
|
||||
outputMax: 100,
|
||||
outputRateLimitUp: 5, // units per second
|
||||
outputRateLimitDown: 5, // units per second
|
||||
});
|
||||
|
||||
const t0 = Date.now();
|
||||
const out1 = pid.update(10, 10, t0 + 100); // inside deadband -> no action
|
||||
const out2 = pid.update(20, 0, t0 + 200); // strong error but limited by rate
|
||||
|
||||
assert.equal(out1, 0);
|
||||
// 5 units/sec * 0.1 sec = max 0.5 rise per cycle
|
||||
assert.equal(out2 <= 0.5 + 1e-9, true);
|
||||
});
|
||||
|
||||
test('cascade pid computes primary and secondary outputs', () => {
|
||||
const cascade = new CascadePIDController({
|
||||
primary: {
|
||||
kp: 2,
|
||||
ki: 0,
|
||||
kd: 0,
|
||||
sampleTime: 100,
|
||||
outputMin: 0,
|
||||
outputMax: 100,
|
||||
},
|
||||
secondary: {
|
||||
kp: 1,
|
||||
ki: 0,
|
||||
kd: 0,
|
||||
sampleTime: 100,
|
||||
outputMin: 0,
|
||||
outputMax: 100,
|
||||
},
|
||||
});
|
||||
|
||||
const t0 = Date.now();
|
||||
const result = cascade.update({
|
||||
setpoint: 10,
|
||||
primaryMeasurement: 5,
|
||||
secondaryMeasurement: 2,
|
||||
timestamp: t0 + 100,
|
||||
});
|
||||
|
||||
assert.equal(typeof result.primaryOutput, 'number');
|
||||
assert.equal(typeof result.secondaryOutput, 'number');
|
||||
assert.equal(result.primaryOutput > 0, true);
|
||||
assert.equal(result.secondaryOutput > 0, true);
|
||||
});
|
||||
141
test/validation-utils.test.js
Normal file
141
test/validation-utils.test.js
Normal file
@@ -0,0 +1,141 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const ValidationUtils = require('../src/helper/validationUtils.js');
|
||||
|
||||
const schema = {
|
||||
functionality: {
|
||||
softwareType: {
|
||||
default: 'measurement',
|
||||
rules: { type: 'string' },
|
||||
},
|
||||
},
|
||||
enabled: {
|
||||
default: true,
|
||||
rules: { type: 'boolean' },
|
||||
},
|
||||
mode: {
|
||||
default: 'auto',
|
||||
rules: {
|
||||
type: 'enum',
|
||||
values: [{ value: 'auto' }, { value: 'manual' }],
|
||||
},
|
||||
},
|
||||
name: {
|
||||
default: 'sensor',
|
||||
rules: { type: 'string' },
|
||||
},
|
||||
asset: {
|
||||
default: {},
|
||||
rules: {
|
||||
type: 'object',
|
||||
schema: {
|
||||
unit: {
|
||||
default: 'm3/h',
|
||||
rules: { type: 'string' },
|
||||
},
|
||||
curveUnits: {
|
||||
default: {},
|
||||
rules: {
|
||||
type: 'object',
|
||||
schema: {
|
||||
power: {
|
||||
default: 'kW',
|
||||
rules: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
test('validateSchema applies defaults and type coercion where supported', () => {
|
||||
const validation = new ValidationUtils(false, 'error');
|
||||
const result = validation.validateSchema({ enabled: 'true', name: 'SENSOR' }, schema, 'test');
|
||||
|
||||
assert.equal(result.enabled, true);
|
||||
assert.equal(result.name, 'SENSOR');
|
||||
assert.equal(result.mode, 'auto');
|
||||
assert.equal(result.functionality.softwareType, 'measurement');
|
||||
});
|
||||
|
||||
test('enum with non-string value falls back to default', () => {
|
||||
const validation = new ValidationUtils(false, 'error');
|
||||
const result = validation.validateSchema({ mode: 123 }, schema, 'test');
|
||||
assert.equal(result.mode, 'auto');
|
||||
});
|
||||
|
||||
test('curve validation falls back to default for invalid dimension structure', () => {
|
||||
const validation = new ValidationUtils(false, 'error');
|
||||
const defaultCurve = { 1: { x: [1, 2], y: [10, 20] } };
|
||||
const invalid = { 1: { x: [2, 1], y: [20, 10] } };
|
||||
const curve = validation.validateCurve(invalid, defaultCurve);
|
||||
assert.deepEqual(curve, defaultCurve);
|
||||
});
|
||||
|
||||
test('removeUnwantedKeys handles primitive values without throwing', () => {
|
||||
const validation = new ValidationUtils(false, 'error');
|
||||
const input = {
|
||||
a: { default: 1, rules: { type: 'number' } },
|
||||
b: 2,
|
||||
c: 'x',
|
||||
};
|
||||
assert.doesNotThrow(() => validation.removeUnwantedKeys(input));
|
||||
});
|
||||
|
||||
test('unit-like fields preserve case while regular strings are normalized', () => {
|
||||
const validation = new ValidationUtils(false, 'error');
|
||||
const result = validation.validateSchema(
|
||||
{
|
||||
name: 'RotatingMachine',
|
||||
asset: {
|
||||
unit: 'kW',
|
||||
curveUnits: { power: 'kW' },
|
||||
},
|
||||
},
|
||||
schema,
|
||||
'machine'
|
||||
);
|
||||
|
||||
assert.equal(result.name, 'RotatingMachine');
|
||||
assert.equal(result.asset.unit, 'kW');
|
||||
assert.equal(result.asset.curveUnits.power, 'kW');
|
||||
});
|
||||
|
||||
test('array with minLength 0 accepts empty arrays without fallback warning path', () => {
|
||||
const validation = new ValidationUtils(false, 'error');
|
||||
const localSchema = {
|
||||
functionality: {
|
||||
softwareType: {
|
||||
default: 'measurement',
|
||||
rules: { type: 'string' },
|
||||
},
|
||||
},
|
||||
assetRegistration: {
|
||||
default: { childAssets: ['default'] },
|
||||
rules: {
|
||||
type: 'object',
|
||||
schema: {
|
||||
childAssets: {
|
||||
default: ['default'],
|
||||
rules: {
|
||||
type: 'array',
|
||||
itemType: 'string',
|
||||
minLength: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = validation.validateSchema(
|
||||
{ assetRegistration: { childAssets: [] } },
|
||||
localSchema,
|
||||
'measurement'
|
||||
);
|
||||
|
||||
assert.deepEqual(result.assetRegistration.childAssets, []);
|
||||
});
|
||||
Reference in New Issue
Block a user