From d0b81bb7fb5e06aaf75947a8318915afb9b3b067 Mon Sep 17 00:00:00 2001 From: Martin Bauer Date: Sun, 17 May 2020 15:57:26 +0200 Subject: [PATCH] App: peak detection, basic graph works --- App.js | 133 +++++++++++++-------- components/DeviceHttpDataSource.js | 73 +++++++++++ components/Graph.js | 13 +- data-analysis/PeakDetection.js | 186 +++++++++++++++++++++++++++++ 4 files changed, 349 insertions(+), 56 deletions(-) create mode 100644 components/DeviceHttpDataSource.js create mode 100644 data-analysis/PeakDetection.js diff --git a/App.js b/App.js index 7a0733f..8af5922 100644 --- a/App.js +++ b/App.js @@ -1,13 +1,14 @@ import React from 'react'; -import {View, StyleSheet} from 'react-native'; +import { View, StyleSheet } from 'react-native'; import { AppLoading } from 'expo'; import { Container, Text, Header, Content, Left, Body, Right, Button, Icon, Title, Card, CardItem } from 'native-base'; import * as Font from 'expo-font'; import { Ionicons } from '@expo/vector-icons'; import { LinearGradient } from 'expo-linear-gradient'; import IconCard from './components/IconCard'; -/*import Graph from './components/Graph';*/ +import Graph from './components/Graph'; import DeviceHttpDataSource from './components/DeviceHttpDataSource'; +import { PeakDetectorSimple } from './data-analysis/PeakDetection'; export default class App extends React.Component { @@ -16,7 +17,25 @@ export default class App extends React.Component { this.state = { isReady: false, themeNumber: 0, + numPeaks: 0, + numLaps: 0, + measurements: [] }; + + this.config = { + deviceUrl: "http://smartswim", + peakThreshold: 30, + peaksPerLap: 30, + updateInterval : 3000, + }; + + this.peakDetector = new PeakDetectorSimple(this.config.peakThreshold, peaks => { + console.log("peaks:", peaks.length); + this.setState({ + numPeaks: peaks.length, + numLaps: (peaks.length / this.config.peaksPerLap).toFixed(1) + }); + }); } async componentDidMount() { @@ -28,51 +47,69 @@ export default class App extends React.Component { this.setState({ isReady: true }); } + handleStart = () => { + fetch(new URL("/api/session/start", this.config.deviceUrl)); + } + + handleStop = () => { + fetch(new URL("/api/session/stop", this.config.deviceUrl)); + } + handleThemeChange = () => { - this.setState( (state, props) => {return {themeNumber: ((state.themeNumber + 1) % themeArray.length) }}) - }; + this.setState((state, props) => { return { themeNumber: ((state.themeNumber + 1) % themeArray.length) } }) + } + + handleNewData = (fullData, newDataStart) => { + const newData = fullData.slice(newDataStart); + console.log("New data", newData.length, "Full data", fullData.length, "new data start", newDataStart); + console.log("new data", newData); + this.peakDetector.addVector(newData); + this.setState({measurements: fullData}); + } render() { if (!this.state.isReady) { return ; } - - const handleNewData = data => { - console.log("new data callback", data); - }; return ( - + -
- - TRAINING LÄUFT - - - +
+ + TRAINING LÄUFT + + + - + - -
+ +
+
- - - - - - {/* */} - + + + + {/* + + */} + +
@@ -94,22 +131,22 @@ const themeArray = [ ]; const styles = StyleSheet.create({ - card: { - flexDirection: 'row', - backgroundColor: 'rgba(0, 0, 0, 0.2)', - margin: 5, - padding: 5, - borderRadius: 3, - justifyContent: 'space-between', - /* - shadowColor: "#000", - shadowOffset: { - width: 0, - height: 1, - }, - shadowOpacity: 0.18, - shadowRadius: 1.00, - - elevation: 1,*/ - } + card: { + flexDirection: 'row', + backgroundColor: 'rgba(0, 0, 0, 0.2)', + margin: 5, + padding: 5, + borderRadius: 3, + justifyContent: 'space-between', + /* + shadowColor: "#000", + shadowOffset: { + width: 0, + height: 1, + }, + shadowOpacity: 0.18, + shadowRadius: 1.00, + + elevation: 1,*/ + } }); diff --git a/components/DeviceHttpDataSource.js b/components/DeviceHttpDataSource.js new file mode 100644 index 0000000..72697c4 --- /dev/null +++ b/components/DeviceHttpDataSource.js @@ -0,0 +1,73 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import msgpack5 from 'msgpack5'; + + +/* +config = { + peakDetector: 'simple', + peakDetectorConfig: {'threshold': 3000}, + + deviceUrl: 'http://smartswim', + peaksPerLap: 30, + forceAverage +} +*/ + +class DeviceHttpDataSource extends React.Component { + + constructor(props) { + super(props); + this.data = []; + this.dataUrl = new URL("/api/session/data", this.props.deviceUrl); + + // msgpack setup + this.msgpack = msgpack5(); + this.msgpack.registerDecoder(205, function (byteArr) { + const buffer = byteArr.buffer.slice(byteArr.byteOffset, byteArr.byteLength + byteArr.byteOffset); + return new Int16Array(buffer); + }); + + this.fetchDataHttp = this.fetchDataHttp.bind(this); + } + + async fetchDataHttp() { + this.dataUrl.searchParams.set("startIdx", this.data.length); + const response = await fetch(this.dataUrl); + const arrayBuffer = await response.arrayBuffer(); + const decoded = this.msgpack.decode(arrayBuffer); + const typedValueArr = decoded['values']; + console.log("new data inside fetch", typedValueArr); + const newDataStart = this.data.length; + for (let i = 0; i < typedValueArr.length; ++i) { + this.data.push(typedValueArr[i]); + } + this.props.onNewData(this.data, newDataStart); + } + + componentDidMount() { + this.timer = setInterval(this.fetchDataHttp, this.props.pollInterval); + } + + componentWillUnmount() { + clearInterval(this.timer); + this.timer = null; + } + + render() { + return ""; + } +} + + +DeviceHttpDataSource.propTypes = { + deviceUrl: PropTypes.string.isRequired, + onNewData: PropTypes.func.isRequired, + pollInterval: PropTypes.number // poll interval in ms +}; + +DeviceHttpDataSource.defaultProps = { + pollInterval: 20000 +}; + +export default DeviceHttpDataSource; \ No newline at end of file diff --git a/components/Graph.js b/components/Graph.js index f079419..4111e55 100644 --- a/components/Graph.js +++ b/components/Graph.js @@ -1,18 +1,15 @@ import React from 'react'; import {View, StyleSheet, Text} from 'react-native'; -import Svg, {Polyline, Polygon, Rect, G} from 'react-native-svg'; +import Svg, {Polyline, Polygon, Rect, G} from 'react-native-svg-web'; const Graph = props => { const graphHeight = 100; - const data = []; - for(let i=0; i < 300; ++i) { - data.push( Math.random() * 100); - } - - const coordStr = data.map((element, i) => `${i*2}, ${element}`); + const data = props.data.slice(-600); + + const coordStr = data.map((element, i) => `${i}, ${element / 2}`); return ( @@ -25,7 +22,7 @@ const Graph = props => { strokeWidth="3" strokeOpacity="0.5" strokeLinejoin="round" - fill="none" + fill="none" /> diff --git a/data-analysis/PeakDetection.js b/data-analysis/PeakDetection.js new file mode 100644 index 0000000..caf6242 --- /dev/null +++ b/data-analysis/PeakDetection.js @@ -0,0 +1,186 @@ + +/** + * A simple peak detector + * + * Usage: Successively add values via add() and query the indices of the peaks with peaks + * + * A peak is detected if the current point is local maximum (no filtering!) and the current + * value is larger than (threshold + minimum_since_last_peak) + */ +class PeakDetectorSimple { + constructor(threshold, handleNewPeaks) { + this.peaks = []; + this._threshold = threshold; + this._queue = []; + this._last_min = 0; + this._counter = 0; + this._handleNewPeaks = handleNewPeaks; + } + + addVector(vec) { + const callbackBackup = this._handleNewPeaks; + const numPeaksBefore = this.peaks.length; + this._handleNewPeaks = null; + for (let i = 0; i < vec.length; ++i) { + this.add(vec[i]); + } + this._handleNewPeaks = callbackBackup; + if (numPeaksBefore != this.peaks.length) { + this._handleNewPeaks(this.peaks); + } + } + + add(value) { + this._queue.push(value); + if (this._queue.length > 3) { + this._queue.shift(); + } + if (this._queue.length !== 3) { + return; + } + const [last, current, next] = this._queue; + const is_maximum = current > next && current > last; + if (is_maximum && (current - this._last_min) > this._threshold) { + this.peaks.push(this._counter); + this._last_min = current; + if (this._handleNewPeaks) { + this._handleNewPeaks(this.peaks); + } + } + this._last_min = Math.min(this._last_min, current); + this._counter += 1; + } +} + + +/** + * Implementation of Z-Score peak detection according to + * https://stackoverflow.com/questions/22583391/peak-signal-detection-in-realtime-timeseries-data + */ +class PeakDetectorZScore { + constructor(lag, threshold, influence, handleNewPeaks) { + this.peaks = []; + this._filter = ZScoreFilter(lag, threshold, influence); + this._counter = 0; + this._previous_signal = 0; + this._max = null; + this._max_index = null; + this._handleNewPeaks = handleNewPeaks; + } + + addVector(vec) { + const callbackBackup = this._handleNewPeaks; + const numPeaksBefore = this.peaks.length; + this._handleNewPeaks = null; + for (let i = 0; i < vec.length; ++i) { + this.add(vec[i]); + } + this._handleNewPeaks = callbackBackup; + if (numPeaksBefore != this.peaks.length) { + this._handleNewPeaks(this.peaks); + } + } + + add(value) { + const signal = this._filter.add(value); + if (signal != null) { + const rising_flank = this._previous_signal !== 1 && signal === 1; + const falling_flank = this._previous_signal === 1 && signal !== 1; + if (rising_flank) + this._max = -1; + if (signal === 1 && this._max != null && value > this._max) { + this._max = value; + this._max_index = this._counter; + } + if (falling_flank) { + this.peaks.push(this._max_index); + if (this._handleNewPeaks) { + this._handleNewPeaks(this.peaks); + } + } + + this._previous_signal = signal; + } + this._counter += 1; + } +} + + +export { PeakDetectorSimple, PeakDetectorZScore }; + + +// --------------------------------------- Helper classes ------------------------------------------------------------- + +class StatisticsQueue { + constructor(size) { + this._size = size; + + this._queue = []; + this._queue_sum = 0; // running sum over all elements currently in _queue + this._queue_sum_sq = 0; // sum of squared elements in _queue + } + + add(value) { + this._queue.push(value); + + this._queue_sum += value; + this._queue_sum_sq += value * value; + + if (this._queue.length > this._size) { + const removed = this._queue[0]; + this._queue.shift(); + this._queue_sum -= removed; + this._queue_sum_sq -= removed * removed; + } + } + + get avg() { + return this._queue_sum / self._queue.length; + } + + get variance() { + const exp_sq = this._queue_sum_sq / self._queue.length; + const my_avg = self.avg; + return exp_sq - (my_avg * my_avg); + } + + get std_deviation() { + return Math.sqrt(this.variance); + } + + get filled() { + return this._queue.length === this._size; + } +} + +class ZScoreFilter { + + constructor(lag, threshold, influence) { + this._threshold = threshold; + this._influence = influence; + + this._last_value = null; + this._stat_queue = StatisticsQueue(lag); + } + + add(value) { + let sq = this._stat_queue; + if (!sq.filled) { + sq.add(value); + this._last_value = value; + return null; + } else { + const avg = sq.avg; + if (Math.abs(value - avg) > this._threshold * sq.std_deviation) { + const signal = value > avg ? 1 : -1; + const filtered = this._influence * value + (1 - this._influence) * this._last_value; + sq.add(filtered); + this._last_value = filtered; + return signal; + } else { + sq.add(value); + this._last_value = value; + } + } + } +}