Compare commits
71 Commits
2fb73e6713
...
fix/valida
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13d1f83a85 | ||
|
|
f96476bd23 | ||
|
|
12fce6c549 | ||
|
|
814ee3d763 | ||
|
|
31928fd124 | ||
|
|
7e40ea0797 | ||
|
|
dec5f63b21 | ||
|
|
fe2631f29b | ||
|
|
bf39b9df42 | ||
|
|
f95ef43f05 | ||
|
|
89aec9a7eb | ||
|
|
135dfc31d3 | ||
|
|
96fdf2a27a | ||
|
|
c698e5a1bc | ||
|
|
089f4c5129 | ||
|
|
82094d8d09 | ||
|
|
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
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,11 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
class AssetLoader {
|
||||
constructor() {
|
||||
constructor(maxCacheSize = 100) {
|
||||
this.relPath = './'
|
||||
this.baseDir = path.resolve(__dirname, this.relPath);
|
||||
this.cache = new Map(); // Cache loaded JSON files for better performance
|
||||
this.cache = new Map();
|
||||
this.maxCacheSize = maxCacheSize;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -25,7 +26,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 +39,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;
|
||||
}
|
||||
|
||||
@@ -46,7 +51,11 @@ class AssetLoader {
|
||||
const rawData = fs.readFileSync(filePath, 'utf8');
|
||||
const assetData = JSON.parse(rawData);
|
||||
|
||||
// Cache the result
|
||||
// Cache the result (evict oldest if at capacity)
|
||||
if (this.cache.size >= this.maxCacheSize) {
|
||||
const oldestKey = this.cache.keys().next().value;
|
||||
this.cache.delete(oldestKey);
|
||||
}
|
||||
this.cache.set(cacheKey, assetData);
|
||||
|
||||
return assetData;
|
||||
@@ -56,6 +65,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
|
||||
|
||||
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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
45
index.js
45
index.js
@@ -8,29 +8,39 @@
|
||||
*/
|
||||
|
||||
// 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');
|
||||
const { POSITIONS, POSITION_VALUES, isValidPosition } = require('./src/constants/positions.js');
|
||||
const Fysics = require('./src/convert/fysics.js');
|
||||
|
||||
// Export everything
|
||||
module.exports = {
|
||||
predict,
|
||||
interpolation,
|
||||
configManager,
|
||||
assetApiConfig,
|
||||
outputUtils,
|
||||
configUtils,
|
||||
logger,
|
||||
@@ -39,8 +49,19 @@ module.exports = {
|
||||
MeasurementContainer,
|
||||
nrmse,
|
||||
state,
|
||||
coolprop,
|
||||
convert,
|
||||
MenuManager,
|
||||
PIDController,
|
||||
CascadePIDController,
|
||||
createPidController,
|
||||
createCascadePidController,
|
||||
childRegistrationUtils,
|
||||
loadCurve
|
||||
loadCurve, //deprecated replace with loadModel
|
||||
loadModel,
|
||||
gravity,
|
||||
POSITIONS,
|
||||
POSITION_VALUES,
|
||||
isValidPosition,
|
||||
Fysics
|
||||
};
|
||||
|
||||
10
package.json
10
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",
|
||||
|
||||
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
|
||||
}
|
||||
};
|
||||
85
src/configs/baseConfig.json
Normal file
85
src/configs/baseConfig.json
Normal file
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"general": {
|
||||
"name": {
|
||||
"default": "Unnamed Node",
|
||||
"rules": { "type": "string", "description": "Human-readable name for this node." }
|
||||
},
|
||||
"id": {
|
||||
"default": null,
|
||||
"rules": { "type": "string", "nullable": true, "description": "Unique node identifier (set at runtime)." }
|
||||
},
|
||||
"unit": {
|
||||
"default": "unitless",
|
||||
"rules": { "type": "string", "description": "Default measurement unit." }
|
||||
},
|
||||
"logging": {
|
||||
"logLevel": {
|
||||
"default": "info",
|
||||
"rules": {
|
||||
"type": "enum",
|
||||
"values": [
|
||||
{ "value": "debug", "description": "Verbose diagnostic messages." },
|
||||
{ "value": "info", "description": "General informational messages." },
|
||||
{ "value": "warn", "description": "Warning messages." },
|
||||
{ "value": "error", "description": "Error level messages only." }
|
||||
]
|
||||
}
|
||||
},
|
||||
"enabled": {
|
||||
"default": true,
|
||||
"rules": { "type": "boolean", "description": "Enable or disable logging." }
|
||||
}
|
||||
}
|
||||
},
|
||||
"functionality": {
|
||||
"softwareType": {
|
||||
"default": "unknown",
|
||||
"rules": { "type": "string", "description": "Software type identifier for parent-child registration." }
|
||||
},
|
||||
"role": {
|
||||
"default": "Generic EVOLV node",
|
||||
"rules": { "type": "string", "description": "Describes the functional role of this node." }
|
||||
},
|
||||
"positionVsParent": {
|
||||
"default": "atEquipment",
|
||||
"rules": {
|
||||
"type": "enum",
|
||||
"values": [
|
||||
{ "value": "upstream", "description": "Upstream of parent equipment." },
|
||||
{ "value": "atEquipment", "description": "At equipment level." },
|
||||
{ "value": "downstream", "description": "Downstream of parent equipment." }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"asset": {
|
||||
"uuid": {
|
||||
"default": null,
|
||||
"rules": { "type": "string", "nullable": true, "description": "Asset UUID from asset management system." }
|
||||
},
|
||||
"tagCode": {
|
||||
"default": null,
|
||||
"rules": { "type": "string", "nullable": true, "description": "Asset tag code." }
|
||||
},
|
||||
"supplier": {
|
||||
"default": "Unknown",
|
||||
"rules": { "type": "string", "description": "Equipment supplier." }
|
||||
},
|
||||
"category": {
|
||||
"default": "sensor",
|
||||
"rules": { "type": "string", "description": "Asset category." }
|
||||
},
|
||||
"type": {
|
||||
"default": "Unknown",
|
||||
"rules": { "type": "string", "description": "Asset type." }
|
||||
},
|
||||
"model": {
|
||||
"default": "Unknown",
|
||||
"rules": { "type": "string", "description": "Equipment model." }
|
||||
},
|
||||
"unit": {
|
||||
"default": "unitless",
|
||||
"rules": { "type": "string", "description": "Asset measurement unit." }
|
||||
}
|
||||
}
|
||||
}
|
||||
111
src/configs/diffuser.json
Normal file
111
src/configs/diffuser.json
Normal file
@@ -0,0 +1,111 @@
|
||||
{
|
||||
"general": {
|
||||
"name": {
|
||||
"default": "Diffuser",
|
||||
"rules": {
|
||||
"type": "string",
|
||||
"description": "A human-readable name for this diffuser zone."
|
||||
}
|
||||
},
|
||||
"id": {
|
||||
"default": null,
|
||||
"rules": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "Unique identifier for this diffuser node."
|
||||
}
|
||||
},
|
||||
"unit": {
|
||||
"default": "Nm3/h",
|
||||
"rules": {
|
||||
"type": "string",
|
||||
"description": "Default airflow unit for this diffuser."
|
||||
}
|
||||
},
|
||||
"logging": {
|
||||
"logLevel": {
|
||||
"default": "info",
|
||||
"rules": {
|
||||
"type": "enum",
|
||||
"values": [
|
||||
{ "value": "debug", "description": "Verbose diagnostic messages." },
|
||||
{ "value": "info", "description": "General informational messages." },
|
||||
{ "value": "warn", "description": "Warning messages." },
|
||||
{ "value": "error", "description": "Error level messages only." }
|
||||
]
|
||||
}
|
||||
},
|
||||
"enabled": {
|
||||
"default": true,
|
||||
"rules": {
|
||||
"type": "boolean",
|
||||
"description": "Enable or disable logging."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"functionality": {
|
||||
"softwareType": {
|
||||
"default": "diffuser",
|
||||
"rules": {
|
||||
"type": "string",
|
||||
"description": "Software type identifier for parent-child registration."
|
||||
}
|
||||
},
|
||||
"role": {
|
||||
"default": "Aeration diffuser",
|
||||
"rules": {
|
||||
"type": "string",
|
||||
"description": "Describes the functional role of this node."
|
||||
}
|
||||
},
|
||||
"positionVsParent": {
|
||||
"default": "atEquipment",
|
||||
"rules": {
|
||||
"type": "enum",
|
||||
"values": [
|
||||
{ "value": "upstream", "description": "Upstream of parent equipment." },
|
||||
{ "value": "atEquipment", "description": "At equipment level." },
|
||||
{ "value": "downstream", "description": "Downstream of parent equipment." }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"diffuser": {
|
||||
"number": {
|
||||
"default": 1,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"description": "Sequential diffuser zone number."
|
||||
}
|
||||
},
|
||||
"elements": {
|
||||
"default": 1,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"description": "Number of diffuser elements in the zone."
|
||||
}
|
||||
},
|
||||
"density": {
|
||||
"default": 2.4,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"description": "Installed diffuser density per square meter."
|
||||
}
|
||||
},
|
||||
"waterHeight": {
|
||||
"default": 0,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"description": "Water column height above the diffuser."
|
||||
}
|
||||
},
|
||||
"alfaFactor": {
|
||||
"default": 0.7,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"description": "Alpha factor used for oxygen transfer correction."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,52 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
/**
|
||||
* Current config version. All config JSONs should declare this version.
|
||||
* Bump this when the config schema changes.
|
||||
*/
|
||||
const CURRENT_CONFIG_VERSION = '1.0.0';
|
||||
|
||||
class ConfigManager {
|
||||
constructor(relPath = '.') {
|
||||
this.configDir = path.resolve(__dirname, relPath);
|
||||
|
||||
/**
|
||||
* Migration functions keyed by "fromVersion->toVersion".
|
||||
* Each function receives a config object and returns the migrated config.
|
||||
*
|
||||
* Example:
|
||||
* this.migrations['1.0.0->1.1.0'] = (config) => {
|
||||
* config.newSection = { enabled: false };
|
||||
* return config;
|
||||
* };
|
||||
*/
|
||||
this.migrations = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a configuration file by name
|
||||
* Load a configuration file by name.
|
||||
* Automatically checks the config version and migrates if needed.
|
||||
* @param {string} configName - Name of the config file (without .json extension)
|
||||
* @returns {Object} Parsed configuration object
|
||||
* @returns {Object} Parsed configuration object (migrated to current version if necessary)
|
||||
*/
|
||||
getConfig(configName) {
|
||||
try {
|
||||
const configPath = path.resolve(this.configDir, `${configName}.json`);
|
||||
const configData = fs.readFileSync(configPath, 'utf8');
|
||||
return JSON.parse(configData);
|
||||
let config = JSON.parse(configData);
|
||||
|
||||
// Auto-migrate if version is behind current
|
||||
const configVersion = config.version || '0.0.0';
|
||||
if (configVersion !== CURRENT_CONFIG_VERSION) {
|
||||
config = this.migrateConfig(config, configVersion, CURRENT_CONFIG_VERSION);
|
||||
}
|
||||
|
||||
return config;
|
||||
} catch (error) {
|
||||
if (error.message && error.message.startsWith('Failed to load config')) {
|
||||
throw error;
|
||||
}
|
||||
throw new Error(`Failed to load config '${configName}': ${error.message}`);
|
||||
}
|
||||
}
|
||||
@@ -47,6 +77,94 @@ class ConfigManager {
|
||||
return fs.existsSync(configPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a runtime config by merging base schema + node schema + UI overrides.
|
||||
* Eliminates the need for each nodeClass to manually construct general/asset/functionality sections.
|
||||
*
|
||||
* @param {string} nodeName - Node type name (e.g., 'valve', 'measurement')
|
||||
* @param {object} uiConfig - Raw config from Node-RED UI
|
||||
* @param {string} nodeId - Node-RED node ID (from node.id)
|
||||
* @param {object} [domainConfig={}] - Domain-specific config sections (e.g., { scaling: {...}, smoothing: {...} })
|
||||
* @returns {object} Merged runtime config
|
||||
*
|
||||
* @example
|
||||
* const cfgMgr = new ConfigManager();
|
||||
* const config = cfgMgr.buildConfig('measurement', uiConfig, node.id, {
|
||||
* scaling: { enabled: uiConfig.scaling, inputMin: uiConfig.i_min, ... },
|
||||
* smoothing: { smoothWindow: uiConfig.count, ... }
|
||||
* });
|
||||
*/
|
||||
buildConfig(nodeName, uiConfig, nodeId, domainConfig = {}) {
|
||||
// Build base sections from UI config (common to ALL nodes)
|
||||
const config = {
|
||||
general: {
|
||||
name: uiConfig.name || nodeName,
|
||||
id: nodeId,
|
||||
unit: uiConfig.unit || 'unitless',
|
||||
logging: {
|
||||
enabled: uiConfig.enableLog !== undefined ? uiConfig.enableLog : true,
|
||||
logLevel: uiConfig.logLevel || 'info'
|
||||
}
|
||||
},
|
||||
functionality: {
|
||||
softwareType: nodeName.toLowerCase(),
|
||||
positionVsParent: uiConfig.positionVsParent || 'atEquipment',
|
||||
distance: uiConfig.hasDistance ? uiConfig.distance : undefined
|
||||
},
|
||||
output: {
|
||||
process: uiConfig.processOutputFormat || 'process',
|
||||
dbase: uiConfig.dbaseOutputFormat || 'influxdb'
|
||||
}
|
||||
};
|
||||
|
||||
// Add asset section if UI provides asset fields
|
||||
if (uiConfig.supplier || uiConfig.category || uiConfig.assetType || uiConfig.model) {
|
||||
config.asset = {
|
||||
uuid: uiConfig.uuid || uiConfig.assetUuid || null,
|
||||
tagCode: uiConfig.tagCode || uiConfig.assetTagCode || null,
|
||||
supplier: uiConfig.supplier || 'Unknown',
|
||||
category: uiConfig.category || 'sensor',
|
||||
type: uiConfig.assetType || 'Unknown',
|
||||
model: uiConfig.model || 'Unknown',
|
||||
unit: uiConfig.unit || 'unitless'
|
||||
};
|
||||
}
|
||||
|
||||
// Merge domain-specific sections
|
||||
Object.assign(config, domainConfig);
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate a config object from one version to another by applying
|
||||
* registered migration functions in sequence.
|
||||
* @param {object} config - The config object to migrate
|
||||
* @param {string} fromVersion - Current version of the config
|
||||
* @param {string} toVersion - Target version
|
||||
* @returns {object} Migrated config with updated version field
|
||||
*/
|
||||
migrateConfig(config, fromVersion, toVersion) {
|
||||
const migrationKey = `${fromVersion}->${toVersion}`;
|
||||
const migrationFn = this.migrations[migrationKey];
|
||||
|
||||
if (migrationFn) {
|
||||
config = migrationFn(config);
|
||||
}
|
||||
|
||||
// Stamp the current version so it won't re-migrate
|
||||
config.version = toVersion;
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the base config schema (shared across all nodes).
|
||||
* @returns {object} Base config schema
|
||||
*/
|
||||
getBaseConfig() {
|
||||
return this.getConfig('baseConfig');
|
||||
}
|
||||
|
||||
createEndpoint(nodeName) {
|
||||
try {
|
||||
// Load the config for this node
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
},
|
||||
"functionality": {
|
||||
"softwareType": {
|
||||
"default": "machineGroup",
|
||||
"default": "machinegroupcontrol",
|
||||
"rules": {
|
||||
"type": "string",
|
||||
"description": "Logical name identifying the software type."
|
||||
|
||||
@@ -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)."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
202
src/configs/reactor.json
Normal file
202
src/configs/reactor.json
Normal file
@@ -0,0 +1,202 @@
|
||||
{
|
||||
"general": {
|
||||
"name": {
|
||||
"default": "Reactor",
|
||||
"rules": {
|
||||
"type": "string",
|
||||
"description": "A human-readable name for this reactor."
|
||||
}
|
||||
},
|
||||
"id": {
|
||||
"default": null,
|
||||
"rules": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "Unique identifier for this reactor node."
|
||||
}
|
||||
},
|
||||
"unit": {
|
||||
"default": null,
|
||||
"rules": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "Default measurement unit."
|
||||
}
|
||||
},
|
||||
"logging": {
|
||||
"logLevel": {
|
||||
"default": "info",
|
||||
"rules": {
|
||||
"type": "enum",
|
||||
"values": [
|
||||
{ "value": "debug", "description": "Verbose diagnostic messages." },
|
||||
{ "value": "info", "description": "General informational messages." },
|
||||
{ "value": "warn", "description": "Warning messages." },
|
||||
{ "value": "error", "description": "Error level messages only." }
|
||||
]
|
||||
}
|
||||
},
|
||||
"enabled": {
|
||||
"default": true,
|
||||
"rules": {
|
||||
"type": "boolean",
|
||||
"description": "Enable or disable logging."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"functionality": {
|
||||
"softwareType": {
|
||||
"default": "reactor",
|
||||
"rules": {
|
||||
"type": "string",
|
||||
"description": "Software type identifier for parent-child registration."
|
||||
}
|
||||
},
|
||||
"role": {
|
||||
"default": "Biological reactor for wastewater treatment",
|
||||
"rules": {
|
||||
"type": "string",
|
||||
"description": "Describes the functional role of this node."
|
||||
}
|
||||
},
|
||||
"positionVsParent": {
|
||||
"default": "atEquipment",
|
||||
"rules": {
|
||||
"type": "enum",
|
||||
"values": [
|
||||
{ "value": "upstream", "description": "Upstream of parent equipment." },
|
||||
{ "value": "atEquipment", "description": "At equipment level." },
|
||||
{ "value": "downstream", "description": "Downstream of parent equipment." }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"reactor": {
|
||||
"reactor_type": {
|
||||
"default": "CSTR",
|
||||
"rules": {
|
||||
"type": "enum",
|
||||
"values": [
|
||||
{ "value": "CSTR", "description": "Continuous Stirred Tank Reactor - fully mixed." },
|
||||
{ "value": "PFR", "description": "Plug Flow Reactor - spatial gradient along length." }
|
||||
]
|
||||
}
|
||||
},
|
||||
"volume": {
|
||||
"default": 1000,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"min": 0,
|
||||
"unit": "m3",
|
||||
"description": "Reactor volume in cubic meters."
|
||||
}
|
||||
},
|
||||
"length": {
|
||||
"default": 10,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"min": 0,
|
||||
"unit": "m",
|
||||
"description": "Reactor length (relevant for PFR spatial discretization)."
|
||||
}
|
||||
},
|
||||
"resolution_L": {
|
||||
"default": 10,
|
||||
"rules": {
|
||||
"type": "integer",
|
||||
"min": 1,
|
||||
"description": "Number of spatial segments for PFR discretization."
|
||||
}
|
||||
},
|
||||
"alpha": {
|
||||
"default": 0.5,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"min": 0,
|
||||
"max": 1,
|
||||
"description": "Dispersion coefficient alpha (0 = plug flow, 1 = fully mixed)."
|
||||
}
|
||||
},
|
||||
"n_inlets": {
|
||||
"default": 1,
|
||||
"rules": {
|
||||
"type": "integer",
|
||||
"min": 1,
|
||||
"description": "Number of inlet points along the reactor."
|
||||
}
|
||||
},
|
||||
"kla": {
|
||||
"default": 0,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"min": 0,
|
||||
"unit": "1/h",
|
||||
"description": "Oxygen mass transfer coefficient (KLa)."
|
||||
}
|
||||
},
|
||||
"timeStep": {
|
||||
"default": 0.001,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"min": 0.0001,
|
||||
"unit": "h",
|
||||
"description": "Integration time step for the reactor model."
|
||||
}
|
||||
}
|
||||
},
|
||||
"initialState": {
|
||||
"S_O": {
|
||||
"default": 0,
|
||||
"rules": { "type": "number", "unit": "mg/L", "description": "Initial dissolved oxygen concentration." }
|
||||
},
|
||||
"S_I": {
|
||||
"default": 30,
|
||||
"rules": { "type": "number", "unit": "mg/L", "description": "Initial inert soluble COD." }
|
||||
},
|
||||
"S_S": {
|
||||
"default": 70,
|
||||
"rules": { "type": "number", "unit": "mg/L", "description": "Initial readily biodegradable substrate." }
|
||||
},
|
||||
"S_NH": {
|
||||
"default": 25,
|
||||
"rules": { "type": "number", "unit": "mg/L", "description": "Initial ammonium nitrogen." }
|
||||
},
|
||||
"S_N2": {
|
||||
"default": 0,
|
||||
"rules": { "type": "number", "unit": "mg/L", "description": "Initial dinitrogen (N2)." }
|
||||
},
|
||||
"S_NO": {
|
||||
"default": 0,
|
||||
"rules": { "type": "number", "unit": "mg/L", "description": "Initial nitrate and nitrite nitrogen." }
|
||||
},
|
||||
"S_HCO": {
|
||||
"default": 5,
|
||||
"rules": { "type": "number", "unit": "mmol/L", "description": "Initial alkalinity (bicarbonate)." }
|
||||
},
|
||||
"X_I": {
|
||||
"default": 1000,
|
||||
"rules": { "type": "number", "unit": "mg/L", "description": "Initial inert particulate COD." }
|
||||
},
|
||||
"X_S": {
|
||||
"default": 100,
|
||||
"rules": { "type": "number", "unit": "mg/L", "description": "Initial slowly biodegradable substrate." }
|
||||
},
|
||||
"X_H": {
|
||||
"default": 2000,
|
||||
"rules": { "type": "number", "unit": "mg/L", "description": "Initial heterotrophic biomass." }
|
||||
},
|
||||
"X_STO": {
|
||||
"default": 0,
|
||||
"rules": { "type": "number", "unit": "mg/L", "description": "Initial stored COD in biomass." }
|
||||
},
|
||||
"X_A": {
|
||||
"default": 200,
|
||||
"rules": { "type": "number", "unit": "mg/L", "description": "Initial autotrophic biomass." }
|
||||
},
|
||||
"X_TS": {
|
||||
"default": 3500,
|
||||
"rules": { "type": "number", "unit": "mg/L", "description": "Initial total suspended solids." }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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')."
|
||||
@@ -58,7 +58,7 @@
|
||||
},
|
||||
"functionality": {
|
||||
"softwareType": {
|
||||
"default": "machine",
|
||||
"default": "rotatingmachine",
|
||||
"rules": {
|
||||
"type": "string",
|
||||
"description": "Specified software type for this configuration."
|
||||
@@ -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,24 +335,21 @@
|
||||
}
|
||||
},
|
||||
"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": {},
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
75
src/configs/settler.json
Normal file
75
src/configs/settler.json
Normal file
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"general": {
|
||||
"name": {
|
||||
"default": "Settler",
|
||||
"rules": {
|
||||
"type": "string",
|
||||
"description": "A human-readable name for this settler."
|
||||
}
|
||||
},
|
||||
"id": {
|
||||
"default": null,
|
||||
"rules": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "Unique identifier for this settler node."
|
||||
}
|
||||
},
|
||||
"unit": {
|
||||
"default": null,
|
||||
"rules": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "Default measurement unit."
|
||||
}
|
||||
},
|
||||
"logging": {
|
||||
"logLevel": {
|
||||
"default": "info",
|
||||
"rules": {
|
||||
"type": "enum",
|
||||
"values": [
|
||||
{ "value": "debug", "description": "Verbose diagnostic messages." },
|
||||
{ "value": "info", "description": "General informational messages." },
|
||||
{ "value": "warn", "description": "Warning messages." },
|
||||
{ "value": "error", "description": "Error level messages only." }
|
||||
]
|
||||
}
|
||||
},
|
||||
"enabled": {
|
||||
"default": true,
|
||||
"rules": {
|
||||
"type": "boolean",
|
||||
"description": "Enable or disable logging."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"functionality": {
|
||||
"softwareType": {
|
||||
"default": "settler",
|
||||
"rules": {
|
||||
"type": "string",
|
||||
"description": "Software type identifier for parent-child registration."
|
||||
}
|
||||
},
|
||||
"role": {
|
||||
"default": "Secondary settler for sludge separation",
|
||||
"rules": {
|
||||
"type": "string",
|
||||
"description": "Describes the functional role of this node."
|
||||
}
|
||||
},
|
||||
"positionVsParent": {
|
||||
"default": "downstream",
|
||||
"rules": {
|
||||
"type": "enum",
|
||||
"values": [
|
||||
{ "value": "upstream", "description": "Upstream of parent equipment." },
|
||||
{ "value": "atEquipment", "description": "At equipment level." },
|
||||
{ "value": "downstream", "description": "Downstream of parent equipment." }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,7 @@
|
||||
},
|
||||
"functionality": {
|
||||
"softwareType": {
|
||||
"default": "valveGroupControl",
|
||||
"default": "valvegroupcontrol",
|
||||
"rules": {
|
||||
"type": "string",
|
||||
"description": "Specified software type for this configuration."
|
||||
|
||||
18
src/constants/positions.js
Normal file
18
src/constants/positions.js
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Canonical position constants for parent-child relationships.
|
||||
* Use these instead of hardcoded strings throughout the codebase.
|
||||
*/
|
||||
const POSITIONS = Object.freeze({
|
||||
UPSTREAM: 'upstream',
|
||||
DOWNSTREAM: 'downstream',
|
||||
AT_EQUIPMENT: 'atEquipment',
|
||||
DELTA: 'delta',
|
||||
});
|
||||
|
||||
const POSITION_VALUES = Object.freeze(Object.values(POSITIONS));
|
||||
|
||||
function isValidPosition(pos) {
|
||||
return POSITION_VALUES.includes(pos);
|
||||
}
|
||||
|
||||
module.exports = { POSITIONS, POSITION_VALUES, isValidPosition };
|
||||
@@ -1,5 +1,4 @@
|
||||
var metric
|
||||
, imperial;
|
||||
var metric;
|
||||
|
||||
metric = {
|
||||
ea: {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
var metric
|
||||
, imperial;
|
||||
var metric;
|
||||
|
||||
metric = {
|
||||
ppm: {
|
||||
|
||||
@@ -127,7 +127,7 @@ Converter.prototype.toBest = function(options) {
|
||||
if(!this.origin)
|
||||
throw new Error('.toBest must be called after .from');
|
||||
|
||||
var options = Object.assign({
|
||||
options = Object.assign({
|
||||
exclude: [],
|
||||
cutOffNumber: 1,
|
||||
}, options)
|
||||
@@ -249,7 +249,7 @@ Converter.prototype.list = function (measure) {
|
||||
Converter.prototype.throwUnsupportedUnitError = function (what) {
|
||||
var validUnits = [];
|
||||
|
||||
each(measures, function (systems, measure) {
|
||||
each(measures, function (systems, _measure) {
|
||||
each(systems, function (units, system) {
|
||||
if(system == '_anchors')
|
||||
return false;
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
* Available under MIT license <http://lodash.com/license>
|
||||
*/
|
||||
var isObject = require('./../lodash.isobject'),
|
||||
noop = require('./../lodash.noop'),
|
||||
reNative = require('./../lodash._renative');
|
||||
|
||||
/* Native method shortcuts for methods with the same name as other `lodash` methods */
|
||||
@@ -21,12 +20,12 @@ var nativeCreate = reNative.test(nativeCreate = Object.create) && nativeCreate;
|
||||
* @param {Object} prototype The object to inherit from.
|
||||
* @returns {Object} Returns the new object.
|
||||
*/
|
||||
function baseCreate(prototype, properties) {
|
||||
function baseCreate(prototype, _properties) { // eslint-disable-line no-func-assign
|
||||
return isObject(prototype) ? nativeCreate(prototype) : {};
|
||||
}
|
||||
// fallback for browsers without `Object.create`
|
||||
if (!nativeCreate) {
|
||||
baseCreate = (function() {
|
||||
baseCreate = (function() { // eslint-disable-line no-func-assign
|
||||
function Object() {}
|
||||
return function(prototype) {
|
||||
if (isObject(prototype)) {
|
||||
|
||||
@@ -47,7 +47,7 @@ function createWrapper(func, bitmask, partialArgs, partialRightArgs, thisArg, ar
|
||||
var isBind = bitmask & 1,
|
||||
isBindKey = bitmask & 2,
|
||||
isCurry = bitmask & 4,
|
||||
isCurryBound = bitmask & 8,
|
||||
/* isCurryBound = bitmask & 8, */
|
||||
isPartial = bitmask & 16,
|
||||
isPartialRight = bitmask & 32;
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ var defineProperty = (function() {
|
||||
var o = {},
|
||||
func = reNative.test(func = Object.defineProperty) && func,
|
||||
result = func(o, o, o) && func;
|
||||
} catch(e) { }
|
||||
} catch(e) { /* intentionally empty */ }
|
||||
return result;
|
||||
}());
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
* Available under MIT license <http://lodash.com/license>
|
||||
*/
|
||||
var createWrapper = require('./../lodash._createwrapper'),
|
||||
reNative = require('./../lodash._renative'),
|
||||
slice = require('./../lodash._slice');
|
||||
|
||||
/**
|
||||
|
||||
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)) {
|
||||
|
||||
@@ -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 || '').toLowerCase();
|
||||
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
|
||||
@@ -35,23 +49,25 @@ class ChildRegistrationUtils {
|
||||
|
||||
// IMPORTANT: Only call parent registration - no automatic handling and if parent has this function then try to register this child
|
||||
if (typeof this.mainClass.registerChild === 'function') {
|
||||
this.mainClass.registerChild(child, softwareType);
|
||||
return this.mainClass.registerChild(child, softwareType);
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -1,260 +0,0 @@
|
||||
// ChildRegistrationUtils.js
|
||||
class ChildRegistrationUtils {
|
||||
constructor(mainClass) {
|
||||
this.mainClass = mainClass; // Reference to the main class
|
||||
this.logger = mainClass.logger;
|
||||
}
|
||||
|
||||
async registerChild(child, positionVsParent) {
|
||||
|
||||
this.logger.debug(`Registering child: ${child.id} with position=${positionVsParent}`);
|
||||
const { softwareType } = child.config.functionality;
|
||||
const { name, id, unit } = child.config.general;
|
||||
const { category = "", type = "" } = child.config.asset || {};
|
||||
console.log(`Registering child: ${name}, id: ${id}, softwareType: ${softwareType}, category: ${category}, type: ${type}, positionVsParent: ${positionVsParent}` );
|
||||
const emitter = child.emitter;
|
||||
|
||||
//define position vs parent in child
|
||||
child.positionVsParent = positionVsParent;
|
||||
child.parent = this.mainClass;
|
||||
|
||||
if (!this.mainClass.child) this.mainClass.child = {};
|
||||
if (!this.mainClass.child[softwareType])
|
||||
this.mainClass.child[softwareType] = {};
|
||||
if (!this.mainClass.child[softwareType][category])
|
||||
this.mainClass.child[softwareType][category] = {};
|
||||
if (!this.mainClass.child[softwareType][category][type])
|
||||
this.mainClass.child[softwareType][category][type] = {};
|
||||
|
||||
// Use an array to handle multiple categories
|
||||
if (!Array.isArray(this.mainClass.child[softwareType][category][type])) {
|
||||
this.mainClass.child[softwareType][category][type] = [];
|
||||
}
|
||||
|
||||
// Push the new child to the array of the mainclass so we can track the childs
|
||||
this.mainClass.child[softwareType][category][type].push({
|
||||
name,
|
||||
id,
|
||||
unit,
|
||||
emitter,
|
||||
});
|
||||
|
||||
//then connect the child depending on the type type etc..
|
||||
this.connectChild(
|
||||
id,
|
||||
softwareType,
|
||||
emitter,
|
||||
category,
|
||||
child,
|
||||
type,
|
||||
positionVsParent
|
||||
);
|
||||
}
|
||||
|
||||
connectChild(
|
||||
id,
|
||||
softwareType,
|
||||
emitter,
|
||||
category,
|
||||
child,
|
||||
type,
|
||||
positionVsParent
|
||||
) {
|
||||
this.logger.debug(
|
||||
`Connecting child id=${id}: desc=${softwareType}, category=${category},type=${type}, position=${positionVsParent}`
|
||||
);
|
||||
|
||||
switch (softwareType) {
|
||||
case "measurement":
|
||||
this.logger.debug(
|
||||
`Registering measurement child: ${id} with category=${category}`
|
||||
);
|
||||
this.connectMeasurement(child, type, positionVsParent);
|
||||
break;
|
||||
|
||||
case "machine":
|
||||
this.logger.debug(`Registering complete machine child: ${id}`);
|
||||
this.connectMachine(child);
|
||||
break;
|
||||
|
||||
case "valve":
|
||||
this.logger.debug(`Registering complete valve child: ${id}`);
|
||||
this.connectValve(child);
|
||||
break;
|
||||
|
||||
case "machineGroup":
|
||||
this.logger.debug(`Registering complete machineGroup child: ${id}`);
|
||||
this.connectMachineGroup(child);
|
||||
break;
|
||||
|
||||
case "actuator":
|
||||
this.logger.debug(`Registering linear actuator child: ${id}`);
|
||||
this.connectActuator(child,positionVsParent);
|
||||
break;
|
||||
|
||||
default:
|
||||
this.logger.error(`Child registration unrecognized desc: ${desc}`);
|
||||
this.logger.error(`Unrecognized softwareType: ${softwareType}`);
|
||||
}
|
||||
}
|
||||
|
||||
connectMeasurement(child, type, position) {
|
||||
this.logger.debug(
|
||||
`Connecting measurement child: ${type} with position=${position}`
|
||||
);
|
||||
|
||||
// Check if type is valid
|
||||
if (!type) {
|
||||
this.logger.error(`Invalid type for measurement: ${type}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// initialize the measurement to a number - logging each step for debugging
|
||||
try {
|
||||
this.logger.debug(
|
||||
`Initializing measurement: ${type}, position: ${position} value: 0`
|
||||
);
|
||||
const typeResult = this.mainClass.measurements.type(type);
|
||||
const variantResult = typeResult.variant("measured");
|
||||
const positionResult = variantResult.position(position);
|
||||
positionResult.value(0);
|
||||
|
||||
this.logger.debug(
|
||||
`Subscribing on mAbs event for measurement: ${type}, position: ${position}`
|
||||
);
|
||||
// Listen for the mAbs event and update the measurement
|
||||
|
||||
this.logger.debug(
|
||||
`Successfully initialized measurement: ${type}, position: ${position}`
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to initialize measurement: ${error.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
//testing new emitter strategy
|
||||
child.measurements.emitter.on("newValue", (data) => {
|
||||
this.logger.warn(
|
||||
`Value change event received for measurement: ${type}, position: ${position}, value: ${data.value}`
|
||||
);
|
||||
});
|
||||
|
||||
child.emitter.on("mAbs", (value) => {
|
||||
// Use the same method chaining approach that worked during initialization
|
||||
this.mainClass.measurements
|
||||
.type(type)
|
||||
.variant("measured")
|
||||
.position(position)
|
||||
.value(value);
|
||||
this.mainClass.updateMeasurement("measured", type, value, position);
|
||||
//this.logger.debug(`--------->>>>>>>>>Updated measurement: ${type}, value: ${value}, position: ${position}`);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
connectMachine(machine) {
|
||||
if (!machine) {
|
||||
this.logger.error("Invalid machine provided.");
|
||||
return;
|
||||
}
|
||||
|
||||
const machineId = Object.keys(this.mainClass.machines).length + 1;
|
||||
this.mainClass.machines[machineId] = machine;
|
||||
|
||||
this.logger.info(
|
||||
`Setting up pressureChange listener for machine ${machineId}`
|
||||
);
|
||||
|
||||
machine.emitter.on("pressureChange", () =>
|
||||
this.mainClass.handlePressureChange(machine)
|
||||
);
|
||||
|
||||
//update of child triggers the handler
|
||||
this.mainClass.handleChildChange();
|
||||
|
||||
this.logger.info(`Machine ${machineId} registered successfully.`);
|
||||
}
|
||||
|
||||
connectValve(valve) {
|
||||
if (!valve) {
|
||||
this.logger.warn("Invalid valve provided.");
|
||||
return;
|
||||
}
|
||||
const valveId = Object.keys(this.mainClass.valves).length + 1;
|
||||
this.mainClass.valves[valveId] = valve; // Gooit valve object in de valves attribute met valve objects
|
||||
|
||||
valve.state.emitter.on("positionChange", (data) => {
|
||||
//ValveGroupController abboneren op klepstand verandering
|
||||
this.mainClass.logger.debug(`Position change of valve detected: ${data}`);
|
||||
this.mainClass.calcValveFlows();
|
||||
}); //bepaal nieuwe flow per valve
|
||||
valve.emitter.on("deltaPChange", () => {
|
||||
this.mainClass.logger.debug("DeltaP change of valve detected");
|
||||
this.mainClass.calcMaxDeltaP();
|
||||
}); //bepaal nieuwe max deltaP
|
||||
|
||||
this.logger.info(`Valve ${valveId} registered successfully.`);
|
||||
}
|
||||
|
||||
connectMachineGroup(machineGroup) {
|
||||
if (!machineGroup) {
|
||||
this.logger.warn("Invalid machineGroup provided.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const machineGroupId = Object.keys(this.mainClass.machineGroups).length + 1;
|
||||
this.mainClass.machineGroups[machineGroupId] = machineGroup;
|
||||
} catch (error) {
|
||||
this.logger.warn(`Skip machinegroup connnection: ${error.message}`);
|
||||
}
|
||||
|
||||
machineGroup.emitter.on("totalFlowChange", (data) => {
|
||||
this.mainClass.logger.debug('Total flow change of machineGroup detected');
|
||||
this.mainClass.handleInput("parent", "totalFlowChange", data)}); //Geef nieuwe totale flow door aan valveGrouControl
|
||||
|
||||
this.logger.info(`MachineGroup ${machineGroup.config.general.name} registered successfully.`);
|
||||
}
|
||||
|
||||
connectActuator(actuator, positionVsParent) {
|
||||
if (!actuator) {
|
||||
this.logger.warn("Invalid actuator provided.");
|
||||
return;
|
||||
}
|
||||
|
||||
//Special case gateGroupControl
|
||||
if (
|
||||
this.mainClass.config.functionality.softwareType == "gateGroupControl"
|
||||
) {
|
||||
if (Object.keys(this.mainClass.actuators).length < 2) {
|
||||
if (positionVsParent == "downstream") {
|
||||
this.mainClass.actuators[0] = actuator;
|
||||
}
|
||||
|
||||
if (positionVsParent == "upstream") {
|
||||
this.mainClass.actuators[1] = actuator;
|
||||
}
|
||||
//define emitters
|
||||
actuator.state.emitter.on("positionChange", (data) => {
|
||||
this.mainClass.logger.debug(`Position change of actuator detected: ${data}`);
|
||||
this.mainClass.eventUpdate();
|
||||
});
|
||||
|
||||
//define emitters
|
||||
actuator.state.emitter.on("stateChange", (data) => {
|
||||
this.mainClass.logger.debug(`State change of actuator detected: ${data}`);
|
||||
this.mainClass.eventUpdate();
|
||||
});
|
||||
|
||||
} else {
|
||||
this.logger.error(
|
||||
"Too many actuators registered. Only two are allowed."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//wanneer hij deze ontvangt is deltaP van een van de valves veranderd (kan ook zijn niet child zijn, maar dat maakt niet uit)
|
||||
}
|
||||
|
||||
module.exports = ChildRegistrationUtils;
|
||||
@@ -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]) {
|
||||
if (Object.prototype.hasOwnProperty.call(obj2, 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;
|
||||
|
||||
44
src/helper/formatters/csvFormatter.js
Normal file
44
src/helper/formatters/csvFormatter.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* CSV formatter
|
||||
* Produces a single CSV line: timestamp,measurement,field1=val1,field2=val2,...
|
||||
*
|
||||
* Values are escaped if they contain commas or quotes.
|
||||
*
|
||||
* @param {string} measurement - The measurement name (e.g. node name)
|
||||
* @param {object} metadata - { fields, tags }
|
||||
* - fields: key/value pairs of changed data points
|
||||
* - tags: flat key/value string pairs (included as columns)
|
||||
* @returns {string} CSV-formatted line
|
||||
*/
|
||||
function format(measurement, metadata) {
|
||||
const { fields, tags } = metadata;
|
||||
const timestamp = new Date().toISOString();
|
||||
const parts = [escapeCSV(timestamp), escapeCSV(measurement)];
|
||||
|
||||
// Append tags first, then fields
|
||||
if (tags) {
|
||||
for (const key of Object.keys(tags).sort()) {
|
||||
parts.push(escapeCSV(`${key}=${tags[key]}`));
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of Object.keys(fields).sort()) {
|
||||
parts.push(escapeCSV(`${key}=${fields[key]}`));
|
||||
}
|
||||
|
||||
return parts.join(',');
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes a value for safe inclusion in a CSV field.
|
||||
* Wraps in double quotes if the value contains a comma, quote, or newline.
|
||||
*/
|
||||
function escapeCSV(value) {
|
||||
const str = String(value);
|
||||
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
|
||||
return '"' + str.replace(/"/g, '""') + '"';
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
module.exports = { format };
|
||||
60
src/helper/formatters/index.js
Normal file
60
src/helper/formatters/index.js
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Formatter Registry
|
||||
* ------------------
|
||||
* Maps format names to formatter modules.
|
||||
* Each formatter exports: format(measurement, metadata) => string|object
|
||||
*
|
||||
* Usage:
|
||||
* const { getFormatter, registerFormatter } = require('./formatters');
|
||||
* const fmt = getFormatter('json');
|
||||
* const output = fmt.format('pump1', { fields: {...}, tags: {...} });
|
||||
*/
|
||||
|
||||
const influxdbFormatter = require('./influxdbFormatter');
|
||||
const jsonFormatter = require('./jsonFormatter');
|
||||
const csvFormatter = require('./csvFormatter');
|
||||
const processFormatter = require('./processFormatter');
|
||||
|
||||
// Built-in registry
|
||||
const registry = {
|
||||
influxdb: influxdbFormatter,
|
||||
json: jsonFormatter,
|
||||
csv: csvFormatter,
|
||||
process: processFormatter,
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieve a formatter by name.
|
||||
* @param {string} name - Format name (e.g. 'influxdb', 'json', 'csv')
|
||||
* @returns {object} Formatter with a .format() method
|
||||
* @throws {Error} If the format name is not registered
|
||||
*/
|
||||
function getFormatter(name) {
|
||||
const formatter = registry[name];
|
||||
if (!formatter) {
|
||||
throw new Error(`Unknown output format: "${name}". Registered formats: ${Object.keys(registry).join(', ')}`);
|
||||
}
|
||||
return formatter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a custom formatter at runtime.
|
||||
* @param {string} name - Format name
|
||||
* @param {object} formatter - Object with a .format(measurement, metadata) method
|
||||
*/
|
||||
function registerFormatter(name, formatter) {
|
||||
if (typeof formatter.format !== 'function') {
|
||||
throw new Error('Formatter must have a .format(measurement, metadata) method');
|
||||
}
|
||||
registry[name] = formatter;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all registered format names.
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function getRegisteredFormats() {
|
||||
return Object.keys(registry);
|
||||
}
|
||||
|
||||
module.exports = { getFormatter, registerFormatter, getRegisteredFormats };
|
||||
22
src/helper/formatters/influxdbFormatter.js
Normal file
22
src/helper/formatters/influxdbFormatter.js
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* InfluxDB formatter
|
||||
* Produces the structured object expected by Node-RED InfluxDB nodes:
|
||||
* { measurement, fields, tags, timestamp }
|
||||
*
|
||||
* @param {string} measurement - The measurement name (e.g. node name)
|
||||
* @param {object} metadata - { fields, tags }
|
||||
* - fields: key/value pairs of changed data points
|
||||
* - tags: flat key/value string pairs (InfluxDB tags)
|
||||
* @returns {string|object} Formatted payload (object for InfluxDB)
|
||||
*/
|
||||
function format(measurement, metadata) {
|
||||
const { fields, tags } = metadata;
|
||||
return {
|
||||
measurement: measurement,
|
||||
fields: fields,
|
||||
tags: tags || {},
|
||||
timestamp: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { format };
|
||||
22
src/helper/formatters/jsonFormatter.js
Normal file
22
src/helper/formatters/jsonFormatter.js
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* JSON formatter
|
||||
* Produces a JSON string suitable for MQTT, REST APIs, etc.
|
||||
*
|
||||
* @param {string} measurement - The measurement name (e.g. node name)
|
||||
* @param {object} metadata - { fields, tags }
|
||||
* - fields: key/value pairs of changed data points
|
||||
* - tags: flat key/value string pairs
|
||||
* @returns {string} JSON-encoded string
|
||||
*/
|
||||
function format(measurement, metadata) {
|
||||
const { fields, tags } = metadata;
|
||||
const payload = {
|
||||
measurement: measurement,
|
||||
fields: fields,
|
||||
tags: tags || {},
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
return JSON.stringify(payload);
|
||||
}
|
||||
|
||||
module.exports = { format };
|
||||
9
src/helper/formatters/processFormatter.js
Normal file
9
src/helper/formatters/processFormatter.js
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Process formatter
|
||||
* Keeps the existing process-port behaviour: emit only changed fields as an object.
|
||||
*/
|
||||
function format(_measurement, metadata) {
|
||||
return metadata.fields;
|
||||
}
|
||||
|
||||
module.exports = { format };
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
123
src/helper/menu/dataFetching.js
Normal file
123
src/helper/menu/dataFetching.js
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Data fetching methods for MenuUtils.
|
||||
* Handles primary/fallback URL fetching and API calls.
|
||||
*/
|
||||
|
||||
const dataFetching = {
|
||||
async fetchData(url, fallbackUrl) {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
|
||||
const responsData = await response.json();
|
||||
//responsData
|
||||
const data = responsData.data;
|
||||
/* .map(item => {
|
||||
const { vendor_name, ...rest } = item;
|
||||
return {
|
||||
name: vendor_name,
|
||||
...rest
|
||||
};
|
||||
}); */
|
||||
console.log(url);
|
||||
console.log("Response Data: ", data);
|
||||
return data;
|
||||
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`Primary URL failed: ${url}. Trying fallback URL: ${fallbackUrl}`,
|
||||
err
|
||||
);
|
||||
try {
|
||||
const response = await fetch(fallbackUrl);
|
||||
if (!response.ok)
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
return await response.json();
|
||||
} catch (fallbackErr) {
|
||||
console.error("Both primary and fallback URLs failed:", fallbackErr);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async fetchProjectData(url) {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
|
||||
const responsData = await response.json();
|
||||
console.log("Response Data: ", responsData);
|
||||
return responsData;
|
||||
|
||||
} catch (err) {
|
||||
/* intentionally empty */
|
||||
}
|
||||
},
|
||||
|
||||
// Save changes to API
|
||||
async apiCall(node) {
|
||||
try{
|
||||
// OLFIANT when a browser refreshes the tag code is lost!!! fix this later!!!!!
|
||||
// FIX UUID ALSO LATER
|
||||
|
||||
if(node.assetTagCode !== "" || node.assetTagCode !== null){ /* intentionally empty */ }
|
||||
// API call to register or check asset in central database
|
||||
let assetregisterAPI = node.configUrls.cloud.taggcodeAPI + "/asset/create_asset.php";
|
||||
|
||||
const assetModelId = node.modelMetadata.id; //asset_product_model_id
|
||||
const uuid = node.uuid; //asset_product_model_uuid
|
||||
const assetName = node.assetType; //asset_name / type?
|
||||
const description = node.name; // asset_description
|
||||
const assetStatus = "actief"; //asset_status -> koppel aan enable / disable node ? or make dropdown ?
|
||||
const assetProfileId = 1; //asset_profile_id these are the rules to check if the childs are valid under this node (parent / child id?)
|
||||
const child_assets = ["63247"]; //child_assets tagnummer of id?
|
||||
const assetProcessId = node.processId; //asset_process_id
|
||||
const assetLocationId = node.locationId; //asset_location_id
|
||||
const tagCode = node.assetTagCode; // if already exists in the node information use it to tell the api it exists and it will update else we will get it from the api call
|
||||
//console.log(`this is my tagCode: ${tagCode}`);
|
||||
|
||||
// Build base URL with required parameters
|
||||
let apiUrl = `?asset_product_model_id=${assetModelId}&asset_product_model_uuid=${uuid}&asset_name=${assetName}&asset_description=${description}&asset_status=${assetStatus}&asset_profile_id=${assetProfileId}&asset_location_id=${assetLocationId}&asset_process_id=${assetProcessId}&child_assets=${child_assets}`;
|
||||
|
||||
// Only add tagCode to URL if it exists
|
||||
if (tagCode) {
|
||||
apiUrl += `&asset_tag_number=${tagCode}`;
|
||||
console.log('hello there');
|
||||
}
|
||||
|
||||
assetregisterAPI += apiUrl;
|
||||
console.log("API call to register asset in central database", assetregisterAPI);
|
||||
|
||||
const response = await fetch(assetregisterAPI, {
|
||||
method: "POST"
|
||||
});
|
||||
|
||||
// Get the response text first
|
||||
const responseText = await response.text();
|
||||
console.log("Raw API response:", responseText);
|
||||
|
||||
// Try to parse the JSON, handling potential parsing errors
|
||||
let jsonResponse;
|
||||
try {
|
||||
jsonResponse = JSON.parse(responseText);
|
||||
} catch (parseError) {
|
||||
console.error("JSON Parsing Error:", parseError);
|
||||
console.error("Response that could not be parsed:", responseText);
|
||||
throw new Error("Failed to parse API response");
|
||||
}
|
||||
|
||||
console.log(jsonResponse);
|
||||
|
||||
if(jsonResponse.success){
|
||||
console.log(`${jsonResponse.message}, tag number: ${jsonResponse.asset_tag_number}, asset id: ${jsonResponse.asset_id}`);
|
||||
// Save the asset tag number and id to the node
|
||||
} else {
|
||||
console.log("Asset not registered in central database");
|
||||
}
|
||||
return jsonResponse;
|
||||
|
||||
} catch (error) {
|
||||
console.log("Error saving changes to asset register API", error);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = dataFetching;
|
||||
283
src/helper/menu/dropdownPopulation.js
Normal file
283
src/helper/menu/dropdownPopulation.js
Normal file
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* Dropdown population methods for MenuUtils.
|
||||
* Handles populating and cascading dropdown menus for assets, suppliers, models, units, etc.
|
||||
*/
|
||||
|
||||
const dropdownPopulation = {
|
||||
populateSmoothingMethods(configUrls, elements, node) {
|
||||
this.fetchData(configUrls.cloud.config, configUrls.local.config)
|
||||
.then((configData) => {
|
||||
const smoothingMethods =
|
||||
configData.smoothing?.smoothMethod?.rules?.values?.map(
|
||||
(o) => o.value
|
||||
) || [];
|
||||
this.populateDropdown(
|
||||
elements.smoothMethod,
|
||||
smoothingMethods,
|
||||
node,
|
||||
"smooth_method"
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Error loading smoothing methods", err);
|
||||
});
|
||||
},
|
||||
|
||||
populateInterpolationMethods(configUrls, elements, node) {
|
||||
this.fetchData(configUrls.cloud.config, configUrls.local.config)
|
||||
.then((configData) => {
|
||||
const interpolationMethods =
|
||||
configData?.interpolation?.type?.rules?.values.map((m) => m.value) ||
|
||||
[];
|
||||
this.populateDropdown(
|
||||
elements.interpolationMethodInput,
|
||||
interpolationMethods,
|
||||
node,
|
||||
"interpolationMethod"
|
||||
);
|
||||
|
||||
// Find the selected method and use it to spawn 1 more field to fill in tension
|
||||
//const selectedMethod = interpolationMethods.find(m => m === node.interpolationMethod);
|
||||
this.initTensionToggles(elements, node);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Error loading interpolation methods", err);
|
||||
});
|
||||
},
|
||||
|
||||
populateLogLevelOptions(logLevelSelect, configData, node) {
|
||||
// debug log level
|
||||
//console.log("Displaying configData => ", configData) ;
|
||||
|
||||
const logLevels =
|
||||
configData?.general?.logging?.logLevel?.rules?.values?.map(
|
||||
(l) => l.value
|
||||
) || [];
|
||||
|
||||
//console.log("Displaying logLevels => ", logLevels);
|
||||
|
||||
// Reuse your existing generic populateDropdown helper
|
||||
this.populateDropdown(logLevelSelect, logLevels, node.logLevel);
|
||||
},
|
||||
|
||||
//cascade dropdowns for asset type, supplier, subType, model, unit
|
||||
fetchAndPopulateDropdowns(configUrls, elements, node) {
|
||||
this.fetchData(configUrls.cloud.config, configUrls.local.config)
|
||||
.then((configData) => {
|
||||
const assetType = configData.asset?.type?.default;
|
||||
const localSuppliersUrl = this.constructUrl(configUrls.local.taggcodeAPI,`${assetType}s`,"suppliers.json");
|
||||
const cloudSuppliersUrl = this.constructCloudURL(configUrls.cloud.taggcodeAPI, "/vendor/get_vendors.php");
|
||||
|
||||
return this.fetchData(cloudSuppliersUrl, localSuppliersUrl)
|
||||
.then((supplierData) => {
|
||||
|
||||
const suppliers = supplierData.map((supplier) => supplier.name);
|
||||
|
||||
// Populate suppliers dropdown and set up its change handler
|
||||
return this.populateDropdown(
|
||||
elements.supplier,
|
||||
suppliers,
|
||||
node,
|
||||
"supplier",
|
||||
function (selectedSupplier) {
|
||||
if (selectedSupplier) {
|
||||
this.populateSubTypes(configUrls, elements, node, selectedSupplier);
|
||||
}
|
||||
}
|
||||
);
|
||||
})
|
||||
.then(() => {
|
||||
// If we have a saved supplier, trigger subTypes population
|
||||
if (node.supplier) {
|
||||
this.populateSubTypes(configUrls, elements, node, node.supplier);
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error in initial dropdown population:", error);
|
||||
});
|
||||
},
|
||||
|
||||
populateSubTypes(configUrls, elements, node, selectedSupplier) {
|
||||
|
||||
this.fetchData(configUrls.cloud.config, configUrls.local.config)
|
||||
.then((configData) => {
|
||||
const assetType = configData.asset?.type?.default;
|
||||
const supplierFolder = this.constructUrl( configUrls.local.taggcodeAPI, `${assetType}s`, selectedSupplier );
|
||||
|
||||
const localSubTypesUrl = this.constructUrl(supplierFolder, "subtypes.json");
|
||||
const cloudSubTypesUrl = this.constructCloudURL(configUrls.cloud.taggcodeAPI, "/product/get_subtypesFromVendor.php?vendor_name=" + selectedSupplier);
|
||||
|
||||
return this.fetchData(cloudSubTypesUrl, localSubTypesUrl)
|
||||
.then((subTypeData) => {
|
||||
const subTypes = subTypeData.map((subType) => subType.name);
|
||||
|
||||
return this.populateDropdown(
|
||||
elements.subType,
|
||||
subTypes,
|
||||
node,
|
||||
"subType",
|
||||
function (selectedSubType) {
|
||||
if (selectedSubType) {
|
||||
// When subType changes, update both models and units
|
||||
this.populateModels(
|
||||
configUrls,
|
||||
elements,
|
||||
node,
|
||||
selectedSupplier,
|
||||
selectedSubType
|
||||
);
|
||||
this.populateUnitsForSubType(
|
||||
configUrls,
|
||||
elements,
|
||||
node,
|
||||
selectedSubType
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
})
|
||||
.then(() => {
|
||||
// If we have a saved subType, trigger both models and units population
|
||||
if (node.subType) {
|
||||
this.populateModels(
|
||||
configUrls,
|
||||
elements,
|
||||
node,
|
||||
selectedSupplier,
|
||||
node.subType
|
||||
);
|
||||
this.populateUnitsForSubType(configUrls, elements, node, node.subType);
|
||||
}
|
||||
//console.log("In fetch part of subtypes ");
|
||||
// Store all data from selected model
|
||||
/* node["modelMetadata"] = modelData.find(
|
||||
(model) => model.name === node.model
|
||||
);
|
||||
console.log("Model Metadata: ", node["modelMetadata"]); */
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error populating subtypes:", error);
|
||||
});
|
||||
},
|
||||
|
||||
populateUnitsForSubType(configUrls, elements, node, selectedSubType) {
|
||||
// Fetch the units data
|
||||
this.fetchData(configUrls.cloud.units, configUrls.local.units)
|
||||
.then((unitsData) => {
|
||||
// Find the category that matches the subType name
|
||||
const categoryData = unitsData.units.find(
|
||||
(category) =>
|
||||
category.category.toLowerCase() === selectedSubType.toLowerCase()
|
||||
);
|
||||
|
||||
if (categoryData) {
|
||||
// Extract just the unit values and descriptions
|
||||
const units = categoryData.values.map((unit) => ({
|
||||
value: unit.value,
|
||||
description: unit.description,
|
||||
}));
|
||||
|
||||
// Create the options array with descriptions as labels
|
||||
const options = units.map((unit) => ({
|
||||
value: unit.value,
|
||||
label: `${unit.value} - ${unit.description}`,
|
||||
}));
|
||||
|
||||
// Populate the units dropdown
|
||||
this.populateDropdown(
|
||||
elements.unit,
|
||||
options.map((opt) => opt.value),
|
||||
node,
|
||||
"unit"
|
||||
);
|
||||
|
||||
// If there's no currently selected unit but we have options, select the first one
|
||||
if (!node.unit && options.length > 0) {
|
||||
node.unit = options[0].value;
|
||||
elements.unit.value = options[0].value;
|
||||
}
|
||||
} else {
|
||||
// If no matching category is found, provide a default % option
|
||||
const defaultUnits = [{ value: "%", description: "Percentage" }];
|
||||
this.populateDropdown(
|
||||
elements.unit,
|
||||
defaultUnits.map((unit) => unit.value),
|
||||
node,
|
||||
"unit"
|
||||
);
|
||||
console.warn(
|
||||
`No matching unit category found for subType: ${selectedSubType}`
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error fetching units:", error);
|
||||
});
|
||||
},
|
||||
|
||||
populateModels(
|
||||
configUrls,
|
||||
elements,
|
||||
node,
|
||||
selectedSupplier,
|
||||
selectedSubType
|
||||
) {
|
||||
|
||||
this.fetchData(configUrls.cloud.config, configUrls.local.config)
|
||||
.then((configData) => {
|
||||
const assetType = configData.asset?.type?.default;
|
||||
// save assetType to fetch later
|
||||
node.assetType = assetType;
|
||||
|
||||
const supplierFolder = this.constructUrl( configUrls.local.taggcodeAPI,`${assetType}s`,selectedSupplier);
|
||||
const subTypeFolder = this.constructUrl(supplierFolder, selectedSubType);
|
||||
const localModelsUrl = this.constructUrl(subTypeFolder, "models.json");
|
||||
const cloudModelsUrl = this.constructCloudURL(configUrls.cloud.taggcodeAPI, "/product/get_product_models.php?vendor_name=" + selectedSupplier + "&product_subtype_name=" + selectedSubType);
|
||||
|
||||
return this.fetchData(cloudModelsUrl, localModelsUrl).then((modelData) => {
|
||||
const models = modelData.map((model) => model.name); // use this to populate the dropdown
|
||||
|
||||
// If a model is already selected, store its metadata immediately
|
||||
if (node.model) {
|
||||
node["modelMetadata"] = modelData.find((model) => model.name === node.model);
|
||||
}
|
||||
|
||||
this.populateDropdown(elements.model, models, node, "model", (selectedModel) => {
|
||||
// Store only the metadata for the selected model
|
||||
node["modelMetadata"] = modelData.find((model) => model.name === selectedModel);
|
||||
});
|
||||
/*
|
||||
console.log('hello here I am:');
|
||||
console.log(node["modelMetadata"]);
|
||||
*/
|
||||
});
|
||||
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error populating models:", error);
|
||||
});
|
||||
},
|
||||
|
||||
async populateDropdown(
|
||||
htmlElement,
|
||||
options,
|
||||
node,
|
||||
property,
|
||||
callback
|
||||
) {
|
||||
this.generateHtml(htmlElement, options, node[property]);
|
||||
|
||||
htmlElement.addEventListener("change", async (e) => {
|
||||
const newValue = e.target.value;
|
||||
console.log(`Dropdown changed: ${property} = ${newValue}`);
|
||||
node[property] = newValue;
|
||||
|
||||
RED.nodes.dirty(true);
|
||||
if (callback) await callback(newValue); // Ensure async callback completion
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = dropdownPopulation;
|
||||
151
src/helper/menu/htmlGeneration.js
Normal file
151
src/helper/menu/htmlGeneration.js
Normal file
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* HTML generation and endpoint methods for MenuUtils.
|
||||
* Handles generating dropdown HTML and serving MenuUtils code to the browser.
|
||||
*/
|
||||
|
||||
const htmlGeneration = {
|
||||
generateHtml(htmlElement, options, savedValue) {
|
||||
htmlElement.innerHTML = options.length
|
||||
? `<option value="">Select...</option>${options
|
||||
.map((opt) => `<option value="${opt}">${opt}</option>`)
|
||||
.join("")}`
|
||||
: "<option value=''>No options available</option>";
|
||||
|
||||
if (savedValue && options.includes(savedValue)) {
|
||||
htmlElement.value = savedValue;
|
||||
}
|
||||
},
|
||||
|
||||
createMenuUtilsEndpoint(RED, nodeName, customHelpers = {}, 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.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));
|
||||
},
|
||||
|
||||
generateMenuUtilsData(nodeName, customHelpers = {}, options = {}) {
|
||||
const defaultHelpers = {
|
||||
validateRequired: `function(value) {
|
||||
return value && value.toString().trim() !== '';
|
||||
}`,
|
||||
formatDisplayValue: `function(value, unit) {
|
||||
return \`\${value} \${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);
|
||||
});
|
||||
})();
|
||||
`;
|
||||
},
|
||||
|
||||
generateLegacyMenuUtilsCode(nodeName, customHelpers = {}) {
|
||||
const allHelpers = { ...this.generateMenuUtilsData(nodeName).helpers, ...customHelpers };
|
||||
|
||||
const helpersCode = Object.entries(allHelpers)
|
||||
.map(([name, func]) => ` ${name}: ${func}`)
|
||||
.join(',\n');
|
||||
|
||||
const classCode = this.constructor.toString(); // <-- this gives full class MenuUtils {...}
|
||||
|
||||
return `
|
||||
// Create EVOLV namespace structure
|
||||
window.EVOLV = window.EVOLV || {};
|
||||
window.EVOLV.nodes = window.EVOLV.nodes || {};
|
||||
window.EVOLV.nodes.${nodeName} = window.EVOLV.nodes.${nodeName} || {};
|
||||
|
||||
// Inject MenuUtils class
|
||||
${classCode}
|
||||
|
||||
// Expose MenuUtils instance to namespace
|
||||
window.EVOLV.nodes.${nodeName}.utils = {
|
||||
menuUtils: new MenuUtils(),
|
||||
|
||||
helpers: {
|
||||
${helpersCode}
|
||||
}
|
||||
};
|
||||
|
||||
// Optionally expose globally
|
||||
window.MenuUtils = MenuUtils;
|
||||
|
||||
console.log('${nodeName} utilities loaded in namespace');
|
||||
`;
|
||||
},
|
||||
|
||||
// Backward-compatible alias
|
||||
generateMenuUtilsCode(nodeName, customHelpers = {}) {
|
||||
return this.generateLegacyMenuUtilsCode(nodeName, customHelpers);
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = htmlGeneration;
|
||||
18
src/helper/menu/index.js
Normal file
18
src/helper/menu/index.js
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* menu/index.js
|
||||
* Barrel file for the menu module components.
|
||||
*/
|
||||
|
||||
const toggles = require('./toggles');
|
||||
const dataFetching = require('./dataFetching');
|
||||
const urlUtils = require('./urlUtils');
|
||||
const dropdownPopulation = require('./dropdownPopulation');
|
||||
const htmlGeneration = require('./htmlGeneration');
|
||||
|
||||
module.exports = {
|
||||
toggles,
|
||||
dataFetching,
|
||||
urlUtils,
|
||||
dropdownPopulation,
|
||||
htmlGeneration,
|
||||
};
|
||||
56
src/helper/menu/toggles.js
Normal file
56
src/helper/menu/toggles.js
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Toggle initialization methods for MenuUtils.
|
||||
* Controls visibility of UI elements based on checkbox/dropdown state.
|
||||
*/
|
||||
|
||||
const toggles = {
|
||||
initBasicToggles(elements) {
|
||||
// Toggle visibility for log level
|
||||
elements.logCheckbox.addEventListener("change", function () {
|
||||
elements.rowLogLevel.style.display = this.checked ? "block" : "none";
|
||||
});
|
||||
elements.rowLogLevel.style.display = elements.logCheckbox.checked
|
||||
? "block"
|
||||
: "none";
|
||||
},
|
||||
|
||||
// Define the initialize toggles function within scope
|
||||
initMeasurementToggles(elements) {
|
||||
// Toggle visibility for scaling inputs
|
||||
elements.scalingCheckbox.addEventListener("change", function () {
|
||||
elements.rowInputMin.style.display = this.checked ? "block" : "none";
|
||||
elements.rowInputMax.style.display = this.checked ? "block" : "none";
|
||||
});
|
||||
|
||||
// Set initial states
|
||||
elements.rowInputMin.style.display = elements.scalingCheckbox.checked
|
||||
? "block"
|
||||
: "none";
|
||||
elements.rowInputMax.style.display = elements.scalingCheckbox.checked
|
||||
? "block"
|
||||
: "none";
|
||||
},
|
||||
|
||||
initTensionToggles(elements, node) {
|
||||
const currentMethod = node.interpolationMethod;
|
||||
elements.rowTension.style.display =
|
||||
currentMethod === "monotone_cubic_spline" ? "block" : "none";
|
||||
console.log(
|
||||
"Initial tension row display: ",
|
||||
elements.rowTension.style.display
|
||||
);
|
||||
|
||||
elements.interpolationMethodInput.addEventListener("change", function () {
|
||||
const selectedMethod = this.value;
|
||||
console.log(`Interpolation method changed: ${selectedMethod}`);
|
||||
node.interpolationMethod = selectedMethod;
|
||||
|
||||
// Toggle visibility for tension input
|
||||
elements.rowTension.style.display =
|
||||
selectedMethod === "monotone_cubic_spline" ? "block" : "none";
|
||||
console.log("Tension row display: ", elements.rowTension.style.display);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = toggles;
|
||||
39
src/helper/menu/urlUtils.js
Normal file
39
src/helper/menu/urlUtils.js
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* URL construction methods for MenuUtils.
|
||||
* Helpers for building API and config URLs.
|
||||
*/
|
||||
|
||||
const urlUtils = {
|
||||
getSpecificConfigUrl(nodeName, cloudAPI) {
|
||||
const cloudConfigURL = cloudAPI + "/config/" + nodeName + ".json";
|
||||
const localConfigURL = "http://localhost:1880/"+ nodeName + "/dependencies/"+ nodeName + "/" + nodeName + "Config.json";
|
||||
|
||||
return { cloudConfigURL, localConfigURL };
|
||||
},
|
||||
|
||||
// Helper function to construct a URL from a base and path internal
|
||||
constructUrl(base, ...paths) {
|
||||
// Remove trailing slash from base and leading slashes from paths
|
||||
const sanitizedBase = (base || "").replace(/\/+$/, "");
|
||||
const sanitizedPaths = paths.map((path) => path.replace(/^\/+|\/+$/g, ""));
|
||||
|
||||
// Join sanitized base and paths
|
||||
const url = `${sanitizedBase}/${sanitizedPaths.join("/")}`;
|
||||
console.log("Base:", sanitizedBase);
|
||||
console.log("Paths:", sanitizedPaths);
|
||||
console.log("Constructed URL:", url);
|
||||
return url;
|
||||
},
|
||||
|
||||
//Adjust for API Gateway
|
||||
constructCloudURL(base, ...paths) {
|
||||
// Remove trailing slash from base and leading slashes from paths
|
||||
const sanitizedBase = base.replace(/\/+$/, "");
|
||||
const sanitizedPaths = paths.map((path) => path.replace(/^\/+|\/+$/g, ""));
|
||||
// Join sanitized base and paths
|
||||
const url = `${sanitizedBase}/${sanitizedPaths.join("/")}`;
|
||||
return url;
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = urlUtils;
|
||||
@@ -1,543 +1,34 @@
|
||||
/**
|
||||
* MenuUtils — UI menu helper for Node-RED editor.
|
||||
* Methods are split across focused modules under ./menu/ and mixed onto the prototype.
|
||||
*/
|
||||
|
||||
const toggles = require('./menu/toggles');
|
||||
const dataFetching = require('./menu/dataFetching');
|
||||
const urlUtils = require('./menu/urlUtils');
|
||||
const dropdownPopulation = require('./menu/dropdownPopulation');
|
||||
const htmlGeneration = require('./menu/htmlGeneration');
|
||||
|
||||
class MenuUtils {
|
||||
constructor() {
|
||||
this.isCloud = false;
|
||||
this.configData = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
initBasicToggles(elements) {
|
||||
// Toggle visibility for log level
|
||||
elements.logCheckbox.addEventListener("change", function () {
|
||||
elements.rowLogLevel.style.display = this.checked ? "block" : "none";
|
||||
// Mix all method groups onto the prototype
|
||||
const mixins = [toggles, dataFetching, urlUtils, dropdownPopulation, htmlGeneration];
|
||||
for (const mixin of mixins) {
|
||||
for (const [name, fn] of Object.entries(mixin)) {
|
||||
if (typeof fn === 'function') {
|
||||
Object.defineProperty(MenuUtils.prototype, name, {
|
||||
value: fn,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
enumerable: false,
|
||||
});
|
||||
elements.rowLogLevel.style.display = elements.logCheckbox.checked
|
||||
? "block"
|
||||
: "none";
|
||||
}
|
||||
|
||||
// Define the initialize toggles function within scope
|
||||
initMeasurementToggles(elements) {
|
||||
// Toggle visibility for scaling inputs
|
||||
elements.scalingCheckbox.addEventListener("change", function () {
|
||||
elements.rowInputMin.style.display = this.checked ? "block" : "none";
|
||||
elements.rowInputMax.style.display = this.checked ? "block" : "none";
|
||||
});
|
||||
|
||||
// Set initial states
|
||||
elements.rowInputMin.style.display = elements.scalingCheckbox.checked
|
||||
? "block"
|
||||
: "none";
|
||||
elements.rowInputMax.style.display = elements.scalingCheckbox.checked
|
||||
? "block"
|
||||
: "none";
|
||||
}
|
||||
|
||||
initTensionToggles(elements, node) {
|
||||
const currentMethod = node.interpolationMethod;
|
||||
elements.rowTension.style.display =
|
||||
currentMethod === "monotone_cubic_spline" ? "block" : "none";
|
||||
console.log(
|
||||
"Initial tension row display: ",
|
||||
elements.rowTension.style.display
|
||||
);
|
||||
|
||||
elements.interpolationMethodInput.addEventListener("change", function () {
|
||||
const selectedMethod = this.value;
|
||||
console.log(`Interpolation method changed: ${selectedMethod}`);
|
||||
node.interpolationMethod = selectedMethod;
|
||||
|
||||
// Toggle visibility for tension input
|
||||
elements.rowTension.style.display =
|
||||
selectedMethod === "monotone_cubic_spline" ? "block" : "none";
|
||||
console.log("Tension row display: ", elements.rowTension.style.display);
|
||||
});
|
||||
}
|
||||
// Define the smoothing methods population function within scope
|
||||
populateSmoothingMethods(configUrls, elements, node) {
|
||||
this.fetchData(configUrls.cloud.config, configUrls.local.config)
|
||||
.then((configData) => {
|
||||
const smoothingMethods =
|
||||
configData.smoothing?.smoothMethod?.rules?.values?.map(
|
||||
(o) => o.value
|
||||
) || [];
|
||||
this.populateDropdown(
|
||||
elements.smoothMethod,
|
||||
smoothingMethods,
|
||||
node,
|
||||
"smooth_method"
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Error loading smoothing methods", err);
|
||||
});
|
||||
}
|
||||
|
||||
populateInterpolationMethods(configUrls, elements, node) {
|
||||
this.fetchData(configUrls.cloud.config, configUrls.local.config)
|
||||
.then((configData) => {
|
||||
const interpolationMethods =
|
||||
configData?.interpolation?.type?.rules?.values.map((m) => m.value) ||
|
||||
[];
|
||||
this.populateDropdown(
|
||||
elements.interpolationMethodInput,
|
||||
interpolationMethods,
|
||||
node,
|
||||
"interpolationMethod"
|
||||
);
|
||||
|
||||
// Find the selected method and use it to spawn 1 more field to fill in tension
|
||||
//const selectedMethod = interpolationMethods.find(m => m === node.interpolationMethod);
|
||||
this.initTensionToggles(elements, node);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Error loading interpolation methods", err);
|
||||
});
|
||||
}
|
||||
|
||||
populateLogLevelOptions(logLevelSelect, configData, node) {
|
||||
// debug log level
|
||||
//console.log("Displaying configData => ", configData) ;
|
||||
|
||||
const logLevels =
|
||||
configData?.general?.logging?.logLevel?.rules?.values?.map(
|
||||
(l) => l.value
|
||||
) || [];
|
||||
|
||||
//console.log("Displaying logLevels => ", logLevels);
|
||||
|
||||
// Reuse your existing generic populateDropdown helper
|
||||
this.populateDropdown(logLevelSelect, logLevels, node.logLevel);
|
||||
}
|
||||
|
||||
//cascade dropdowns for asset type, supplier, subType, model, unit
|
||||
fetchAndPopulateDropdowns(configUrls, elements, node) {
|
||||
this.fetchData(configUrls.cloud.config, configUrls.local.config)
|
||||
.then((configData) => {
|
||||
const assetType = configData.asset?.type?.default;
|
||||
const localSuppliersUrl = this.constructUrl(configUrls.local.taggcodeAPI,`${assetType}s`,"suppliers.json");
|
||||
const cloudSuppliersUrl = this.constructCloudURL(configUrls.cloud.taggcodeAPI, "/vendor/get_vendors.php");
|
||||
|
||||
return this.fetchData(cloudSuppliersUrl, localSuppliersUrl)
|
||||
.then((supplierData) => {
|
||||
|
||||
const suppliers = supplierData.map((supplier) => supplier.name);
|
||||
|
||||
// Populate suppliers dropdown and set up its change handler
|
||||
return this.populateDropdown(
|
||||
elements.supplier,
|
||||
suppliers,
|
||||
node,
|
||||
"supplier",
|
||||
function (selectedSupplier) {
|
||||
if (selectedSupplier) {
|
||||
this.populateSubTypes(configUrls, elements, node, selectedSupplier);
|
||||
}
|
||||
}
|
||||
);
|
||||
})
|
||||
.then(() => {
|
||||
// If we have a saved supplier, trigger subTypes population
|
||||
if (node.supplier) {
|
||||
this.populateSubTypes(configUrls, elements, node, node.supplier);
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error in initial dropdown population:", error);
|
||||
});
|
||||
}
|
||||
|
||||
getSpecificConfigUrl(nodeName,cloudAPI) {
|
||||
|
||||
const cloudConfigURL = cloudAPI + "/config/" + nodeName + ".json";
|
||||
const localConfigURL = "http://localhost:1880/"+ nodeName + "/dependencies/"+ nodeName + "/" + nodeName + "Config.json";
|
||||
|
||||
return { cloudConfigURL, localConfigURL };
|
||||
|
||||
}
|
||||
|
||||
// Save changes to API
|
||||
async apiCall(node) {
|
||||
try{
|
||||
// OLFIANT when a browser refreshes the tag code is lost!!! fix this later!!!!!
|
||||
// FIX UUID ALSO LATER
|
||||
|
||||
if(node.assetTagCode !== "" || node.assetTagCode !== null){ }
|
||||
// API call to register or check asset in central database
|
||||
let assetregisterAPI = node.configUrls.cloud.taggcodeAPI + "/asset/create_asset.php";
|
||||
|
||||
const assetModelId = node.modelMetadata.id; //asset_product_model_id
|
||||
const uuid = node.uuid; //asset_product_model_uuid
|
||||
const assetName = node.assetType; //asset_name / type?
|
||||
const description = node.name; // asset_description
|
||||
const assetStatus = "actief"; //asset_status -> koppel aan enable / disable node ? or make dropdown ?
|
||||
const assetProfileId = 1; //asset_profile_id these are the rules to check if the childs are valid under this node (parent / child id?)
|
||||
const child_assets = ["63247"]; //child_assets tagnummer of id?
|
||||
const assetProcessId = node.processId; //asset_process_id
|
||||
const assetLocationId = node.locationId; //asset_location_id
|
||||
const tagCode = node.assetTagCode; // if already exists in the node information use it to tell the api it exists and it will update else we will get it from the api call
|
||||
//console.log(`this is my tagCode: ${tagCode}`);
|
||||
|
||||
// Build base URL with required parameters
|
||||
let apiUrl = `?asset_product_model_id=${assetModelId}&asset_product_model_uuid=${uuid}&asset_name=${assetName}&asset_description=${description}&asset_status=${assetStatus}&asset_profile_id=${assetProfileId}&asset_location_id=${assetLocationId}&asset_process_id=${assetProcessId}&child_assets=${child_assets}`;
|
||||
|
||||
// Only add tagCode to URL if it exists
|
||||
if (tagCode) {
|
||||
apiUrl += `&asset_tag_number=${tagCode}`;
|
||||
console.log('hello there');
|
||||
}
|
||||
|
||||
assetregisterAPI += apiUrl;
|
||||
console.log("API call to register asset in central database", assetregisterAPI);
|
||||
|
||||
const response = await fetch(assetregisterAPI, {
|
||||
method: "POST"
|
||||
});
|
||||
|
||||
// Get the response text first
|
||||
const responseText = await response.text();
|
||||
console.log("Raw API response:", responseText);
|
||||
|
||||
// Try to parse the JSON, handling potential parsing errors
|
||||
let jsonResponse;
|
||||
try {
|
||||
jsonResponse = JSON.parse(responseText);
|
||||
} catch (parseError) {
|
||||
console.error("JSON Parsing Error:", parseError);
|
||||
console.error("Response that could not be parsed:", responseText);
|
||||
throw new Error("Failed to parse API response");
|
||||
}
|
||||
|
||||
console.log(jsonResponse);
|
||||
|
||||
if(jsonResponse.success){
|
||||
console.log(`${jsonResponse.message}, tag number: ${jsonResponse.asset_tag_number}, asset id: ${jsonResponse.asset_id}`);
|
||||
// Save the asset tag number and id to the node
|
||||
} else {
|
||||
console.log("Asset not registered in central database");
|
||||
}
|
||||
return jsonResponse;
|
||||
|
||||
} catch (error) {
|
||||
console.log("Error saving changes to asset register API", error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async fetchData(url, fallbackUrl) {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
|
||||
const responsData = await response.json();
|
||||
//responsData
|
||||
const data = responsData.data;
|
||||
/* .map(item => {
|
||||
const { vendor_name, ...rest } = item;
|
||||
return {
|
||||
name: vendor_name,
|
||||
...rest
|
||||
};
|
||||
}); */
|
||||
console.log(url);
|
||||
console.log("Response Data: ", data);
|
||||
return data;
|
||||
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`Primary URL failed: ${url}. Trying fallback URL: ${fallbackUrl}`,
|
||||
err
|
||||
);
|
||||
try {
|
||||
const response = await fetch(fallbackUrl);
|
||||
if (!response.ok)
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
return await response.json();
|
||||
} catch (fallbackErr) {
|
||||
console.error("Both primary and fallback URLs failed:", fallbackErr);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fetchProjectData(url) {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
|
||||
const responsData = await response.json();
|
||||
console.log("Response Data: ", responsData);
|
||||
return responsData;
|
||||
|
||||
} catch (err) {
|
||||
}
|
||||
}
|
||||
|
||||
async populateDropdown(
|
||||
htmlElement,
|
||||
options,
|
||||
node,
|
||||
property,
|
||||
callback
|
||||
) {
|
||||
this.generateHtml(htmlElement, options, node[property]);
|
||||
|
||||
htmlElement.addEventListener("change", async (e) => {
|
||||
const newValue = e.target.value;
|
||||
console.log(`Dropdown changed: ${property} = ${newValue}`);
|
||||
node[property] = newValue;
|
||||
|
||||
RED.nodes.dirty(true);
|
||||
if (callback) await callback(newValue); // Ensure async callback completion
|
||||
});
|
||||
}
|
||||
|
||||
// Helper function to construct a URL from a base and path internal
|
||||
constructUrl(base, ...paths) {
|
||||
|
||||
// Remove trailing slash from base and leading slashes from paths
|
||||
const sanitizedBase = (base || "").replace(/\/+$/, "");
|
||||
const sanitizedPaths = paths.map((path) => path.replace(/^\/+|\/+$/g, ""));
|
||||
|
||||
// Join sanitized base and paths
|
||||
const url = `${sanitizedBase}/${sanitizedPaths.join("/")}`;
|
||||
console.log("Base:", sanitizedBase);
|
||||
console.log("Paths:", sanitizedPaths);
|
||||
console.log("Constructed URL:", url);
|
||||
return url;
|
||||
}
|
||||
|
||||
//Adjust for API Gateway
|
||||
constructCloudURL(base, ...paths) {
|
||||
// Remove trailing slash from base and leading slashes from paths
|
||||
const sanitizedBase = base.replace(/\/+$/, "");
|
||||
const sanitizedPaths = paths.map((path) => path.replace(/^\/+|\/+$/g, ""));
|
||||
// Join sanitized base and paths
|
||||
const url = `${sanitizedBase}/${sanitizedPaths.join("/")}`;
|
||||
return url;
|
||||
}
|
||||
|
||||
populateSubTypes(configUrls, elements, node, selectedSupplier) {
|
||||
|
||||
this.fetchData(configUrls.cloud.config, configUrls.local.config)
|
||||
.then((configData) => {
|
||||
const assetType = configData.asset?.type?.default;
|
||||
const supplierFolder = this.constructUrl( configUrls.local.taggcodeAPI, `${assetType}s`, selectedSupplier );
|
||||
|
||||
const localSubTypesUrl = this.constructUrl(supplierFolder, "subtypes.json");
|
||||
const cloudSubTypesUrl = this.constructCloudURL(configUrls.cloud.taggcodeAPI, "/product/get_subtypesFromVendor.php?vendor_name=" + selectedSupplier);
|
||||
|
||||
return this.fetchData(cloudSubTypesUrl, localSubTypesUrl)
|
||||
.then((subTypeData) => {
|
||||
const subTypes = subTypeData.map((subType) => subType.name);
|
||||
|
||||
return this.populateDropdown(
|
||||
elements.subType,
|
||||
subTypes,
|
||||
node,
|
||||
"subType",
|
||||
function (selectedSubType) {
|
||||
if (selectedSubType) {
|
||||
// When subType changes, update both models and units
|
||||
this.populateModels(
|
||||
configUrls,
|
||||
elements,
|
||||
node,
|
||||
selectedSupplier,
|
||||
selectedSubType
|
||||
);
|
||||
this.populateUnitsForSubType(
|
||||
configUrls,
|
||||
elements,
|
||||
node,
|
||||
selectedSubType
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
})
|
||||
.then(() => {
|
||||
// If we have a saved subType, trigger both models and units population
|
||||
if (node.subType) {
|
||||
this.populateModels(
|
||||
configUrls,
|
||||
elements,
|
||||
node,
|
||||
selectedSupplier,
|
||||
node.subType
|
||||
);
|
||||
this.populateUnitsForSubType(configUrls, elements, node, node.subType);
|
||||
}
|
||||
//console.log("In fetch part of subtypes ");
|
||||
// Store all data from selected model
|
||||
/* node["modelMetadata"] = modelData.find(
|
||||
(model) => model.name === node.model
|
||||
);
|
||||
console.log("Model Metadata: ", node["modelMetadata"]); */
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error populating subtypes:", error);
|
||||
});
|
||||
}
|
||||
|
||||
populateUnitsForSubType(configUrls, elements, node, selectedSubType) {
|
||||
// Fetch the units data
|
||||
this.fetchData(configUrls.cloud.units, configUrls.local.units)
|
||||
.then((unitsData) => {
|
||||
// Find the category that matches the subType name
|
||||
const categoryData = unitsData.units.find(
|
||||
(category) =>
|
||||
category.category.toLowerCase() === selectedSubType.toLowerCase()
|
||||
);
|
||||
|
||||
if (categoryData) {
|
||||
// Extract just the unit values and descriptions
|
||||
const units = categoryData.values.map((unit) => ({
|
||||
value: unit.value,
|
||||
description: unit.description,
|
||||
}));
|
||||
|
||||
// Create the options array with descriptions as labels
|
||||
const options = units.map((unit) => ({
|
||||
value: unit.value,
|
||||
label: `${unit.value} - ${unit.description}`,
|
||||
}));
|
||||
|
||||
// Populate the units dropdown
|
||||
this.populateDropdown(
|
||||
elements.unit,
|
||||
options.map((opt) => opt.value),
|
||||
node,
|
||||
"unit"
|
||||
);
|
||||
|
||||
// If there's no currently selected unit but we have options, select the first one
|
||||
if (!node.unit && options.length > 0) {
|
||||
node.unit = options[0].value;
|
||||
elements.unit.value = options[0].value;
|
||||
}
|
||||
} else {
|
||||
// If no matching category is found, provide a default % option
|
||||
const defaultUnits = [{ value: "%", description: "Percentage" }];
|
||||
this.populateDropdown(
|
||||
elements.unit,
|
||||
defaultUnits.map((unit) => unit.value),
|
||||
node,
|
||||
"unit"
|
||||
);
|
||||
console.warn(
|
||||
`No matching unit category found for subType: ${selectedSubType}`
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error fetching units:", error);
|
||||
});
|
||||
}
|
||||
|
||||
populateModels(
|
||||
configUrls,
|
||||
elements,
|
||||
node,
|
||||
selectedSupplier,
|
||||
selectedSubType
|
||||
) {
|
||||
|
||||
this.fetchData(configUrls.cloud.config, configUrls.local.config)
|
||||
.then((configData) => {
|
||||
const assetType = configData.asset?.type?.default;
|
||||
// save assetType to fetch later
|
||||
node.assetType = assetType;
|
||||
|
||||
const supplierFolder = this.constructUrl( configUrls.local.taggcodeAPI,`${assetType}s`,selectedSupplier);
|
||||
const subTypeFolder = this.constructUrl(supplierFolder, selectedSubType);
|
||||
const localModelsUrl = this.constructUrl(subTypeFolder, "models.json");
|
||||
const cloudModelsUrl = this.constructCloudURL(configUrls.cloud.taggcodeAPI, "/product/get_product_models.php?vendor_name=" + selectedSupplier + "&product_subtype_name=" + selectedSubType);
|
||||
|
||||
return this.fetchData(cloudModelsUrl, localModelsUrl).then((modelData) => {
|
||||
const models = modelData.map((model) => model.name); // use this to populate the dropdown
|
||||
|
||||
// If a model is already selected, store its metadata immediately
|
||||
if (node.model) {
|
||||
node["modelMetadata"] = modelData.find((model) => model.name === node.model);
|
||||
}
|
||||
|
||||
this.populateDropdown(elements.model, models, node, "model", (selectedModel) => {
|
||||
// Store only the metadata for the selected model
|
||||
node["modelMetadata"] = modelData.find((model) => model.name === selectedModel);
|
||||
});
|
||||
/*
|
||||
console.log('hello here I am:');
|
||||
console.log(node["modelMetadata"]);
|
||||
*/
|
||||
});
|
||||
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error populating models:", error);
|
||||
});
|
||||
}
|
||||
|
||||
generateHtml(htmlElement, options, savedValue) {
|
||||
htmlElement.innerHTML = options.length
|
||||
? `<option value="">Select...</option>${options
|
||||
.map((opt) => `<option value="${opt}">${opt}</option>`)
|
||||
.join("")}`
|
||||
: "<option value=''>No options available</option>";
|
||||
|
||||
if (savedValue && options.includes(savedValue)) {
|
||||
htmlElement.value = savedValue;
|
||||
}
|
||||
}
|
||||
|
||||
createMenuUtilsEndpoint(RED, nodeName, customHelpers = {}) {
|
||||
RED.httpAdmin.get(`/${nodeName}/resources/menuUtils.js`, function(req, res) {
|
||||
console.log(`Serving menuUtils.js for ${nodeName} node`);
|
||||
res.set('Content-Type', 'application/javascript');
|
||||
|
||||
const browserCode = this.generateMenuUtilsCode(nodeName, customHelpers);
|
||||
res.send(browserCode);
|
||||
}.bind(this));
|
||||
}
|
||||
|
||||
generateMenuUtilsCode(nodeName, customHelpers = {}) {
|
||||
const defaultHelpers = {
|
||||
validateRequired: `function(value) {
|
||||
return value && value.toString().trim() !== '';
|
||||
}`,
|
||||
formatDisplayValue: `function(value, unit) {
|
||||
return \`\${value} \${unit || ''}\`.trim();
|
||||
}`
|
||||
};
|
||||
|
||||
const allHelpers = { ...defaultHelpers, ...customHelpers };
|
||||
|
||||
const helpersCode = Object.entries(allHelpers)
|
||||
.map(([name, func]) => ` ${name}: ${func}`)
|
||||
.join(',\n');
|
||||
|
||||
const classCode = MenuUtils.toString(); // <-- this gives full class MenuUtils {...}
|
||||
|
||||
return `
|
||||
// Create EVOLV namespace structure
|
||||
window.EVOLV = window.EVOLV || {};
|
||||
window.EVOLV.nodes = window.EVOLV.nodes || {};
|
||||
window.EVOLV.nodes.${nodeName} = window.EVOLV.nodes.${nodeName} || {};
|
||||
|
||||
// Inject MenuUtils class
|
||||
${classCode}
|
||||
|
||||
// Expose MenuUtils instance to namespace
|
||||
window.EVOLV.nodes.${nodeName}.utils = {
|
||||
menuUtils: new MenuUtils(),
|
||||
|
||||
helpers: {
|
||||
${helpersCode}
|
||||
}
|
||||
};
|
||||
|
||||
// Optionally expose globally
|
||||
window.MenuUtils = MenuUtils;
|
||||
|
||||
console.log('${nodeName} utilities loaded in namespace');
|
||||
`;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = MenuUtils;
|
||||
@@ -1,543 +0,0 @@
|
||||
class MenuUtils {
|
||||
|
||||
|
||||
initBasicToggles(elements) {
|
||||
// Toggle visibility for log level
|
||||
elements.logCheckbox.addEventListener("change", function () {
|
||||
elements.rowLogLevel.style.display = this.checked ? "block" : "none";
|
||||
});
|
||||
elements.rowLogLevel.style.display = elements.logCheckbox.checked
|
||||
? "block"
|
||||
: "none";
|
||||
}
|
||||
|
||||
// Define the initialize toggles function within scope
|
||||
initMeasurementToggles(elements) {
|
||||
// Toggle visibility for scaling inputs
|
||||
elements.scalingCheckbox.addEventListener("change", function () {
|
||||
elements.rowInputMin.style.display = this.checked ? "block" : "none";
|
||||
elements.rowInputMax.style.display = this.checked ? "block" : "none";
|
||||
});
|
||||
|
||||
// Set initial states
|
||||
elements.rowInputMin.style.display = elements.scalingCheckbox.checked
|
||||
? "block"
|
||||
: "none";
|
||||
elements.rowInputMax.style.display = elements.scalingCheckbox.checked
|
||||
? "block"
|
||||
: "none";
|
||||
}
|
||||
|
||||
initTensionToggles(elements, node) {
|
||||
const currentMethod = node.interpolationMethod;
|
||||
elements.rowTension.style.display =
|
||||
currentMethod === "monotone_cubic_spline" ? "block" : "none";
|
||||
console.log(
|
||||
"Initial tension row display: ",
|
||||
elements.rowTension.style.display
|
||||
);
|
||||
|
||||
elements.interpolationMethodInput.addEventListener("change", function () {
|
||||
const selectedMethod = this.value;
|
||||
console.log(`Interpolation method changed: ${selectedMethod}`);
|
||||
node.interpolationMethod = selectedMethod;
|
||||
|
||||
// Toggle visibility for tension input
|
||||
elements.rowTension.style.display =
|
||||
selectedMethod === "monotone_cubic_spline" ? "block" : "none";
|
||||
console.log("Tension row display: ", elements.rowTension.style.display);
|
||||
});
|
||||
}
|
||||
// Define the smoothing methods population function within scope
|
||||
populateSmoothingMethods(configUrls, elements, node) {
|
||||
this.fetchData(configUrls.cloud.config, configUrls.local.config)
|
||||
.then((configData) => {
|
||||
const smoothingMethods =
|
||||
configData.smoothing?.smoothMethod?.rules?.values?.map(
|
||||
(o) => o.value
|
||||
) || [];
|
||||
this.populateDropdown(
|
||||
elements.smoothMethod,
|
||||
smoothingMethods,
|
||||
node,
|
||||
"smooth_method"
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Error loading smoothing methods", err);
|
||||
});
|
||||
}
|
||||
|
||||
populateInterpolationMethods(configUrls, elements, node) {
|
||||
this.fetchData(configUrls.cloud.config, configUrls.local.config)
|
||||
.then((configData) => {
|
||||
const interpolationMethods =
|
||||
configData?.interpolation?.type?.rules?.values.map((m) => m.value) ||
|
||||
[];
|
||||
this.populateDropdown(
|
||||
elements.interpolationMethodInput,
|
||||
interpolationMethods,
|
||||
node,
|
||||
"interpolationMethod"
|
||||
);
|
||||
|
||||
// Find the selected method and use it to spawn 1 more field to fill in tension
|
||||
//const selectedMethod = interpolationMethods.find(m => m === node.interpolationMethod);
|
||||
this.initTensionToggles(elements, node);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Error loading interpolation methods", err);
|
||||
});
|
||||
}
|
||||
|
||||
populateLogLevelOptions(logLevelSelect, configData, node) {
|
||||
// debug log level
|
||||
//console.log("Displaying configData => ", configData) ;
|
||||
|
||||
const logLevels =
|
||||
configData?.general?.logging?.logLevel?.rules?.values?.map(
|
||||
(l) => l.value
|
||||
) || [];
|
||||
|
||||
//console.log("Displaying logLevels => ", logLevels);
|
||||
|
||||
// Reuse your existing generic populateDropdown helper
|
||||
this.populateDropdown(logLevelSelect, logLevels, node.logLevel);
|
||||
}
|
||||
|
||||
//cascade dropdowns for asset type, supplier, subType, model, unit
|
||||
fetchAndPopulateDropdowns(configUrls, elements, node) {
|
||||
this.fetchData(configUrls.cloud.config, configUrls.local.config)
|
||||
.then((configData) => {
|
||||
const assetType = configData.asset?.type?.default;
|
||||
const localSuppliersUrl = this.constructUrl(configUrls.local.taggcodeAPI,`${assetType}s`,"suppliers.json");
|
||||
const cloudSuppliersUrl = this.constructCloudURL(configUrls.cloud.taggcodeAPI, "/vendor/get_vendors.php");
|
||||
|
||||
return this.fetchData(cloudSuppliersUrl, localSuppliersUrl)
|
||||
.then((supplierData) => {
|
||||
|
||||
const suppliers = supplierData.map((supplier) => supplier.name);
|
||||
|
||||
// Populate suppliers dropdown and set up its change handler
|
||||
return this.populateDropdown(
|
||||
elements.supplier,
|
||||
suppliers,
|
||||
node,
|
||||
"supplier",
|
||||
function (selectedSupplier) {
|
||||
if (selectedSupplier) {
|
||||
this.populateSubTypes(configUrls, elements, node, selectedSupplier);
|
||||
}
|
||||
}
|
||||
);
|
||||
})
|
||||
.then(() => {
|
||||
// If we have a saved supplier, trigger subTypes population
|
||||
if (node.supplier) {
|
||||
this.populateSubTypes(configUrls, elements, node, node.supplier);
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error in initial dropdown population:", error);
|
||||
});
|
||||
}
|
||||
|
||||
getSpecificConfigUrl(nodeName,cloudAPI) {
|
||||
|
||||
const cloudConfigURL = cloudAPI + "/config/" + nodeName + ".json";
|
||||
const localConfigURL = "http://localhost:1880/"+ nodeName + "/dependencies/"+ nodeName + "/" + nodeName + "Config.json";
|
||||
|
||||
return { cloudConfigURL, localConfigURL };
|
||||
|
||||
}
|
||||
|
||||
// Save changes to API
|
||||
async apiCall(node) {
|
||||
try{
|
||||
// OLFIANT when a browser refreshes the tag code is lost!!! fix this later!!!!!
|
||||
// FIX UUID ALSO LATER
|
||||
|
||||
if(node.assetTagCode !== "" || node.assetTagCode !== null){ }
|
||||
// API call to register or check asset in central database
|
||||
let assetregisterAPI = node.configUrls.cloud.taggcodeAPI + "/asset/create_asset.php";
|
||||
|
||||
const assetModelId = node.modelMetadata.id; //asset_product_model_id
|
||||
const uuid = node.uuid; //asset_product_model_uuid
|
||||
const assetName = node.assetType; //asset_name / type?
|
||||
const description = node.name; // asset_description
|
||||
const assetStatus = "actief"; //asset_status -> koppel aan enable / disable node ? or make dropdown ?
|
||||
const assetProfileId = 1; //asset_profile_id these are the rules to check if the childs are valid under this node (parent / child id?)
|
||||
const child_assets = ["63247"]; //child_assets tagnummer of id?
|
||||
const assetProcessId = node.processId; //asset_process_id
|
||||
const assetLocationId = node.locationId; //asset_location_id
|
||||
const tagCode = node.assetTagCode; // if already exists in the node information use it to tell the api it exists and it will update else we will get it from the api call
|
||||
//console.log(`this is my tagCode: ${tagCode}`);
|
||||
|
||||
// Build base URL with required parameters
|
||||
let apiUrl = `?asset_product_model_id=${assetModelId}&asset_product_model_uuid=${uuid}&asset_name=${assetName}&asset_description=${description}&asset_status=${assetStatus}&asset_profile_id=${assetProfileId}&asset_location_id=${assetLocationId}&asset_process_id=${assetProcessId}&child_assets=${child_assets}`;
|
||||
|
||||
// Only add tagCode to URL if it exists
|
||||
if (tagCode) {
|
||||
apiUrl += `&asset_tag_number=${tagCode}`;
|
||||
console.log('hello there');
|
||||
}
|
||||
|
||||
assetregisterAPI += apiUrl;
|
||||
console.log("API call to register asset in central database", assetregisterAPI);
|
||||
|
||||
const response = await fetch(assetregisterAPI, {
|
||||
method: "POST"
|
||||
});
|
||||
|
||||
// Get the response text first
|
||||
const responseText = await response.text();
|
||||
console.log("Raw API response:", responseText);
|
||||
|
||||
// Try to parse the JSON, handling potential parsing errors
|
||||
let jsonResponse;
|
||||
try {
|
||||
jsonResponse = JSON.parse(responseText);
|
||||
} catch (parseError) {
|
||||
console.error("JSON Parsing Error:", parseError);
|
||||
console.error("Response that could not be parsed:", responseText);
|
||||
throw new Error("Failed to parse API response");
|
||||
}
|
||||
|
||||
console.log(jsonResponse);
|
||||
|
||||
if(jsonResponse.success){
|
||||
console.log(`${jsonResponse.message}, tag number: ${jsonResponse.asset_tag_number}, asset id: ${jsonResponse.asset_id}`);
|
||||
// Save the asset tag number and id to the node
|
||||
} else {
|
||||
console.log("Asset not registered in central database");
|
||||
}
|
||||
return jsonResponse;
|
||||
|
||||
} catch (error) {
|
||||
console.log("Error saving changes to asset register API", error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async fetchData(url, fallbackUrl) {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
|
||||
const responsData = await response.json();
|
||||
//responsData
|
||||
const data = responsData.data;
|
||||
/* .map(item => {
|
||||
const { vendor_name, ...rest } = item;
|
||||
return {
|
||||
name: vendor_name,
|
||||
...rest
|
||||
};
|
||||
}); */
|
||||
console.log(url);
|
||||
console.log("Response Data: ", data);
|
||||
return data;
|
||||
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`Primary URL failed: ${url}. Trying fallback URL: ${fallbackUrl}`,
|
||||
err
|
||||
);
|
||||
try {
|
||||
const response = await fetch(fallbackUrl);
|
||||
if (!response.ok)
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
return await response.json();
|
||||
} catch (fallbackErr) {
|
||||
console.error("Both primary and fallback URLs failed:", fallbackErr);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fetchProjectData(url) {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
|
||||
const responsData = await response.json();
|
||||
console.log("Response Data: ", responsData);
|
||||
return responsData;
|
||||
|
||||
} catch (err) {
|
||||
}
|
||||
}
|
||||
|
||||
async populateDropdown(
|
||||
htmlElement,
|
||||
options,
|
||||
node,
|
||||
property,
|
||||
callback
|
||||
) {
|
||||
this.generateHtml(htmlElement, options, node[property]);
|
||||
|
||||
htmlElement.addEventListener("change", async (e) => {
|
||||
const newValue = e.target.value;
|
||||
console.log(`Dropdown changed: ${property} = ${newValue}`);
|
||||
node[property] = newValue;
|
||||
|
||||
RED.nodes.dirty(true);
|
||||
if (callback) await callback(newValue); // Ensure async callback completion
|
||||
});
|
||||
}
|
||||
|
||||
// Helper function to construct a URL from a base and path internal
|
||||
constructUrl(base, ...paths) {
|
||||
|
||||
// Remove trailing slash from base and leading slashes from paths
|
||||
const sanitizedBase = (base || "").replace(/\/+$/, "");
|
||||
const sanitizedPaths = paths.map((path) => path.replace(/^\/+|\/+$/g, ""));
|
||||
|
||||
// Join sanitized base and paths
|
||||
const url = `${sanitizedBase}/${sanitizedPaths.join("/")}`;
|
||||
console.log("Base:", sanitizedBase);
|
||||
console.log("Paths:", sanitizedPaths);
|
||||
console.log("Constructed URL:", url);
|
||||
return url;
|
||||
}
|
||||
|
||||
//Adjust for API Gateway
|
||||
constructCloudURL(base, ...paths) {
|
||||
// Remove trailing slash from base and leading slashes from paths
|
||||
const sanitizedBase = base.replace(/\/+$/, "");
|
||||
const sanitizedPaths = paths.map((path) => path.replace(/^\/+|\/+$/g, ""));
|
||||
// Join sanitized base and paths
|
||||
const url = `${sanitizedBase}/${sanitizedPaths.join("/")}`;
|
||||
return url;
|
||||
}
|
||||
|
||||
populateSubTypes(configUrls, elements, node, selectedSupplier) {
|
||||
|
||||
this.fetchData(configUrls.cloud.config, configUrls.local.config)
|
||||
.then((configData) => {
|
||||
const assetType = configData.asset?.type?.default;
|
||||
const supplierFolder = this.constructUrl( configUrls.local.taggcodeAPI, `${assetType}s`, selectedSupplier );
|
||||
|
||||
const localSubTypesUrl = this.constructUrl(supplierFolder, "subtypes.json");
|
||||
const cloudSubTypesUrl = this.constructCloudURL(configUrls.cloud.taggcodeAPI, "/product/get_subtypesFromVendor.php?vendor_name=" + selectedSupplier);
|
||||
|
||||
return this.fetchData(cloudSubTypesUrl, localSubTypesUrl)
|
||||
.then((subTypeData) => {
|
||||
const subTypes = subTypeData.map((subType) => subType.name);
|
||||
|
||||
return this.populateDropdown(
|
||||
elements.subType,
|
||||
subTypes,
|
||||
node,
|
||||
"subType",
|
||||
function (selectedSubType) {
|
||||
if (selectedSubType) {
|
||||
// When subType changes, update both models and units
|
||||
this.populateModels(
|
||||
configUrls,
|
||||
elements,
|
||||
node,
|
||||
selectedSupplier,
|
||||
selectedSubType
|
||||
);
|
||||
this.populateUnitsForSubType(
|
||||
configUrls,
|
||||
elements,
|
||||
node,
|
||||
selectedSubType
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
})
|
||||
.then(() => {
|
||||
// If we have a saved subType, trigger both models and units population
|
||||
if (node.subType) {
|
||||
this.populateModels(
|
||||
configUrls,
|
||||
elements,
|
||||
node,
|
||||
selectedSupplier,
|
||||
node.subType
|
||||
);
|
||||
this.populateUnitsForSubType(configUrls, elements, node, node.subType);
|
||||
}
|
||||
//console.log("In fetch part of subtypes ");
|
||||
// Store all data from selected model
|
||||
/* node["modelMetadata"] = modelData.find(
|
||||
(model) => model.name === node.model
|
||||
);
|
||||
console.log("Model Metadata: ", node["modelMetadata"]); */
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error populating subtypes:", error);
|
||||
});
|
||||
}
|
||||
|
||||
populateUnitsForSubType(configUrls, elements, node, selectedSubType) {
|
||||
// Fetch the units data
|
||||
this.fetchData(configUrls.cloud.units, configUrls.local.units)
|
||||
.then((unitsData) => {
|
||||
// Find the category that matches the subType name
|
||||
const categoryData = unitsData.units.find(
|
||||
(category) =>
|
||||
category.category.toLowerCase() === selectedSubType.toLowerCase()
|
||||
);
|
||||
|
||||
if (categoryData) {
|
||||
// Extract just the unit values and descriptions
|
||||
const units = categoryData.values.map((unit) => ({
|
||||
value: unit.value,
|
||||
description: unit.description,
|
||||
}));
|
||||
|
||||
// Create the options array with descriptions as labels
|
||||
const options = units.map((unit) => ({
|
||||
value: unit.value,
|
||||
label: `${unit.value} - ${unit.description}`,
|
||||
}));
|
||||
|
||||
// Populate the units dropdown
|
||||
this.populateDropdown(
|
||||
elements.unit,
|
||||
options.map((opt) => opt.value),
|
||||
node,
|
||||
"unit"
|
||||
);
|
||||
|
||||
// If there's no currently selected unit but we have options, select the first one
|
||||
if (!node.unit && options.length > 0) {
|
||||
node.unit = options[0].value;
|
||||
elements.unit.value = options[0].value;
|
||||
}
|
||||
} else {
|
||||
// If no matching category is found, provide a default % option
|
||||
const defaultUnits = [{ value: "%", description: "Percentage" }];
|
||||
this.populateDropdown(
|
||||
elements.unit,
|
||||
defaultUnits.map((unit) => unit.value),
|
||||
node,
|
||||
"unit"
|
||||
);
|
||||
console.warn(
|
||||
`No matching unit category found for subType: ${selectedSubType}`
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error fetching units:", error);
|
||||
});
|
||||
}
|
||||
|
||||
populateModels(
|
||||
configUrls,
|
||||
elements,
|
||||
node,
|
||||
selectedSupplier,
|
||||
selectedSubType
|
||||
) {
|
||||
|
||||
this.fetchData(configUrls.cloud.config, configUrls.local.config)
|
||||
.then((configData) => {
|
||||
const assetType = configData.asset?.type?.default;
|
||||
// save assetType to fetch later
|
||||
node.assetType = assetType;
|
||||
|
||||
const supplierFolder = this.constructUrl( configUrls.local.taggcodeAPI,`${assetType}s`,selectedSupplier);
|
||||
const subTypeFolder = this.constructUrl(supplierFolder, selectedSubType);
|
||||
const localModelsUrl = this.constructUrl(subTypeFolder, "models.json");
|
||||
const cloudModelsUrl = this.constructCloudURL(configUrls.cloud.taggcodeAPI, "/product/get_product_models.php?vendor_name=" + selectedSupplier + "&product_subtype_name=" + selectedSubType);
|
||||
|
||||
return this.fetchData(cloudModelsUrl, localModelsUrl).then((modelData) => {
|
||||
const models = modelData.map((model) => model.name); // use this to populate the dropdown
|
||||
|
||||
// If a model is already selected, store its metadata immediately
|
||||
if (node.model) {
|
||||
node["modelMetadata"] = modelData.find((model) => model.name === node.model);
|
||||
}
|
||||
|
||||
this.populateDropdown(elements.model, models, node, "model", (selectedModel) => {
|
||||
// Store only the metadata for the selected model
|
||||
node["modelMetadata"] = modelData.find((model) => model.name === selectedModel);
|
||||
});
|
||||
/*
|
||||
console.log('hello here I am:');
|
||||
console.log(node["modelMetadata"]);
|
||||
*/
|
||||
});
|
||||
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error populating models:", error);
|
||||
});
|
||||
}
|
||||
|
||||
generateHtml(htmlElement, options, savedValue) {
|
||||
htmlElement.innerHTML = options.length
|
||||
? `<option value="">Select...</option>${options
|
||||
.map((opt) => `<option value="${opt}">${opt}</option>`)
|
||||
.join("")}`
|
||||
: "<option value=''>No options available</option>";
|
||||
|
||||
if (savedValue && options.includes(savedValue)) {
|
||||
htmlElement.value = savedValue;
|
||||
}
|
||||
}
|
||||
|
||||
createMenuUtilsEndpoint(RED, nodeName, customHelpers = {}) {
|
||||
RED.httpAdmin.get(`/${nodeName}/resources/menuUtils.js`, function(req, res) {
|
||||
console.log(`Serving menuUtils.js for ${nodeName} node`);
|
||||
res.set('Content-Type', 'application/javascript');
|
||||
|
||||
const browserCode = this.generateMenuUtilsCode(nodeName, customHelpers);
|
||||
res.send(browserCode);
|
||||
}.bind(this));
|
||||
}
|
||||
|
||||
generateMenuUtilsCode(nodeName, customHelpers = {}) {
|
||||
const defaultHelpers = {
|
||||
validateRequired: `function(value) {
|
||||
return value && value.toString().trim() !== '';
|
||||
}`,
|
||||
formatDisplayValue: `function(value, unit) {
|
||||
return \`\${value} \${unit || ''}\`.trim();
|
||||
}`
|
||||
};
|
||||
|
||||
const allHelpers = { ...defaultHelpers, ...customHelpers };
|
||||
|
||||
const helpersCode = Object.entries(allHelpers)
|
||||
.map(([name, func]) => ` ${name}: ${func}`)
|
||||
.join(',\n');
|
||||
|
||||
const classCode = MenuUtils.toString(); // <-- this gives full class MenuUtils {...}
|
||||
|
||||
return `
|
||||
// Create EVOLV namespace structure
|
||||
window.EVOLV = window.EVOLV || {};
|
||||
window.EVOLV.nodes = window.EVOLV.nodes || {};
|
||||
window.EVOLV.nodes.${nodeName} = window.EVOLV.nodes.${nodeName} || {};
|
||||
|
||||
// Inject MenuUtils class
|
||||
${classCode}
|
||||
|
||||
// Expose MenuUtils instance to namespace
|
||||
window.EVOLV.nodes.${nodeName}.utils = {
|
||||
menuUtils: new MenuUtils(),
|
||||
|
||||
helpers: {
|
||||
${helpersCode}
|
||||
}
|
||||
};
|
||||
|
||||
// Optionally expose globally
|
||||
window.MenuUtils = MenuUtils;
|
||||
|
||||
console.log('${nodeName} utilities loaded in namespace');
|
||||
`;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = MenuUtils;
|
||||
@@ -53,4 +53,4 @@ const nodeTemplates = {
|
||||
// …add more node “templates” here…
|
||||
};
|
||||
|
||||
export default nodeTemplates;
|
||||
module.exports = nodeTemplates;
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
const { getFormatter } = require('./formatters');
|
||||
|
||||
//this class will handle the output events for the node red node
|
||||
class OutputUtils {
|
||||
constructor() {
|
||||
this.output ={};
|
||||
this.output['influxdb'] = {};
|
||||
this.output['process'] = {};
|
||||
this.output = {};
|
||||
}
|
||||
|
||||
checkForChanges(output, format) {
|
||||
if (!output || typeof output !== 'object') {
|
||||
return {};
|
||||
}
|
||||
this.output[format] = this.output[format] || {};
|
||||
const changedFields = {};
|
||||
for (const key in output) {
|
||||
if (output.hasOwnProperty(key) && output[key] !== this.output[format][key]) {
|
||||
if (Object.prototype.hasOwnProperty.call(output, key) && output[key] !== this.output[format][key]) {
|
||||
let value = output[key];
|
||||
// For fields: if the value is an object (and not a Date), stringify it.
|
||||
if (value !== null && typeof value === 'object' && !(value instanceof Date)) {
|
||||
@@ -27,66 +31,56 @@ class OutputUtils {
|
||||
}
|
||||
|
||||
formatMsg(output, config, format) {
|
||||
|
||||
//define emtpy message
|
||||
let msg = {};
|
||||
|
||||
// Compare output with last output and only include changed values
|
||||
const changedFields = this.checkForChanges(output,format);
|
||||
|
||||
if (Object.keys(changedFields).length > 0) {
|
||||
|
||||
switch (format) {
|
||||
case 'influxdb':
|
||||
// Extract the relevant config properties.
|
||||
const relevantConfig = this.extractRelevantConfig(config);
|
||||
// Flatten the tags so that no nested objects are passed on.
|
||||
const flatTags = this.flattenTags(relevantConfig);
|
||||
msg = this.influxDBFormat(changedFields, config, flatTags);
|
||||
|
||||
break;
|
||||
|
||||
case 'process':
|
||||
|
||||
// Compare output with last output and only include changed values
|
||||
msg = this.processFormat(changedFields,config);
|
||||
//console.log(msg);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log('Unknown format in output utils');
|
||||
break;
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
influxDBFormat(changedFields, config , flatTags) {
|
||||
// Create the measurement and topic using softwareType and name config.functionality.softwareType + .
|
||||
const measurement = config.general.name;
|
||||
const payload = {
|
||||
measurement: measurement,
|
||||
const flatTags = this.flattenTags(this.extractRelevantConfig(config));
|
||||
const formatterName = this.resolveFormatterName(config, format);
|
||||
const formatter = getFormatter(formatterName);
|
||||
const payload = formatter.format(measurement, {
|
||||
fields: changedFields,
|
||||
tags: flatTags,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
const topic = measurement;
|
||||
const msg = { topic: topic, payload: payload };
|
||||
config,
|
||||
channel: format,
|
||||
});
|
||||
msg = this.wrapMessage(measurement, payload);
|
||||
return msg;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
resolveFormatterName(config, channel) {
|
||||
const outputConfig = config.output || {};
|
||||
if (channel === 'process') {
|
||||
return outputConfig.process || 'process';
|
||||
}
|
||||
if (channel === 'influxdb') {
|
||||
return outputConfig.dbase || 'influxdb';
|
||||
}
|
||||
return outputConfig[channel] || channel;
|
||||
}
|
||||
|
||||
wrapMessage(measurement, payload) {
|
||||
return {
|
||||
topic: measurement,
|
||||
payload,
|
||||
};
|
||||
}
|
||||
|
||||
flattenTags(obj) {
|
||||
const result = {};
|
||||
for (const key in obj) {
|
||||
if (obj.hasOwnProperty(key)) {
|
||||
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
||||
const value = obj[key];
|
||||
if (value !== null && typeof value === 'object' && !(value instanceof Date)) {
|
||||
// Recursively flatten the nested object.
|
||||
const flatChild = this.flattenTags(value);
|
||||
for (const childKey in flatChild) {
|
||||
if (flatChild.hasOwnProperty(childKey)) {
|
||||
if (Object.prototype.hasOwnProperty.call(flatChild, childKey)) {
|
||||
result[`${key}_${childKey}`] = String(flatChild[childKey]);
|
||||
}
|
||||
}
|
||||
@@ -104,29 +98,19 @@ 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 payload = changedFields;
|
||||
const topic = measurement;
|
||||
const msg = { topic: topic, payload: payload };
|
||||
return msg;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = OutputUtils;
|
||||
|
||||
@@ -28,17 +28,44 @@
|
||||
* @module ValidationUtils
|
||||
* @requires Logger
|
||||
* @exports ValidationUtils
|
||||
* @version 0.1.0
|
||||
* @version 0.2.0
|
||||
* @since 0.1.0
|
||||
*/
|
||||
|
||||
const Logger = require("./logger");
|
||||
const { validateNumber, validateInteger, validateBoolean, validateString, validateEnum } = require("./validators/typeValidators");
|
||||
const { validateArray, validateSet, validateObject } = require("./validators/collectionValidators");
|
||||
const { validateCurve, validateMachineCurve } = require("./validators/curveValidator");
|
||||
|
||||
// Strategy registry: maps rules.type to a handler function
|
||||
const VALIDATORS = {
|
||||
number: (cv, rules, fs, name, key, logger) => validateNumber(cv, rules, fs, name, key, logger),
|
||||
integer: (cv, rules, fs, name, key, logger) => validateInteger(cv, rules, fs, name, key, logger),
|
||||
boolean: (cv, _rules, _fs, name, key, logger) => validateBoolean(cv, name, key, logger),
|
||||
string: (cv, rules, fs, name, key, logger) => validateString(cv, rules, fs, name, key, logger),
|
||||
enum: (cv, rules, fs, name, key, logger) => validateEnum(cv, rules, fs, name, key, logger),
|
||||
array: (cv, rules, fs, name, key, logger) => validateArray(cv, rules, fs, name, key, logger),
|
||||
set: (cv, rules, fs, name, key, logger) => validateSet(cv, rules, fs, name, key, logger),
|
||||
};
|
||||
|
||||
class ValidationUtils {
|
||||
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) {
|
||||
@@ -68,11 +95,19 @@ class ValidationUtils {
|
||||
// Validate each key in the schema and loop over wildcards if they are not in schema
|
||||
for ( const key in schema ) {
|
||||
|
||||
if (key === "rules" || key === "description" || key === "schema") {
|
||||
if (key === "rules" || key === "description" || key === "schema" || key === "version") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fieldSchema = schema[key];
|
||||
|
||||
// Skip non-object schema entries (e.g. primitive values injected by migration)
|
||||
if (fieldSchema === null || typeof fieldSchema !== 'object') {
|
||||
this.logger.debug(`${name}.${key} has a non-object schema entry (${typeof fieldSchema}). Skipping.`);
|
||||
validatedConfig[key] = fieldSchema;
|
||||
continue;
|
||||
}
|
||||
|
||||
const { rules = {} } = fieldSchema;
|
||||
|
||||
// Default to the schema's default value if the key is missing
|
||||
@@ -96,7 +131,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;
|
||||
@@ -105,63 +140,44 @@ class ValidationUtils {
|
||||
configValue = config[key] !== undefined ? config[key] : fieldSchema.default;
|
||||
}
|
||||
|
||||
// Attempt to parse the value to the expected type if possible
|
||||
switch (rules.type) {
|
||||
|
||||
case "number":
|
||||
configValue = this.validateNumber(configValue, rules, fieldSchema, name, key);
|
||||
break;
|
||||
case "boolean":
|
||||
configValue = this.validateBoolean(configValue, name, key);
|
||||
break;
|
||||
|
||||
case "string":
|
||||
configValue = this.validateString(configValue,rules,fieldSchema, name, key);
|
||||
break;
|
||||
|
||||
case "array":
|
||||
configValue = this.validateArray(configValue, rules, fieldSchema, name, key);
|
||||
break;
|
||||
|
||||
case "set":
|
||||
configValue = this.validateSet(configValue, rules, fieldSchema, name, key);
|
||||
break;
|
||||
|
||||
case "object":
|
||||
configValue = this.validateObject(configValue, rules, fieldSchema, name, key);
|
||||
break;
|
||||
|
||||
case "enum":
|
||||
configValue = this.validateEnum(configValue, rules, fieldSchema, name, key);
|
||||
break;
|
||||
|
||||
case "curve":
|
||||
validatedConfig[key] = this.validateCurve(configValue,fieldSchema.default);
|
||||
// Handle curve types (they use continue, so handle separately)
|
||||
if (rules.type === "curve") {
|
||||
validatedConfig[key] = validateCurve(configValue, fieldSchema.default, this.logger);
|
||||
continue;
|
||||
|
||||
case "machineCurve":
|
||||
validatedConfig[key] = this.validateMachineCurve(configValue,fieldSchema.default);
|
||||
}
|
||||
if (rules.type === "machineCurve") {
|
||||
validatedConfig[key] = validateMachineCurve(configValue, fieldSchema.default, this.logger);
|
||||
continue;
|
||||
}
|
||||
|
||||
case "integer":
|
||||
validatedConfig[key] = this.validateInteger(configValue, rules, fieldSchema, name, key);
|
||||
// Handle object type (needs recursive validateSchema reference)
|
||||
if (rules.type === "object") {
|
||||
validatedConfig[key] = validateObject(
|
||||
configValue, rules, fieldSchema, name, key,
|
||||
(c, s, n) => this.validateSchema(c, s, n),
|
||||
this.logger
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
case undefined:
|
||||
// If we see 'rules.schema' but no 'rules.type', treat it like an object:
|
||||
// Handle undefined type
|
||||
if (rules.type === undefined) {
|
||||
if (rules.schema && !rules.type) {
|
||||
// Log a warning and skip the extra pass for nested schema
|
||||
this.logger.warn(
|
||||
`${name}.${key} has a nested schema but no type. ` +
|
||||
`Treating it as type="object" to skip extra pass.`
|
||||
);
|
||||
} else {
|
||||
// Otherwise, fallback to your existing "validateUndefined" logic
|
||||
validatedConfig[key] = this.validateUndefined(configValue, fieldSchema, name, key);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
default:
|
||||
// Use the strategy registry for all other types
|
||||
const handler = VALIDATORS[rules.type];
|
||||
if (handler) {
|
||||
configValue = handler(configValue, rules, fieldSchema, name, key, this.logger);
|
||||
} else {
|
||||
this.logger.warn(`${name}.${key} has an unknown validation type: ${rules.type}. Skipping validation.`);
|
||||
validatedConfig[key] = fieldSchema.default;
|
||||
continue;
|
||||
@@ -191,7 +207,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;
|
||||
@@ -204,318 +220,6 @@ class ValidationUtils {
|
||||
return obj;
|
||||
}
|
||||
|
||||
validateMachineCurve(curve, defaultCurve) {
|
||||
if (!curve || typeof curve !== "object" || Object.keys(curve).length === 0) {
|
||||
this.logger.warn("Curve is missing or invalid. Defaulting to basic curve.");
|
||||
return defaultCurve;
|
||||
}
|
||||
|
||||
// Validate that nq and np exist and are objects
|
||||
const { nq, np } = curve;
|
||||
if (!nq || typeof nq !== "object" || !np || typeof np !== "object") {
|
||||
this.logger.warn("Curve must contain valid 'nq' and 'np' objects. Defaulting to basic curve.");
|
||||
return defaultCurve;
|
||||
}
|
||||
|
||||
// Validate that each dimension key points to a valid object with x and y arrays
|
||||
const validatedNq = this.validateDimensionStructure(nq, "nq");
|
||||
const validatedNp = this.validateDimensionStructure(np, "np");
|
||||
|
||||
if (!validatedNq || !validatedNp) {
|
||||
return defaultCurve;
|
||||
}
|
||||
|
||||
return { nq: validatedNq, np: validatedNp }; // Return the validated curve
|
||||
}
|
||||
|
||||
validateCurve(curve, defaultCurve) {
|
||||
if (!curve || typeof curve !== "object" || Object.keys(curve).length === 0) {
|
||||
this.logger.warn("Curve is missing or invalid. Defaulting to basic curve.");
|
||||
return defaultCurve;
|
||||
}
|
||||
|
||||
// Validate that each dimension key points to a valid object with x and y arrays
|
||||
const validatedCurve = this.validateDimensionStructure(curve, "curve");
|
||||
if (!validatedCurve) {
|
||||
return defaultCurve;
|
||||
}
|
||||
|
||||
return validatedCurve; // Return the validated curve
|
||||
}
|
||||
|
||||
validateDimensionStructure(dimension, name) {
|
||||
const validatedDimension = {};
|
||||
|
||||
for (const [key, value] of Object.entries(dimension)) {
|
||||
// Validate that each key points to an object with x and y arrays
|
||||
if (typeof value !== "object") {
|
||||
this.logger.warn(`Dimension '${name}' key '${key}' is not valid. Returning to default.`);
|
||||
return false;
|
||||
}
|
||||
// Validate that x and y are arrays
|
||||
else if (!Array.isArray(value.x) || !Array.isArray(value.y)) {
|
||||
this.logger.warn(`Dimension '${name}' key '${key}' is missing x or y arrays. Converting to arrays.`);
|
||||
// Try to convert to arrays first
|
||||
value.x = Object.values(value.x);
|
||||
value.y = Object.values(value.y);
|
||||
|
||||
// If still not arrays return false
|
||||
if (!Array.isArray(value.x) || !Array.isArray(value.y)) {
|
||||
this.logger.warn(`Dimension '${name}' key '${key}' is not valid. Returning to default.`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Validate that x and y arrays are the same length
|
||||
else if (value.x.length !== value.y.length) {
|
||||
this.logger.warn(`Dimension '${name}' key '${key}' has mismatched x and y lengths. Ignoring this key.`);
|
||||
return false;
|
||||
}
|
||||
// Validate that x values are in ascending order
|
||||
else if (!this.isSorted(value.x)) {
|
||||
this.logger.warn(`Dimension '${name}' key '${key}' has unsorted x values. Sorting...`);
|
||||
return false;
|
||||
}
|
||||
// Validate that x values are unique
|
||||
else if (!this.isUnique(value.x)) {
|
||||
this.logger.warn(`Dimension '${name}' key '${key}' has duplicate x values. Removing duplicates...`);
|
||||
return false;
|
||||
}
|
||||
// Validate that y values are numbers
|
||||
else if (!this.areNumbers(value.y)) {
|
||||
this.logger.warn(`Dimension '${name}' key '${key}' has non-numeric y values. Ignoring this key.`);
|
||||
return false;
|
||||
}
|
||||
|
||||
validatedDimension[key] = value;
|
||||
}
|
||||
return validatedDimension;
|
||||
}
|
||||
|
||||
isSorted(arr) {
|
||||
return arr.every((_, i) => i === 0 || arr[i] >= arr[i - 1]);
|
||||
}
|
||||
|
||||
isUnique(arr) {
|
||||
return new Set(arr).size === arr.length;
|
||||
}
|
||||
|
||||
areNumbers(arr) {
|
||||
return arr.every((x) => typeof x === "number");
|
||||
}
|
||||
|
||||
validateNumber(configValue, rules, fieldSchema, name, key) {
|
||||
|
||||
if (typeof configValue !== "number") {
|
||||
const parsedValue = parseFloat(configValue);
|
||||
if (!isNaN(parsedValue)) {
|
||||
this.logger.warn(`${name}.${key} was parsed to a number: ${configValue} -> ${parsedValue}`);
|
||||
configValue = parsedValue;
|
||||
}
|
||||
}
|
||||
|
||||
if (rules.min !== undefined && configValue < rules.min) {
|
||||
this.logger.warn(
|
||||
`${name}.${key} is below the minimum (${rules.min}). Using default value.`
|
||||
);
|
||||
return fieldSchema.default;
|
||||
}
|
||||
if (rules.max !== undefined && configValue > rules.max) {
|
||||
this.logger.warn(
|
||||
`${name}.${key} exceeds the maximum (${rules.max}). Using default value.`
|
||||
);
|
||||
return fieldSchema.default;
|
||||
}
|
||||
|
||||
this.logger.debug(`${name}.${key} is a valid number: ${configValue}`);
|
||||
|
||||
return configValue;
|
||||
}
|
||||
|
||||
|
||||
validateInteger(configValue, rules, fieldSchema, name, key) {
|
||||
if (typeof configValue !== "number" || !Number.isInteger(configValue)) {
|
||||
const parsedValue = parseInt(configValue, 10);
|
||||
if (!isNaN(parsedValue) && Number.isInteger(parsedValue)) {
|
||||
this.logger.warn(`${name}.${key} was parsed to an integer: ${configValue} -> ${parsedValue}`);
|
||||
configValue = parsedValue;
|
||||
} else {
|
||||
this.logger.warn(`${name}.${key} is not a valid integer. Using default value.`);
|
||||
return fieldSchema.default;
|
||||
}
|
||||
}
|
||||
|
||||
if (rules.min !== undefined && configValue < rules.min) {
|
||||
this.logger.warn(`${name}.${key} is below the minimum integer value (${rules.min}). Using default value.`);
|
||||
return fieldSchema.default;
|
||||
}
|
||||
|
||||
if (rules.max !== undefined && configValue > rules.max) {
|
||||
this.logger.warn(`${name}.${key} exceeds the maximum integer value (${rules.max}). Using default value.`);
|
||||
return fieldSchema.default;
|
||||
}
|
||||
|
||||
this.logger.debug(`${name}.${key} is a valid integer: ${configValue}`);
|
||||
return configValue;
|
||||
}
|
||||
|
||||
validateBoolean(configValue, name, key) {
|
||||
if (typeof configValue !== "boolean") {
|
||||
if (configValue === "true" || configValue === "false") {
|
||||
const parsedValue = configValue === "true";
|
||||
this.logger.debug(`${name}.${key} was parsed to a boolean: ${configValue} -> ${parsedValue}`);
|
||||
configValue = parsedValue;
|
||||
}
|
||||
}
|
||||
return configValue;
|
||||
}
|
||||
|
||||
validateString(configValue, rules, fieldSchema, name, key) {
|
||||
let newConfigValue = configValue;
|
||||
|
||||
if (typeof configValue !== "string") {
|
||||
//check if the value is nullable
|
||||
if(rules.nullable){
|
||||
if(configValue === null){
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.warn(`${name}.${key} is not a string. Trying to convert to string.`);
|
||||
newConfigValue = String(configValue); // Coerce to string if not already
|
||||
}
|
||||
|
||||
//check if the string is a valid string after conversion
|
||||
if (typeof newConfigValue !== "string") {
|
||||
this.logger.warn(`${name}.${key} is not a valid string. Using default value.`);
|
||||
return fieldSchema.default;
|
||||
}
|
||||
|
||||
// Check for uppercase characters and convert to lowercase if present
|
||||
if (newConfigValue !== newConfigValue.toLowerCase()) {
|
||||
this.logger.warn(`${name}.${key} contains uppercase characters. Converting to lowercase: ${newConfigValue} -> ${newConfigValue.toLowerCase()}`);
|
||||
newConfigValue = newConfigValue.toLowerCase();
|
||||
}
|
||||
|
||||
return newConfigValue;
|
||||
}
|
||||
|
||||
validateSet(configValue, rules, fieldSchema, name, key) {
|
||||
// 1. Ensure we have a Set. If not, use default.
|
||||
if (!(configValue instanceof Set)) {
|
||||
this.logger.info(`${name}.${key} is not a Set. Converting to one using default value.`);
|
||||
return new Set(fieldSchema.default);
|
||||
}
|
||||
|
||||
// 2. Convert the Set to an array for easier filtering.
|
||||
const validatedArray = [...configValue]
|
||||
.filter((item) => {
|
||||
// 3. Filter based on `rules.itemType`.
|
||||
switch (rules.itemType) {
|
||||
case "number":
|
||||
return typeof item === "number";
|
||||
case "string":
|
||||
return typeof item === "string";
|
||||
case "null":
|
||||
// "null" might mean no type restriction (your usage may vary).
|
||||
return true;
|
||||
default:
|
||||
// Fallback if itemType is something else
|
||||
return typeof item === rules.itemType;
|
||||
}
|
||||
})
|
||||
.slice(0, rules.maxLength || Infinity);
|
||||
|
||||
// 4. Check if the filtered array meets the minimum length.
|
||||
if (validatedArray.length < (rules.minLength || 1)) {
|
||||
this.logger.warn(
|
||||
`${name}.${key} contains fewer items than allowed (${rules.minLength}). Using default value.`
|
||||
);
|
||||
return new Set(fieldSchema.default);
|
||||
}
|
||||
|
||||
// 5. Return a new Set containing only the valid items.
|
||||
return new Set(validatedArray);
|
||||
}
|
||||
|
||||
validateArray(configValue, rules, fieldSchema, name, key) {
|
||||
if (!Array.isArray(configValue)) {
|
||||
this.logger.info(`${name}.${key} is not an array. Using default value.`);
|
||||
return fieldSchema.default;
|
||||
}
|
||||
|
||||
// Validate individual items in the array
|
||||
const validatedArray = configValue
|
||||
.filter((item) => {
|
||||
switch (rules.itemType) {
|
||||
case "number":
|
||||
return typeof item === "number";
|
||||
case "string":
|
||||
return typeof item === "string";
|
||||
case "null":
|
||||
// anything goes
|
||||
return true;
|
||||
default:
|
||||
return typeof item === rules.itemType;
|
||||
}
|
||||
})
|
||||
.slice(0, rules.maxLength || Infinity);
|
||||
|
||||
if (validatedArray.length < (rules.minLength || 1)) {
|
||||
this.logger.warn(
|
||||
`${name}.${key} contains fewer items than allowed (${rules.minLength}). Using default value.`
|
||||
);
|
||||
return fieldSchema.default;
|
||||
}
|
||||
|
||||
return validatedArray;
|
||||
}
|
||||
|
||||
validateObject(configValue, rules, fieldSchema, name, key) {
|
||||
if (typeof configValue !== "object" || Array.isArray(configValue)) {
|
||||
this.logger.warn(`${name}.${key} is not a valid object. Using default value.`);
|
||||
return fieldSchema.default;
|
||||
}
|
||||
|
||||
if (rules.schema) {
|
||||
// Recursively validate nested objects if a schema is defined
|
||||
return this.validateSchema(configValue || {}, rules.schema, `${name}.${key}`);
|
||||
} else {
|
||||
// If no schema is defined, log a warning and use the default
|
||||
this.logger.warn(`${name}.${key} is an object with no schema. Using default value.`);
|
||||
return fieldSchema.default;
|
||||
}
|
||||
}
|
||||
|
||||
validateEnum(configValue, rules, fieldSchema, name, key) {
|
||||
|
||||
if (Array.isArray(rules.values)) {
|
||||
|
||||
//if value is null take default
|
||||
if(configValue === null){
|
||||
this.logger.warn(`${name}.${key} is null. Using default value.`);
|
||||
return fieldSchema.default;
|
||||
}
|
||||
|
||||
const validValues = rules.values.map(e => e.value.toLowerCase());
|
||||
|
||||
//remove caps
|
||||
configValue = configValue.toLowerCase();
|
||||
|
||||
if (!validValues.includes(configValue)) {
|
||||
this.logger.warn(
|
||||
`${name}.${key} has an invalid value : ${configValue}. Allowed values: [${validValues.join(", ")}]. Using default value.`
|
||||
);
|
||||
return fieldSchema.default;
|
||||
}
|
||||
} else {
|
||||
this.logger.warn(
|
||||
`${name}.${key} is an enum with no 'values' array. Using default value.`
|
||||
);
|
||||
return fieldSchema.default;
|
||||
}
|
||||
return configValue;
|
||||
}
|
||||
|
||||
validateUndefined(configValue, fieldSchema, name, key) {
|
||||
if (typeof configValue === "object" && !Array.isArray(configValue)) {
|
||||
|
||||
|
||||
66
src/helper/validators/collectionValidators.js
Normal file
66
src/helper/validators/collectionValidators.js
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Standalone collection validation functions extracted from validationUtils.js.
|
||||
*/
|
||||
|
||||
function validateArray(configValue, rules, fieldSchema, name, key, logger) {
|
||||
if (!Array.isArray(configValue)) {
|
||||
logger.info(`${name}.${key} is not an array. Using default value.`);
|
||||
return fieldSchema.default;
|
||||
}
|
||||
const validatedArray = configValue
|
||||
.filter((item) => {
|
||||
switch (rules.itemType) {
|
||||
case "number": return typeof item === "number";
|
||||
case "string": return typeof item === "string";
|
||||
case "null": return true;
|
||||
default: return typeof item === rules.itemType;
|
||||
}
|
||||
})
|
||||
.slice(0, rules.maxLength || Infinity);
|
||||
if (validatedArray.length < (rules.minLength || 1)) {
|
||||
logger.warn(
|
||||
`${name}.${key} contains fewer items than allowed (${rules.minLength}). Using default value.`
|
||||
);
|
||||
return fieldSchema.default;
|
||||
}
|
||||
return validatedArray;
|
||||
}
|
||||
|
||||
function validateSet(configValue, rules, fieldSchema, name, key, logger) {
|
||||
if (!(configValue instanceof Set)) {
|
||||
logger.info(`${name}.${key} is not a Set. Converting to one using default value.`);
|
||||
return new Set(fieldSchema.default);
|
||||
}
|
||||
const validatedArray = [...configValue]
|
||||
.filter((item) => {
|
||||
switch (rules.itemType) {
|
||||
case "number": return typeof item === "number";
|
||||
case "string": return typeof item === "string";
|
||||
case "null": return true;
|
||||
default: return typeof item === rules.itemType;
|
||||
}
|
||||
})
|
||||
.slice(0, rules.maxLength || Infinity);
|
||||
if (validatedArray.length < (rules.minLength || 1)) {
|
||||
logger.warn(
|
||||
`${name}.${key} contains fewer items than allowed (${rules.minLength}). Using default value.`
|
||||
);
|
||||
return new Set(fieldSchema.default);
|
||||
}
|
||||
return new Set(validatedArray);
|
||||
}
|
||||
|
||||
function validateObject(configValue, rules, fieldSchema, name, key, validateSchemaFn, logger) {
|
||||
if (typeof configValue !== "object" || Array.isArray(configValue)) {
|
||||
logger.warn(`${name}.${key} is not a valid object. Using default value.`);
|
||||
return fieldSchema.default;
|
||||
}
|
||||
if (rules.schema) {
|
||||
return validateSchemaFn(configValue || {}, rules.schema, `${name}.${key}`);
|
||||
} else {
|
||||
logger.warn(`${name}.${key} is an object with no schema. Using default value.`);
|
||||
return fieldSchema.default;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { validateArray, validateSet, validateObject };
|
||||
108
src/helper/validators/curveValidator.js
Normal file
108
src/helper/validators/curveValidator.js
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Curve validation strategies for machine curves and generic curves.
|
||||
* Extracted from validationUtils.js for modularity.
|
||||
*/
|
||||
|
||||
function isSorted(arr) {
|
||||
return arr.every((_, i) => i === 0 || arr[i] >= arr[i - 1]);
|
||||
}
|
||||
|
||||
function isUnique(arr) {
|
||||
return new Set(arr).size === arr.length;
|
||||
}
|
||||
|
||||
function areNumbers(arr) {
|
||||
return arr.every((x) => typeof x === "number");
|
||||
}
|
||||
|
||||
function validateDimensionStructure(dimension, name, logger) {
|
||||
const validatedDimension = {};
|
||||
|
||||
for (const [key, value] of Object.entries(dimension)) {
|
||||
if (typeof value !== "object") {
|
||||
logger.warn(`Dimension '${name}' key '${key}' is not valid. Returning to default.`);
|
||||
return false;
|
||||
}
|
||||
else if (!Array.isArray(value.x) || !Array.isArray(value.y)) {
|
||||
logger.warn(`Dimension '${name}' key '${key}' is missing x or y arrays. Converting to arrays.`);
|
||||
value.x = Object.values(value.x);
|
||||
value.y = Object.values(value.y);
|
||||
if (!Array.isArray(value.x) || !Array.isArray(value.y)) {
|
||||
logger.warn(`Dimension '${name}' key '${key}' is not valid. Returning to default.`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
else if (value.x.length !== value.y.length) {
|
||||
logger.warn(`Dimension '${name}' key '${key}' has mismatched x and y lengths. Ignoring this key.`);
|
||||
return false;
|
||||
}
|
||||
else if (!isSorted(value.x)) {
|
||||
logger.warn(`Dimension '${name}' key '${key}' has unsorted x values. Sorting...`);
|
||||
const indices = value.x.map((_v, i) => i);
|
||||
indices.sort((a, b) => value.x[a] - value.x[b]);
|
||||
value.x = indices.map(i => value.x[i]);
|
||||
value.y = indices.map(i => value.y[i]);
|
||||
}
|
||||
if (!isUnique(value.x)) {
|
||||
logger.warn(`Dimension '${name}' key '${key}' has duplicate x values. Removing duplicates...`);
|
||||
const seen = new Set();
|
||||
const uniqueX = [];
|
||||
const uniqueY = [];
|
||||
for (let i = 0; i < value.x.length; i++) {
|
||||
if (!seen.has(value.x[i])) {
|
||||
seen.add(value.x[i]);
|
||||
uniqueX.push(value.x[i]);
|
||||
uniqueY.push(value.y[i]);
|
||||
}
|
||||
}
|
||||
value.x = uniqueX;
|
||||
value.y = uniqueY;
|
||||
}
|
||||
if (!areNumbers(value.y)) {
|
||||
logger.warn(`Dimension '${name}' key '${key}' has non-numeric y values. Ignoring this key.`);
|
||||
return false;
|
||||
}
|
||||
|
||||
validatedDimension[key] = value;
|
||||
}
|
||||
return validatedDimension;
|
||||
}
|
||||
|
||||
function validateCurve(configValue, defaultCurve, logger) {
|
||||
if (!configValue || typeof configValue !== "object" || Object.keys(configValue).length === 0) {
|
||||
logger.warn("Curve is missing or invalid. Defaulting to basic curve.");
|
||||
return defaultCurve;
|
||||
}
|
||||
const validatedCurve = validateDimensionStructure(configValue, "curve", logger);
|
||||
if (!validatedCurve) {
|
||||
return defaultCurve;
|
||||
}
|
||||
return validatedCurve;
|
||||
}
|
||||
|
||||
function validateMachineCurve(configValue, defaultCurve, logger) {
|
||||
if (!configValue || typeof configValue !== "object" || Object.keys(configValue).length === 0) {
|
||||
logger.warn("Curve is missing or invalid. Defaulting to basic curve.");
|
||||
return defaultCurve;
|
||||
}
|
||||
const { nq, np } = configValue;
|
||||
if (!nq || typeof nq !== "object" || !np || typeof np !== "object") {
|
||||
logger.warn("Curve must contain valid 'nq' and 'np' objects. Defaulting to basic curve.");
|
||||
return defaultCurve;
|
||||
}
|
||||
const validatedNq = validateDimensionStructure(nq, "nq", logger);
|
||||
const validatedNp = validateDimensionStructure(np, "np", logger);
|
||||
if (!validatedNq || !validatedNp) {
|
||||
return defaultCurve;
|
||||
}
|
||||
return { nq: validatedNq, np: validatedNp };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
validateCurve,
|
||||
validateMachineCurve,
|
||||
validateDimensionStructure,
|
||||
isSorted,
|
||||
isUnique,
|
||||
areNumbers
|
||||
};
|
||||
158
src/helper/validators/typeValidators.js
Normal file
158
src/helper/validators/typeValidators.js
Normal file
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Standalone type validation functions extracted from validationUtils.js.
|
||||
*/
|
||||
|
||||
function validateNumber(configValue, rules, fieldSchema, name, key, logger) {
|
||||
if (typeof configValue !== "number") {
|
||||
const parsedValue = parseFloat(configValue);
|
||||
if (!isNaN(parsedValue)) {
|
||||
logger.warn(`${name}.${key} was parsed to a number: ${configValue} -> ${parsedValue}`);
|
||||
configValue = parsedValue;
|
||||
}
|
||||
}
|
||||
if (rules.min !== undefined && configValue < rules.min) {
|
||||
logger.warn(`${name}.${key} is below the minimum (${rules.min}). Using default value.`);
|
||||
return fieldSchema.default;
|
||||
}
|
||||
if (rules.max !== undefined && configValue > rules.max) {
|
||||
logger.warn(`${name}.${key} exceeds the maximum (${rules.max}). Using default value.`);
|
||||
return fieldSchema.default;
|
||||
}
|
||||
logger.debug(`${name}.${key} is a valid number: ${configValue}`);
|
||||
return configValue;
|
||||
}
|
||||
|
||||
function validateInteger(configValue, rules, fieldSchema, name, key, logger) {
|
||||
if (typeof configValue !== "number" || !Number.isInteger(configValue)) {
|
||||
const parsedValue = parseInt(configValue, 10);
|
||||
if (!isNaN(parsedValue) && Number.isInteger(parsedValue)) {
|
||||
logger.warn(`${name}.${key} was parsed to an integer: ${configValue} -> ${parsedValue}`);
|
||||
configValue = parsedValue;
|
||||
} else {
|
||||
logger.warn(`${name}.${key} is not a valid integer. Using default value.`);
|
||||
return fieldSchema.default;
|
||||
}
|
||||
}
|
||||
if (rules.min !== undefined && configValue < rules.min) {
|
||||
logger.warn(`${name}.${key} is below the minimum integer value (${rules.min}). Using default value.`);
|
||||
return fieldSchema.default;
|
||||
}
|
||||
if (rules.max !== undefined && configValue > rules.max) {
|
||||
logger.warn(`${name}.${key} exceeds the maximum integer value (${rules.max}). Using default value.`);
|
||||
return fieldSchema.default;
|
||||
}
|
||||
logger.debug(`${name}.${key} is a valid integer: ${configValue}`);
|
||||
return configValue;
|
||||
}
|
||||
|
||||
function validateBoolean(configValue, name, key, logger) {
|
||||
if (typeof configValue !== "boolean") {
|
||||
if (configValue === "true" || configValue === "false") {
|
||||
const parsedValue = configValue === "true";
|
||||
logger.debug(`${name}.${key} was parsed to a boolean: ${configValue} -> ${parsedValue}`);
|
||||
configValue = parsedValue;
|
||||
}
|
||||
}
|
||||
return configValue;
|
||||
}
|
||||
|
||||
function _isUnitLikeField(path) {
|
||||
const normalized = String(path || "").toLowerCase();
|
||||
if (!normalized) return false;
|
||||
return /(^|\.)([a-z0-9]*unit|units)(\.|$)/.test(normalized)
|
||||
|| normalized.includes(".curveunits.");
|
||||
}
|
||||
|
||||
function _resolveStringNormalizeMode(path) {
|
||||
const normalized = String(path || "").toLowerCase();
|
||||
if (!normalized) return "none";
|
||||
|
||||
if (_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";
|
||||
}
|
||||
|
||||
function validateString(configValue, rules, fieldSchema, name, key, logger) {
|
||||
let newConfigValue = configValue;
|
||||
|
||||
if (typeof configValue !== "string") {
|
||||
//check if the value is nullable
|
||||
if(rules.nullable){
|
||||
if(configValue === null){
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
logger.warn(`${name}.${key} is not a string. Trying to convert to string.`);
|
||||
newConfigValue = String(configValue); // Coerce to string if not already
|
||||
}
|
||||
|
||||
//check if the string is a valid string after conversion
|
||||
if (typeof newConfigValue !== "string") {
|
||||
logger.warn(`${name}.${key} is not a valid string. Using default value.`);
|
||||
return fieldSchema.default;
|
||||
}
|
||||
|
||||
const keyString = `${name}.${key}`;
|
||||
const normalizeMode = rules.normalize || _resolveStringNormalizeMode(keyString);
|
||||
const preserveCase = normalizeMode !== "lowercase";
|
||||
|
||||
// Check for uppercase characters and convert to lowercase if present
|
||||
if (!preserveCase && newConfigValue !== newConfigValue.toLowerCase()) {
|
||||
logger.info(
|
||||
`${name}.${key} normalized to lowercase: ${newConfigValue} -> ${newConfigValue.toLowerCase()}`
|
||||
);
|
||||
newConfigValue = newConfigValue.toLowerCase();
|
||||
}
|
||||
|
||||
return newConfigValue;
|
||||
}
|
||||
|
||||
function validateEnum(configValue, rules, fieldSchema, name, key, logger) {
|
||||
if (Array.isArray(rules.values)) {
|
||||
//if value is null take default
|
||||
if(configValue === null){
|
||||
logger.warn(`${name}.${key} is null. Using default value.`);
|
||||
return fieldSchema.default;
|
||||
}
|
||||
|
||||
if (typeof configValue !== "string") {
|
||||
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
|
||||
configValue = configValue.toLowerCase();
|
||||
if (!validValues.includes(configValue)) {
|
||||
logger.warn(
|
||||
`${name}.${key} has an invalid value : ${configValue}. Allowed values: [${validValues.join(", ")}]. Using default value.`
|
||||
);
|
||||
return fieldSchema.default;
|
||||
}
|
||||
} else {
|
||||
logger.warn(
|
||||
`${name}.${key} is an enum with no 'values' array. Using default value.`
|
||||
);
|
||||
return fieldSchema.default;
|
||||
}
|
||||
return configValue;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
validateNumber,
|
||||
validateInteger,
|
||||
validateBoolean,
|
||||
validateString,
|
||||
validateEnum,
|
||||
};
|
||||
@@ -68,6 +68,25 @@ class Measurement {
|
||||
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;
|
||||
const sum = this.values.reduce((acc, val) => acc + val, 0);
|
||||
@@ -96,7 +115,6 @@ 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 +179,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(
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
const MeasurementBuilder = require('./MeasurementBuilder');
|
||||
const EventEmitter = require('events');
|
||||
const convertModule = require('../convert/index');
|
||||
const { POSITIONS } = require('../constants/positions');
|
||||
|
||||
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',
|
||||
@@ -26,9 +29,47 @@ class MeasurementContainer {
|
||||
...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 +90,11 @@ class MeasurementContainer {
|
||||
return this;
|
||||
}
|
||||
|
||||
child(childId) {
|
||||
this._currentChildId = childId || 'default';
|
||||
return this;
|
||||
}
|
||||
|
||||
setChildName(childName) {
|
||||
this.childName = childName;
|
||||
return this;
|
||||
@@ -65,6 +111,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 +123,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;
|
||||
}
|
||||
@@ -114,33 +251,39 @@ class MeasurementContainer {
|
||||
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 +292,7 @@ class MeasurementContainer {
|
||||
value: convertedValue,
|
||||
originalValue: val,
|
||||
unit: finalUnit,
|
||||
sourceUnit: sourceUnit,
|
||||
sourceUnit: unitPolicy.sourceUnit,
|
||||
timestamp,
|
||||
position: this._currentPosition,
|
||||
distance: this._currentDistance,
|
||||
@@ -163,11 +306,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;
|
||||
@@ -181,35 +366,46 @@ class MeasurementContainer {
|
||||
// Terminal operations - get data out
|
||||
get() {
|
||||
if (!this._ensureChainIsValid()) return null;
|
||||
return this._getOrCreateMeasurement();
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
if (this.logger) this.logger.error(`Unit conversion failed: ${error.message}`);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
getAverage(requestedUnit = null) {
|
||||
const measurement = this.get();
|
||||
@@ -247,36 +443,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 = POSITIONS.DOWNSTREAM, to = POSITIONS.UPSTREAM, unit: requestedUnit } = {}) {
|
||||
if (!this._currentType || !this._currentVariant) {
|
||||
throw new Error('Type and variant must be specified for difference calculation');
|
||||
if (this.logger) {
|
||||
this.logger.warn('difference() ignored: type and variant must be specified');
|
||||
}
|
||||
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) {
|
||||
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 +612,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()
|
||||
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] = {};
|
||||
}
|
||||
|
||||
const bucket = this.measurements[this._currentType][this._currentVariant][positionKey];
|
||||
|
||||
if (!bucket[childKey]) {
|
||||
bucket[childKey] = new MeasurementBuilder()
|
||||
.setType(this._currentType)
|
||||
.setVariant(this._currentVariant)
|
||||
.setPosition(this._currentPosition)
|
||||
.setPosition(positionKey)
|
||||
.setWindowSize(this.windowSize)
|
||||
.setDistance(this._currentDistance)
|
||||
.build();
|
||||
}
|
||||
|
||||
return this.measurements[this._currentType][this._currentVariant][this._currentPosition];
|
||||
return bucket[childKey];
|
||||
}
|
||||
|
||||
// Additional utility methods
|
||||
@@ -321,15 +641,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] ||
|
||||
@@ -345,11 +683,13 @@ class MeasurementContainer {
|
||||
this._currentType = null;
|
||||
this._currentVariant = null;
|
||||
this._currentPosition = null;
|
||||
this._currentDistance = null;
|
||||
this._unit = null;
|
||||
}
|
||||
|
||||
// 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 +708,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 {
|
||||
@@ -414,11 +742,11 @@ class MeasurementContainer {
|
||||
|
||||
_convertPositionStr2Num(positionString) {
|
||||
switch(positionString) {
|
||||
case "atEquipment":
|
||||
case POSITIONS.AT_EQUIPMENT:
|
||||
return 0;
|
||||
case "upstream":
|
||||
case POSITIONS.UPSTREAM:
|
||||
return Number.POSITIVE_INFINITY;
|
||||
case "downstream":
|
||||
case POSITIONS.DOWNSTREAM:
|
||||
return Number.NEGATIVE_INFINITY;
|
||||
|
||||
default:
|
||||
@@ -430,16 +758,19 @@ class MeasurementContainer {
|
||||
}
|
||||
|
||||
_convertPositionNum2Str(positionValue) {
|
||||
switch (positionValue) {
|
||||
case 0:
|
||||
return "atEquipment";
|
||||
case (positionValue < 0):
|
||||
return "upstream";
|
||||
case (positionValue > 0):
|
||||
return "downstream";
|
||||
default:
|
||||
console.log(`Invalid position provided: ${positionValue}`);
|
||||
if (positionValue === 0) {
|
||||
return POSITIONS.AT_EQUIPMENT;
|
||||
}
|
||||
if (positionValue < 0) {
|
||||
return POSITIONS.UPSTREAM;
|
||||
}
|
||||
if (positionValue > 0) {
|
||||
return POSITIONS.DOWNSTREAM;
|
||||
}
|
||||
if (this.logger) {
|
||||
this.logger.warn(`Invalid position provided: ${positionValue}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
const { MeasurementContainer } = require('./index');
|
||||
const { POSITIONS } = require('../constants/positions');
|
||||
|
||||
const measurements = new MeasurementContainer();
|
||||
|
||||
console.log('=== MEASUREMENT CONTAINER EXAMPLES ===\n');
|
||||
console.log('This guide shows how to use the MeasurementContainer for storing,');
|
||||
@@ -27,7 +30,7 @@ console.log('\nSetting pressure values with distances:');
|
||||
basicContainer
|
||||
.type('pressure')
|
||||
.variant('measured')
|
||||
.position('upstream')
|
||||
.position(POSITIONS.UPSTREAM)
|
||||
.distance(1.5)
|
||||
.value(100)
|
||||
.unit('psi');
|
||||
@@ -35,7 +38,7 @@ basicContainer
|
||||
basicContainer
|
||||
.type('pressure')
|
||||
.variant('measured')
|
||||
.position('downstream')
|
||||
.position(POSITIONS.DOWNSTREAM)
|
||||
.distance(5.2)
|
||||
.value(95)
|
||||
.unit('psi');
|
||||
@@ -44,7 +47,7 @@ basicContainer
|
||||
basicContainer
|
||||
.type('pressure')
|
||||
.variant('measured')
|
||||
.position('downstream')
|
||||
.position(POSITIONS.DOWNSTREAM)
|
||||
.value(90); // distance 5.2 is automatically reused
|
||||
|
||||
console.log('✅ Basic setup complete\n');
|
||||
@@ -53,7 +56,7 @@ console.log('✅ Basic setup complete\n');
|
||||
const upstreamPressure = basicContainer
|
||||
.type('pressure')
|
||||
.variant('measured')
|
||||
.position('upstream')
|
||||
.position(POSITIONS.UPSTREAM)
|
||||
.get();
|
||||
|
||||
console.log(`Retrieved upstream pressure: ${upstreamPressure.getCurrentValue()} ${upstreamPressure.unit}`);
|
||||
@@ -83,14 +86,15 @@ console.log('Adding pressure with auto-conversion:');
|
||||
autoContainer
|
||||
.type('pressure')
|
||||
.variant('measured')
|
||||
.position('upstream')
|
||||
.position(POSITIONS.UPSTREAM)
|
||||
.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')
|
||||
.position('upstream')
|
||||
.position(POSITIONS.UPSTREAM)
|
||||
.get();
|
||||
|
||||
console.log(`Stored as: ${converted.getCurrentValue()} ${converted.unit} (distance=${converted.distance}m)`);
|
||||
@@ -104,14 +108,14 @@ console.log('--- Example 3: Unit Conversion on Retrieval ---');
|
||||
autoContainer
|
||||
.type('flow')
|
||||
.variant('predicted')
|
||||
.position('upstream')
|
||||
.position(POSITIONS.UPSTREAM)
|
||||
.distance(2.4)
|
||||
.value(100, Date.now(), 'l/min');
|
||||
|
||||
const flowMeasurement = autoContainer
|
||||
.type('flow')
|
||||
.variant('predicted')
|
||||
.position('upstream')
|
||||
.position(POSITIONS.UPSTREAM)
|
||||
.get();
|
||||
|
||||
console.log(`Flow in l/min: ${flowMeasurement.getCurrentValue('l/min')}`);
|
||||
@@ -152,13 +156,13 @@ console.log('--- Example 5: Basic Value Retrieval ---');
|
||||
const upstreamVal = basicContainer
|
||||
.type('pressure')
|
||||
.variant('measured')
|
||||
.position('upstream')
|
||||
.position(POSITIONS.UPSTREAM)
|
||||
.getCurrentValue();
|
||||
|
||||
const upstreamData = basicContainer
|
||||
.type('pressure')
|
||||
.variant('measured')
|
||||
.position('upstream')
|
||||
.position(POSITIONS.UPSTREAM)
|
||||
.get();
|
||||
|
||||
console.log(`Upstream: ${upstreamVal} ${upstreamData.unit} at ${upstreamData.distance}m`);
|
||||
@@ -166,15 +170,34 @@ console.log(`Upstream: ${upstreamVal} ${upstreamData.unit} at ${upstreamData.dis
|
||||
const downstreamVal = basicContainer
|
||||
.type('pressure')
|
||||
.variant('measured')
|
||||
.position('downstream')
|
||||
.position(POSITIONS.DOWNSTREAM)
|
||||
.getCurrentValue();
|
||||
|
||||
const downstreamData = basicContainer
|
||||
.type('pressure')
|
||||
.variant('measured')
|
||||
.position('downstream')
|
||||
.position(POSITIONS.DOWNSTREAM)
|
||||
.get();
|
||||
|
||||
//check wether a serie exists
|
||||
const hasSeries = basicContainer // eslint-disable-line no-unused-vars
|
||||
.type("flow")
|
||||
.variant("measured")
|
||||
.exists(); // true if any position exists
|
||||
|
||||
const hasUpstreamValues = basicContainer // eslint-disable-line no-unused-vars
|
||||
.type("flow")
|
||||
.variant("measured")
|
||||
.exists({ position: POSITIONS.UPSTREAM, requireValues: true });
|
||||
|
||||
// Passing everything explicitly
|
||||
const hasPercent = basicContainer.exists({ // eslint-disable-line no-unused-vars
|
||||
type: "volume",
|
||||
variant: "percent",
|
||||
position: POSITIONS.AT_EQUIPMENT,
|
||||
});
|
||||
|
||||
|
||||
console.log(`Downstream: ${downstreamVal} ${downstreamData.unit} at ${downstreamData.distance}m\n`);
|
||||
|
||||
// ====================================
|
||||
@@ -185,7 +208,7 @@ console.log('--- Example 6: Calculations & Statistics ---');
|
||||
basicContainer
|
||||
.type('flow')
|
||||
.variant('predicted')
|
||||
.position('upstream')
|
||||
.position(POSITIONS.UPSTREAM)
|
||||
.distance(3.0)
|
||||
.value(200)
|
||||
.unit('gpm');
|
||||
@@ -193,7 +216,7 @@ basicContainer
|
||||
basicContainer
|
||||
.type('flow')
|
||||
.variant('predicted')
|
||||
.position('downstream')
|
||||
.position(POSITIONS.DOWNSTREAM)
|
||||
.distance(8.5)
|
||||
.value(195)
|
||||
.unit('gpm');
|
||||
@@ -201,7 +224,7 @@ basicContainer
|
||||
const flowAvg = basicContainer
|
||||
.type('flow')
|
||||
.variant('predicted')
|
||||
.position('upstream')
|
||||
.position(POSITIONS.UPSTREAM)
|
||||
.getAverage();
|
||||
|
||||
console.log(`Average upstream flow: ${flowAvg.toFixed(1)} gpm`);
|
||||
@@ -213,6 +236,10 @@ const pressureDiff = basicContainer
|
||||
|
||||
console.log(`Pressure difference: ${pressureDiff.value} ${pressureDiff.unit}\n`);
|
||||
|
||||
//reversable difference
|
||||
const deltaP = basicContainer.type("pressure").variant("measured").difference(); // eslint-disable-line no-unused-vars -- defaults to downstream - upstream
|
||||
const netFlow = basicContainer.type("flow").variant("measured").difference({ from: POSITIONS.UPSTREAM, to: POSITIONS.DOWNSTREAM }); // eslint-disable-line no-unused-vars
|
||||
|
||||
// ====================================
|
||||
// ADVANCED STATISTICS & HISTORY
|
||||
// ====================================
|
||||
@@ -221,7 +248,7 @@ console.log('--- Example 7: Advanced Statistics & History ---');
|
||||
basicContainer
|
||||
.type('flow')
|
||||
.variant('measured')
|
||||
.position('upstream')
|
||||
.position(POSITIONS.UPSTREAM)
|
||||
.distance(3.0)
|
||||
.value(210)
|
||||
.value(215)
|
||||
@@ -233,7 +260,7 @@ basicContainer
|
||||
const stats = basicContainer
|
||||
.type('flow')
|
||||
.variant('measured')
|
||||
.position('upstream');
|
||||
.position(POSITIONS.UPSTREAM);
|
||||
|
||||
const statsData = stats.get();
|
||||
|
||||
@@ -248,6 +275,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 +348,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,61 +1,88 @@
|
||||
// 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;
|
||||
}
|
||||
|
||||
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 || []
|
||||
}))
|
||||
}))
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
resolveCategoryForNode(nodeName) {
|
||||
const keys = Object.keys(this.categories);
|
||||
if (keys.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (this.softwareType && this.categories[this.softwareType]) {
|
||||
return this.softwareType;
|
||||
}
|
||||
|
||||
if (nodeName) {
|
||||
const normalized = typeof nodeName === 'string' ? nodeName.toLowerCase() : nodeName;
|
||||
if (normalized && this.categories[normalized]) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 keys[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
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 =
|
||||
@@ -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) {
|
||||
getDataInjectionCode(nodeName) {
|
||||
return `
|
||||
// Asset Data loader for ${nodeName}
|
||||
window.EVOLV.nodes.${nodeName}.assetMenu.loadData = function(node) {
|
||||
const data = window.EVOLV.nodes.${nodeName}.menuData.asset;
|
||||
// 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'),
|
||||
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'));
|
||||
|
||||
function resolveCategoryKey() {
|
||||
if (node.softwareType && categories[node.softwareType]) {
|
||||
return node.softwareType;
|
||||
}
|
||||
// initial population
|
||||
populate(elems.supplier, Object.keys(data), node.supplier);
|
||||
};
|
||||
`
|
||||
if (node.category && categories[node.category]) {
|
||||
return node.category;
|
||||
}
|
||||
return defaultCategory;
|
||||
}
|
||||
|
||||
getEventInjectionCode(nodeName) {
|
||||
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}
|
||||
// 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')
|
||||
};
|
||||
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'));
|
||||
|
||||
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;
|
||||
}
|
||||
elems.supplier.addEventListener('change', ()=>{
|
||||
populate(elems.category,
|
||||
elems.supplier.value? Object.keys(data[elems.supplier.value]||{}) : [],
|
||||
node.category);
|
||||
const opt = document.createElement('option');
|
||||
opt.value = option.value;
|
||||
opt.textContent = option.label;
|
||||
selectEl.appendChild(opt);
|
||||
});
|
||||
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);
|
||||
});
|
||||
};
|
||||
`
|
||||
|
||||
if (selectedValue) {
|
||||
selectEl.value = selectedValue;
|
||||
if (!selectEl.value) {
|
||||
selectEl.value = '';
|
||||
}
|
||||
} else {
|
||||
selectEl.value = '';
|
||||
}
|
||||
if (selectEl.value !== previous) {
|
||||
selectEl.dispatchEvent(new Event('change'));
|
||||
}
|
||||
}
|
||||
|
||||
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.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.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,15 +568,19 @@ 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}
|
||||
@@ -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'];
|
||||
console.log('Saving asset properties for ${nodeName}');
|
||||
const menuAsset = window.EVOLV.nodes.${nodeName}.menuData.asset || {};
|
||||
const categories = menuAsset.categories || {};
|
||||
const defaultCategory = menuAsset.defaultCategory || Object.keys(categories)[0] || null;
|
||||
const resolveCategoryKey = () => {
|
||||
if (node.softwareType && categories[node.softwareType]) {
|
||||
return node.softwareType;
|
||||
}
|
||||
if (node.category && categories[node.category]) {
|
||||
return node.category;
|
||||
}
|
||||
return defaultCategory || '';
|
||||
};
|
||||
|
||||
node.category = resolveCategoryKey();
|
||||
|
||||
const fields = ['supplier', 'assetType', 'model', 'unit', 'assetTagNumber'];
|
||||
const errors = [];
|
||||
fields.forEach(f => {
|
||||
const el = document.getElementById(\`node-input-\${f}\`);
|
||||
node[f] = el ? el.value : '';
|
||||
|
||||
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 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);
|
||||
if (node.assetType && !node.unit) {
|
||||
errors.push('Unit must be set when a type is specified.');
|
||||
}
|
||||
if (!node.unit) {
|
||||
errors.push('Unit is required.');
|
||||
}
|
||||
|
||||
return errors.length===0;
|
||||
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;
|
||||
@@ -1,17 +1,23 @@
|
||||
const AssetMenu = require('./asset.js');
|
||||
const { TagcodeApp, DynamicAssetMenu } = require('./tagcodeApp.js');
|
||||
// TagcodeApp and DynamicAssetMenu available via ./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] = {};
|
||||
|
||||
@@ -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.");
|
||||
return 0;
|
||||
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 < predicted.length; i++) {
|
||||
const err = predicted[i] - measured[i];
|
||||
for (let i = 0; i < p.length; i += 1) {
|
||||
const err = p[i] - m[i];
|
||||
sumSqError += err * err;
|
||||
}
|
||||
return sumSqError / predicted.length;
|
||||
return sumSqError / p.length;
|
||||
}
|
||||
|
||||
rootMeanSquaredError(predicted, measured) {
|
||||
return Math.sqrt(this.meanSquaredError(predicted, measured));
|
||||
rootMeanSquaredError(predicted, measured, options = {}) {
|
||||
return Math.sqrt(this.meanSquaredError(predicted, measured, options));
|
||||
}
|
||||
|
||||
normalizedRootMeanSquaredError(predicted, measured, processMin, processMax) {
|
||||
const range = processMax - processMin;
|
||||
if (range <= 0) {
|
||||
this.logger.error("Invalid process range: processMax must be greater than processMin.");
|
||||
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);
|
||||
const rmse = this.rootMeanSquaredError(predicted, measured, options);
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
if(newCount >= 100) {
|
||||
// Return the current NRMSD value, not just the contribution from this sample
|
||||
return this.cumNRMSD;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
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.");
|
||||
// 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;
|
||||
}
|
||||
const rmse = this.rootMeanSquaredError(predicted, measured);
|
||||
return rmse / range;
|
||||
|
||||
state.sampleCount += 1;
|
||||
const alpha = profile.ewmaAlpha;
|
||||
state.longTermEwma = state.sampleCount === 1 ? value : (alpha * value) + ((1 - alpha) * state.longTermEwma);
|
||||
|
||||
if (metricKey === this.legacyMetricId) {
|
||||
this.cumCount = state.sampleCount;
|
||||
this.cumNRMSD = state.longTermEwma;
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
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
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user