New setup from scratch - all modules updated - app now in subfolder

This commit is contained in:
Martin Bauer
2023-09-29 22:04:52 +02:00
parent e28ab91935
commit 9b6bb7f126
63 changed files with 9633 additions and 44259 deletions

View File

@@ -0,0 +1,80 @@
import { PeakDetectorSimple } from './PeakDetection';
import { MovingAverage} from './MovingAverage';
import { List } from 'immutable';
export default class DataAnalysis {
constructor() {
this._resetCache(null, 0);
}
analyze(analysisParameters, sessionId, allMeasurements) {
const cacheValid = (
this.sessionId === sessionId &&
this.analyzedUpToIdx <= allMeasurements.size &&
this.analysisParameters === analysisParameters);
let newData = null;
if (cacheValid) {
newData = allMeasurements.slice(this.analyzedUpToIdx);
}
else {
this._resetCache(analysisParameters, sessionId);
newData = allMeasurements;
console.log("cache reset");
}
const allMeasurementsSize = allMeasurements.size ? allMeasurements.size : allMeasurements.length;
const newDataArr = (typeof newData.toArray ==="function") ? newData.toArray() : newData;
// active time
const newAverages = this.movingAverage.addVector(newDataArr);
this.activeMeasurements += newAverages.reduce((n, val) => {
return n + ((val >= analysisParameters.activeTimeThreshold) ? 1 : 0);
}, 0);
// peaks
const newPeaks = this.peakDetectorSimple.addVector(newDataArr);
this.allPeaks = this.allPeaks.concat(List(newPeaks));
// aggregated sum/max
this.aggregatedMomentum = newData.reduce((sum, x) => sum + x, this.aggregatedMomentum);
this.peakMax = newData.reduce((running, x) => Math.max(x, running), this.peakMax);
// windowed
const windowNumDataPoints = analysisParameters.windowSizeInSecs * analysisParameters.numMeasurementsPerSec;
const windowed = allMeasurements.slice(-windowNumDataPoints);
const peakMaxWindow = windowed.reduce((running, x) => Math.max(x, running), 0);
const momentumWindow = windowed.reduce((sum, x) => sum + x, 0);
this.analyzedUpToIdx = allMeasurementsSize;
return {
peaks: this.allPeaks,
totalTime: allMeasurementsSize / analysisParameters.numMeasurementsPerSec,
activeTime: this.activeMeasurements / analysisParameters.numMeasurementsPerSec,
totalMomentum: this.aggregatedMomentum,
peakMax: this.peakMax,
momentumWindow: momentumWindow,
peakMaxWindow: peakMaxWindow,
};
}
_resetCache(analysisParameters, sessionId) {
this.movingAverage = analysisParameters ? new MovingAverage(analysisParameters.movingAverageWindowSize) : null;
this.activeMeasurements = 0;
this.peakDetectorSimple = analysisParameters ? new PeakDetectorSimple(analysisParameters.peakDetectorSimpleThreshold) : null;
this.allPeaks = List();
this.aggregatedMomentum = 0;
this.peakMax = 0;
this.sessionId = sessionId;
this.analyzedUpToIdx = 0;
this.analysisParameters = analysisParameters;
}
};

View File

@@ -0,0 +1,128 @@
import DeviceHttpDataSource from './DeviceDataSource';
import { List } from 'immutable';
import { reportDeviceData, resetDeviceData } from '../state/ActionCreators';
import { PeakDetectorSimple } from './PeakDetection';
// todo: put in settings?
const NUM_MEASUREMENTS_PER_SECOND = 10;
const WINDOW_SIZE_SECS = 5;
class DataProcessing {
constructor(reduxStore) {
this.store = reduxStore;
this.store.subscribe(this.onStateChange);
this.state = this.store.getState();
this.dataSource = null;
this.peakDetectorSimple = new PeakDetectorSimple(this.state.settings.peakDetectorSimpleThreshold);
this.onDataSourceChanged(this.state.settings.deviceURL);
this.onRunningChanged(this.state.session.running, this.state.settings.deviceURL);
}
onStateChange = () => {
const newState = this.store.getState();
if (newState.settings.deviceURL !== this.state.settings.deviceURL)
this.onDataSourceChanged(newState.settings.deviceURL);
if (newState.session.running !== this.state.session.running) {
this.onRunningChanged(newState.session.running, newState.settings.deviceURL);
};
if (newState.settings.peakDetectorSimpleThreshold !== this.state.settings.peakDetectorSimpleThreshold) {
this.onAnalysisParameterChange();
};
this.state = newState;
}
resetAnalysis = () => {
this.peakDetectorSimple = new PeakDetectorSimple(this.state.settings.peakDetectorSimpleThreshold);
}
onAnalysisParameterChange = () => {
this.resetAnalysis();
this.peakDetectorSimple.addVector(this.state.session.rawData.toArray());
const analysis = this.analyzeNewMeasurements(data.values, List());
this.store.dispatch(reportDeviceData(this.state.session.sessionId, this.state.session.rawData.size, this.state.session.rawData, analysis));
}
onDataSourceChanged = (newDeviceURL) => {
if (this.dataSource !== null) {
this.dataSource.stop();
this.dataSource = null;
}
this.dataSource = new DeviceHttpDataSource(newDeviceURL + "/api/session/data", this.onNewData);
}
onRunningChanged = (running, deviceURL) => {
let req = new XMLHttpRequest();
if (running) {
//console.log("Starting session", deviceURL + "/api/session/start");
req.open("GET", deviceURL + "/api/session/start");
this.dataSource.startIndex = 0;
this.dataSource.start();
} else {
//console.log("Stopping session");
req.open("GET", deviceURL + "/api/session/stop");
this.dataSource.stop();
this.dataSource.startIndex = 0;
}
req.addEventListener("error", evt => console.log(evt));
req.addEventListener("abort", evt => console.log(evt));
req.send();
}
onNewData = (data) => {
if (data.sessionStartTime == this.state.session.sessionId &&
data.startIndex == this.state.session.rawData.size) {
// normal case, add received data to measurement array
const newData = this.state.session.rawData.concat(List(data.values));
const analysis = this.analyzeNewMeasurements(data.values, this.state.session.rawData);
this.store.dispatch(reportDeviceData(data.sessionStartTime, data.startIndex, newData, analysis));
}
else if (data.startIndex === 0) {
this.resetAnalysis();
const newData = List(data.values);
const analysis = this.analyzeNewMeasurements(data.values, this.state.session.rawData);
this.store.dispatch(reportDeviceData(data.sessionStartTime, data.startIndex, newData, analysis));
} else {
// missed some data -> re-query
console.log("Requery :(");
//console.log("Session times", data.sessionStartTime == this.state.session.sessionId, data.sessionStartTime, this.state.session.sessionId);
//console.log("Index ",data.startIndex == this.state.session.rawData.size, data.startIndex, this.state.session.rawData.size);
this.resetAnalysis();
this.dataSource.startIndex = 0;
this.store.dispatch(resetDeviceData());
}
}
analyzeNewMeasurements = (newData, oldData) => {
const newPeaks = this.peakDetectorSimple.addVector(newData);
const allPeaks = this.state.session.analysis.peaks.concat(List(newPeaks));
const allMeasurements = oldData.concat(List(newData));
const totalMomentum = allMeasurements.reduce((sum, x) => sum + x, 0);
const peakMax = allMeasurements.reduce((running, x) => Math.max(x, running), 0);
// windowed quantities
const windowSizeMeasurements = WINDOW_SIZE_SECS * NUM_MEASUREMENTS_PER_SECOND;
const windowedSeq = allMeasurements.slice(-windowSizeMeasurements);
const peakMaxWindow = windowedSeq.reduce((running, x) => Math.max(x, running), 0);
const momentumWindow = windowedSeq.reduce((sum, x) => sum + x, 0);
return {
peaks: allPeaks,
totalTime: allMeasurements.length / NUM_MEASUREMENTS_PER_SECOND,
activeTime: 0,
totalMomentum: totalMomentum,
peakFrequency: 0,
peakMax: peakMax,
// windowed quantities
momentumWindow: momentumWindow,
frequencyWindow: 0,
peakMaxWindow: peakMaxWindow,
}
};
}
export default DataProcessing;

View File

@@ -0,0 +1,76 @@
import * as msgpack from 'msgpack-lite';
class DeviceHttpDataSource {
constructor(dataUrl, onNewData, pollInterval=800, startIndex = 0) {
this.dataUrl = dataUrl;
this.onNewData = onNewData;
this.pollInterval = pollInterval;
this.startIndex = startIndex;
this.timer = null;
// msgpack setup
this.msgpackCodec = msgpack.createCodec();
this.msgpackCodec.addExtUnpacker(205, function (byteArr) {
const buffer = byteArr.buffer.slice(byteArr.byteOffset, byteArr.byteLength + byteArr.byteOffset);
const result = new Int16Array(buffer);
return result;
});
this.fetchDataHttp = this.fetchDataHttp.bind(this);
}
getUrl(url) {
return new Promise((accept, reject) => {
var req = new XMLHttpRequest();
req.open("GET", url, true);
req.responseType = "arraybuffer";
req.onload = function (event) {
var resp = req.response;
if (resp) {
accept(resp);
}
};
req.addEventListener("error", evt => reject(evt));
req.addEventListener("abort", evt => reject(evt));
req.send(null);
});
}
async fetchDataHttp() {
try {
const url = this.dataUrl + "?startIdx=" + this.startIndex;
const arrayBuffer = await this.getUrl(url);
const decoded = msgpack.decode(new Uint8Array(arrayBuffer), { codec: this.msgpackCodec });
this.startIndex += decoded["values"].length;
this.onNewData(decoded);
} catch (err) {
console.log(err);
}
}
start() {
if (this.timer === null) {
console.log("Start monitoring");
this.timer = setInterval(this.fetchDataHttp, this.pollInterval);
return true;
} else {
return false;
}
}
stop() {
if (this.timer !== null) {
console.log("stop monitoring");
clearInterval(this.timer);
this.timer = null;
return true;
} else {
return false;
}
}
};
export default DeviceHttpDataSource;

View File

@@ -0,0 +1,29 @@
/**
* A moving average computation
*/
export class MovingAverage {
constructor(windowSize) {
this._windowSize = windowSize;
this._queue = [];
this._queueSum = 0;
}
windowSize() {
return this._windowSize;
}
addVector(vec) {
return vec.map(this.add.bind(this));
}
add(value) {
this._queueSum += value;
this._queue.push(value);
if(this._queue.length > this._windowSize) {
this._queueSum -= this._queue[0];
this._queue.shift();
}
return this._queueSum / this._queue.length;
}
};

View File

@@ -0,0 +1,185 @@
/**
* 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) {
this._threshold = threshold;
this._queue = [];
this._last_min = 0;
this._counter = 0;
}
getThreshold() {
return this._threshold;
}
addVector(vec) {
let result = [];
for (let i = 0; i < vec.length; ++i) {
const res = this.add(vec[i]);
if(res !== null)
result.push(res);
}
return result;
}
add(value) {
let result = null;
this._queue.push(value);
if (this._queue.length > 3) {
this._queue.shift();
}
if (this._queue.length !== 3) {
return null;
}
const [last, current, next] = this._queue;
const is_maximum = current > next && current > last;
if (is_maximum && (current - this._last_min) > this._threshold) {
result = this._counter + 1;
this._last_min = current;
}
this._last_min = Math.min(this._last_min, current);
this._counter += 1;
return result;
}
}
/**
* 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;
}
}
}
}

View File

@@ -0,0 +1,11 @@
import { PeakDetectorSimple } from './PeakDetection';
describe("test PeakDetectorSimple", () => {
it("detects simple peak", () => {
let pd = new PeakDetectorSimple(40);
const result = pd.addVector([0, 10, 30, 50, 30, 50, 2, 0, 60, 0 ]);
expect(result).toEqual([3, 8]);
});
});

View File

@@ -0,0 +1,213 @@
import ReconnectingWebSocket from 'reconnecting-websocket';
import * as msgpack from 'msgpack-lite';
const OpCodes = {
// from swim tracker device to frontend
ERROR: 1,
INITIAL_INFO: 2,
SESSION_STARTED: 3,
SESSION_STOPPED: 4,
SESSION_NEW_DATA: 5,
ANSWER_USER_LIST: 6,
ANSWER_SESSION_LIST: 7,
WIFI_STATE_RESPONSE: 8,
WIFI_SCAN_RESPONSE: 9,
APP_LAYER_PING: 10,
LOG_UPDATE: 11,
// from frontend to device
START_SESSION: 128,
STOP_SESSION: 129,
TARE: 130,
QUERY_USER_LIST: 131,
QUERY_SESSION_LIST: 132,
WIFI_STATE_SET: 133,
WIFI_STATE_GET: 134,
WIFI_TRIGGER_SCAN: 135,
LOG_STREAMING_START: 136,
LOG_STREAMING_STOP: 137
};
const HEARTBEAT_TIMEOUT = 3000;
const PROVISIONING_IP = "192.168.42.1";
export default class SwimTrackerWebsocketConnection {
/**
* Creates a new persistent websocket connection to a swimtracker device
*
* @param {string} swimTrackerHost hostname or ip of the swimtracker device
* @param {(data: Uint16Array) => any} onData called whenever new measurement data is available
* @param {(sessionId: number) => any} onStarted called when a new measurement session was started
* @param {() => any} onStopped called when session was stopped
* @param {(wifistate : object) => any} onWifiStateInfo wifi state contains "state" (STATION_MODE|AP_PROVISIONING|AP_SECURE) and "hostname"
* @param {() => any} onConnect called when websocket connection was established
* @param {() => any} onDisconnect called when websocket disconnected
*/
constructor(swimTrackerHost, onData, onStarted, onStopped, onWifiStateInfo, onConnect, onDisconnect, reconnectingWsOptions={}) {
this.swimTrackerHost = swimTrackerHost;
this.onData = onData;
this.onStarted = onStarted;
this.onStopped = onStopped;
this.onWifiStateInfo = onWifiStateInfo;
this.onConnect = onConnect;
this.onDisconnect = onDisconnect;
this.onLogMessage = () => {};
// try configured URL and provisioning URL
const urls = [`ws://${swimTrackerHost}:81`, `ws://${PROVISIONING_IP}:81`];
let urlIndex = 0;
const urlProvider = () => urls[urlIndex++ % urls.length]; // round robin url provider
this.ws = new ReconnectingWebSocket(urlProvider, [], { ...reconnectingWsOptions, maxReconnectionDelay: 3000 });
this.ws.onmessage = this._onMessage;
this.ws.onopen = this.onConnect;
this.ws.onclose = this.onDisconnect;
this.ws.onerror = this._onError;
this.ws.binaryType = 'arraybuffer';
this.msgpackCodec = msgpack.createCodec();
this.msgpackCodec.addExtUnpacker(205, function (byteArr) {
const buffer = byteArr.buffer.slice(byteArr.byteOffset, byteArr.byteLength + byteArr.byteOffset);
const result = new Int16Array(buffer);
return result;
});
this._wifiScanPromises = [];
this.pingTimeout = null;
}
heartbeat() {
clearTimeout(this.pingTimeout);
let connection = this;
this.pingTimeout = setTimeout(() => {
if(connection.ws !== null)
connection.ws.reconnect();
}, HEARTBEAT_TIMEOUT);
}
close() {
if (this.ws !== null) {
this.ws.onmessage = null;
this.ws.onopen = null;
this.ws.onclose = null;
this.ws.onerror = null;
this.ws.close();
this.ws = null;
}
}
sendStartCommand() {
this._sendMsg(OpCodes.START_SESSION);
}
sendStopCommand() {
this._sendMsg(OpCodes.STOP_SESSION);
}
sendTareCommand = () => {
this._sendMsg(OpCodes.TARE);
}
sendLogStreamStartCommand = () => {
this._sendMsg(OpCodes.LOG_STREAMING_START);
}
sendLogStreamStopCommand = () => {
this._sendMsg(OpCodes.LOG_STREAMING_STOP);
}
scanWifiNetworks() {
console.log("Trigger wifi scan");
this._sendMsg(OpCodes.WIFI_TRIGGER_SCAN);
let conn = this;
return new Promise((resolve, reject) => {
conn._wifiScanPromises.push({ resolve: resolve, reject: reject });
});
}
sendTareCommand = () => {
this._sendMsg(OpCodes.WIFI_STATE_SET, {
"reset_to_provisioning": true,
});
}
wifiSetModeAP(password) {
this._sendMsg(OpCodes.WIFI_STATE_SET, {
"ap_password": password,
});
}
wifiSetModeSTA(ssid, password) {
console.log("Setting sta mode", ssid, password);
this._sendMsg(OpCodes.WIFI_STATE_SET, {
"sta_ssid": ssid,
"sta_password": password,
});
}
_sendMsg(code, data) {
let msg = undefined;
if (data) {
const serializedData = msgpack.encode(data);
msg = new Uint8Array([code, ...serializedData]);
} else {
msg = new Uint8Array(1);
msg[0] = code;
}
this.ws.send(msg);
}
_onMessage = (e) => {
const dv = new DataView(e.data);
const opCode = dv.getInt8(0);
const payload = new Uint8Array(e.data).slice(1);
this.heartbeat();
if (opCode === OpCodes.INITIAL_INFO) {
const headerSize = 6;
const running = Boolean(dv.getInt8(1));
const sessionId = dv.getUint32(2);
if (running && e.data.byteLength > headerSize) {
const data = new Uint16Array(e.data.slice(headerSize));
this.onStarted(sessionId);
this.onData(data);
} else
this.onStopped();
} else if (opCode === OpCodes.SESSION_STARTED) {
const sessionId = dv.getUint32(1);
this.onStarted(sessionId);
} else if (opCode === OpCodes.SESSION_STOPPED) {
this.onStopped();
} else if (opCode === OpCodes.SESSION_NEW_DATA) {
const data = new Uint16Array(e.data.slice(1));
this.onData(data);
} else if (opCode === OpCodes.WIFI_SCAN_RESPONSE) {
const scanResult = msgpack.decode(payload, { codec: this.msgpackCodec });
for (let i = 0; i < this._wifiScanPromises.length; ++i) {
this._wifiScanPromises[i].resolve(scanResult);
}
this._wifiScanPromises.length = 0;
} else if (opCode === OpCodes.WIFI_STATE_RESPONSE) {
const wifiInfo = msgpack.decode(payload, { codec: this.msgpackCodec });
this.onWifiStateInfo(wifiInfo);
} else if (opCode === OpCodes.LOG_UPDATE) {
const logMsg = msgpack.decode(payload, { codec: this.msgpackCodec });
this.onLogMessage(logMsg);
} else if (opCode === OpCodes.APP_LAYER_PING) {
//console.log("got heartbeat");
}
}
_onError = (ev) => {
console.log("Websocket error", ev);
}
};