Jelajahi Sumber

Add min/max to titles + fix day timestamps

- Changed `TimeSeries` to `TimePoint` as it's a point in time, and
  created a new `TimeSeries` type to contain it, plus min/max.
- Getting the min/max in `alignToWindow()` for a few reasons:
  1. Avoid additional loops.
  2. It occurs after removing spikes, so the values can be trusted.
  3. It allows getting the actual min/max, rather than the averages that
     would be shown after it's aligned.
- Timestamps (eg: 2023-10-25) were showing the wrong month as
  `Date.getMonth()` returns zero-indexed months.
Jason Tarka 2 tahun lalu
induk
melakukan
0e935dab23

+ 4 - 0
web-view/src/index.html

@@ -20,6 +20,10 @@
 			display: inline-block !important;
 		}
 
+		div.graph-container h2 {
+			display: inline-block;
+			margin-right: 0.5em;
+		}
 
 		#gauges {
 			display: flex;

+ 42 - 29
web-view/src/js/cleanData.test.ts

@@ -1,50 +1,59 @@
 import {alignToWindow, removeSpikes} from './cleanData';
 import {testData} from './testData';
 import * as should from 'should';
+import {TimePoint, TimeSeries} from "./types";
 
 describe('Clean data', () => {
 	describe('Align to window', () => {
 		it('Aligns data to 15 minute increments', () => {
 			// Adapted from the data in testData.ts
-			const expected = [
-				{ time: 1640296800000, value: -4.2 },
-				{ time: 1640297700000, value: -4.3 },
-				{ time: 1640298600000, value: -4.4 },
-				{ time: 1640299500000, value: -4.2 },
-				{ time: 1640300400000, value: -4.2 },
-				{ time: 1640301300000, value: -4.2 },
-				{ time: 1640302200000, value: -0.2 },
-				{ time: 1640303100000, value: -2.9 },
-				{ time: 1640304000000, value: -3.9 },
-				{ time: 1640304900000, value: -4.0 },
-				{ time: 1640305800000, value: -3.9 },
-				{ time: 1640306700000, value: -3.8 },
-				{ time: 1640307600000, value: -3.8 },
-			];
+			const expected: TimeSeries = {
+				minimum: -4.4,
+				maximum: 3.7,
+				series: [
+					{ time: 1640296800000, value: -4.2 },
+					{ time: 1640297700000, value: -4.3 },
+					{ time: 1640298600000, value: -4.4 },
+					{ time: 1640299500000, value: -4.2 },
+					{ time: 1640300400000, value: -4.2 },
+					{ time: 1640301300000, value: -4.2 },
+					{ time: 1640302200000, value: -0.2 },
+					{ time: 1640303100000, value: -2.9 },
+					{ time: 1640304000000, value: -3.9 },
+					{ time: 1640304900000, value: -4.0 },
+					{ time: 1640305800000, value: -3.9 },
+					{ time: 1640306700000, value: -3.8 },
+					{ time: 1640307600000, value: -3.8 },
+				]
+			};
 
 			const actual = alignToWindow(testData, 15);
 			should(actual).deepEqual(expected);
 		});
 
 		it('Skips the first period when empty', () => {
-			const input = [
+			const input: TimePoint[] = [
 				{ time: 1640296752000, value: -4.21 },
 				{ time: 1640297111475, value: -4.28 },
 				{ time: 1640297410320, value: -4.29 },
 				{ time: 1640297709214, value: -4.34 },
 			];
 
-			const expected = [
-				{ time: 1640296800000, value: -4.2 },
-				{ time: 1640297700000, value: -4.3 },
-			];
+			const expected: TimeSeries = {
+				minimum: -4.3,
+				maximum: -4.2,
+				series: [
+					{ time: 1640296800000, value: -4.2 },
+					{ time: 1640297700000, value: -4.3 },
+				]
+			};
 
 			const actual = alignToWindow(input, 15);
 			should(actual).deepEqual(expected);
 		});
 
 		it('Inserts NaN when a middle period is empty', () => {
-			const input = [
+			const input: TimePoint[] = [
 				{ time: 1640296812283, value: -4.21 },
 				{ time: 1640297111475, value: -4.28 },
 				// Empty period here
@@ -53,11 +62,15 @@ describe('Clean data', () => {
 				{ time: 1640298905167, value: -4.27 },
 			];
 
-			const expected = [
-				{ time: 1640296800000, value: -4.2 },
-				{ time: 1640297700000, value: NaN },
-				{ time: 1640298600000, value: -4.4 },
-			];
+			const expected: TimeSeries = {
+				minimum: -4.4,
+				maximum: -4.2,
+				series: [
+					{ time: 1640296800000, value: -4.2 },
+					{ time: 1640297700000, value: NaN },
+					{ time: 1640298600000, value: -4.4 },
+				]
+			};
 
 			const actual = alignToWindow(input, 15);
 			should(actual).deepEqual(expected);
@@ -69,7 +82,7 @@ describe('Clean data', () => {
 			// Testing that abnormally large spikes are removed. For example, if
 			// the chip heats up and causes a 2+ Celsius spike in value.
 
-			const input = [
+			const input: TimePoint[] = [
 				{ time: 1, value: -4.23 },
 				{ time: 2, value: -4.14 },
 				{ time: 3, value: -4.12 },
@@ -92,7 +105,7 @@ describe('Clean data', () => {
 
 		it('Sorts data', () => {
 			// Same data as earlier test, but rearranged
-			const input = [
+			const input: TimePoint[] = [
 				{ time: 4, value: 3.65 }, // Large spike
 				{ time: 1, value: -4.23 },
 				{ time: 7, value: -3.63 },
@@ -118,7 +131,7 @@ describe('Clean data', () => {
 			// as in the summer when the sun starts setting.
 
 			// Nothing should be removed from the list.
-			const input = [
+			const input: TimePoint[] = [
 				{ time: 1, value: 32.6 },
 				{ time: 2, value: 32.3 },
 				{ time: 3, value: 32.1 },

+ 33 - 15
web-view/src/js/cleanData.ts

@@ -1,5 +1,5 @@
 import {formatTime} from './utils';
-import {TimeSeries} from './types';
+import {TimePoint, TimeSeries} from './types';
 import {WindowType} from "./consts";
 
 /**
@@ -13,10 +13,10 @@ import {WindowType} from "./consts";
  * @param windowType The type of processing to do on the data.
  */
 export function alignToWindow(
-	data:TimeSeries[],
+	data: TimePoint[],
 	period:number = 15,
 	windowType: WindowType = WindowType.AVERAGE
-):TimeSeries[] {
+): TimeSeries {
 	const sorted = data.sort((a,b) => a.time - b.time);
 
 	const firstTime = new Date(data[0].time),
@@ -35,8 +35,10 @@ export function alignToWindow(
 			firstTime.getHours(), nextMinute, 0, 0).getTime(),
 		windowEnd = windowTime + halfPeriodMs;
 
-	let windows = [],
-		currNums = [];
+	let windows: TimePoint[] = [],
+		currNums: number[] = [];
+	let minimum = 1000000,
+		maximum = -1000000;
 	for(let i = 0; i < sorted.length; i++) {
 		let d = sorted[i];
 
@@ -44,7 +46,7 @@ export function alignToWindow(
 		if(d.time > windowEnd) {
 			i--; // Keep it in the next window
 
-			// Don't push the first window if it's empty
+			// Only push the first window if it's not empty.
 			if(windows.length || currNums.length) {
 				// Push the average, or NaN if there are no elements
 				const w = {
@@ -64,6 +66,8 @@ export function alignToWindow(
 			currNums = [];
 		} else {
 			currNums.push(d.value);
+			maximum = max([maximum, d.value]);
+			minimum = min([minimum, d.value]);
 		}
 	}
 
@@ -78,7 +82,10 @@ export function alignToWindow(
 		windows.push(w);
 	}
 
-	return windows;
+	return {
+		minimum, maximum,
+		series: windows
+	};
 }
 
 function average(nums:number[], decimalPlaces:number = 1):number {
@@ -86,14 +93,25 @@ function average(nums:number[], decimalPlaces:number = 1):number {
 		return NaN;
 
 	const total = nums.reduce((prev, curr) => prev + curr, 0);
-	return parseFloat((total / nums.length).toFixed(decimalPlaces));
+	return number(parseFloat((total / nums.length).toFixed(decimalPlaces)));
 }
 
-function max(nums: number[], decimalPlaces:number = 1) {
+function max(nums: number[], decimalPlaces:number = 1): number {
 	if(!nums.length)
 		return NaN;
 
-	return Math.max(...nums).toFixed(decimalPlaces);
+	return number(Math.max(...nums).toFixed(decimalPlaces));
+}
+
+function min(nums: number[], decimalPlaces:number = 1): number {
+	if(!nums.length)
+		return NaN;
+
+	return number(Math.min(...nums).toFixed(decimalPlaces));
+}
+
+function number(num: string|number) {
+	return parseFloat(num as string);
 }
 
 /**
@@ -101,24 +119,24 @@ function max(nums: number[], decimalPlaces:number = 1) {
  * Since the main concern is about the device heating up from multiple retries, a
  * cooling "spike" can be a lot larger before it gets excluded.
  *
- * @param data: The data to remove spikes from, where each object contains a
+ * @param data  The data to remove spikes from, where each object contains a
  *              data field specified in `dataField`.
- * @param minChange: The minimum absolute difference between elements before
+ * @param minChange  The minimum absolute difference between elements before
  *                   it's considered a spike. eg: a difference of 0.1 is 10x an
  *                   earlier diff of 0.01, but isn't a spike. A `minChange` of
  *                   2 would ignore this.
 *                    Double this value is used when cooling.
- * @param minDiffMultiple: The minimum multiplier of differences between sibling
+ * @param minDiffMultiple The minimum multiplier of differences between sibling
  *                         elements before it's considered a spike. eg: The
  *                         previous diff was 1.5*C, current diff is 3.8*C, this
  *                         is a 2.5x difference. A min multiplier of 3 would
  *                         ignore this.
  */
 export function removeSpikes(
-	data:TimeSeries[],
+	data: TimePoint[],
 	minChange:number = 2,
 	minDiffMultiple:number = 3
-):TimeSeries[] {
+): TimePoint[] {
 	data = data.sort((a, b) => a.time - b.time);
 
 	const minLength = 5;

+ 8 - 3
web-view/src/js/consts.ts

@@ -1,6 +1,6 @@
 export const HOURS_QUERY:string = 'hours';
 export const HOURS_DEFAULT:number = 12;
-export const MAX_HOURS_AGO = 168;
+export const MAX_HOURS_AGO = 672;
 
 /**
  * The total padding to add to a range on a graph to make tiny differences
@@ -128,6 +128,11 @@ export const FIELDS: FieldInfo[] = [
 export const FIELD_KEYS: string[] = FIELDS.map(f => f.key);
 
 /** The hours that can be selected in the dropdown */
-export const HOUR_OPTIONS:number[] = [
-	2, 4, 6, 12, 24, 48
+export const HOUR_OPTIONS: Array<number|[number, string]> = [
+	2, 4, 6, 12,
+	[24, 'day'],
+	[48, '2 days'],
+	[168, '1 week'],
+	[336, '2 weeks'],
+	[672, '4 weeks'],
 ];

+ 18 - 9
web-view/src/js/graphs.ts

@@ -1,5 +1,5 @@
 import { Chart, registerables } from 'chart.js';
-import {TimeSeries, Values} from './types';
+import {TimePoint, TimeSeries, Values} from './types';
 import {FieldInfo, FIELDS, RANGE_PADDING} from './consts';
 import {formatTime} from './utils';
 Chart.register(...registerables);
@@ -29,7 +29,7 @@ export function createGraphs(
 }
 
 function addCanvas(
-	data:TimeSeries[],
+	data: TimeSeries,
 	parentElement:HTMLElement,
 	fieldInfo:FieldInfo
 ):HTMLCanvasElement {
@@ -39,23 +39,31 @@ function addCanvas(
 	const header = document.createElement('h2');
 	header.textContent = fieldInfo.label;
 
+	const minMax: HTMLSpanElement = document.createElement('span');
+	let unit = fieldInfo.unit ? `&ThinSpace;${fieldInfo.unit}` : '';
+	minMax.innerHTML = `(${data.minimum}${unit} - ${data.maximum}${unit})`;
+
+	const title = document.createElement('div');
+	title.append(header, minMax);
+	title.classList.add('graph-title');
+
 	const canvas = document.createElement('canvas');
 	canvas.height = CANVAS_HEIGHT;
 	canvas.width = CANVAS_WIDTH;
 	canvas.classList.add('time-series', fieldInfo.key);
 
-	div.append(header, canvas);
+	div.append(title, canvas);
 
 	parentElement.appendChild(div);
 	return canvas;
 }
 
 function createGraph(
-	data:TimeSeries[],
+	data: TimeSeries,
 	canvas:HTMLCanvasElement,
 	fieldInfo:FieldInfo
 ) {
-	let minMax = data.reduce((prev, curr) => {
+	let minMax = data.series.reduce((prev, curr) => {
 		return isNaN(curr.value) ?
 			prev
 			: {
@@ -67,10 +75,10 @@ function createGraph(
 	let config:any = {
 		type: 'line',
 		data: {
-			labels: data.map(d => formatTime(d.time)),
+			labels: data.series.map(d => formatTime(d.time)),
 			datasets: [{
 				label: `${fieldInfo.label} (${fieldInfo.unit})`,
-				data: data.map(d => d.value),
+				data: data.series.map(d => d.value),
 				backgroundColor: fieldInfo.colour, // Colour of the dots
 				borderColor: fieldInfo.colour, // Colour of the line
 				fill: {
@@ -104,8 +112,9 @@ export function createGauges(
 	parentElement:HTMLElement
 ) {
 	for(let field of FIELDS) {
-		let fieldData = data[field.key];
-		let value = fieldData[fieldData.length - 1].value;
+		let fieldData: TimeSeries = data[field.key];
+		let series: TimePoint[] = fieldData.series;
+		let value = series[series.length - 1].value;
 
 		if(!field.excludeGauge) {
 			addGauge(

+ 8 - 2
web-view/src/js/main.ts

@@ -72,8 +72,14 @@ function showHourSelection() {
 
 	for(let h of HOUR_OPTIONS) {
 		const opt = document.createElement('option');
-		opt.value = h.toString();
-		opt.textContent = `Last ${h} hours`;
+		if (typeof h === 'number') {
+			opt.value = h.toString();
+			opt.textContent = `Last ${h} hours`;
+		} else {
+			opt.value = h[0].toString();
+			opt.textContent = `Last ${h[1]}`;
+			h = h[0];
+		}
 		if(h === current) {
 			opt.selected = true;
 		}

+ 49 - 23
web-view/src/js/processData.test.ts

@@ -1,4 +1,4 @@
-import {Minutes, Reading, TimeSeries, Timestamp, Values} from './types';
+import {Minutes, Reading, TimePoint, TimeSeries, Timestamp, Values} from './types';
 import {processData, splitData} from './processData';
 import should = require('should');
 
@@ -8,26 +8,46 @@ describe('Process Data', () => {
 			const input: Reading[] = createTestData(1);
 
 			const expected: Values = {
-				temperature: [{
-					value: input[0].temperature,
-					time: input[0].time
-				}],
-				humidity: [{
-					value: input[0].humidity,
-					time: input[0].time
-				}],
-				pressure: [{
-					value: input[0].pressure,
-					time: input[0].time
-				}],
-				light: [{
-					value: input[0].light,
-					time: input[0].time
-				}],
-				delay: [{
-					value: 0,
-					time: input[0].time
-				}],
+				temperature: {
+					series: [{
+						value: input[0].temperature,
+						time: input[0].time
+					}],
+					minimum: undefined,
+					maximum: undefined,
+				},
+				humidity: {
+					series: [{
+						value: input[0].humidity,
+						time: input[0].time
+					}],
+					minimum: undefined,
+					maximum: undefined,
+				},
+				pressure: {
+					series: [{
+						value: input[0].pressure,
+						time: input[0].time
+					}],
+					minimum: undefined,
+					maximum: undefined,
+				},
+				light: {
+					series: [{
+						value: input[0].light,
+						time: input[0].time
+					}],
+					minimum: undefined,
+					maximum: undefined,
+				},
+				delay: {
+					series: [{
+						value: 0,
+						time: input[0].time
+					}],
+					minimum: undefined,
+					maximum: undefined,
+				},
 			};
 
 			const actual = splitData(input);
@@ -47,15 +67,21 @@ describe('Process Data', () => {
 			const time = (i+1) * alignWindow * 60 * 1000;
 			expectedMaxSeries.push({
 				time,
-				value: expectedMaxDelays[i]
+				value: parseFloat(expectedMaxDelays[i] as string)
 			});
 		}
 
+		const expected: TimeSeries = {
+			minimum: 2.0,
+			maximum: 5.0,
+			series: expectedMaxSeries
+		};
+
 		const output: Values = processData(input, {
 			alignWindow
 		});
 
-		should(output.delay).deepEqual(expectedMaxSeries);
+		should(output.delay).deepEqual(expected);
 	});
 });
 

+ 13 - 10
web-view/src/js/processData.ts

@@ -1,4 +1,4 @@
-import {Reading, TimeSeries, Values} from './types';
+import {Reading, TimePoint, TimeSeries, Values} from './types';
 import {alignToWindow, removeSpikes} from './cleanData';
 import {FIELD_KEYS, FIELDS} from './consts';
 
@@ -34,8 +34,8 @@ const defaultConfig:ProcessingConfig = {
  * Fully process the data into a Values object, including removing spikes,
  * and aligning to a window.
  *
- * @param data: The readings to process.
- * @param config: Configuration to use.
+ * @param data The readings to process.
+ * @param config Configuration to use.
  */
 export function processData(
 	data:Reading[],
@@ -50,19 +50,18 @@ export function processData(
 
 	for(let field of FIELDS) {
 		const key = field.key;
-		let series:TimeSeries[] = values[key];
+		let series: TimePoint[] = values[key].series;
 		series = field.canSpike
 			? removeSpikes(series, config.minSpikeChange, config.minSpikeMultiplier)
 			: series;
-		series = alignToWindow(series, config.alignWindow, field.windowType);
-		values[key] = series;
+		values[key] = alignToWindow(series, config.alignWindow, field.windowType);
 	}
 
 	return values;
 }
 
 export function splitData(data:Reading[]): Values {
-	const ret = {};
+	const ret: Values = {} as Values;
 
 	// Process delays first so the FIELD_KEYS bit works.
 	for(let i = 0; i < data.length; i++) {
@@ -78,12 +77,16 @@ export function splitData(data:Reading[]): Values {
 	}
 
 	for(let field of FIELD_KEYS) {
-		ret[field] = data.map(d => splitValue(d, field));
+		ret[field] = {
+			minimum: undefined,
+			maximum: undefined,
+			series: data.map(d => splitValue(d, field)),
+		};
 	}
-	return ret as Values;
+	return ret;
 }
 
-function splitValue(reading:Reading, field:string) {
+function splitValue(reading:Reading, field:string): TimePoint {
 	return {
 		value: reading[field],
 		time: reading.time

+ 1 - 1
web-view/src/js/testData.ts

@@ -74,7 +74,7 @@ export const testData = [
 	},
 	{
 		"time": 1640302550740,  // 2021-12-23 23:35:50 UTC
-		"value": 3.65
+		"value": 3.66
 	},
 	{
 		"time": 1640302829882,  // 2021-12-23 23:40:29 UTC

+ 16 - 6
web-view/src/js/types.d.ts

@@ -23,22 +23,32 @@ export type Reading = {
 /**
  * A single point of data within a time series.
  */
-export type TimeSeries = {
+export type TimePoint = {
 	value: number,
 	time: Timestamp
 };
 
+/**
+ * A series of time points for a particular data stream, plus related
+ * additional information.
+ */
+export type TimeSeries = {
+	series: TimePoint[],
+	minimum: number,
+	maximum: number,
+};
+
 /**
  * A collection of time series, for each measurement that is taken.
  * Used to prevent processing (eg: spike removal) on one measurement from
  * affecting another.
  */
 export type Values = {
-	temperature: TimeSeries[],
-	humidity: TimeSeries[],
-	light: TimeSeries[],
-	pressure: TimeSeries[],
+	temperature: TimeSeries,
+	humidity: TimeSeries,
+	light: TimeSeries,
+	pressure: TimeSeries,
 
 	/** The maximum delay, in minutes, between samples during the window. */
-	delay: TimeSeries[],
+	delay: TimeSeries,
 };

+ 5 - 2
web-view/src/js/utils.test.ts

@@ -15,10 +15,13 @@ describe('Utils', () => {
 				expected: '23:24' // Assumes EST timezone
 			}, {
 				input: new Date(2022, 1, 2, 0, 0),
-				expected: '2022-01-02'
+				expected: '2022-02-02'
+			}, {
+				input: 1698292800000,
+				expected: '2023-10-26' // Assumes EST timezone
 			}, {
 				input: new Date(2022, 10, 12, 0, 0),
-				expected: '2022-10-12'
+				expected: '2022-11-12'
 			},
 		].forEach(data => it(`Formats ${data.input} to ${data.expected}`, () => {
 			const actual = formatTime(data.input);

+ 1 - 1
web-view/src/js/utils.ts

@@ -11,7 +11,7 @@ export function formatTime(date:Date|number):string {
 
 	let time = `${pad(date.getHours())}:${pad(date.getMinutes())}`;
 	return time === '00:00'
-		? `${date.getFullYear()}-${pad(date.getMonth())}-${pad(date.getDate())}`
+		? `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
 		: time;
 
 	function pad(num:number):string {