appChart.js 36.9 KB
Newer Older
Pithon Kabiro's avatar
Pithon Kabiro committed
1
"use strict";
Pithon Kabiro's avatar
Pithon Kabiro committed
2

3
const BASE_URL = "http://193.196.39.91:8080/frost-icity-tp31/v1.1";
4

5
6
/**
 * Retrieve the datastream ID that corresponds to a particular building
7
8
9
 * @param {Number | String} buildingNumber Integer representing the building ID
 * @param {String} phenomenon String representing the phenomenon of interest
 * @param {String} samplingRate String representing the sampling rate of the observations
10
11
 * @returns {Number} Datastream corresponding to the input building
 */
12
const getDatastreamIdFromBuildingNumber = function (
13
14
15
16
17
18
19
20
  buildingNumber,
  phenomenon,
  samplingRate
) {
  const buildingToDatastreamMapping = {
    101: {
      vl: { "15min": "69", "60min": "75" },
      rl: { "15min": "81", "60min": "87" },
21
22
23
24
25
26

      // These Datastreams do not yet have Observations
      // flow: { "15min": "93", "60min": "99" },
      // power: { "15min": "105", "60min": "111" },
      // energy: { "15min": "117", "60min": "123" },
      // energy_verb: { "15min": "129", "60min": "135" },
27
    },
28

29
30
31
    102: {
      vl: { "15min": "70", "60min": "76" },
      rl: { "15min": "82", "60min": "88" },
32
33
34
35
36
37

      // These Datastreams do not yet have Observations
      // flow: { "15min": "94", "60min": "100" },
      // power: { "15min": "106", "60min": "112" },
      // energy: { "15min": "118", "60min": "124" },
      // energy_verb: { "15min": "130", "60min": "136" },
38
    },
39

40
41
42
    107: {
      vl: { "15min": "71", "60min": "77" },
      rl: { "15min": "83", "60min": "89" },
43
44
45
46
47
48

      // These Datastreams do not yet have Observations
      // flow: { "15min": "95", "60min": "101" },
      // power: { "15min": "107", "60min": "113" },
      // energy: { "15min": "119", "60min": "125" },
      // energy_verb: { "15min": "131", "60min": "137" },
49
    },
50

51
    "112, 118": {
52
53
      vl: { "15min": "72", "60min": "78" },
      rl: { "15min": "84", "60min": "90" },
54
55
56
57
58
59

      // These Datastreams do not yet have Observations
      // flow: { "15min": "96", "60min": "102" },
      // power: { "15min": "108", "60min": "114" },
      // energy: { "15min": "120", "60min": "126" },
      // energy_verb: { "15min": "132", "60min": "138" },
60
    },
61

62
63
64
    125: {
      vl: { "15min": "73", "60min": "79" },
      rl: { "15min": "85", "60min": "91" },
65
66
67
68
69
70

      // These Datastreams do not yet have Observations
      // flow: { "15min": "97", "60min": "103" },
      // power: { "15min": "109", "60min": "115" },
      // energy: { "15min": "121", "60min": "127" },
      // energy_verb: { "15min": "133", "60min": "139" },
71
    },
72

73
74
75
76
77
78
79
80
    225: {
      vl: { "15min": "74", "60min": "80" },
      rl: { "15min": "86", "60min": "92" },
      flow: { "15min": "98", "60min": "104" },
      power: { "15min": "110", "60min": "116" },
      energy: { "15min": "122", "60min": "128" },
      energy_verb: { "15min": "134", "60min": "140" },
    },
81
82
83
84

    weather_station_521: {
      outside_temp: { "15min": "141", "60min": "142" },
    },
85
86
  };

87
88
89
90
91
92
  if (
    buildingToDatastreamMapping?.[buildingNumber]?.[phenomenon]?.[
      samplingRate
    ] === undefined
  )
    return;
93

94
95
96
97
98
99
100
  const datastreamIdMatched = Number(
    buildingToDatastreamMapping[buildingNumber][phenomenon][samplingRate]
  );

  return datastreamIdMatched;
};

101
102
103
104
105
106
/**
 * Create URL to fetch the details of single Datastream
 * @param {String} baseUrl Base URL of the STA server
 * @param {Number} datastreamID Integer representing the Datastream ID
 * @returns {String} URL string for fetching a single Datastream
 */
107
const createDatastreamUrl = function (baseUrl, datastreamID) {
108
109
110
111
112
  if (!datastreamID) return;
  const fullDatastreamURL = `${baseUrl}/Datastreams(${datastreamID})`;
  return fullDatastreamURL;
};

113
/**
114
 * Create URL to fetch Observations
115
 * @param {String} baseUrl Base URL of the STA server
116
117
 * @param {Number} datastreamID Integer representing the Datastream ID
 * @returns {String} URL string for fetching Observations
118
 */
119
const createObservationsUrl = function (baseUrl, datastreamID) {
120
  if (!datastreamID) return;
121
122
  const fullObservationsURL = `${baseUrl}/Datastreams(${datastreamID})/Observations`;
  return fullObservationsURL;
123
124
125
126
};

/**
 * Create a temporal filter string for the fetched Observations
127
128
129
 * @param {String} dateStart Start date in YYYY-MM-DD format
 * @param {String} dateStop Stop date in YYYY-MM-DD format
 * @returns {String} Temporal filter string
130
 */
131
const createTemporalFilterString = function (dateStart, dateStop) {
132
133
134
135
136
  if (!dateStart || !dateStop) return;
  const filterString = `resultTime ge ${dateStart}T00:00:00.000Z and resultTime le ${dateStop}T00:00:00.000Z`;
  return filterString;
};

137
138
139
140
141
142
143
const QUERY_PARAM_RESULT_FORMAT = "dataArray";
const QUERY_PARAM_ORDER_BY = "phenomenonTime asc";
const QUERY_PARAM_FILTER = createTemporalFilterString(
  "2020-01-01",
  "2021-01-01"
);
const QUERY_PARAM_SELECT = "result,phenomenonTime";
144
const QUERY_PARAMS_COMBINED = {
145
146
147
148
  "$resultFormat": QUERY_PARAM_RESULT_FORMAT,
  "$orderBy": QUERY_PARAM_ORDER_BY,
  "$filter": QUERY_PARAM_FILTER,
  "$select": QUERY_PARAM_SELECT,
149
};
Pithon Kabiro's avatar
Pithon Kabiro committed
150

151
152
/**
 * Perform a GET request using the Axios library
153
 * @param {String} urlObservations A URL that fetches Observations from an STA instance
154
 * @param {Object} urlParamObj The URL parameters to be sent together with the GET request
155
 * @returns {Promise} A promise that contains the first page of results when fulfilled
156
 */
157
const performGetRequestUsingAxios = function (urlObservations, urlParamObj) {
158
159
160
161
  return axios.get(urlObservations, {
    params: urlParamObj,
  });
};
162

163
164
165
166
167
168
/**
 * Retrieve the metadata for a single datastream
 * @async
 * @param {String} urlDatastream A URL that fetches a Datastream from an STA instance
 * @returns {Promise} A promise that contains a metadata object for a Datastream when fulfilled
 */
169
const getMetadataFromSingleDatastream = async function (urlDatastream) {
170
171
172
173
  try {
    // Extract properties of interest
    const {
      data: { description, name, unitOfMeasurement },
174
    } = await performGetRequestUsingAxios(urlDatastream);
175
176
177

    return { description, name, unitOfMeasurement };
  } catch (err) {
178
    console.error(err);
179
180
181
  }
};

182
183
/**
 * Retrieve metadata from multiple datastreams
184
 * @async
185
186
187
188
189
190
191
192
193
194
195
 * @param {Array} datastreamsUrlArr An array that contains N Datastream URL strings
 * @returns {Promise} A promise that contains an array of N Datastream metadata objects when fulfilled
 */
const getMetadataFromMultipleDatastreams = async function (datastreamsUrlArr) {
  try {
    // Array to store our final result
    const datastreamMetadataArr = [];

    // Use for/of loop - we need to maintain the order of execution of the async operations
    for (const datastreamUrl of datastreamsUrlArr) {
      // Metadata from a single Datastream
196
197
198
      const datastreamMetadata = await getMetadataFromSingleDatastream(
        datastreamUrl
      );
199
200
201
202
203
204
205
206
207
      datastreamMetadataArr.push(datastreamMetadata);
    }

    return datastreamMetadataArr;
  } catch (err) {
    console.error(err);
  }
};

Pithon Kabiro's avatar
Pithon Kabiro committed
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
/**
 * Match the unitOfMeasurement's string representation of a symbol to an actual symbol, where necessary
 * @param {String} unitOfMeasurementSymbolString String representation of the unitOfMeasurement's symbol
 * @returns {String} The unitOfMeasurement's symbol
 */
const matchUnitOfMeasurementSymbolStringToSymbol = function (
  unitOfMeasurementSymbolString
) {
  const unicodeCodePointDegreeSymbol = "\u00B0";
  const unicodeCodePointSuperscriptThree = "\u00B3";

  if (unitOfMeasurementSymbolString === "degC")
    return `${unicodeCodePointDegreeSymbol}C`;

  if (unitOfMeasurementSymbolString === "m3/h")
    return `m${unicodeCodePointSuperscriptThree}/h`;

  // If no symbol exists
  return unitOfMeasurementSymbolString;
};

/**
 * Extract the phenomenon name from a Datastream's name
 * @param {String} datastreamName A string representing the Datastream's name
 * @returns {String} The extracted phenomenon name
 */
const extractPhenomenonNameFromDatastreamName = function (datastreamName) {
  const regex = /\/ (.*) DS/;
  return datastreamName.match(regex)[1]; // use second element in array
};

239
240
241
242
243
/**
 * Format the response containing a Datastream's metadata from Sensorthings API
 * @param {Object} datastreamMetadata An object containing a Datastream's metadata
 * @returns {Object} An object containing the formatted metadata that is suitable for use in a line chart or heatmap
 */
244
const formatDatastreamMetadataForChart = function (datastreamMetadata) {
245
246
247
248
249
250
251
  const {
    description: datastreamDescription,
    name: datastreamName,
    unitOfMeasurement,
  } = datastreamMetadata;

  // Extract phenomenon name from Datastream name
Pithon Kabiro's avatar
Pithon Kabiro committed
252
253
254
255
256
257
258
  const phenomenonName =
    extractPhenomenonNameFromDatastreamName(datastreamName);

  // Get the unitOfMeasurement's symbol
  const unitOfMeasurementSymbol = matchUnitOfMeasurementSymbolStringToSymbol(
    unitOfMeasurement.symbol
  );
259
260
261
262
263
264
265
266
267

  return {
    datastreamDescription,
    datastreamName,
    phenomenonName,
    unitOfMeasurementSymbol,
  };
};

268
/**
269
 * Format the response from SensorThings API to make it suitable for use in a heatmap
Pithon Kabiro's avatar
Pithon Kabiro committed
270
 * @param {Array} obsArray Array of observations (timestamp + value) that is response from SensorThings API
271
 * @returns {Array} Array of formatted observations suitable for use in a heatmap
272
 */
273
const formatSensorThingsApiResponseForHeatMap = function (obsArray) {
Pithon Kabiro's avatar
Pithon Kabiro committed
274
  if (!obsArray) return;
275
276

  const dataSTAFormatted = obsArray.map((obs) => {
277
278
279
280
281
282
283
284
285
286
287
288
289
    // Get the date/time string; first element in input array; remove trailing "Z"
    const obsDateTimeInput = obs[0].slice(0, -1);
    // Get the "date" part of an observation
    const obsDateInput = obs[0].slice(0, 10);
    // Create Date objects
    const obsDateTime = new Date(obsDateTimeInput);
    const obsDate = new Date(obsDateInput);
    // x-axis -> timestamp; will be the same for observations from the same date
    const timestamp = Date.parse(obsDate);
    // y-axis -> hourOfDay
    const hourOfDay = obsDateTime.getHours();
    // value -> the observation's value; second element in input array
    const value = obs[1];
290
    return [timestamp, hourOfDay, value];
291
  });
292

293
294
295
  return dataSTAFormatted;
};

Pithon Kabiro's avatar
Pithon Kabiro committed
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
/**
 * Calculate the minimum and maximum values for a heatmap's color axis
 * @param {Array} formattedObsArrHeatmap Response from SensorThings API formatted for use in a heatmap
 * @returns {Object} An object containing the minimum and maximum values
 */
const calculateMinMaxValuesForHeatmapColorAxis = function (
  formattedObsArrHeatmap
) {
  // The observation value is the third element in array
  const obsValueArr = formattedObsArrHeatmap.map((obs) => obs[2]);

  // Extract integer part
  const minValue = Math.trunc(Math.min(...obsValueArr));
  const maxValue = Math.trunc(Math.max(...obsValueArr));

  // Calculate the closest multiple of 5
  const minObsValue = minValue - (minValue % 5);
  const maxObsValue = maxValue + (5 - (maxValue % 5));

  return { minObsValue, maxObsValue };
};

318
319
/**
 * Draw a heatmap using Highcharts library
320
 * @param {Array} formattedObsArrayForHeatmap Response from SensorThings API formatted for use in a heatmap
321
 * @param {Object} formattedDatastreamMetadata Object containing Datastream metadata
322
 * @returns {undefined} undefined
323
 */
324
const drawHeatMapHighcharts = function (
325
  formattedObsArrayForHeatmap,
326
  formattedDatastreamMetadata
327
) {
328
329
330
331
332
333
334
  const {
    datastreamDescription: DATASTREAM_DESCRIPTION,
    datastreamName: DATASTREAM_NAME,
    phenomenonName: PHENOMENON_NAME,
    unitOfMeasurementSymbol: PHENOMENON_SYMBOL,
  } = formattedDatastreamMetadata;

Pithon Kabiro's avatar
Pithon Kabiro committed
335
336
337
  const {
    minObsValue: MINIMUM_VALUE_COLOR_AXIS,
    maxObsValue: MAXIMUM_VALUE_COLOR_AXIS,
Pithon Kabiro's avatar
Pithon Kabiro committed
338
  } = calculateMinMaxValuesForHeatmapColorAxis(formattedObsArrayForHeatmap);
Pithon Kabiro's avatar
Pithon Kabiro committed
339

340
341
342
343
344
345
346
347
348
349
350
  Highcharts.chart("chart-heatmap", {
    chart: {
      type: "heatmap",
      zoomType: "x",
    },

    boost: {
      useGPUTranslations: true,
    },

    title: {
351
      text: DATASTREAM_DESCRIPTION,
352
353
354
355
356
      align: "left",
      x: 40,
    },

    subtitle: {
357
      text: DATASTREAM_NAME,
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
      align: "left",
      x: 40,
    },

    xAxis: {
      type: "datetime",
      // min: Date.UTC(2017, 0, 1),
      // max: Date.UTC(2017, 11, 31, 23, 59, 59),
      labels: {
        align: "left",
        x: 5,
        y: 14,
        format: "{value:%B}", // long month
      },
      showLastLabel: false,
      tickLength: 16,
    },

    yAxis: {
      title: {
        text: null,
      },
      labels: {
        format: "{value}:00",
      },
      minPadding: 0,
      maxPadding: 0,
      startOnTick: false,
      endOnTick: false,
      tickPositions: [0, 3, 6, 9, 12, 15, 18, 21, 24],
      tickWidth: 1,
      min: 0,
      max: 23,
      reversed: true,
    },

    colorAxis: {
      stops: [
        [0, "#3060cf"],
        [0.5, "#fffbbc"],
        [0.9, "#c4463a"],
        [1, "#c4463a"],
      ],
Pithon Kabiro's avatar
Pithon Kabiro committed
401
402
      min: MINIMUM_VALUE_COLOR_AXIS,
      max: MAXIMUM_VALUE_COLOR_AXIS,
403
404
405
      startOnTick: false,
      endOnTick: false,
      labels: {
406
407
        // format: "{value}℃",
        format: `{value}${PHENOMENON_SYMBOL}`,
408
409
410
411
412
      },
    },

    series: [
      {
413
        data: formattedObsArrayForHeatmap,
414
415
416
417
418
        boostThreshold: 100,
        borderWidth: 0,
        nullColor: "#525252",
        colsize: 24 * 36e5, // one day
        tooltip: {
419
420
          headerFormat: `${PHENOMENON_NAME}<br/>`,
          valueDecimals: 2,
421
          pointFormat:
422
423
            // "{point.x:%e %b, %Y} {point.y}:00: <b>{point.value} ℃</b>",
            `{point.x:%e %b, %Y} {point.y}:00: <b>{point.value} ${PHENOMENON_SYMBOL}</b>`,
Pithon Kabiro's avatar
Pithon Kabiro committed
424
          nullFormat: `{point.x:%e %b, %Y} {point.y}:00: <b>null</b>`,
425
426
427
428
429
430
431
        },
        turboThreshold: Number.MAX_VALUE, // #3404, remove after 4.0.5 release
      },
    ],
  });
};

Pithon Kabiro's avatar
Pithon Kabiro committed
432
/**
433
 * Format the response from SensorThings API to make it suitable for use in a line chart
434
 * @param {Array} obsArray Response from SensorThings API as array
Pithon Kabiro's avatar
Pithon Kabiro committed
435
 * @returns {Array} Array of formatted observations suitable for use in a line chart
Pithon Kabiro's avatar
Pithon Kabiro committed
436
 */
437
const formatSensorThingsApiResponseForLineChart = function (obsArray) {
Pithon Kabiro's avatar
Pithon Kabiro committed
438
  if (!obsArray) return;
439
440

  const dataSTAFormatted = obsArray.map((result) => {
Pithon Kabiro's avatar
Pithon Kabiro committed
441
442
    const timestampObs = new Date(result[0].slice(0, -1)).getTime(); // slice() removes trailing "Z" character in timestamp
    const valueObs = result[1];
443
    return [timestampObs, valueObs];
Pithon Kabiro's avatar
Pithon Kabiro committed
444
  });
445

Pithon Kabiro's avatar
Pithon Kabiro committed
446
447
448
449
450
  return dataSTAFormatted;
};

/**
 * Draw a line chart using Highcharts library
451
 * @param {Array} formattedObsArrayForLineChart Response from SensorThings API formatted for use in a line chart
452
 * @param {Object} formattedDatastreamMetadata Object containing Datastream metadata
453
 * @returns {undefined} undefined
Pithon Kabiro's avatar
Pithon Kabiro committed
454
 */
455
const drawLineChartHighcharts = function (
456
  formattedObsArrayForLineChart,
457
  formattedDatastreamMetadata
458
) {
459
460
461
462
463
464
465
  const {
    datastreamDescription: DATASTREAM_DESCRIPTION,
    datastreamName: DATASTREAM_NAME,
    phenomenonName: PHENOMENON_NAME,
    unitOfMeasurementSymbol: PHENOMENON_SYMBOL,
  } = formattedDatastreamMetadata;

Pithon Kabiro's avatar
Pithon Kabiro committed
466
467
468
469
470
471
  Highcharts.stockChart("chart-line", {
    chart: {
      zoomType: "x",
    },

    rangeSelector: {
472
      selected: 5,
Pithon Kabiro's avatar
Pithon Kabiro committed
473
474
475
    },

    title: {
476
      text: DATASTREAM_DESCRIPTION,
477
478
479
480
      "align": "left",
    },

    subtitle: {
481
      text: DATASTREAM_NAME,
482
      align: "left",
Pithon Kabiro's avatar
Pithon Kabiro committed
483
484
485
486
    },

    series: [
      {
487
        name: `${PHENOMENON_NAME} (${PHENOMENON_SYMBOL})`,
488
        data: formattedObsArrayForLineChart,
Pithon Kabiro's avatar
Pithon Kabiro committed
489
490
491
492
493
494
495
496
497
        tooltip: {
          valueDecimals: 2,
        },
        turboThreshold: Number.MAX_VALUE, // #3404, remove after 4.0.5 release
      },
    ],
  });
};

498
499
500
501
502
503
504
505
506
507
508
509
510
511
/**
 * Determines the timestamps that are missing from a smaller set of observations. Based on the comparison of two observation arrays, where one array is larger than the other
 * @param {Array} obsTimestampArrayOne An array of timestamps for the first set of observations
 * @param {Array} obsTimestampArrayTwo An array of timstamps for the second set of observations
 * @returns {Array} An array of timestamps missing from either set of observations
 */
const getSymmetricDifferenceBetweenArrays = function (
  obsTimestampArrayOne,
  obsTimestampArrayTwo
) {
  const differenceBetweenArrays = obsTimestampArrayOne
    .filter((timestampOne) => !obsTimestampArrayTwo.includes(timestampOne))
    .concat(
      obsTimestampArrayTwo.filter(
Pithon Kabiro's avatar
Pithon Kabiro committed
512
        (timestampTwo) => !obsTimestampArrayOne.includes(timestampTwo)
513
514
515
516
517
518
519
      )
    );

  return differenceBetweenArrays;
};

/**
Pithon Kabiro's avatar
Pithon Kabiro committed
520
521
522
 * Determines the indexes of timestamps that are unique to the larger set of observatiuons. Based on the comparison of two observation arrays, where one array is larger than the other
 * @param {Array} uniqueTimestampsArr An array of timestamps unique to the larger set of observations
 * @param {Array} largerObsTimestampArr An array of timestamps for the larger set of observations
523
524
 * @returns {Array} An array of the indexes of the missing observations
 */
Pithon Kabiro's avatar
Pithon Kabiro committed
525
526
const getIndexesOfUniqueObservations = function (
  uniqueTimestampsArr,
527
528
  largerObsTimestampArr
) {
Pithon Kabiro's avatar
Pithon Kabiro committed
529
  const indexesMissingObs = uniqueTimestampsArr.map((index) =>
530
531
532
533
534
535
536
    largerObsTimestampArr.indexOf(index)
  );

  return indexesMissingObs;
};

/**
Pithon Kabiro's avatar
Pithon Kabiro committed
537
538
 * Removes observations (by modifying array in place) that are unique to a larger set of observations. Based on the comparison of two observation arrays, where one array is larger than the other
 * @param {Array} uniqueIndexesArr An array of the indexes unique to the larger set of observations
539
540
 * @param {Array} largerObsArr The larger array of observations (timestamp + value)
 * @returns {Array} The larger array with the unique indexes removed
541
 */
Pithon Kabiro's avatar
Pithon Kabiro committed
542
543
const removeUniqueObservationsFromLargerArray = function (
  uniqueIndexesArr,
544
545
  largerObsArr
) {
546
547
548
549
550
  // Create a reversed copy of the indexes array, so that the larger index is removed first
  const reversedUniqueIndexesArr = uniqueIndexesArr.reverse();

  // Create a copy the larger observation array, will be modified in place
  const processedLargerObsArr = largerObsArr;
Pithon Kabiro's avatar
Pithon Kabiro committed
551

552
  reversedUniqueIndexesArr.forEach((index) => {
553
    if (index > -1) {
554
      processedLargerObsArr.splice(index, 1);
555
556
    }
  });
557
558

  return processedLargerObsArr;
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
};

/**
 * Compares the length of two input arrays to determine the larger one
 * @param {Array} firstArr First input array
 * @param {Array} secondArr Second input array
 * @returns {Array} The larger array
 */
const getLargerArrayBetweenTwoInputArrays = function (firstArr, secondArr) {
  if (firstArr.length === secondArr.length) return;

  if (firstArr.length > secondArr.length) return firstArr;

  if (firstArr.length < secondArr.length) return secondArr;
};

Pithon Kabiro's avatar
Pithon Kabiro committed
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
/**
 * Compares the length of two input arrays to determine the smaller one
 * @param {Array} firstArr First input array
 * @param {Array} secondArr Second input array
 * @returns {Array} The smaller array
 */
const getSmallerArrayBetweenTwoInputArrays = function (firstArr, secondArr) {
  if (firstArr.length === secondArr.length) return;

  if (firstArr.length < secondArr.length) return firstArr;

  if (firstArr.length > secondArr.length) return secondArr;
};

/**
 * Utility function for deleting the unique observations from a larger array
 * @param {Array} obsArrayOne Array of observations (timestamp + value) that is response from SensorThings API
 * @param {Array} obsArrayTwo Array of observations (timestamp + value) that is response from SensorThings API
 * @returns {Array} Two arrays of observations (timestamp + value) with matching timestamps and equal lengths
 */
const deleteUniqueObservationsFromLargerArray = function (
  obsArrayOne,
  obsArrayTwo
) {
  // Create arrays with timestamps only
  const obsArrayOneTimestamp = obsArrayOne.map(
    (obsTimeValue) => obsTimeValue[0]
  );
  const obsArrayTwoTimestamp = obsArrayTwo.map(
    (obsTimeValue) => obsTimeValue[0]
  );

  const missingTimestamp = getSymmetricDifferenceBetweenArrays(
    obsArrayOneTimestamp,
    obsArrayTwoTimestamp
  );

  // Determine the larger observation timestamp array
  const biggerObsTimestampArr = getLargerArrayBetweenTwoInputArrays(
    obsArrayOneTimestamp,
    obsArrayTwoTimestamp
  );

  // Indexes of the missing observations
  const indexesMissingObsArr = getIndexesOfUniqueObservations(
    missingTimestamp,
    biggerObsTimestampArr
  );

  // Determine the larger observation array
  const biggerObsArr = getLargerArrayBetweenTwoInputArrays(
    obsArrayOne,
    obsArrayTwo
  );

  // Determine the smaller observation array
  const smallerObsArr = getSmallerArrayBetweenTwoInputArrays(
    obsArrayOne,
    obsArrayTwo
  );

  // Remove the missing observation from the larger array of observations
637
638
639
640
  const modifiedBiggerObsArr = removeUniqueObservationsFromLargerArray(
    indexesMissingObsArr,
    biggerObsArr
  );
Pithon Kabiro's avatar
Pithon Kabiro committed
641

642
  return [modifiedBiggerObsArr, smallerObsArr];
Pithon Kabiro's avatar
Pithon Kabiro committed
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
};

/**
 * Utility function for deleting the unique observations from a larger array AND ensuring the order of input arrays is maintained
 * @param {Array} obsArrayOne Array of observations (timestamp + value) that is response from SensorThings API
 * @param {Array} obsArrayTwo Array of observations (timestamp + value) that is response from SensorThings API
 * @returns {Array} Two arrays of observations (timestamp + value) with matching timestamps and equal lengths
 */
const checkForAndDeleteUniqueObservationsFromLargerArray = function (
  obsArrayOne,
  obsArrayTwo
) {
  if (obsArrayOne.length === obsArrayTwo.length) return;

  //   Case 1: obsArrayOne.length < obsArrayTwo.length
  if (obsArrayOne.length < obsArrayTwo.length) {
    const [biggerObsArr, smallerObsArr] =
      deleteUniqueObservationsFromLargerArray(obsArrayOne, obsArrayTwo);

    return [smallerObsArr, biggerObsArr];
  }

  //   Case 2: obsArrayOne.length > obsArrayTwo.length
  return deleteUniqueObservationsFromLargerArray(obsArrayOne, obsArrayTwo);
};

669
/**
670
 * Extracts and combines observation values from two input observation arrays of equal length
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
 * @param {Array} obsArrayOne First set of N observations (timestamp + value)
 * @param {Array} obsArrayTwo Second set of N observations (timestamp + value)
 * @returns {Array} A 2*N array of observation values from both input observation arrays
 */
const createCombinedObservationValues = function (obsArrayOne, obsArrayTwo) {
  // Extract the values from the two observation arrays
  const obsValuesOne = obsArrayOne.map((result) => result[1]);
  const obsValuesTwo = obsArrayTwo.map((result) => result[1]);

  //  Since the arrays are of equal length, we need only use one of the arrays for looping
  const obsValuesOnePlusTwo = obsValuesOne.map((obsValOne, i) => {
    return [obsValOne, obsValuesTwo[i]];
  });

  return obsValuesOnePlusTwo;
};

/**
689
 * Format the response from SensorThings API to make it suitable for use in a scatter plot
Pithon Kabiro's avatar
Pithon Kabiro committed
690
691
 * @param {Array} obsArrayOne Array of observations (timestamp + value) that is response from SensorThings API
 * @param {Array} obsArrayTwo Array of observations (timestamp + value) that is response from SensorThings API
692
693
 * @returns {Array} Array of formatted observations suitable for use in a scatter plot
 */
694
695
696
697
const formatSensorThingsApiResponseForScatterPlot = function (
  obsArrayOne,
  obsArrayTwo
) {
698
699
  // When our observation arrays have DIFFERENT lengths
  if (obsArrayOne.length !== obsArrayTwo.length) {
Pithon Kabiro's avatar
Pithon Kabiro committed
700
701
702
703
704
    const [obsArrayOneFinal, obsArrayTwoFinal] =
      checkForAndDeleteUniqueObservationsFromLargerArray(
        obsArrayOne,
        obsArrayTwo
      );
705

Pithon Kabiro's avatar
Pithon Kabiro committed
706
    return createCombinedObservationValues(obsArrayOneFinal, obsArrayTwoFinal);
707
708
709
710
711
712
713
714
715
716
717
718
719
  }

  // When our observation arrays already have SAME lengths
  return createCombinedObservationValues(obsArrayOne, obsArrayTwo);
};

/**
 * Draw a scatter plot using Highcharts library
 * @param {*} formattedObsArrayForSeriesOnePlusSeriesTwo Response from SensorThings API formatted for use in a scatter plot
 * @param {*} formattedDatastreamMetadataSeriesOne Object containing Datastream metadata for the first chart series
 * @param {*} formattedDatastreamMetadataSeriesTwo Object containing Datastream metadata for the second chart series
 * @returns {undefined}
 */
720
const drawScatterPlotHighcharts = function (
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
  formattedObsArrayForSeriesOnePlusSeriesTwo,
  formattedDatastreamMetadataSeriesOne,
  formattedDatastreamMetadataSeriesTwo
) {
  const {
    datastreamDescription: DATASTREAM_DESCRIPTION_SERIES_1,
    datastreamName: DATASTREAM_NAME_SERIES_1,
    phenomenonName: PHENOMENON_NAME_SERIES_1,
    unitOfMeasurementSymbol: PHENOMENON_SYMBOL_SERIES_1,
  } = formattedDatastreamMetadataSeriesOne;

  const {
    datastreamDescription: DATASTREAM_DESCRIPTION_SERIES_2,
    datastreamName: DATASTREAM_NAME_SERIES_2,
    phenomenonName: PHENOMENON_NAME_SERIES_2,
    unitOfMeasurementSymbol: PHENOMENON_SYMBOL_SERIES_2,
  } = formattedDatastreamMetadataSeriesTwo;

  // Order of axes
  // Y-Axis -- Series 2
  // X-Axis -- Series 1

  const CHART_TITLE = `${PHENOMENON_NAME_SERIES_2} Versus ${PHENOMENON_NAME_SERIES_1}`;
  const CHART_SUBTITLE = `Source: ${DATASTREAM_NAME_SERIES_2} & ${DATASTREAM_NAME_SERIES_1}`;

  const SERIES_1_NAME = `${PHENOMENON_NAME_SERIES_1}`;
  const SERIES_1_SYMBOL = `${PHENOMENON_SYMBOL_SERIES_1}`;

  const SERIES_2_NAME = `${PHENOMENON_NAME_SERIES_2}`;
  const SERIES_2_SYMBOL = `${PHENOMENON_SYMBOL_SERIES_2}`;

  const SERIES_COMBINED_NAME = "Y, X";
  const SERIES_COMBINED_SYMBOL_COLOR_RGB_ELEMENTS = "223, 83, 83";
  const SERIES_COMBINED_SYMBOL_COLOR_OPACITY = ".3";
  const SERIES_COMBINED_SYMBOL_COLOR = `rgba(${SERIES_COMBINED_SYMBOL_COLOR_RGB_ELEMENTS}, ${SERIES_COMBINED_SYMBOL_COLOR_OPACITY})`;

  const MARKER_RADIUS = 2;

  Highcharts.chart("chart-scatter-plot", {
    chart: {
      type: "scatter",
      zoomType: "xy",
    },

    boost: {
      useGPUTranslations: true,
      usePreAllocated: true,
    },

    title: {
      text: CHART_TITLE,
    },

    subtitle: {
      text: CHART_SUBTITLE,
    },

    xAxis: {
      labels: {
        format: `{value}`,
      },
      title: {
        enabled: true,
        text: `${SERIES_1_NAME} [${SERIES_1_SYMBOL}]`,
      },
      startOnTick: true,
      endOnTick: true,
      showLastLabel: true,
    },

    yAxis: [
      {
        labels: {
          format: `{value}`,
        },
        title: {
          text: `${SERIES_2_NAME} [${SERIES_2_SYMBOL}]`,
        },
      },
    ],

    legend: {
      enabled: false,
    },

    plotOptions: {
      scatter: {
        marker: {
          radius: MARKER_RADIUS,
          states: {
            hover: {
              enabled: true,
              lineColor: "rgb(100,100,100)",
            },
          },
        },
        states: {
          hover: {
            marker: {
              enabled: false,
            },
          },
        },
        tooltip: {
          headerFormat: "{series.name}<br>",
          pointFormat: `<b>{point.y:.2f} ${SERIES_1_SYMBOL}, {point.x:.2f} ${SERIES_2_SYMBOL}</b>`,
        },
      },
    },

    series: [
      {
        name: SERIES_COMBINED_NAME,
        color: SERIES_COMBINED_SYMBOL_COLOR,
        data: formattedObsArrayForSeriesOnePlusSeriesTwo,
      },
    ],
  });
};

Pithon Kabiro's avatar
Pithon Kabiro committed
841
/**
842
 * Traverses all the pages that make up the response from a SensorThingsAPI instance. The link to the next page, if present, is denoted by the presence of a "@iot.nextLink" property in the response object. This function concatenates all the values so that the complete results are returned in one array.
Pithon Kabiro's avatar
Pithon Kabiro committed
843
 * @async
844
845
 * @param {Promise} httpGetRequestPromise Promise object resulting from an Axios GET request
 * @returns {Promise} A promise that contains an object containing results from all the pages when fulfilled
Pithon Kabiro's avatar
Pithon Kabiro committed
846
 */
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
const combineResultsFromAllPages = async function (httpGetRequestPromise) {
  try {
    if (!httpGetRequestPromise) return;

    const lastSuccess = await httpGetRequestPromise;
    if (lastSuccess.data["@iot.nextLink"]) {
      const nextLinkSuccess = await combineResultsFromAllPages(
        axios.get(lastSuccess.data["@iot.nextLink"])
      );
      nextLinkSuccess.data.value = lastSuccess.data.value.concat(
        nextLinkSuccess.data.value
      );
      return nextLinkSuccess;
    } else {
      return lastSuccess;
    }
  } catch (err) {
    console.error(err);
  }
Pithon Kabiro's avatar
Pithon Kabiro committed
866
867
};

868
/**
869
 * Traverses all the pages that make up the response from a SensorThingsAPI instance and extracts the combined Observations
870
 * @async
871
 * @param {Promise} httpGetRequestPromise Promise object resulting from an Axios GET request
872
 * @returns {Promise} A promise that contains an array of Observations when fulfilled
873
 */
874
const extractCombinedObservationsFromAllPages = async function (
875
  httpGetRequestPromise
876
) {
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
  try {
    const successResponse = await combineResultsFromAllPages(
      httpGetRequestPromise
    );

    // Extract value array from the success response object
    const {
      data,
      data: { value: valueArr },
    } = successResponse;

    // Array that will hold the combined observations
    const combinedObservations = [];

    valueArr.forEach((val) => {
      // Each page of results will have a dataArray that holds the observations
      const { dataArray } = val;
      combinedObservations.push(...dataArray);
    });

    return new Promise((resolve, reject) => {
      resolve(combinedObservations);
899
    });
900
901
902
  } catch (err) {
    console.error(err);
  }
903
};
904

905
906
907
908
909
910
/**
 * Retrieve the metadata for a Datastream as well as the Observations corresponding to it
 * @async
 * @param {Promise} metadataPlusObsPromiseArray An array that contains two promises, one for datastream metadata, the other for observations
 * @returns {Promise} A promise that contains two arrays when fulfilled, one for datastream metadata and the other for observations
 */
911
const getMetadataPlusObservationsFromSingleDatastream = async function (
912
  metadataPlusObsPromiseArray
913
) {
914
915
916
  try {
    // Array to store our final result
    const combinedResolvedPromises = [];
917

918
919
    // Use for/of loop - we need to maintain the order of execution of the async operations
    for (const promise of metadataPlusObsPromiseArray) {
920
      // Resolved value of a single promise
921
922
923
      const resolvedPromise = await promise;
      combinedResolvedPromises.push(resolvedPromise);
    }
924
925
926
927

    return combinedResolvedPromises;
  } catch (err) {
    console.error(err);
928
929
930
  }
};

931
932
933
/**
 * Retrieve all the Observations from an array of Observations promises
 * @async
934
 * @param {Promise} observationPromiseArray An array that contains N observation promises
935
936
937
938
939
 * @returns {Promise} A promise that contains an array of Observations from multiple Datastreams when fulfilled
 */
const getObservationsFromMultipleDatastreams = async function (
  observationPromiseArray
) {
940
941
942
  try {
    // Array to store our final result
    const observationsAllDatastreamsArr = [];
943

944
945
    // Use for/of loop - we need to maintain the order of execution of the async operations
    for (const observationPromise of observationPromiseArray) {
946
947
948
949
      // Observations from a single Datastream
      const observations = await observationPromise;
      observationsAllDatastreamsArr.push(observations);
    }
950
951
952
953

    return observationsAllDatastreamsArr;
  } catch (err) {
    console.error(err);
954
955
  }
};
956

957
958
959
960
961
962
963
964
965
966
967
/**
 * Retrieve the metadata from multiple Datastreams and the Observations corresponding to these Datastreams
 * @async
 * @param {Array} bldgSensorSamplingRateArr A 3*N array containing buildings, sensors & sampling rates as strings, e.g. ["101", "rl", "60min"]
 * @returns {Promise} A promise that contains a N*2 array (the first element is an array of Observations and the second element is an array of Datastream metadata objects) when fulfilled
 */
const getMetadataPlusObservationsFromMultipleDatastreams = async function (
  bldgSensorSamplingRateArr
) {
  try {
    if (!bldgSensorSamplingRateArr) return;
968

969
970
971
972
973
    // Datastreams IDs
    const datastreamsIdsArr = bldgSensorSamplingRateArr.map(
      (bldgSensorSamplingRate) =>
        getDatastreamIdFromBuildingNumber(...bldgSensorSamplingRate)
    );
974

975
976
    // Observations URLs
    const observationsUrlArr = datastreamsIdsArr.map((datastreamId) =>
977
      createObservationsUrl(BASE_URL, datastreamId)
978
979
980
981
    );

    // Datastreams URLs
    const datastreamsUrlArr = datastreamsIdsArr.map((datastreamId) =>
982
      createDatastreamUrl(BASE_URL, datastreamId)
983
984
985
986
    );

    // Promise objects - Observations
    const observationsPromisesArr = observationsUrlArr.map((obsUrl) =>
987
988
      extractCombinedObservationsFromAllPages(
        performGetRequestUsingAxios(obsUrl, QUERY_PARAMS_COMBINED)
989
990
991
992
993
994
995
996
997
998
999
1000
      )
    );

    // Observations array
    const observationsArr = await getObservationsFromMultipleDatastreams(
      observationsPromisesArr
    );

    // Metadata array
    const metadataArr = await getMetadataFromMultipleDatastreams(
      datastreamsUrlArr
    );
1001

1002
1003
1004
1005
1006
    return [observationsArr, metadataArr];
  } catch (err) {
    console.error(err);
  }
};
1007

Pithon Kabiro's avatar
Pithon Kabiro committed
1008
1009
1010
1011
1012
/**
 * Calculates the temperature difference, dT, between Vorlauf temperature [VL] and Rücklauf temperature [RL] (i.e., dT = VL - RL)
 * @async
 * @param {String} buildingId The building ID as a string
 * @param {String} samplingRate The sampling rate as a string
1013
 * @returns {Promise} A promise that contains an array (that is made up of a temperature difference array and a metadata object) when fulfilled
Pithon Kabiro's avatar
Pithon Kabiro committed
1014
1015
1016
1017
1018
 */
const calculateVorlaufMinusRuecklaufTemperature = async function (
  buildingId,
  samplingRate
) {
1019
1020
1021
1022
1023
  try {
    const bldgSensorSamplingRateArr = [
      [buildingId, "vl", samplingRate],
      [buildingId, "rl", samplingRate],
    ];
Pithon Kabiro's avatar
Pithon Kabiro committed
1024

1025
1026
    const BUILDING_ID = buildingId;
    const SAMPLING_RATE = samplingRate;
Pithon Kabiro's avatar
Pithon Kabiro committed
1027

1028
1029
1030
1031
    const observationsPlusMetadata =
      await getMetadataPlusObservationsFromMultipleDatastreams(
        bldgSensorSamplingRateArr
      );
Pithon Kabiro's avatar
Pithon Kabiro committed
1032

1033
1034
1035
    // Extract Vorlauf temperature, Ruecklauf temperature and metadata
    const [[vorlaufTemp, ruecklaufTemp], [metadataVorlauf, metadataRuecklauf]] =
      observationsPlusMetadata;
Pithon Kabiro's avatar
Pithon Kabiro committed
1036

1037
1038
1039
    // Extract the temperature values
    const vorlaufTempValues = vorlaufTemp.map((obs) => obs[1]);
    const ruecklaufTempValues = ruecklaufTemp.map((obs) => obs[1]);
Pithon Kabiro's avatar
Pithon Kabiro committed
1040

1041
1042
1043
1044
1045
1046
    // The arrays have equal length, we need only use one of them for looping
    // Resulting array contains the following pairs (timestamp + dT)
    const vorlaufMinusRuecklaufTemp = vorlaufTemp.map((obs, i) => [
      obs[0],
      vorlaufTempValues[i] - ruecklaufTempValues[i],
    ]);
Pithon Kabiro's avatar
Pithon Kabiro committed
1047

1048
1049
1050
1051
1052
    // From Vorlauf metadata, extract `name` and `unitOfMeasurement`
    const {
      name: datastreamNameVorlauf,
      unitOfMeasurement: unitOfMeasurementVorlauf,
    } = metadataVorlauf;
Pithon Kabiro's avatar
Pithon Kabiro committed
1053

1054
1055
    // From Ruecklauf metadata, extract `name`
    const { name: datastreamNameRuecklauf } = metadataRuecklauf;
Pithon Kabiro's avatar
Pithon Kabiro committed
1056

1057
1058
1059
1060
1061
1062
1063
    // Extract the phenomenon names from the Datastream names
    const phenomenonNameVorlauf = extractPhenomenonNameFromDatastreamName(
      datastreamNameVorlauf
    );
    const phenomenonNameRuecklauf = extractPhenomenonNameFromDatastreamName(
      datastreamNameRuecklauf
    );
Pithon Kabiro's avatar
Pithon Kabiro committed
1064

1065
1066
1067
1068
1069
1070
1071
1072
    // Create our custom datastream description text
    // The resulting datastream description string has two `temperature` substrings;
    // replace the first occurence with an empty string
    const descriptionTempDifference =
      `Computed dT: ${phenomenonNameVorlauf} minus ${phenomenonNameRuecklauf}`.replace(
        "temperature",
        ""
      );
Pithon Kabiro's avatar
Pithon Kabiro committed
1073

1074
1075
    // Create our custom datastream name text
    const nameTempDifference = `BOSCH_${BUILDING_ID} / dT Temperature difference (VL-RL) DS:${SAMPLING_RATE}`;
Pithon Kabiro's avatar
Pithon Kabiro committed
1076

1077
1078
1079
1080
    // The datastream object that we return needs to have these property names
    const description = descriptionTempDifference;
    const name = nameTempDifference;
    const unitOfMeasurement = unitOfMeasurementVorlauf;
Pithon Kabiro's avatar
Pithon Kabiro committed
1081

1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
    return [
      vorlaufMinusRuecklaufTemp,
      {
        description,
        name,
        unitOfMeasurement,
      },
    ];
  } catch (err) {
    console.error(err);
  }
Pithon Kabiro's avatar
Pithon Kabiro committed
1093
1094
1095
1096
1097
1098
1099
1100
1101
};

/**
 * Test plotting of temp difference (dT) using heatmap
 */
const drawHeatmapHCUsingTempDifference = async function () {
  const [tempDifferenceObsArrBau225, tempDifferenceMetadataBau225] =
    await calculateVorlaufMinusRuecklaufTemperature("225", "60min");

1102
1103
  drawHeatMapHighcharts(
    formatSensorThingsApiResponseForHeatMap(tempDifferenceObsArrBau225),
Pithon Kabiro's avatar
Pithon Kabiro committed
1104
1105
1106
1107
    formatDatastreamMetadataForChart(tempDifferenceMetadataBau225)
  );
};

1108
1109
1110
/**
 * Test drawing of scatter plot chart
 */
1111
const drawScatterPlotHCTest2 = async function () {
1112
1113
  const sensorsOfInterestArr = [
    ["225", "vl", "60min"],
1114
1115
    // ["125", "rl", "60min"],
    ["weather_station_521", "outside_temp", "60min"],
1116
1117
1118
1119
1120
  ];

  const observationsPlusMetadata =
    await getMetadataPlusObservationsFromMultipleDatastreams(
      sensorsOfInterestArr
1121
1122
    );

1123
1124
1125
1126
1127
1128
  // Extract the observations and metadata for each sensor
  // Array elements in same order as input array
  const [
    [obsSensorOneArr, obsSensorTwoArr],
    [metadataSensorOne, metadataSensorTwo],
  ] = observationsPlusMetadata;
1129

1130
1131
1132
1133
1134
  drawScatterPlotHighcharts(
    formatSensorThingsApiResponseForScatterPlot(
      obsSensorOneArr,
      obsSensorTwoArr
    ),
1135
1136
    formatDatastreamMetadataForChart(metadataSensorOne),
    formatDatastreamMetadataForChart(metadataSensorTwo)
1137
  );
1138
1139
1140
};

(async () => {
Pithon Kabiro's avatar
Pithon Kabiro committed
1141
1142
  // await drawScatterPlotHCTest2();
  await drawHeatmapHCUsingTempDifference();
1143
})();
1144

1145
1146
1147
1148
export {
  BASE_URL,
  QUERY_PARAMS_COMBINED,
  getDatastreamIdFromBuildingNumber,
1149
1150
  createDatastreamUrl,
  createObservationsUrl,
1151
  createTemporalFilterString,
1152
  performGetRequestUsingAxios,
1153
  getMetadataFromSingleDatastream,
1154
  formatDatastreamMetadataForChart,
1155
1156
1157
1158
1159
  formatSensorThingsApiResponseForHeatMap,
  drawHeatMapHighcharts,
  formatSensorThingsApiResponseForLineChart,
  drawLineChartHighcharts,
  extractCombinedObservationsFromAllPages,
1160
  getMetadataPlusObservationsFromSingleDatastream,
1161
};