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

3
4
5
// DEBUG:
// Observations WITHOUT data gap - Bau 225 / Datastream ID = 80
// Observations WITH data gap - Bau 112 / Datastream ID = 78
6

7
export const BASE_URL = "http://193.196.39.91:8080/frost-icity-tp31/v1.1";
8

9
10
/**
 * Retrieve the datastream ID that corresponds to a particular building
11
12
13
 * @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
14
15
 * @returns {Number} Datastream corresponding to the input building
 */
16
export const getDatastreamIdFromBuildingNumber = function (
17
18
19
20
21
22
23
24
  buildingNumber,
  phenomenon,
  samplingRate
) {
  const buildingToDatastreamMapping = {
    101: {
      vl: { "15min": "69", "60min": "75" },
      rl: { "15min": "81", "60min": "87" },
25
26
27
28
29
30

      // 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" },
31
    },
32

33
34
35
    102: {
      vl: { "15min": "70", "60min": "76" },
      rl: { "15min": "82", "60min": "88" },
36
37
38
39
40
41

      // 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" },
42
    },
43

44
45
46
    107: {
      vl: { "15min": "71", "60min": "77" },
      rl: { "15min": "83", "60min": "89" },
47
48
49
50
51
52

      // 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" },
53
    },
54

55
    "112, 118": {
56
57
      vl: { "15min": "72", "60min": "78" },
      rl: { "15min": "84", "60min": "90" },
58
59
60
61
62
63

      // 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" },
64
    },
65

66
67
68
    125: {
      vl: { "15min": "73", "60min": "79" },
      rl: { "15min": "85", "60min": "91" },
69
70
71
72
73
74

      // 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" },
75
    },
76

77
78
79
80
81
82
83
84
85
86
    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" },
    },
  };

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
export const getDatastreamUrl = 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
export const getObservationsUrl = 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
// const BASE_URL_OBSERVATIONS = getObservationsUrl(80);
138
139
140
141
142
143
144
145
146
147
148
149
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";
export const QUERY_PARAMS_COMBINED = {
  "$resultFormat": QUERY_PARAM_RESULT_FORMAT,
  "$orderBy": QUERY_PARAM_ORDER_BY,
  "$filter": QUERY_PARAM_FILTER,
  "$select": QUERY_PARAM_SELECT,
150
};
Pithon Kabiro's avatar
Pithon Kabiro committed
151

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

164
165
166
167
168
169
/**
 * 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
 */
170
export const getDatastreamMetadata = async function (urlDatastream) {
171
172
173
174
175
176
177
178
  try {
    // Extract properties of interest
    const {
      data: { description, name, unitOfMeasurement },
    } = await axiosGetRequest(urlDatastream);

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

183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
/**
 * 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
 */
export const formatDatastreamMetadataForChart = function (datastreamMetadata) {
  const {
    description: datastreamDescription,
    name: datastreamName,
    unitOfMeasurement,
  } = datastreamMetadata;

  // Extract phenomenon name from Datastream name
  const regex = /\/ (.*) DS/;
  const phenomenonName = datastreamName.match(regex)[1]; // use second element in array

  // Match the unitOfMeasurement's string representation of a symbol
  // to an actual symbol, where necessary
  const unitOfMeasurementSymbol = (() => {
    if (unitOfMeasurement.symbol === "degC") {
      return "";
    } else if (unitOfMeasurement.symbol === "m3/h") {
      return "m<sup>3</sup>/h";
    } else {
      return unitOfMeasurement.symbol;
    }
  })();

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

219
/**
220
 * Format the response from SensorThings API to make it suitable for heatmap
221
222
 * @param {Array} obsArray Response from SensorThings API as array
 * @returns {Array} Array of formatted observations suitable for use in a heatmap
223
 */
224
export const formatSTAResponseForHeatMap = function (obsArray) {
Pithon Kabiro's avatar
Pithon Kabiro committed
225
  if (!obsArray) return;
226
227

  const dataSTAFormatted = obsArray.map((obs) => {
228
229
230
231
232
233
234
235
236
237
238
239
240
    // 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];
241
    return [timestamp, hourOfDay, value];
242
  });
243

244
245
246
247
248
  return dataSTAFormatted;
};

/**
 * Draw a heatmap using Highcharts library
249
 * @param {Array} formattedObsArrayForHeatmap Response from SensorThings API formatted for use in a heatmap
250
 * @param {Object} formattedDatastreamMetadata Object containing Datastream metadata
251
 * @returns {undefined} undefined
252
 */
253
254
export const drawHeatMapHC = function (
  formattedObsArrayForHeatmap,
255
  formattedDatastreamMetadata
256
) {
257
258
259
260
261
262
263
  const {
    datastreamDescription: DATASTREAM_DESCRIPTION,
    datastreamName: DATASTREAM_NAME,
    phenomenonName: PHENOMENON_NAME,
    unitOfMeasurementSymbol: PHENOMENON_SYMBOL,
  } = formattedDatastreamMetadata;

264
265
266
267
268
269
270
271
272
273
274
  Highcharts.chart("chart-heatmap", {
    chart: {
      type: "heatmap",
      zoomType: "x",
    },

    boost: {
      useGPUTranslations: true,
    },

    title: {
275
      text: DATASTREAM_DESCRIPTION,
276
277
278
279
280
      align: "left",
      x: 40,
    },

    subtitle: {
281
      text: DATASTREAM_NAME,
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
      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, 6, 12, 18, 24],
      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"],
      ],
      min: 60,
      max: 85,
      startOnTick: false,
      endOnTick: false,
      labels: {
331
332
        // format: "{value}℃",
        format: `{value}${PHENOMENON_SYMBOL}`,
333
334
335
336
337
      },
    },

    series: [
      {
338
        data: formattedObsArrayForHeatmap,
339
340
341
342
343
        boostThreshold: 100,
        borderWidth: 0,
        nullColor: "#525252",
        colsize: 24 * 36e5, // one day
        tooltip: {
344
345
          headerFormat: `${PHENOMENON_NAME}<br/>`,
          valueDecimals: 2,
346
          pointFormat:
347
348
            // "{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>`,
349
350
351
352
353
354
355
        },
        turboThreshold: Number.MAX_VALUE, // #3404, remove after 4.0.5 release
      },
    ],
  });
};

Pithon Kabiro's avatar
Pithon Kabiro committed
356
357
/**
 * Convert the observations' phenomenonTime from an ISO 8601 string to Unix epoch
358
359
 * @param {Array} obsArray Response from SensorThings API as array
 * @returns {Array} Array of formatted observations suitable for use in a line chart
Pithon Kabiro's avatar
Pithon Kabiro committed
360
 */
361
export const formatSTAResponseForLineChart = function (obsArray) {
Pithon Kabiro's avatar
Pithon Kabiro committed
362
  if (!obsArray) return;
363
364

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

Pithon Kabiro's avatar
Pithon Kabiro committed
370
371
372
373
374
  return dataSTAFormatted;
};

/**
 * Draw a line chart using Highcharts library
375
 * @param {Array} formattedObsArrayForLineChart Response from SensorThings API formatted for use in a line chart
376
 * @param {Object} formattedDatastreamMetadata Object containing Datastream metadata
377
 * @returns {undefined} undefined
Pithon Kabiro's avatar
Pithon Kabiro committed
378
 */
379
380
export const drawLineChartHC = function (
  formattedObsArrayForLineChart,
381
  formattedDatastreamMetadata
382
) {
383
384
385
386
387
388
389
  const {
    datastreamDescription: DATASTREAM_DESCRIPTION,
    datastreamName: DATASTREAM_NAME,
    phenomenonName: PHENOMENON_NAME,
    unitOfMeasurementSymbol: PHENOMENON_SYMBOL,
  } = formattedDatastreamMetadata;

Pithon Kabiro's avatar
Pithon Kabiro committed
390
391
392
393
394
395
396
397
398
399
  Highcharts.stockChart("chart-line", {
    chart: {
      zoomType: "x",
    },

    rangeSelector: {
      selected: 1,
    },

    title: {
400
      text: DATASTREAM_DESCRIPTION,
401
402
403
404
      "align": "left",
    },

    subtitle: {
405
      text: DATASTREAM_NAME,
406
      align: "left",
Pithon Kabiro's avatar
Pithon Kabiro committed
407
408
409
410
    },

    series: [
      {
411
        name: `${PHENOMENON_NAME} (${PHENOMENON_SYMBOL})`,
412
        data: formattedObsArrayForLineChart,
Pithon Kabiro's avatar
Pithon Kabiro committed
413
414
415
416
417
418
419
420
421
        tooltip: {
          valueDecimals: 2,
        },
        turboThreshold: Number.MAX_VALUE, // #3404, remove after 4.0.5 release
      },
    ],
  });
};

Pithon Kabiro's avatar
Pithon Kabiro committed
422
/**
Pithon Kabiro's avatar
Pithon Kabiro committed
423
 * Follows "@iot.nextLink" links in SensorThingsAPI's response
Pithon Kabiro's avatar
Pithon Kabiro committed
424
 * Appends new results to existing results
Pithon Kabiro's avatar
Pithon Kabiro committed
425
 * @async
426
427
 * @param {Promise} responsePromise Promise object resulting from an Axios GET request
 * @returns {Object} Object containing results from all the "@iot.nextLink" links
Pithon Kabiro's avatar
Pithon Kabiro committed
428
 */
429
const followNextLink = function (responsePromise) {
Pithon Kabiro's avatar
Pithon Kabiro committed
430
  if (!responsePromise) return;
Pithon Kabiro's avatar
Pithon Kabiro committed
431
  return responsePromise
432
    .then((lastSuccess) => {
Pithon Kabiro's avatar
Pithon Kabiro committed
433
434
435
      if (lastSuccess.data["@iot.nextLink"]) {
        return followNextLink(
          axios.get(lastSuccess.data["@iot.nextLink"])
436
        ).then((nextLinkSuccess) => {
Pithon Kabiro's avatar
Pithon Kabiro committed
437
438
439
440
441
442
443
444
445
          nextLinkSuccess.data.value = lastSuccess.data.value.concat(
            nextLinkSuccess.data.value
          );
          return nextLinkSuccess;
        });
      } else {
        return lastSuccess;
      }
    })
446
    .catch((err) => {
447
      console.error(err);
Pithon Kabiro's avatar
Pithon Kabiro committed
448
449
450
    });
};

451
452
453
/**
 * Retrieve all the Observations from a Datastream after traversing all the "@iot.nextLink" links
 * @async
454
455
 * @param {Promise} httpGetRequestPromise Promise object resulting from an Axios GET request
 * @returns {Promise} A promise that contains an array of Observations from a single Datastream when fulfilled
456
457
 */
export const getCombinedObservationsFromAllNextLinks = function (
458
  httpGetRequestPromise
459
) {
460
  return followNextLink(httpGetRequestPromise)
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
    .then((success) => {
      const successValue = success.data.value;
      // Array that will hold the combined observations
      const combinedObservations = [];
      successValue.forEach((dataObj) => {
        // Each page of results will have a dataArray that holds the observations
        const dataArrays = dataObj.dataArray;
        combinedObservations.push(...dataArrays);
      });

      return new Promise((resolve, reject) => {
        resolve(combinedObservations);
      });
    })
    .catch((err) => {
476
      console.error(err);
477
478
    });
};
479

480
481
482
483
484
485
/**
 * 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
 */
486
export const getMetadataPlusObservationsForChart = async function (
487
  metadataPlusObsPromiseArray
488
) {
489
  // Array to store our final result
490
491
  const combinedResolvedPromises = [];

492
493
  // Use for/of loop - we need to maintain the order of execution of the async operations
  for (const promise of metadataPlusObsPromiseArray) {
494
    try {
495
      // Resolved value of a single promise
496
497
498
      const resolvedPromise = await promise;
      combinedResolvedPromises.push(resolvedPromise);
    } catch (err) {
499
      console.error(err);
500
501
502
503
504
    }
  }
  return combinedResolvedPromises;
};

505
506
507
/**
 * Retrieve all the Observations from an array of Observations promises
 * @async
508
 * @param {Promise} observationPromiseArray An array that contains N observation promises
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
 * @returns {Promise} A promise that contains an array of Observations from multiple Datastreams when fulfilled
 */
const getObservationsFromMultipleDatastreams = async function (
  observationPromiseArray
) {
  // Array to store our final result
  const observationsAllDatastreamsArr = [];

  // Use for/of loop - we need to maintain the order of execution of the async operations
  for (const observationPromise of observationPromiseArray) {
    try {
      // Observations from a single Datastream
      const observations = await observationPromise;
      observationsAllDatastreamsArr.push(observations);
    } catch (err) {
524
      console.error(err);
525
526
527
528
    }
  }
  return observationsAllDatastreamsArr;
};
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559

// Building + phenomenon + sampling rate
const buildingsSensorSamplingRateRLArr = [
  ["101", "rl", "60min"],
  ["102", "rl", "60min"],
  ["107", "rl", "60min"],
  ["112, 118", "rl", "60min"],
  ["125", "rl", "60min"],
  ["225", "rl", "60min"],
];

// Datastreams IDs
const datastreamsRLArr = buildingsSensorSamplingRateRLArr.map((bldg) =>
  getDatastreamIdFromBuildingNumber(...bldg)
);

// Datastreams URLs
const datastreamsUrlRLArr = datastreamsRLArr.map((datastreamId) =>
  getObservationsUrl(BASE_URL, datastreamId)
);

// Promise objects - Observations / RL
const observationsPromisesRLArr = datastreamsUrlRLArr.map((obsUrl) =>
  getCombinedObservationsFromAllNextLinks(
    axiosGetRequest(obsUrl, QUERY_PARAMS_COMBINED)
  )
);

// getObservationsFromMultipleDatastreams(observationsPromisesRLArr).then((x) =>
//   console.log(x)
// );