This repository has been archived by the owner on Jun 1, 2024. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 2
/
Copy pathcharts.js
executable file
·419 lines (324 loc) · 14.4 KB
/
charts.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
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
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
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
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
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
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
let chartSeriesByConfigKey = {};
let flot_font_size = 12;
let nominalEfficiencies = null;
// Let's make the date formatting sane if we can
try {
const locale = window.navigator.userLanguage || window.navigator.language;
moment.locale(locale)
} catch{
console.warn("Unable to set date formatting locale")
}
async function indicateTimeWindow(start, end, timeInterval) {
const humanTime = humanizeDuration(timeInterval * 1000, { largest: 2, round: true })
$(".time-window").html(humanTime)
const humanStart = moment(start).calendar()
const humanEnd = moment(end).calendar()
let humanFriendlyMoments = humanStart;
if (humanStart != humanEnd) humanFriendlyMoments += " to " + humanEnd
$(".time-window").attr("title", humanFriendlyMoments)
}
function sum(arrayOfNumber) {
return arrayOfNumber.reduce((p, value) => p + value || 0, 0)
}
function createCoPFeed(inputFeedHistory, outputFeedHistory) {
if (inputFeedHistory == null || outputFeedHistory == null) return []
// Assumes the feeds are aligned in time and there's no gaps in either feed
return inputFeedHistory.map((row, index, array) => {
// If no input then don't declare a CoP
if (row[1] == 0) return null;
// If no output then don't declare a CoP
if (outputFeedHistory[index]) {
if (outputFeedHistory[index][1] == 0) return null;
}
const cohortSize = Math.min(index, 3)
const inputCohortValues = array.slice(index - cohortSize, index + cohortSize + 1).map(([_, value]) => value)
const inputCohortSum = sum(inputCohortValues)
if (inputCohortSum == 0) return null;
const outputCohortValues = outputFeedHistory.slice(index - cohortSize, index + cohortSize + 1).map(([_, value]) => value)
const constrainedOutputCohortValues = outputCohortValues.map((value, index) => {
const upperLimit = 7 * inputCohortValues[index]
return Math.min(upperLimit, value)
})
const outputCohortSum = sum(constrainedOutputCohortValues)
if (outputCohortSum == 0) return null;
return [row[0], outputCohortSum / inputCohortSum]
})
}
function createClientSideCoPFeed(inputConfigKey, outputConfigKey, configKey, color) {
if (chartSeriesByConfigKey[inputConfigKey] == null || chartSeriesByConfigKey[outputConfigKey] == null) return
const copFeedHistory = createCoPFeed(chartSeriesByConfigKey[inputConfigKey].data, chartSeriesByConfigKey[outputConfigKey].data)
chartSeriesByConfigKey[configKey] = {
color,
bars: {
show: true
},
lines: { fill: true },
data: copFeedHistory,
"label": configKey,
"scale": 100,
"scaledUnit": "%",
"fixed": 0
}
}
function createClientSideNominalEfficiencyFeeds(externalTemperatureConfigKeys, flowTemperatureConfigKey) {
const designTemperature = config.app.DesignFlowTemperature.value
const externalFeeds = externalTemperatureConfigKeys.map(configKey => chartSeriesByConfigKey[configKey])
// This hunts for the first feed that can provide reasonable data for the time window being reviewed
const outsideTemperatureSeries = externalFeeds.find(feed => feed != null && feed.data != null && feed.data[0] != null)
const flowTemperatureSeries = chartSeriesByConfigKey[flowTemperatureConfigKey]
if (outsideTemperatureSeries == null || flowTemperatureSeries == null) return
const copAtFlowFeed = []
const copAtDesign = []
// Look for outdoor temperature at the time the flow temperature was logged
flowTemperatureSeries.data.map(row => {
const timestamp = row[0]
// Default to the first temp
let outdoorTemp = outsideTemperatureSeries.data[0][1];
// Try to find a more representative temperature. Stop looking once we pass the reference timestamp
outsideTemperatureSeries.data.find(info => {
if (info[0] > timestamp) return true
outdoorTemp = info[1]
})
// We can only do CoP lookups on whole numbers at the moment
outdoorTemp = Math.round(outdoorTemp)
const flowTemp = Math.round(row[1])
if (nominalEfficiencies[flowTemp]) {
copAtFlowFeed.push([row[0], nominalEfficiencies[flowTemp]["nominal"][outdoorTemp]])
} else {
copAtFlowFeed.push([row[0], 0])
}
copAtDesign.push([row[0], nominalEfficiencies[designTemperature]["nominal"][outdoorTemp]])
})
chartSeriesByConfigKey["Nominal CoP@Flow"] = {
data: copAtFlowFeed,
color: "gold",
"label": "Nominal CoP@Flow",
"scale": 100,
"scaledUnit": "%",
"fixed": 0
}
chartSeriesByConfigKey["Nominal CoP@Design"] = {
data: copAtDesign,
color: "silver",
"label": "Nominal CoP@" + designTemperature,
"scale": 100,
"scaledUnit": "%",
"fixed": 0
}
}
async function loadDataAndRenderCharts(forceRefresh) {
if (view.end == 0) return;
if (view.end <= view.start) console.log("Odd view times:", view.end, view.start)
// Turn off auto-refresh if we've wandered into the past
if (view.end < (newestFeedTime * 1000)) setAutoRefresh(false)
var start = view.start;
var end = view.end;
// Get as much detail as we can reasonably display. We can't easily show more than one visual element per pixel
// Notably we don't do this on each resize because it would cause a very clunky resize experience. We will respect the new size on the next refresh
const npoints = $(this).width();
const timeInterval = (end - start) / 1000;
indicateTimeWindow(start, end, timeInterval)
var interval = timeInterval / npoints;
interval = view.round_interval(interval);
var intervalms = interval * 1000;
start = Math.ceil(start / intervalms) * intervalms;
end = Math.ceil(end / intervalms) * intervalms;
const feedHistoryByConfigKey = {}
// Fetch the feed history
const efficiencyPromise = fetch(applicationURL + "config/interpolated_efficiencies.json")
await Promise.all(Object.keys(config.app).map(async configKey => {
if (feedsByConfigKey[configKey]) {
// This feed has been configured locally
// Start by attaching the known history if there is some
if (chartSeriesByConfigKey[configKey]) {
feedHistoryByConfigKey[configKey] = chartSeriesByConfigKey[configKey].data
}
if (forceRefresh) {
// Don't do anything clever
} else {
const latestTimeOnServerForFeed = feedsByConfigKey[configKey].time * 1000;
if (chartSeriesByConfigKey.hasOwnProperty(configKey)) {
const data = chartSeriesByConfigKey[configKey].data;
if (data) {
const newestRecordOnClient = data[data.length - 1]
if (newestRecordOnClient) {
const latestTimeOnClientForFeed = newestRecordOnClient[0]
if (latestTimeOnClientForFeed >= latestTimeOnServerForFeed) {
return
}
}
}
}
}
// It's really tempting to just add the latest record on to the end of the local list
// However, we may have missed a lot of data
// This is especially true on mobile where where the web page gets put to sleep when it's not showing
const feedId = feedsByConfigKey[configKey].id
const url = "../feed/data.json?id=" + feedId + "&start=" + start + "&end=" + end + "&interval=" + interval + "&skipmissing=1&limitinterval=1"
const feedHistory = await (await fetch(url)).json()
feedHistoryByConfigKey[configKey] = feedHistory;
const chartSeriesOptionsAndData = Object.assign({}, config.app[configKey].displayOptions);
if (chartSeriesOptionsAndData.label == null) chartSeriesOptionsAndData.label = configKey
chartSeriesOptionsAndData.data = feedHistory
chartSeriesOptionsAndData.units = feedsByConfigKey[configKey].unit
chartSeriesByConfigKey[configKey] = chartSeriesOptionsAndData
}
}))
createClientSideCoPFeed("HeatingEnergyConsumedRate1", "HeatingEnergyProducedRate1", "Space Heating CoP", "green")
createClientSideCoPFeed("HotWaterEnergyConsumedRate1", "HotWaterEnergyProducedRate1", "Hot Water CoP", "blue")
createClientSideCoPFeed("TotalEnergyConsumedRate1", "TotalEnergyProducedRate1", "Total CoP")
if (config.app.IncludeNominalEfficiences) {
if (config.app.IncludeNominalEfficiences.value) {
nominalEfficiencies = await (await efficiencyPromise).json()
createClientSideNominalEfficiencyFeeds(["EffectiveTemperature", "OutdoorTemperature"], "FlowTemperature")
}
}
await Promise.all([
updateWindowSummary(feedHistoryByConfigKey, timeInterval),
$(".chart").each(async function () { await drawChart($(this)) })
]);
}
async function drawChart(jQueryElement) {
if (jQueryElement == null) return
const configKeys = jQueryElement.attr("data-config-keys").split(",").map(s => s.trim())
const dataSeries = configKeys.map(configKey => Object.assign({}, chartSeriesByConfigKey[configKey]))
const seriesWithHistory = dataSeries.filter(ds => ds != null && Object.keys(ds).length > 0)
if (seriesWithHistory.length == 0) {
jQueryElement.addClass("chart-with-no-feeds")
return
}
jQueryElement.removeClass("chart-with-no-feeds")
// Find out more about the options at https://github.com/flot/flot/blob/master/API.md
if (jQueryElement.get()[0].childNodes.length == 0) {
// Create a legend container
jQueryElement.before("<div class='legendContainer' data-config-keys='" + configKeys.join(",") + "'>chart legend</div>");
}
const options = {
xaxis: {
mode: "time",
timezone: "browser",
min: view.start,
max: view.end,
font: { size: flot_font_size, color: "black" }
},
yaxis: {
font: { size: flot_font_size, color: "black" },
label: getYAxisLabel(seriesWithHistory, jQueryElement.attr("data-label"))
},
grid: {
borderWidth: 0,
clickable: true,
hoverable: true,
margin: { left: 20 }
},
series: {
lines: { lineWidth: 0.5 }
},
bars: {
align: "center",
barWidth: 60 * 1000, // Should be wider when viewing a longer time window
lineWidth: 0.5
},
selection: { mode: "x" },
legend: {
noColumns: seriesWithHistory.length,
color: "black",
sorted: true,
container: jQueryElement.prev()
}
}
setYAxisTickFormatter(seriesWithHistory, options.yaxis, jQueryElement)
$.plot(jQueryElement, seriesWithHistory, options);
// Flot doesn't have native axis label support
// We can't apply the label until the elements have been drawn
$(".yAxis", jQueryElement).attr("data-label", options.yaxis.label)
jQueryElement.removeClass("processing")
}
function getYAxisLabel(dataSeries, baseLabelText) {
if (dataSeries == null) return "";
const units = dataSeries.map(ds => {
if (ds) return ds.scaledUnit || ds.units || "";
return ""
})
const uniqueUnits = [...new Set(units)];
if (uniqueUnits.length == 1) {
const units = uniqueUnits[0];
if (baseLabelText) {
if (units) {
return baseLabelText + " (" + units + ")"
} else {
return baseLabelText
}
}
return units
} else {
console.log("There were conflicting untis for chart", uniqueUnits)
}
}
function setYAxisTickFormatter(dataSeries, yaxis) {
if (dataSeries == null) return;
const scales = dataSeries.map(ds => {
if (ds) return ds.scale || 1
return 1
})
const uniqueScales = [...new Set(scales)];
if (uniqueScales.length == 1) {
const commonScale = uniqueScales[0]
if (commonScale && commonScale != 1) {
yaxis.tickFormatter = (val, _axis) => {
return Math.round(val * commonScale, yaxis.tickDecimals);
}
}
} else {
console.log("There were conflicting scales for chart", uniqueScales)
}
}
async function showValueInLegendForTimestamp(chart, timestamp) {
const configKeysForChart = chart.dataset.configKeys.split(",").map(k => k.trim());
const chartSeriesByLabel = {}
configKeysForChart.map(configKey => {
const series = chartSeriesByConfigKey[configKey]
if (series) {
chartSeriesByLabel[series.label] = configKey
} else {
// Vanishes during refreshes
}
})
$(".legendLabel", chart).each(function () {
const existing = $(this).html()
const originalLabel = existing.split("=")[0].trim()
const configKey = chartSeriesByLabel[originalLabel];
const chartSeries = chartSeriesByConfigKey[configKey]
if (chartSeries) {
const feedHistory = chartSeries.data
let valueAtTimestamp = null
for (item of feedHistory) {
if (item) {
datapointTime = item[0]
value = item[1]
if (datapointTime > timestamp) break // We've gone past the timestamp
valueAtTimestamp = value
}
}
if (valueAtTimestamp) {
const displayValue = niceDisplayValue(valueAtTimestamp, configKey)
const displayUnits = niceDisplayUnit(configKey)
const newLabel = originalLabel + " = " + displayValue + displayUnits;
$(this).html(newLabel)
} else {
// Wipe out the value display to indicate there isn't one
$(this).html(originalLabel)
}
} else {
// Vanishes during refreshes
}
})
}
$(".chart").bind("plothover", function (_event, pos) {
if (pos.x) {
// Notably we want to show a synchronized value in all the charts for the time the user is hovering over with their mouse
$(".legendContainer").each(function (_index, container) {
showValueInLegendForTimestamp(container, pos.x + 30000)
})
}
});