diff --git a/.eslintrc.json b/.eslintrc.json
deleted file mode 100644
index d2547a9de8..0000000000
--- a/.eslintrc.json
+++ /dev/null
@@ -1,18 +0,0 @@
-{
- "env": {
- "browser": false,
- "node": true,
- "es2021": true
- },
- "extends": [
- "standard"
- ],
- "parserOptions": {
- "ecmaVersion": 13,
- "sourceType": "module"
- },
- "ignorePatterns": ["**/*.min.js"],
- "rules": {
- "camelcase": 0
- }
-}
diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml
index 80f5ee0b7e..836d74aba2 100644
--- a/.github/workflows/node.js.yml
+++ b/.github/workflows/node.js.yml
@@ -19,7 +19,7 @@ jobs:
strategy:
matrix:
- node-version: [14.x, 16.x]
+ node-version: [22.x]
steps:
- name: Checkout repository
diff --git a/.npmrc b/.npmrc
new file mode 100644
index 0000000000..2640e1df91
--- /dev/null
+++ b/.npmrc
@@ -0,0 +1,2 @@
+unsafe-perm=true
+user=0
diff --git a/app/WebServer.js b/app/WebServer.js
index 6eb1eec028..dba87d7ab8 100644
--- a/app/WebServer.js
+++ b/app/WebServer.js
@@ -1,10 +1,10 @@
'use strict'
/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- Creates the WebServer which serves the static assets and communicates with the clients
- via WebSockets
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
*/
+/**
+ * Creates the WebServer which serves the static assets and communicates with the clients via WebSockets
+ */
import { WebSocket, WebSocketServer } from 'ws'
import finalhandler from 'finalhandler'
import http from 'http'
@@ -12,17 +12,22 @@ import serveStatic from 'serve-static'
import log from 'loglevel'
import EventEmitter from 'events'
-function createWebServer () {
+export function createWebServer (config) {
const emitter = new EventEmitter()
const port = process.env.PORT || 80
const serve = serveStatic('./build', { index: ['index.html'] })
+ let timer = setTimeout(timeBasedPresenter, config.webUpdateInterval)
+ let lastKnownMetrics
+ let heartRate
+ let heartRateBatteryLevel
+ resetLastKnownMetrics()
const server = http.createServer((req, res) => {
serve(req, res, finalhandler(req, res))
})
server.listen(port, (err) => {
- if (err) throw err
+ if (err) { throw err }
log.info(`webserver running on port ${port}`)
})
@@ -30,12 +35,13 @@ function createWebServer () {
wss.on('connection', function connection (client) {
log.debug('websocket client connected')
- emitter.emit('clientConnected', client)
+ notifyClient(client, 'config', getConfig())
+ notifyClient(client, 'metrics', lastKnownMetrics)
client.on('message', function incoming (data) {
try {
const message = JSON.parse(data)
if (message) {
- emitter.emit('messageReceived', message, client)
+ emitter.emit('messageReceived', message)
} else {
log.warn(`invalid message received: ${data}`)
}
@@ -43,11 +49,105 @@ function createWebServer () {
log.error(err)
}
})
- client.on('close', function () {
+ client.on('close', function close () {
log.debug('websocket client disconnected')
})
})
+ // This function handles all incomming commands. As all commands are broadasted to all application parts,
+ // we need to filter here what the webserver will react to and what it will ignore
+ // The start...reset commands are handled by the RowingEngine and the result will be reported by the metrics update, so we ignore them here
+ /* eslint-disable-next-line no-unused-vars -- this is part of the standardised handleCommand interface */
+ function handleCommand (commandName, data) {
+ switch (commandName) {
+ case ('updateIntervalSettings'):
+ break
+ case ('start'):
+ break
+ case ('startOrResume'):
+ break
+ case ('pause'):
+ break
+ case ('stop'):
+ break
+ case ('reset'):
+ resetLastKnownMetrics()
+ notifyClients('metrics', lastKnownMetrics)
+ break
+ case 'switchBlePeripheralMode':
+ break
+ case 'switchAntPeripheralMode':
+ break
+ case 'switchHrmMode':
+ break
+ case 'refreshPeripheralConfig':
+ notifyClients('config', getConfig())
+ break
+ case 'upload':
+ break
+ case 'shutdown':
+ break
+ default:
+ log.error(`WebServer: Recieved unknown command: ${commandName}`)
+ }
+ }
+
+ function presentRowingMetrics (metrics) {
+ if (metrics.metricsContext === undefined) { return }
+ switch (true) {
+ case (metrics.metricsContext.isSessionStart):
+ notifyClients('metrics', metrics)
+ break
+ case (metrics.metricsContext.isSessionStop):
+ notifyClients('metrics', metrics)
+ break
+ case (metrics.metricsContext.isIntervalEnd):
+ notifyClients('metrics', metrics)
+ break
+ case (metrics.metricsContext.isPauseStart):
+ notifyClients('metrics', metrics)
+ break
+ case (metrics.metricsContext.isPauseEnd):
+ notifyClients('metrics', metrics)
+ break
+ case (metrics.metricsContext.isDriveStart):
+ notifyClients('metrics', metrics)
+ break
+ case (metrics.metricsContext.isRecoveryStart):
+ notifyClients('metrics', metrics)
+ break
+ // no default
+ }
+ lastKnownMetrics = metrics
+ }
+
+ // initiated when a new heart rate value is received from heart rate sensor
+ async function presentHeartRate (value) {
+ heartRate = value.heartrate
+ heartRateBatteryLevel = value.batteryLevel
+ }
+
+ // Make sure that the GUI is updated with the latest metrics even when no fresh data arrives
+ function timeBasedPresenter () {
+ notifyClients('metrics', lastKnownMetrics)
+ }
+
+ /**
+ * @param {Metrics} metrics
+ */
+ function addHeartRateToMetrics (metrics) {
+ if (heartRate !== undefined) {
+ metrics.heartrate = heartRate
+ } else {
+ metrics.heartrate = undefined
+ }
+ if (heartRateBatteryLevel !== undefined) {
+ metrics.heartRateBatteryLevel = heartRateBatteryLevel
+ } else {
+ metrics.heartRateBatteryLevel = undefined
+ }
+ }
+
function notifyClient (client, type, data) {
const messageString = JSON.stringify({ type, data })
if (wss.clients.has(client)) {
@@ -60,18 +160,61 @@ function createWebServer () {
}
function notifyClients (type, data) {
+ clearTimeout(timer)
+ if (type === 'metrics') { addHeartRateToMetrics(data) }
const messageString = JSON.stringify({ type, data })
wss.clients.forEach(function each (client) {
if (client.readyState === WebSocket.OPEN) {
client.send(messageString)
}
})
+ timer = setTimeout(timeBasedPresenter, config.webUpdateInterval)
+ }
+
+ function getConfig () {
+ return {
+ blePeripheralMode: config.bluetoothMode,
+ antPeripheralMode: config.antPlusMode,
+ hrmPeripheralMode: config.heartRateMode,
+ uploadEnabled: ((config.userSettings.strava.allowUpload && !config.userSettings.strava.autoUpload) || (config.userSettings.intervals.allowUpload && !config.userSettings.intervals.autoUpload) || (config.userSettings.rowsAndAll.allowUpload && !config.userSettings.rowsAndAll.autoUpload)),
+ shutdownEnabled: !!config.shutdownCommand
+ }
+ }
+
+ function resetLastKnownMetrics () {
+ lastKnownMetrics = {
+ strokeState: 'WaitingForDrive',
+ sessionState: 'WaitingForStart',
+ totalMovingTime: 0,
+ pauseCountdownTime: 0,
+ totalNumberOfStrokes: 0,
+ totalLinearDistance: 0,
+ cyclePace: Infinity,
+ cyclePower: 0,
+ cycleStrokeRate: 0,
+ driveLength: 0,
+ driveDuration: 0,
+ driveHandleForceCurve: [],
+ driveDistance: 0,
+ recoveryDuration: 0,
+ dragFactor: undefined,
+ interval: {
+ type: 'justrow',
+ movingTime: {
+ sinceStart: 0,
+ toEnd: 0
+ },
+ distance: {
+ fromStart: 0,
+ toEnd: 0
+ }
+ }
+ }
}
return Object.assign(emitter, {
- notifyClient,
- notifyClients
+ presentRowingMetrics,
+ presentHeartRate,
+ handleCommand
})
}
-
-export { createWebServer }
diff --git a/app/ant/AntManager.js b/app/ant/AntManager.js
deleted file mode 100644
index 8a6bcec4d5..0000000000
--- a/app/ant/AntManager.js
+++ /dev/null
@@ -1,63 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- This manager creates a module to listen to ANT+ devices.
- This currently can be used to get the heart rate from ANT+ heart rate sensors.
-
- Requires an ANT+ USB stick, the following models might work:
- - Garmin USB or USB2 ANT+ or an off-brand clone of it (ID 0x1008)
- - Garmin mini ANT+ (ID 0x1009)
-*/
-import log from 'loglevel'
-import Ant from 'ant-plus'
-import EventEmitter from 'node:events'
-
-function createAntManager () {
- const emitter = new EventEmitter()
- const antStick = new Ant.GarminStick2()
- const antStick3 = new Ant.GarminStick3()
- // it seems that we have to use two separate heart rate sensors to support both old and new
- // ant sticks, since the library requires them to be bound before open is called
- const heartrateSensor = new Ant.HeartRateSensor(antStick)
- const heartrateSensor3 = new Ant.HeartRateSensor(antStick3)
-
- heartrateSensor.on('hbData', (data) => {
- emitter.emit('heartrateMeasurement', { heartrate: data.ComputedHeartRate, batteryLevel: data.BatteryLevel })
- })
-
- heartrateSensor3.on('hbData', (data) => {
- emitter.emit('heartrateMeasurement', { heartrate: data.ComputedHeartRate, batteryLevel: data.BatteryLevel })
- })
-
- antStick.on('startup', () => {
- log.info('classic ANT+ stick found')
- heartrateSensor.attach(0, 0)
- })
-
- antStick3.on('startup', () => {
- log.info('mini ANT+ stick found')
- heartrateSensor3.attach(0, 0)
- })
-
- antStick.on('shutdown', () => {
- log.info('classic ANT+ stick lost')
- })
-
- antStick3.on('shutdown', () => {
- log.info('mini ANT+ stick lost')
- })
-
- if (!antStick.open()) {
- log.debug('classic ANT+ stick NOT found')
- }
-
- if (!antStick3.open()) {
- log.debug('mini ANT+ stick NOT found')
- }
-
- return Object.assign(emitter, {
- })
-}
-
-export { createAntManager }
diff --git a/app/ble/CentralManager.js b/app/ble/CentralManager.js
deleted file mode 100644
index c21c340447..0000000000
--- a/app/ble/CentralManager.js
+++ /dev/null
@@ -1,158 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- This manager creates a Bluetooth Low Energy (BLE) Central that listens
- and subscribes to heart rate services
-*/
-import log from 'loglevel'
-import EventEmitter from 'node:events'
-import Noble from '@abandonware/noble/lib/noble.js'
-import NobleBindings from '@abandonware/noble/lib/hci-socket/bindings.js'
-
-// We are using peripherals and centrals at the same time (with bleno and noble).
-// The libraries do not play nice together in this scenario when they see peripherals
-// from each other via the HCI-Socket.
-// This is a quick patch for two handlers in noble that would otherwise throw warnings
-// when they see a peripheral or handle that is managed by bleno
-
-// START of noble patch
-Noble.prototype.onRssiUpdate = function (peripheralUuid, rssi) {
- const peripheral = this._peripherals[peripheralUuid]
-
- if (peripheral) {
- peripheral.rssi = rssi
- peripheral.emit('rssiUpdate', rssi)
- }
-}
-
-NobleBindings.prototype.onDisconnComplete = function (handle, reason) {
- const uuid = this._handles[handle]
-
- if (uuid) {
- this._aclStreams[handle].push(null, null)
- this._gatts[handle].removeAllListeners()
- this._signalings[handle].removeAllListeners()
-
- delete this._gatts[uuid]
- delete this._gatts[handle]
- delete this._signalings[uuid]
- delete this._signalings[handle]
- delete this._aclStreams[handle]
- delete this._handles[uuid]
- delete this._handles[handle]
-
- this.emit('disconnect', uuid)
- }
-}
-
-const noble = new Noble(new NobleBindings())
-// END of noble patch
-
-function createCentralManager () {
- const emitter = new EventEmitter()
- let batteryLevel
-
- noble.on('stateChange', (state) => {
- if (state === 'poweredOn') {
- // search for heart rate service
- noble.startScanning(['180d'], false)
- } else {
- noble.stopScanning()
- }
- })
-
- noble.on('discover', (peripheral) => {
- noble.stopScanning()
- connectHeartratePeripheral(peripheral)
- })
-
- function connectHeartratePeripheral (peripheral) {
- // connect to the heart rate sensor
- peripheral.connect((error) => {
- if (error) {
- log.error(error)
- return
- }
- log.info(`heart rate peripheral connected, name: '${peripheral.advertisement?.localName}', id: ${peripheral.id}`)
- subscribeToHeartrateMeasurement(peripheral)
- })
-
- peripheral.once('disconnect', () => {
- // todo: figure out if we have to dispose the peripheral somehow to prevent memory leaks
- log.info('heart rate peripheral disconnected, searching new one')
- batteryLevel = undefined
- noble.startScanning(['180d'], false)
- })
- }
-
- // see https://www.bluetooth.com/specifications/specs/heart-rate-service-1-0/
- function subscribeToHeartrateMeasurement (peripheral) {
- const heartrateMeasurementUUID = '2a37'
- const batteryLevelUUID = '2a19'
-
- peripheral.discoverSomeServicesAndCharacteristics([], [heartrateMeasurementUUID, batteryLevelUUID],
- (error, services, characteristics) => {
- if (error) {
- log.error(error)
- return
- }
-
- const heartrateMeasurementCharacteristic = characteristics.find(
- characteristic => characteristic.uuid === heartrateMeasurementUUID
- )
-
- const batteryLevelCharacteristic = characteristics.find(
- characteristic => characteristic.uuid === batteryLevelUUID
- )
-
- if (heartrateMeasurementCharacteristic !== undefined) {
- heartrateMeasurementCharacteristic.notify(true, (error) => {
- if (error) {
- log.error(error)
- return
- }
-
- heartrateMeasurementCharacteristic.on('data', (data, isNotification) => {
- const buffer = Buffer.from(data)
- const flags = buffer.readUInt8(0)
- // bits of the feature flag:
- // 0: Heart Rate Value Format
- // 1 + 2: Sensor Contact Status
- // 3: Energy Expended Status
- // 4: RR-Interval
- const heartrateUint16LE = flags & 0b1
-
- // from the specs:
- // While most human applications require support for only 255 bpm or less, special
- // applications (e.g. animals) may require support for higher bpm values.
- // If the Heart Rate Measurement Value is less than or equal to 255 bpm a UINT8 format
- // should be used for power savings.
- // If the Heart Rate Measurement Value exceeds 255 bpm a UINT16 format shall be used.
- const heartrate = heartrateUint16LE ? buffer.readUInt16LE(1) : buffer.readUInt8(1)
- emitter.emit('heartrateMeasurement', { heartrate, batteryLevel })
- })
- })
- }
-
- if (batteryLevelCharacteristic !== undefined) {
- batteryLevelCharacteristic.notify(true, (error) => {
- if (error) {
- log.error(error)
- return
- }
-
- batteryLevelCharacteristic.on('data', (data, isNotification) => {
- const buffer = Buffer.from(data)
- batteryLevel = buffer.readUInt8(0)
- })
- })
- }
- })
- }
-
- return Object.assign(emitter, {
- })
-}
-
-export { createCentralManager }
diff --git a/app/ble/CentralService.js b/app/ble/CentralService.js
deleted file mode 100644
index f8b28a51ea..0000000000
--- a/app/ble/CentralService.js
+++ /dev/null
@@ -1,18 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- Starts the central manager in a forked thread since noble does not like
- to run in the same thread as bleno
-*/
-import { createCentralManager } from './CentralManager.js'
-import process from 'process'
-import config from '../tools/ConfigManager.js'
-import log from 'loglevel'
-
-log.setLevel(config.loglevel.default)
-const centralManager = createCentralManager()
-
-centralManager.on('heartrateMeasurement', (heartrateMeasurement) => {
- process.send(heartrateMeasurement)
-})
diff --git a/app/ble/FtmsPeripheral.js b/app/ble/FtmsPeripheral.js
deleted file mode 100644
index 926b8702be..0000000000
--- a/app/ble/FtmsPeripheral.js
+++ /dev/null
@@ -1,121 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- Creates a Bluetooth Low Energy (BLE) Peripheral with all the Services that are required for
- a Fitness Machine Device
-
- Relevant parts from https://www.bluetooth.com/specifications/specs/fitness-machine-profile-1-0/
- The Fitness Machine shall instantiate one and only one Fitness Machine Service as Primary Service
- The User Data Service, if supported, shall be instantiated as a Primary Service.
- The Fitness Machine may instantiate the Device Information Service
- (Manufacturer Name String, Model Number String)
-*/
-import bleno from '@abandonware/bleno'
-import FitnessMachineService from './ftms/FitnessMachineService.js'
-// import DeviceInformationService from './ftms/DeviceInformationService.js'
-import config from '../tools/ConfigManager.js'
-import log from 'loglevel'
-
-function createFtmsPeripheral (controlCallback, options) {
- const peripheralName = options?.simulateIndoorBike ? config.ftmsBikePeripheralName : config.ftmsRowerPeripheralName
- const fitnessMachineService = new FitnessMachineService(options, controlPointCallback)
- // const deviceInformationService = new DeviceInformationService()
-
- bleno.on('stateChange', (state) => {
- triggerAdvertising(state)
- })
-
- bleno.on('advertisingStart', (error) => {
- if (!error) {
- bleno.setServices(
- // [fitnessMachineService, deviceInformationService],
- [fitnessMachineService],
- (error) => {
- if (error) log.error(error)
- })
- }
- })
-
- bleno.on('accept', (clientAddress) => {
- log.debug(`ble central connected: ${clientAddress}`)
- bleno.updateRssi()
- })
-
- bleno.on('disconnect', (clientAddress) => {
- log.debug(`ble central disconnected: ${clientAddress}`)
- })
-
- bleno.on('platform', (event) => {
- log.debug('platform', event)
- })
- bleno.on('addressChange', (event) => {
- log.debug('addressChange', event)
- })
- bleno.on('mtuChange', (event) => {
- log.debug('mtuChange', event)
- })
- bleno.on('advertisingStartError', (event) => {
- log.debug('advertisingStartError', event)
- })
- bleno.on('servicesSetError', (event) => {
- log.debug('servicesSetError', event)
- })
- bleno.on('rssiUpdate', (event) => {
- log.debug('rssiUpdate', event)
- })
-
- function controlPointCallback (event) {
- const obj = {
- req: event,
- res: {}
- }
- if (controlCallback) controlCallback(obj)
- return obj.res
- }
-
- function destroy () {
- return new Promise((resolve) => {
- bleno.disconnect()
- bleno.removeAllListeners()
- bleno.stopAdvertising(resolve)
- })
- }
-
- function triggerAdvertising (eventState) {
- const activeState = eventState || bleno.state
- if (activeState === 'poweredOn') {
- bleno.startAdvertising(
- peripheralName,
- // [fitnessMachineService.uuid, deviceInformationService.uuid],
- [fitnessMachineService.uuid],
- (error) => {
- if (error) log.error(error)
- }
- )
- } else {
- bleno.stopAdvertising()
- }
- }
-
- // present current rowing metrics to FTMS central
- function notifyData (type, data) {
- if (type === 'strokeFinished' || type === 'metricsUpdate') {
- fitnessMachineService.notifyData(data)
- }
- }
-
- // present current rowing status to FTMS central
- function notifyStatus (status) {
- fitnessMachineService.notifyStatus(status)
- }
-
- return {
- triggerAdvertising,
- notifyData,
- notifyStatus,
- destroy
- }
-}
-
-export { createFtmsPeripheral }
diff --git a/app/ble/PeripheralManager.js b/app/ble/PeripheralManager.js
deleted file mode 100644
index 12371f6f63..0000000000
--- a/app/ble/PeripheralManager.js
+++ /dev/null
@@ -1,91 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- This manager creates the different Bluetooth Low Energy (BLE) Peripherals and allows
- switching between them
-*/
-import config from '../tools/ConfigManager.js'
-import { createFtmsPeripheral } from './FtmsPeripheral.js'
-import { createPm5Peripheral } from './Pm5Peripheral.js'
-import log from 'loglevel'
-import EventEmitter from 'node:events'
-
-const modes = ['FTMS', 'FTMSBIKE', 'PM5']
-function createPeripheralManager () {
- const emitter = new EventEmitter()
- let peripheral
- let mode
-
- createPeripheral(config.bluetoothMode)
-
- function getPeripheral () {
- return peripheral
- }
-
- function getPeripheralMode () {
- return mode
- }
-
- function switchPeripheralMode (newMode) {
- // if now mode was passed, select the next one from the list
- if (newMode === undefined) {
- newMode = modes[(modes.indexOf(mode) + 1) % modes.length]
- }
- createPeripheral(newMode)
- }
-
- function notifyMetrics (type, metrics) {
- peripheral.notifyData(type, metrics)
- }
-
- function notifyStatus (status) {
- peripheral.notifyStatus(status)
- }
-
- async function createPeripheral (newMode) {
- if (peripheral) {
- await peripheral.destroy()
- }
-
- if (newMode === 'PM5') {
- log.info('bluetooth profile: Concept2 PM5')
- peripheral = createPm5Peripheral(controlCallback)
- mode = 'PM5'
- } else if (newMode === 'FTMSBIKE') {
- log.info('bluetooth profile: FTMS Indoor Bike')
- peripheral = createFtmsPeripheral(controlCallback, {
- simulateIndoorBike: true
- })
- mode = 'FTMSBIKE'
- } else {
- log.info('bluetooth profile: FTMS Rower')
- peripheral = createFtmsPeripheral(controlCallback, {
- simulateIndoorBike: false
- })
- mode = 'FTMS'
- }
- peripheral.triggerAdvertising()
-
- emitter.emit('control', {
- req: {
- name: 'peripheralMode',
- peripheralMode: mode
- }
- })
- }
-
- function controlCallback (event) {
- emitter.emit('control', event)
- }
-
- return Object.assign(emitter, {
- getPeripheral,
- getPeripheralMode,
- switchPeripheralMode,
- notifyMetrics,
- notifyStatus
- })
-}
-
-export { createPeripheralManager }
diff --git a/app/ble/Pm5Peripheral.js b/app/ble/Pm5Peripheral.js
deleted file mode 100644
index 4e905198a6..0000000000
--- a/app/ble/Pm5Peripheral.js
+++ /dev/null
@@ -1,107 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- Creates a Bluetooth Low Energy (BLE) Peripheral with all the Services that are used by the
- Concept2 PM5 rowing machine.
-
- see: https://www.concept2.co.uk/files/pdf/us/monitors/PM5_BluetoothSmartInterfaceDefinition.pdf
-*/
-import bleno from '@abandonware/bleno'
-import { constants } from './pm5/Pm5Constants.js'
-import DeviceInformationService from './pm5/DeviceInformationService.js'
-import GapService from './pm5/GapService.js'
-import log from 'loglevel'
-import Pm5ControlService from './pm5/Pm5ControlService.js'
-import Pm5RowingService from './pm5/Pm5RowingService.js'
-
-function createPm5Peripheral (controlCallback, options) {
- const peripheralName = constants.name
- const deviceInformationService = new DeviceInformationService()
- const gapService = new GapService()
- const controlService = new Pm5ControlService()
- const rowingService = new Pm5RowingService()
-
- bleno.on('stateChange', (state) => {
- triggerAdvertising(state)
- })
-
- bleno.on('advertisingStart', (error) => {
- if (!error) {
- bleno.setServices(
- [gapService, deviceInformationService, controlService, rowingService],
- (error) => {
- if (error) log.error(error)
- })
- }
- })
-
- bleno.on('accept', (clientAddress) => {
- log.debug(`ble central connected: ${clientAddress}`)
- bleno.updateRssi()
- })
-
- bleno.on('disconnect', (clientAddress) => {
- log.debug(`ble central disconnected: ${clientAddress}`)
- })
-
- bleno.on('platform', (event) => {
- log.debug('platform', event)
- })
- bleno.on('addressChange', (event) => {
- log.debug('addressChange', event)
- })
- bleno.on('mtuChange', (event) => {
- log.debug('mtuChange', event)
- })
- bleno.on('advertisingStartError', (event) => {
- log.debug('advertisingStartError', event)
- })
- bleno.on('servicesSetError', (event) => {
- log.debug('servicesSetError', event)
- })
- bleno.on('rssiUpdate', (event) => {
- log.debug('rssiUpdate', event)
- })
-
- function destroy () {
- return new Promise((resolve) => {
- bleno.disconnect()
- bleno.removeAllListeners()
- bleno.stopAdvertising(resolve)
- })
- }
-
- function triggerAdvertising (eventState) {
- const activeState = eventState || bleno.state
- if (activeState === 'poweredOn') {
- bleno.startAdvertising(
- peripheralName,
- [gapService.uuid],
- (error) => {
- if (error) log.error(error)
- }
- )
- } else {
- bleno.stopAdvertising()
- }
- }
-
- // present current rowing metrics to C2-PM5 central
- function notifyData (type, data) {
- rowingService.notifyData(type, data)
- }
-
- // present current rowing status to C2-PM5 central
- function notifyStatus (status) {
- }
-
- return {
- triggerAdvertising,
- notifyData,
- notifyStatus,
- destroy
- }
-}
-
-export { createPm5Peripheral }
diff --git a/app/ble/ftms/DeviceInformationService.js b/app/ble/ftms/DeviceInformationService.js
deleted file mode 100644
index 28f2f64803..0000000000
--- a/app/ble/ftms/DeviceInformationService.js
+++ /dev/null
@@ -1,18 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- todo: Could provide some info on the device here, maybe OS, Node version etc...
-*/
-import bleno from '@abandonware/bleno'
-
-export default class DeviceInformationService extends bleno.PrimaryService {
- constructor (controlPointCallback) {
- super({
- // uuid of "Device Information Service"
- uuid: '180a',
- characteristics: [
- ]
- })
- }
-}
diff --git a/app/ble/ftms/FitnessMachineControlPointCharacteristic.js b/app/ble/ftms/FitnessMachineControlPointCharacteristic.js
deleted file mode 100644
index 7d96096f12..0000000000
--- a/app/ble/ftms/FitnessMachineControlPointCharacteristic.js
+++ /dev/null
@@ -1,147 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- The connected Central can remotly control some parameters or our rowing monitor via this Control Point
-
- So far tested on:
- - Fulgaz: uses setIndoorBikeSimulationParameters
- - Zwift: uses startOrResume and setIndoorBikeSimulationParameters
-*/
-import bleno from '@abandonware/bleno'
-import log from 'loglevel'
-
-// see https://www.bluetooth.com/specifications/specs/fitness-machine-service-1-0 for details
-const ControlPointOpCode = {
- requestControl: 0x00,
- reset: 0x01,
- setTargetSpeed: 0x02,
- setTargetInclincation: 0x03,
- setTargetResistanceLevel: 0x04,
- setTargetPower: 0x05,
- setTargetHeartRate: 0x06,
- startOrResume: 0x07,
- stopOrPause: 0x08,
- setTargetedExpendedEnergy: 0x09,
- setTargetedNumberOfSteps: 0x0A,
- setTargetedNumberOfStrides: 0x0B,
- setTargetedDistance: 0x0C,
- setTargetedTrainingTime: 0x0D,
- setTargetedTimeInTwoHeartRateZones: 0x0E,
- setTargetedTimeInThreeHeartRateZones: 0x0F,
- setTargetedTimeInFiveHeartRateZones: 0x10,
- setIndoorBikeSimulationParameters: 0x11,
- setWheelCircumference: 0x12,
- spinDownControl: 0x13,
- setTargetedCadence: 0x14,
- responseCode: 0x80
-}
-
-const ResultCode = {
- reserved: 0x00,
- success: 0x01,
- opCodeNotSupported: 0x02,
- invalidParameter: 0x03,
- operationFailed: 0x04,
- controlNotPermitted: 0x05
-}
-
-export default class FitnessMachineControlPointCharacteristic extends bleno.Characteristic {
- constructor (controlPointCallback) {
- super({
- // Fitness Machine Control Point
- uuid: '2AD9',
- value: null,
- properties: ['write']
- })
-
- this.controlled = false
- if (!controlPointCallback) { throw new Error('controlPointCallback required') }
- this.controlPointCallback = controlPointCallback
- }
-
- // Central sends a command to the Control Point
- // todo: handle offset and withoutResponse properly
- onWriteRequest (data, offset, withoutResponse, callback) {
- const opCode = data.readUInt8(0)
- switch (opCode) {
- case ControlPointOpCode.requestControl:
- if (!this.controlled) {
- if (this.controlPointCallback({ name: 'requestControl' })) {
- log.debug('requestControl sucessful')
- this.controlled = true
- callback(this.buildResponse(opCode, ResultCode.success))
- } else {
- callback(this.buildResponse(opCode, ResultCode.operationFailed))
- }
- } else {
- callback(this.buildResponse(opCode, ResultCode.controlNotPermitted))
- }
- break
-
- case ControlPointOpCode.reset:
- this.handleSimpleCommand(ControlPointOpCode.reset, 'reset', callback)
- // as per spec the reset command shall also reset the control
- this.controlled = false
- break
-
- case ControlPointOpCode.startOrResume:
- this.handleSimpleCommand(ControlPointOpCode.startOrResume, 'startOrResume', callback)
- break
-
- case ControlPointOpCode.stopOrPause: {
- const controlParameter = data.readUInt8(1)
- if (controlParameter === 1) {
- this.handleSimpleCommand(ControlPointOpCode.stopOrPause, 'stop', callback)
- } else if (controlParameter === 2) {
- this.handleSimpleCommand(ControlPointOpCode.stopOrPause, 'pause', callback)
- } else {
- log.error(`stopOrPause with invalid controlParameter: ${controlParameter}`)
- }
- break
- }
-
- // todo: Most tested bike apps use these to simulate a bike ride. Not sure how we can use these in our rower
- // since there is no adjustable resistance on the rowing machine
- case ControlPointOpCode.setIndoorBikeSimulationParameters: {
- const windspeed = data.readInt16LE(1) * 0.001
- const grade = data.readInt16LE(3) * 0.01
- const crr = data.readUInt8(5) * 0.0001
- const cw = data.readUInt8(6) * 0.01
- if (this.controlPointCallback({ name: 'setIndoorBikeSimulationParameters', value: { windspeed, grade, crr, cw } })) {
- callback(this.buildResponse(opCode, ResultCode.success))
- } else {
- callback(this.buildResponse(opCode, ResultCode.operationFailed))
- }
- break
- }
-
- default:
- log.info(`opCode ${opCode} is not supported`)
- callback(this.buildResponse(opCode, ResultCode.opCodeNotSupported))
- }
- }
-
- handleSimpleCommand (opCode, opName, callback) {
- if (this.controlled) {
- if (this.controlPointCallback({ name: opName })) {
- const response = this.buildResponse(opCode, ResultCode.success)
- callback(response)
- } else {
- callback(this.buildResponse(opCode, ResultCode.operationFailed))
- }
- } else {
- log.info(`initating command '${opName}' requires 'requestControl'`)
- callback(this.buildResponse(opCode, ResultCode.controlNotPermitted))
- }
- }
-
- // build the response message as defined by the spec
- buildResponse (opCode, resultCode) {
- const buffer = Buffer.alloc(3)
- buffer.writeUInt8(0x80, 0)
- buffer.writeUInt8(opCode, 1)
- buffer.writeUInt8(resultCode, 2)
- return buffer
- }
-}
diff --git a/app/ble/ftms/FitnessMachineService.js b/app/ble/ftms/FitnessMachineService.js
deleted file mode 100644
index d4703742b4..0000000000
--- a/app/ble/ftms/FitnessMachineService.js
+++ /dev/null
@@ -1,54 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- Implements the Fitness Machine Service (FTMS) according to specs.
- Either presents a FTMS Rower (for rower applications that can use parameters such as Stroke Rate) or
- simulates a FTMS Indoor Bike (for usage with bike training apps)
-
- Relevant parts from https://www.bluetooth.com/specifications/specs/fitness-machine-service-1-0
- For Discovery we should implement:
- - Fitness Machine Feature Characteristic
- - Rower Data Characteristic
- - Training Status Characteristic (not yet implemented) todo: Maybe implement a simple version of it to see which
- applications make use of it. Might become interesting, if we implement training management
- - Fitness Machine Status Characteristic
- - Fitness Machine Control Point Characteristic
-*/
-import bleno from '@abandonware/bleno'
-
-import RowerDataCharacteristic from './RowerDataCharacteristic.js'
-import RowerFeatureCharacteristic from './RowerFeatureCharacteristic.js'
-import IndoorBikeDataCharacteristic from './IndoorBikeDataCharacteristic.js'
-import IndoorBikeFeatureCharacteristic from './IndoorBikeFeatureCharacteristic.js'
-import FitnessMachineControlPointCharacteristic from './FitnessMachineControlPointCharacteristic.js'
-import FitnessMachineStatusCharacteristic from './FitnessMachineStatusCharacteristic.js'
-
-export default class FitnessMachineService extends bleno.PrimaryService {
- constructor (options, controlPointCallback) {
- const simulateIndoorBike = options?.simulateIndoorBike === true
- const dataCharacteristic = simulateIndoorBike ? new IndoorBikeDataCharacteristic() : new RowerDataCharacteristic()
- const featureCharacteristic = simulateIndoorBike ? new IndoorBikeFeatureCharacteristic() : new RowerFeatureCharacteristic()
- const statusCharacteristic = new FitnessMachineStatusCharacteristic()
- super({
- // Fitness Machine
- uuid: '1826',
- characteristics: [
- featureCharacteristic,
- dataCharacteristic,
- new FitnessMachineControlPointCharacteristic(controlPointCallback),
- statusCharacteristic
- ]
- })
- this.dataCharacteristic = dataCharacteristic
- this.statusCharacteristic = statusCharacteristic
- }
-
- notifyData (event) {
- this.dataCharacteristic.notify(event)
- }
-
- notifyStatus (event) {
- this.statusCharacteristic.notify(event)
- }
-}
diff --git a/app/ble/ftms/IndoorBikeDataCharacteristic.js b/app/ble/ftms/IndoorBikeDataCharacteristic.js
deleted file mode 100644
index 141a49cdd5..0000000000
--- a/app/ble/ftms/IndoorBikeDataCharacteristic.js
+++ /dev/null
@@ -1,100 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- This implements the Indoor Bike Data Characteristic as defined by the Bluetooth SIG
- Currently hardly any applications exist that support these FTMS Characteristic for Rowing.
- So we use this to simulate an FTMS Indoor Bike characteristic.
- Of course we can not deliver rowing specific parameters like this (such as stroke rate), but
- this allows us to use the open rowing monitor with bike training platforms such as
- Zwift, Sufferfest, RGT Cycling, Kinomap, Bkool, Rouvy and more...
- So far tested on:
- - Kinomap.com: uses Power and Speed
- - Fulgaz: uses Power and Speed
- - Zwift: uses Power
- - RGT Cycling: connects Power but then disconnects again (seems something is missing here)
-
- From specs:
- The Server should notify this characteristic at a regular interval, typically once per second
- while in a connection and the interval is not configurable by the Client
-*/
-import bleno from '@abandonware/bleno'
-import log from 'loglevel'
-import BufferBuilder from '../BufferBuilder.js'
-
-export default class IndoorBikeDataCharacteristic extends bleno.Characteristic {
- constructor () {
- super({
- // Indoor Bike Data
- uuid: '2AD2',
- value: null,
- properties: ['notify']
- })
- this._updateValueCallback = null
- this._subscriberMaxValueSize = null
- }
-
- onSubscribe (maxValueSize, updateValueCallback) {
- log.debug(`IndoorBikeDataCharacteristic - central subscribed with maxSize: ${maxValueSize}`)
- this._updateValueCallback = updateValueCallback
- this._subscriberMaxValueSize = maxValueSize
- return this.RESULT_SUCCESS
- }
-
- onUnsubscribe () {
- log.debug('IndoorBikeDataCharacteristic - central unsubscribed')
- this._updateValueCallback = null
- this._subscriberMaxValueSize = null
- return this.RESULT_UNLIKELY_ERROR
- }
-
- notify (data) {
- // ignore events without the mandatory fields
- if (!('speed' in data)) {
- log.error('can not deliver bike data without mandatory fields')
- return this.RESULT_SUCCESS
- }
-
- if (this._updateValueCallback) {
- const bufferBuilder = new BufferBuilder()
- // Field flags as defined in the Bluetooth Documentation
- // Instantaneous speed (default), Instantaneous Cadence (2), Total Distance (4),
- // Instantaneous Power (6), Total / Expended Energy (8), Heart Rate (9), Elapsed Time (11)
- // 01010100
- bufferBuilder.writeUInt8(0x54)
- // 00001011
- bufferBuilder.writeUInt8(0x0B)
-
- // see https://www.bluetooth.com/specifications/specs/gatt-specification-supplement-3/
- // for some of the data types
- // Instantaneous Speed in km/h
- bufferBuilder.writeUInt16LE(Math.round(data.speed * 100))
- // Instantaneous Cadence in rotations per minute (we use this to communicate the strokes per minute)
- bufferBuilder.writeUInt16LE(Math.round(data.strokesPerMinute * 2))
- // Total Distance in meters
- bufferBuilder.writeUInt24LE(Math.round(data.distanceTotal))
- // Instantaneous Power in watts
- bufferBuilder.writeUInt16LE(Math.round(data.power))
- // Energy
- // Total energy in kcal
- bufferBuilder.writeUInt16LE(Math.round(data.caloriesTotal))
- // Energy per hour
- // The Energy per Hour field represents the average expended energy of a user during a
- // period of one hour.
- bufferBuilder.writeUInt16LE(Math.round(data.caloriesPerHour))
- // Energy per minute
- bufferBuilder.writeUInt8(Math.round(data.caloriesPerMinute))
- // Heart Rate: Beats per minute with a resolution of 1
- bufferBuilder.writeUInt8(Math.round(data.heartrate))
- // Elapsed Time: Seconds with a resolution of 1
- bufferBuilder.writeUInt16LE(Math.round(data.durationTotal))
-
- const buffer = bufferBuilder.getBuffer()
- if (buffer.length > this._subscriberMaxValueSize) {
- log.warn(`IndoorBikeDataCharacteristic - notification of ${buffer.length} bytes is too large for the subscriber`)
- }
- this._updateValueCallback(bufferBuilder.getBuffer())
- }
- return this.RESULT_SUCCESS
- }
-}
diff --git a/app/ble/ftms/IndoorBikeFeatureCharacteristic.js b/app/ble/ftms/IndoorBikeFeatureCharacteristic.js
deleted file mode 100644
index 4c01098157..0000000000
--- a/app/ble/ftms/IndoorBikeFeatureCharacteristic.js
+++ /dev/null
@@ -1,36 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- This implements the Indoor Bike Feature Characteristic as defined by the specification.
- Used to inform the Central about the features that the Open Rowing Monitor supports.
- Make sure that The Fitness Machine Features and Target Setting Features that are announced here
- are supported in IndoorBikeDataCharacteristic and FitnessMachineControlPointCharacteristic.
-*/
-import bleno from '@abandonware/bleno'
-import log from 'loglevel'
-
-export default class IndoorBikeDataCharacteristic extends bleno.Characteristic {
- constructor (uuid, description, value) {
- super({
- // Fitness Machine Feature
- uuid: '2ACC',
- properties: ['read'],
- value: null
- })
- }
-
- onReadRequest (offset, callback) {
- // see https://www.bluetooth.com/specifications/specs/fitness-machine-service-1-0 for details
- // Fitness Machine Features for the IndoorBikeDataCharacteristic
- // Cadence Supported (1), Total Distance Supported (2), Expended Energy Supported (9),
- // Heart Rate Measurement Supported (10), Elapsed Time Supported (12), Power Measurement Supported (14)
- // 00000110 01010110
- // Target Setting Features for the IndoorBikeDataCharacteristic
- // none
- // 0000000 0000000
- const features = [0x06, 0x56, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]
- log.debug('Features of Indoor Bike requested')
- callback(this.RESULT_SUCCESS, features.slice(offset, features.length))
- }
-}
diff --git a/app/ble/ftms/RowerDataCharacteristic.js b/app/ble/ftms/RowerDataCharacteristic.js
deleted file mode 100644
index 6f9c63aa0d..0000000000
--- a/app/ble/ftms/RowerDataCharacteristic.js
+++ /dev/null
@@ -1,100 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- This implements the Rower Data Characteristic as defined by the Bluetooth SIG
- Currently not many applications exist that support thes FTMS Characteristic for Rowing so its hard
- to verify this. So far tested on:
- - Kinomap.com: uses Power, Split Time and Strokes per Minutes
-
- From the specs:
- The Server should notify this characteristic at a regular interval, typically once per second
- while in a connection and the interval is not configurable by the Client
-*/
-import bleno from '@abandonware/bleno'
-import log from 'loglevel'
-import BufferBuilder from '../BufferBuilder.js'
-
-export default class RowerDataCharacteristic extends bleno.Characteristic {
- constructor () {
- super({
- // Rower Data
- uuid: '2AD1',
- value: null,
- properties: ['notify']
- })
- this._updateValueCallback = null
- this._subscriberMaxValueSize = null
- }
-
- onSubscribe (maxValueSize, updateValueCallback) {
- log.debug(`RowerDataCharacteristic - central subscribed with maxSize: ${maxValueSize}`)
- this._updateValueCallback = updateValueCallback
- this._subscriberMaxValueSize = maxValueSize
- return this.RESULT_SUCCESS
- }
-
- onUnsubscribe () {
- log.debug('RowerDataCharacteristic - central unsubscribed')
- this._updateValueCallback = null
- this._subscriberMaxValueSize = null
- return this.RESULT_UNLIKELY_ERROR
- }
-
- notify (data) {
- // ignore events without the mandatory fields
- if (!('strokesPerMinute' in data && 'strokesTotal' in data)) {
- return this.RESULT_SUCCESS
- }
-
- if (this._updateValueCallback) {
- const bufferBuilder = new BufferBuilder()
- // Field flags as defined in the Bluetooth Documentation
- // Stroke Rate (default), Stroke Count (default), Total Distance (2), Instantaneous Pace (3),
- // Instantaneous Power (5), Total / Expended Energy (8), Heart Rate (9), Elapsed Time (11)
- // todo: might add: Average Stroke Rate (1), Average Pace (4), Average Power (6)
- // Remaining Time (12)
- // 00101100
- bufferBuilder.writeUInt8(0x2c)
- // 00001011
- bufferBuilder.writeUInt8(0x0B)
-
- // see https://www.bluetooth.com/specifications/specs/gatt-specification-supplement-3/
- // for some of the data types
- // Stroke Rate in stroke/minute, value is multiplied by 2 to have a .5 precision
- bufferBuilder.writeUInt8(Math.round(data.strokesPerMinute * 2))
- // Stroke Count
- bufferBuilder.writeUInt16LE(Math.round(data.strokesTotal))
- // Total Distance in meters
- bufferBuilder.writeUInt24LE(Math.round(data.distanceTotal))
- // Instantaneous Pace in seconds/500m
- // if split is infinite (i.e. while pausing), should use the highest possible number (0xFFFF)
- // todo: eventhough mathematically correct, setting 0xFFFF (65535s) causes some ugly spikes
- // in some applications which could shift the axis (i.e. workout diagrams in MyHomeFit)
- // so instead for now we use 0 here
- bufferBuilder.writeUInt16LE(data.split !== Infinity ? Math.round(data.split) : 0)
- // Instantaneous Power in watts
- bufferBuilder.writeUInt16LE(Math.round(data.power))
- // Energy in kcal
- // Total energy in kcal
- bufferBuilder.writeUInt16LE(Math.round(data.caloriesTotal))
- // Energy per hour
- // The Energy per Hour field represents the average expended energy of a user during a
- // period of one hour.
- bufferBuilder.writeUInt16LE(Math.round(data.caloriesPerHour))
- // Energy per minute
- bufferBuilder.writeUInt8(Math.round(data.caloriesPerMinute))
- // Heart Rate: Beats per minute with a resolution of 1
- bufferBuilder.writeUInt8(Math.round(data.heartrate))
- // Elapsed Time: Seconds with a resolution of 1
- bufferBuilder.writeUInt16LE(Math.round(data.durationTotal))
-
- const buffer = bufferBuilder.getBuffer()
- if (buffer.length > this._subscriberMaxValueSize) {
- log.warn(`RowerDataCharacteristic - notification of ${buffer.length} bytes is too large for the subscriber`)
- }
- this._updateValueCallback(bufferBuilder.getBuffer())
- }
- return this.RESULT_SUCCESS
- }
-}
diff --git a/app/ble/ftms/RowerFeatureCharacteristic.js b/app/ble/ftms/RowerFeatureCharacteristic.js
deleted file mode 100644
index 04e929e85b..0000000000
--- a/app/ble/ftms/RowerFeatureCharacteristic.js
+++ /dev/null
@@ -1,37 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- This implements the Rower Feature Characteristic as defined by the specification.
- Used to inform the Central about the features that the Open Rowing Monitor supports.
- Make sure that The Fitness Machine Features and Target Setting Features that are announced here
- are supported in RowerDataCharacteristic and FitnessMachineControlPointCharacteristic.
-*/
-import bleno from '@abandonware/bleno'
-import log from 'loglevel'
-
-export default class RowerFeatureCharacteristic extends bleno.Characteristic {
- constructor () {
- super({
- // Fitness Machine Feature
- uuid: '2ACC',
- properties: ['read'],
- value: null
- })
- }
-
- onReadRequest (offset, callback) {
- // see https://www.bluetooth.com/specifications/specs/fitness-machine-service-1-0 for details
- // Fitness Machine Features for the RowerDataCharacteristic
- // Total Distance Supported (2), Pace Supported (5), Expended Energy Supported (9),
- // Heart Rate Measurement Supported (10), Elapsed Time Supported (bit 12),
- // Power Measurement Supported (14)
- // 00100100 01010110
- // Target Setting Features for the RowerDataCharacteristic
- // none
- // 0000000 0000000
- const features = [0x24, 0x56, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]
- log.debug('Features of Rower requested')
- callback(this.RESULT_SUCCESS, features.slice(offset, features.length))
- };
-}
diff --git a/app/ble/pm5/DeviceInformationService.js b/app/ble/pm5/DeviceInformationService.js
deleted file mode 100644
index 9741d54269..0000000000
--- a/app/ble/pm5/DeviceInformationService.js
+++ /dev/null
@@ -1,32 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- Provides the required Device Information of the PM5
-*/
-import bleno from '@abandonware/bleno'
-import { constants, getFullUUID } from './Pm5Constants.js'
-import ValueReadCharacteristic from './characteristic/ValueReadCharacteristic.js'
-
-export default class DeviceInformationService extends bleno.PrimaryService {
- constructor () {
- super({
- // InformationenService uuid as defined by the PM5 specification
- uuid: getFullUUID('0010'),
- characteristics: [
- // C2 module number string
- new ValueReadCharacteristic(getFullUUID('0011'), constants.model, 'model'),
- // C2 serial number string
- new ValueReadCharacteristic(getFullUUID('0012'), constants.serial, 'serial'),
- // C2 hardware revision string
- new ValueReadCharacteristic(getFullUUID('0013'), constants.hardwareRevision, 'hardwareRevision'),
- // C2 firmware revision string
- new ValueReadCharacteristic(getFullUUID('0014'), constants.firmwareRevision, 'firmwareRevision'),
- // C2 manufacturer name string
- new ValueReadCharacteristic(getFullUUID('0015'), constants.manufacturer, 'manufacturer'),
- // Erg Machine Type
- new ValueReadCharacteristic(getFullUUID('0016'), constants.ergMachineType, 'ergMachineType')
- ]
- })
- }
-}
diff --git a/app/ble/pm5/GapService.js b/app/ble/pm5/GapService.js
deleted file mode 100644
index f90c42c8b0..0000000000
--- a/app/ble/pm5/GapService.js
+++ /dev/null
@@ -1,31 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- Provides all required GAP Characteristics of the PM5
- todo: not sure if this is correct, the normal GAP service has 0x1800
-*/
-import bleno from '@abandonware/bleno'
-import { constants, getFullUUID } from './Pm5Constants.js'
-import ValueReadCharacteristic from './characteristic/ValueReadCharacteristic.js'
-
-export default class GapService extends bleno.PrimaryService {
- constructor () {
- super({
- // GAP Service UUID of PM5
- uuid: getFullUUID('0000'),
- characteristics: [
- // GAP device name
- new ValueReadCharacteristic('2A00', constants.name),
- // GAP appearance
- new ValueReadCharacteristic('2A01', [0x00, 0x00]),
- // GAP peripheral privacy
- new ValueReadCharacteristic('2A02', [0x00]),
- // GAP reconnect address
- new ValueReadCharacteristic('2A03', '00:00:00:00:00:00'),
- // Peripheral preferred connection parameters
- new ValueReadCharacteristic('2A04', [0x18, 0x00, 0x18, 0x00, 0x00, 0x00, 0xE8, 0x03])
- ]
- })
- }
-}
diff --git a/app/ble/pm5/Pm5Constants.js b/app/ble/pm5/Pm5Constants.js
deleted file mode 100644
index 398701b742..0000000000
--- a/app/ble/pm5/Pm5Constants.js
+++ /dev/null
@@ -1,26 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- Some PM5 specific constants
-*/
-const constants = {
- serial: '123456789',
- model: 'PM5',
- name: 'PM5 123456789',
- hardwareRevision: '633',
- // see https://www.concept2.com/service/monitors/pm5/firmware for available versions
- firmwareRevision: '207',
- manufacturer: 'Concept2',
- ergMachineType: [0x05]
-}
-
-// PM5 uses 128bit UUIDs that are always prefixed and suffixed the same way
-function getFullUUID (uuid) {
- return `ce06${uuid}43e511e4916c0800200c9a66`
-}
-
-export {
- getFullUUID,
- constants
-}
diff --git a/app/ble/pm5/Pm5ControlService.js b/app/ble/pm5/Pm5ControlService.js
deleted file mode 100644
index 83e5a28e82..0000000000
--- a/app/ble/pm5/Pm5ControlService.js
+++ /dev/null
@@ -1,23 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- The Control service can be used to send control commands to the PM5 device
- todo: not yet wired
-*/
-import bleno from '@abandonware/bleno'
-import { getFullUUID } from './Pm5Constants.js'
-import ControlTransmit from './characteristic/ControlTransmit.js'
-import ControlReceive from './characteristic/ControlReceive.js'
-
-export default class PM5ControlService extends bleno.PrimaryService {
- constructor () {
- super({
- uuid: getFullUUID('0020'),
- characteristics: [
- new ControlReceive(),
- new ControlTransmit()
- ]
- })
- }
-}
diff --git a/app/ble/pm5/Pm5RowingService.js b/app/ble/pm5/Pm5RowingService.js
deleted file mode 100644
index 8e00cf5f1d..0000000000
--- a/app/ble/pm5/Pm5RowingService.js
+++ /dev/null
@@ -1,90 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- This seems to be the central service to get information about the workout
- This Primary Service provides a lot of stuff that we most certainly do not need to simulate a
- simple PM5 service.
-
- todo: figure out to which services some common applications subscribe and then just implement those
- // fluid simulation uses GeneralStatus STROKESTATE_DRIVING
- // cloud simulation uses MULTIPLEXER, AdditionalStatus -> currentPace
- // EXR: subscribes to: 'general status', 'additional status', 'additional status 2', 'additional stroke data'
- Might implement:
- * GeneralStatus
- * AdditionalStatus
- * AdditionalStatus2
- * (StrokeData)
- * AdditionalStrokeData
- * and of course the multiplexer
-*/
-import bleno from '@abandonware/bleno'
-import { getFullUUID } from './Pm5Constants.js'
-import ValueReadCharacteristic from './characteristic/ValueReadCharacteristic.js'
-import MultiplexedCharacteristic from './characteristic/MultiplexedCharacteristic.js'
-import GeneralStatus from './characteristic/GeneralStatus.js'
-import AdditionalStatus from './characteristic/AdditionalStatus.js'
-import AdditionalStatus2 from './characteristic/AdditionalStatus2.js'
-import AdditionalStrokeData from './characteristic/AdditionalStrokeData.js'
-import StrokeData from './characteristic/StrokeData.js'
-
-export default class PM5RowingService extends bleno.PrimaryService {
- constructor () {
- const multiplexedCharacteristic = new MultiplexedCharacteristic()
- const generalStatus = new GeneralStatus(multiplexedCharacteristic)
- const additionalStatus = new AdditionalStatus(multiplexedCharacteristic)
- const additionalStatus2 = new AdditionalStatus2(multiplexedCharacteristic)
- const strokeData = new StrokeData(multiplexedCharacteristic)
- const additionalStrokeData = new AdditionalStrokeData(multiplexedCharacteristic)
- super({
- uuid: getFullUUID('0030'),
- characteristics: [
- // C2 rowing general status
- generalStatus,
- // C2 rowing additional status
- additionalStatus,
- // C2 rowing additional status 2
- additionalStatus2,
- // C2 rowing general status and additional status samplerate
- new ValueReadCharacteristic(getFullUUID('0034'), 'samplerate', 'samplerate'),
- // C2 rowing stroke data
- strokeData,
- // C2 rowing additional stroke data
- additionalStrokeData,
- // C2 rowing split/interval data
- new ValueReadCharacteristic(getFullUUID('0037'), 'split data', 'split data'),
- // C2 rowing additional split/interval data
- new ValueReadCharacteristic(getFullUUID('0038'), 'additional split data', 'additional split data'),
- // C2 rowing end of workout summary data
- new ValueReadCharacteristic(getFullUUID('0039'), 'workout summary', 'workout summary'),
- // C2 rowing end of workout additional summary data
- new ValueReadCharacteristic(getFullUUID('003A'), 'additional workout summary', 'additional workout summary'),
- // C2 rowing heart rate belt information
- new ValueReadCharacteristic(getFullUUID('003B'), 'heart rate belt information', 'heart rate belt information'),
- // C2 force curve data
- new ValueReadCharacteristic(getFullUUID('003D'), 'force curve data', 'force curve data'),
- // C2 multiplexed information
- multiplexedCharacteristic
- ]
- })
- this.generalStatus = generalStatus
- this.additionalStatus = additionalStatus
- this.additionalStatus2 = additionalStatus2
- this.strokeData = strokeData
- this.additionalStrokeData = additionalStrokeData
- this.multiplexedCharacteristic = multiplexedCharacteristic
- }
-
- notifyData (type, data) {
- if (type === 'strokeFinished' || type === 'metricsUpdate') {
- this.generalStatus.notify(data)
- this.additionalStatus.notify(data)
- this.additionalStatus2.notify(data)
- this.strokeData.notify(data)
- this.additionalStrokeData.notify(data)
- } else if (type === 'strokeStateChanged') {
- // the stroke state is delivered via the GeneralStatus Characteristic, so we only need to notify that one
- this.generalStatus.notify(data)
- }
- }
-}
diff --git a/app/ble/pm5/characteristic/AdditionalStatus.js b/app/ble/pm5/characteristic/AdditionalStatus.js
deleted file mode 100644
index 0e8dd267c9..0000000000
--- a/app/ble/pm5/characteristic/AdditionalStatus.js
+++ /dev/null
@@ -1,78 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- Implementation of the AdditionalStatus as defined in:
- https://www.concept2.co.uk/files/pdf/us/monitors/PM5_BluetoothSmartInterfaceDefinition.pdf
-*/
-import bleno from '@abandonware/bleno'
-import { getFullUUID } from '../Pm5Constants.js'
-import log from 'loglevel'
-import BufferBuilder from '../../BufferBuilder.js'
-
-export default class AdditionalStatus extends bleno.Characteristic {
- constructor (multiplexedCharacteristic) {
- super({
- // id for AdditionalStatus as defined in the spec
- uuid: getFullUUID('0032'),
- value: null,
- properties: ['notify']
- })
- this._updateValueCallback = null
- this._multiplexedCharacteristic = multiplexedCharacteristic
- }
-
- onSubscribe (maxValueSize, updateValueCallback) {
- log.debug(`AdditionalStatus - central subscribed with maxSize: ${maxValueSize}`)
- this._updateValueCallback = updateValueCallback
- return this.RESULT_SUCCESS
- }
-
- onUnsubscribe () {
- log.debug('AdditionalStatus - central unsubscribed')
- this._updateValueCallback = null
- return this.RESULT_UNLIKELY_ERROR
- }
-
- notify (data) {
- if (this._updateValueCallback || this._multiplexedCharacteristic.centralSubscribed()) {
- const bufferBuilder = new BufferBuilder()
- // elapsedTime: UInt24LE in 0.01 sec
- bufferBuilder.writeUInt24LE(Math.round(data.durationTotal * 100))
- // speed: UInt16LE in 0.001 m/sec
- bufferBuilder.writeUInt16LE(Math.round(data.speed * 1000 / 3.6))
- // strokeRate: UInt8 in strokes/min
- bufferBuilder.writeUInt8(Math.round(data.strokesPerMinute))
- // heartrate: UInt8 in bpm, 255 if invalid
- bufferBuilder.writeUInt8(Math.round(data.heartrate))
- // currentPace: UInt16LE in 0.01 sec/500m
- // if split is infinite (i.e. while pausing), use the highest possible number
- bufferBuilder.writeUInt16LE(data.split !== Infinity ? Math.round(data.split * 100) : 0xFFFF)
- // averagePace: UInt16LE in 0.01 sec/500m
- let averagePace = 0
- if (data.distanceTotal && data.distanceTotal !== 0) {
- averagePace = data.durationTotal / data.distanceTotal * 500
- }
- bufferBuilder.writeUInt16LE(Math.round(averagePace * 100))
- // restDistance: UInt16LE
- bufferBuilder.writeUInt16LE(0)
- // restTime: UInt24LE in 0.01 sec
- bufferBuilder.writeUInt24LE(0 * 100)
- if (!this._updateValueCallback) {
- // the multiplexer uses a slightly different format for the AdditionalStatus
- // it adds averagePower before the ergMachineType
- // averagePower: UInt16LE in watts
- bufferBuilder.writeUInt16LE(Math.round(data.power))
- }
- // ergMachineType: 0 TYPE_STATIC_D
- bufferBuilder.writeUInt8(0)
-
- if (this._updateValueCallback) {
- this._updateValueCallback(bufferBuilder.getBuffer())
- } else {
- this._multiplexedCharacteristic.notify(0x32, bufferBuilder.getBuffer())
- }
- return this.RESULT_SUCCESS
- }
- }
-}
diff --git a/app/ble/pm5/characteristic/AdditionalStatus2.js b/app/ble/pm5/characteristic/AdditionalStatus2.js
deleted file mode 100644
index 67f54f1650..0000000000
--- a/app/ble/pm5/characteristic/AdditionalStatus2.js
+++ /dev/null
@@ -1,71 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- Implementation of the AdditionalStatus2 as defined in:
- https://www.concept2.co.uk/files/pdf/us/monitors/PM5_BluetoothSmartInterfaceDefinition.pdf
-*/
-import bleno from '@abandonware/bleno'
-import { getFullUUID } from '../Pm5Constants.js'
-import log from 'loglevel'
-import BufferBuilder from '../../BufferBuilder.js'
-
-export default class AdditionalStatus2 extends bleno.Characteristic {
- constructor (multiplexedCharacteristic) {
- super({
- // id for AdditionalStatus2 as defined in the spec
- uuid: getFullUUID('0033'),
- value: null,
- properties: ['notify']
- })
- this._updateValueCallback = null
- this._multiplexedCharacteristic = multiplexedCharacteristic
- }
-
- onSubscribe (maxValueSize, updateValueCallback) {
- log.debug(`AdditionalStatus2 - central subscribed with maxSize: ${maxValueSize}`)
- this._updateValueCallback = updateValueCallback
- return this.RESULT_SUCCESS
- }
-
- onUnsubscribe () {
- log.debug('AdditionalStatus2 - central unsubscribed')
- this._updateValueCallback = null
- return this.RESULT_UNLIKELY_ERROR
- }
-
- notify (data) {
- if (this._updateValueCallback || this._multiplexedCharacteristic.centralSubscribed()) {
- const bufferBuilder = new BufferBuilder()
- // elapsedTime: UInt24LE in 0.01 sec
- bufferBuilder.writeUInt24LE(Math.round(data.durationTotal * 100))
- // intervalCount: UInt8
- bufferBuilder.writeUInt8(0)
- if (this._updateValueCallback) {
- // the multiplexer uses a slightly different format for the AdditionalStatus2
- // it skips averagePower before totalCalories
- // averagePower: UInt16LE in watts
- bufferBuilder.writeUInt16LE(Math.round(data.power))
- }
- // totalCalories: UInt16LE in cal
- bufferBuilder.writeUInt16LE(Math.round(data.caloriesTotal))
- // splitAveragePace: UInt16LE in 0.01 sec/500m
- bufferBuilder.writeUInt16LE(0 * 100)
- // splitAveragePower UInt16LE in watts
- bufferBuilder.writeUInt16LE(0)
- // splitAverageCalories
- bufferBuilder.writeUInt16LE(0)
- // lastSplitTime
- bufferBuilder.writeUInt24LE(0 * 100)
- // lastSplitDistance in 1 m
- bufferBuilder.writeUInt24LE(0)
-
- if (this._updateValueCallback) {
- this._updateValueCallback(bufferBuilder.getBuffer())
- } else {
- this._multiplexedCharacteristic.notify(0x33, bufferBuilder.getBuffer())
- }
- return this.RESULT_SUCCESS
- }
- }
-}
diff --git a/app/ble/pm5/characteristic/AdditionalStrokeData.js b/app/ble/pm5/characteristic/AdditionalStrokeData.js
deleted file mode 100644
index 3b6e7bf46a..0000000000
--- a/app/ble/pm5/characteristic/AdditionalStrokeData.js
+++ /dev/null
@@ -1,67 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- Implementation of the AdditionalStrokeData as defined in:
- https://www.concept2.co.uk/files/pdf/us/monitors/PM5_BluetoothSmartInterfaceDefinition.pdf
-*/
-import bleno from '@abandonware/bleno'
-import { getFullUUID } from '../Pm5Constants.js'
-import log from 'loglevel'
-import BufferBuilder from '../../BufferBuilder.js'
-
-export default class AdditionalStrokeData extends bleno.Characteristic {
- constructor (multiplexedCharacteristic) {
- super({
- // id for AdditionalStrokeData as defined in the spec
- uuid: getFullUUID('0036'),
- value: null,
- properties: ['notify']
- })
- this._updateValueCallback = null
- this._multiplexedCharacteristic = multiplexedCharacteristic
- }
-
- onSubscribe (maxValueSize, updateValueCallback) {
- log.debug(`AdditionalStrokeData - central subscribed with maxSize: ${maxValueSize}`)
- this._updateValueCallback = updateValueCallback
- return this.RESULT_SUCCESS
- }
-
- onUnsubscribe () {
- log.debug('AdditionalStrokeData - central unsubscribed')
- this._updateValueCallback = null
- return this.RESULT_UNLIKELY_ERROR
- }
-
- notify (data) {
- if (this._updateValueCallback || this._multiplexedCharacteristic.centralSubscribed()) {
- const bufferBuilder = new BufferBuilder()
- // elapsedTime: UInt24LE in 0.01 sec
- bufferBuilder.writeUInt24LE(Math.round(data.durationTotal * 100))
- // strokePower: UInt16LE in watts
- bufferBuilder.writeUInt16LE(Math.round(data.power))
- // strokeCalories: UInt16LE in cal
- bufferBuilder.writeUInt16LE(0)
- // strokeCount: UInt16LE
- bufferBuilder.writeUInt16LE(Math.round(data.strokesTotal))
- // projectedWorkTime: UInt24LE in 1 sec
- bufferBuilder.writeUInt24LE(0)
- // projectedWorkDistance: UInt24LE in 1 m
- bufferBuilder.writeUInt24LE(0)
- if (!this._updateValueCallback) {
- // the multiplexer uses a slightly different format for the AdditionalStrokeData
- // it adds workPerStroke at the end
- // workPerStroke: UInt16LE
- bufferBuilder.writeUInt16LE(0)
- }
-
- if (this._updateValueCallback) {
- this._updateValueCallback(bufferBuilder.getBuffer())
- } else {
- this._multiplexedCharacteristic.notify(0x36, bufferBuilder.getBuffer())
- }
- return this.RESULT_SUCCESS
- }
- }
-}
diff --git a/app/ble/pm5/characteristic/ControlReceive.js b/app/ble/pm5/characteristic/ControlReceive.js
deleted file mode 100644
index ace8b2347e..0000000000
--- a/app/ble/pm5/characteristic/ControlReceive.js
+++ /dev/null
@@ -1,28 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- Implementation of the ControlReceive Characteristic as defined in:
- https://www.concept2.co.uk/files/pdf/us/monitors/PM5_BluetoothSmartInterfaceDefinition.pdf
- Used to receive controls from the central
-*/
-import bleno from '@abandonware/bleno'
-import { getFullUUID } from '../Pm5Constants.js'
-import log from 'loglevel'
-
-export default class ControlReceive extends bleno.Characteristic {
- constructor () {
- super({
- // id for ControlReceive as defined in the spec
- uuid: getFullUUID('0021'),
- value: null,
- properties: ['write']
- })
- this._updateValueCallback = null
- }
-
- // Central sends a command to the Control Point
- onWriteRequest (data, offset, withoutResponse, callback) {
- log.debug('ControlReceive command: ', data)
- }
-}
diff --git a/app/ble/pm5/characteristic/ControlTransmit.js b/app/ble/pm5/characteristic/ControlTransmit.js
deleted file mode 100644
index 644ec7a2b7..0000000000
--- a/app/ble/pm5/characteristic/ControlTransmit.js
+++ /dev/null
@@ -1,44 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- Implementation of the ControlTransmit Characteristic as defined in:
- https://www.concept2.co.uk/files/pdf/us/monitors/PM5_BluetoothSmartInterfaceDefinition.pdf
- Used to transmit controls to the central
-*/
-import bleno from '@abandonware/bleno'
-import { getFullUUID } from '../Pm5Constants.js'
-import log from 'loglevel'
-import BufferBuilder from '../../BufferBuilder.js'
-
-export default class ControlTransmit extends bleno.Characteristic {
- constructor () {
- super({
- // id for ControlTransmit as defined in the spec
- uuid: getFullUUID('0022'),
- value: null,
- properties: ['notify']
- })
- this._updateValueCallback = null
- }
-
- onSubscribe (maxValueSize, updateValueCallback) {
- log.debug(`ControlTransmit - central subscribed with maxSize: ${maxValueSize}`)
- this._updateValueCallback = updateValueCallback
- return this.RESULT_SUCCESS
- }
-
- onUnsubscribe () {
- log.debug('ControlTransmit - central unsubscribed')
- this._updateValueCallback = null
- return this.RESULT_UNLIKELY_ERROR
- }
-
- notify (data) {
- if (this._updateValueCallback) {
- const bufferBuilder = new BufferBuilder()
- this._updateValueCallback(bufferBuilder.getBuffer())
- return this.RESULT_SUCCESS
- }
- }
-}
diff --git a/app/ble/pm5/characteristic/GeneralStatus.js b/app/ble/pm5/characteristic/GeneralStatus.js
deleted file mode 100644
index 2748b41ba7..0000000000
--- a/app/ble/pm5/characteristic/GeneralStatus.js
+++ /dev/null
@@ -1,71 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- Implementation of the GeneralStatus as defined in:
- https://www.concept2.co.uk/files/pdf/us/monitors/PM5_BluetoothSmartInterfaceDefinition.pdf
-*/
-import bleno from '@abandonware/bleno'
-import { getFullUUID } from '../Pm5Constants.js'
-import log from 'loglevel'
-import BufferBuilder from '../../BufferBuilder.js'
-
-export default class GeneralStatus extends bleno.Characteristic {
- constructor (multiplexedCharacteristic) {
- super({
- // id for GeneralStatus as defined in the spec
- uuid: getFullUUID('0031'),
- value: null,
- properties: ['notify']
- })
- this._updateValueCallback = null
- this._multiplexedCharacteristic = multiplexedCharacteristic
- }
-
- onSubscribe (maxValueSize, updateValueCallback) {
- log.debug(`GeneralStatus - central subscribed with maxSize: ${maxValueSize}`)
- this._updateValueCallback = updateValueCallback
- return this.RESULT_SUCCESS
- }
-
- onUnsubscribe () {
- log.debug('GeneralStatus - central unsubscribed')
- this._updateValueCallback = null
- return this.RESULT_UNLIKELY_ERROR
- }
-
- notify (data) {
- if (this._updateValueCallback || this._multiplexedCharacteristic.centralSubscribed()) {
- const bufferBuilder = new BufferBuilder()
- // elapsedTime: UInt24LE in 0.01 sec
- bufferBuilder.writeUInt24LE(Math.round(data.durationTotal * 100))
- // distance: UInt24LE in 0.1 m
- bufferBuilder.writeUInt24LE(Math.round(data.distanceTotal * 10))
- // workoutType: UInt8 will always use 0 (WORKOUTTYPE_JUSTROW_NOSPLITS)
- bufferBuilder.writeUInt8(0)
- // intervalType: UInt8 will always use 255 (NONE)
- bufferBuilder.writeUInt8(255)
- // workoutState: UInt8 0 WAITTOBEGIN, 1 WORKOUTROW, 10 WORKOUTEND
- bufferBuilder.writeUInt8(data.sessionState === 'rowing' ? 1 : (data.sessionState === 'waitingForStart' ? 0 : 10))
- // rowingState: UInt8 0 INACTIVE, 1 ACTIVE
- bufferBuilder.writeUInt8(data.sessionState === 'rowing' ? 1 : 0)
- // strokeState: UInt8 2 DRIVING, 4 RECOVERY
- bufferBuilder.writeUInt8(data.strokeState === 'DRIVING' ? 2 : 4)
- // totalWorkDistance: UInt24LE in 1 m
- bufferBuilder.writeUInt24LE(Math.round(data.distanceTotal))
- // workoutDuration: UInt24LE in 0.01 sec (if type TIME)
- bufferBuilder.writeUInt24LE(0 * 100)
- // workoutDurationType: UInt8 0 TIME, 1 CALORIES, 2 DISTANCE, 3 WATTS
- bufferBuilder.writeUInt8(0)
- // dragFactor: UInt8
- bufferBuilder.writeUInt8(0)
-
- if (this._updateValueCallback) {
- this._updateValueCallback(bufferBuilder.getBuffer())
- } else {
- this._multiplexedCharacteristic.notify(0x31, bufferBuilder.getBuffer())
- }
- return this.RESULT_SUCCESS
- }
- }
-}
diff --git a/app/ble/pm5/characteristic/MultiplexedCharacteristic.js b/app/ble/pm5/characteristic/MultiplexedCharacteristic.js
deleted file mode 100644
index 7f1ee4e38f..0000000000
--- a/app/ble/pm5/characteristic/MultiplexedCharacteristic.js
+++ /dev/null
@@ -1,59 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- Implements the Multiplexed Characteristic as defined by the spec:
-
- "On some Android platforms, there is a limitation to the number of notification messages allowed.
- To circumvent this issue, a single characteristic (C2 multiplexed data
- info) exists to allow multiple characteristics to be multiplexed onto a single characteristic. The last byte in the
- characteristic will indicate which data characteristic is multiplexed."
-*/
-import bleno from '@abandonware/bleno'
-import { getFullUUID } from '../Pm5Constants.js'
-import log from 'loglevel'
-
-export default class MultiplexedCharacteristic extends bleno.Characteristic {
- constructor () {
- super({
- // id for MultiplexedInformation as defined in the spec
- uuid: getFullUUID('0080'),
- value: null,
- properties: ['notify']
- })
- this._updateValueCallback = null
- }
-
- onSubscribe (maxValueSize, updateValueCallback) {
- log.debug(`MultiplexedCharacteristic - central subscribed with maxSize: ${maxValueSize}`)
- this._updateValueCallback = updateValueCallback
- return this.RESULT_SUCCESS
- }
-
- onUnsubscribe () {
- log.debug('MultiplexedCharacteristic - central unsubscribed')
- this._updateValueCallback = null
- return this.RESULT_UNLIKELY_ERROR
- }
-
- centralSubscribed () {
- return this._updateValueCallback !== null
- }
-
- notify (id, characteristicBuffer) {
- const characteristicId = Buffer.alloc(1)
- characteristicId.writeUInt8(id, 0)
- const buffer = Buffer.concat(
- [
- characteristicId,
- characteristicBuffer
- ],
- characteristicId.length + characteristicBuffer.length
- )
-
- if (this._updateValueCallback) {
- this._updateValueCallback(buffer)
- }
- return this.RESULT_SUCCESS
- }
-}
diff --git a/app/ble/pm5/characteristic/StrokeData.js b/app/ble/pm5/characteristic/StrokeData.js
deleted file mode 100644
index ededdb224b..0000000000
--- a/app/ble/pm5/characteristic/StrokeData.js
+++ /dev/null
@@ -1,72 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- Implementation of the StrokeData as defined in:
- https://www.concept2.co.uk/files/pdf/us/monitors/PM5_BluetoothSmartInterfaceDefinition.pdf
- todo: we could calculate all the missing stroke metrics in the RowerEngine
-*/
-import bleno from '@abandonware/bleno'
-import { getFullUUID } from '../Pm5Constants.js'
-import log from 'loglevel'
-import BufferBuilder from '../../BufferBuilder.js'
-
-export default class StrokeData extends bleno.Characteristic {
- constructor (multiplexedCharacteristic) {
- super({
- // id for StrokeData as defined in the spec
- uuid: getFullUUID('0035'),
- value: null,
- properties: ['notify']
- })
- this._updateValueCallback = null
- this._multiplexedCharacteristic = multiplexedCharacteristic
- }
-
- onSubscribe (maxValueSize, updateValueCallback) {
- log.debug(`StrokeData - central subscribed with maxSize: ${maxValueSize}`)
- this._updateValueCallback = updateValueCallback
- return this.RESULT_SUCCESS
- }
-
- onUnsubscribe () {
- log.debug('StrokeData - central unsubscribed')
- this._updateValueCallback = null
- return this.RESULT_UNLIKELY_ERROR
- }
-
- notify (data) {
- if (this._updateValueCallback || this._multiplexedCharacteristic.centralSubscribed()) {
- const bufferBuilder = new BufferBuilder()
- // elapsedTime: UInt24LE in 0.01 sec
- bufferBuilder.writeUInt24LE(Math.round(data.durationTotal * 100))
- // distance: UInt24LE in 0.1 m
- bufferBuilder.writeUInt24LE(Math.round(data.distanceTotal * 10))
- // driveLength: UInt8 in 0.01 m
- bufferBuilder.writeUInt8(0 * 100)
- // driveTime: UInt8 in 0.01 s
- bufferBuilder.writeUInt8(0 * 100)
- // strokeRecoveryTime: UInt16LE in 0.01 s
- bufferBuilder.writeUInt16LE(0 * 100)
- // strokeDistance: UInt16LE in 0.01 s
- bufferBuilder.writeUInt16LE(0 * 100)
- // peakDriveForce: UInt16LE in 0.1 watts
- bufferBuilder.writeUInt16LE(0 * 10)
- // averageDriveForce: UInt16LE in 0.1 watts
- bufferBuilder.writeUInt16LE(0 * 10)
- if (this._updateValueCallback) {
- // workPerStroke is only added if data is not send via multiplexer
- // workPerStroke: UInt16LE
- bufferBuilder.writeUInt16LE(0)
- }
- // strokeCount: UInt16LE
- bufferBuilder.writeUInt16LE(data.strokesTotal)
- if (this._updateValueCallback) {
- this._updateValueCallback(bufferBuilder.getBuffer())
- } else {
- this._multiplexedCharacteristic.notify(0x35, bufferBuilder.getBuffer())
- }
- return this.RESULT_SUCCESS
- }
- }
-}
diff --git a/app/ble/pm5/characteristic/ValueReadCharacteristic.js b/app/ble/pm5/characteristic/ValueReadCharacteristic.js
deleted file mode 100644
index 7797cd109f..0000000000
--- a/app/ble/pm5/characteristic/ValueReadCharacteristic.js
+++ /dev/null
@@ -1,40 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- A simple Characteristic that gives read and notify access to a static value
- Currently also used as placeholder on a lot of characteristics that are not yet implemented properly
-*/
-import bleno from '@abandonware/bleno'
-import log from 'loglevel'
-
-export default class ValueReadCharacteristic extends bleno.Characteristic {
- constructor (uuid, value, description) {
- super({
- uuid,
- properties: ['read', 'notify'],
- value: null
- })
- this.uuid = uuid
- this._value = Buffer.isBuffer(value) ? value : Buffer.from(value)
- this._description = description
- this._updateValueCallback = null
- }
-
- onReadRequest (offset, callback) {
- log.debug(`ValueReadRequest: ${this._description ? this._description : this.uuid}`)
- callback(this.RESULT_SUCCESS, this._value.slice(offset, this._value.length))
- }
-
- onSubscribe (maxValueSize, updateValueCallback) {
- log.debug(`characteristic ${this._description ? this._description : this.uuid} - central subscribed with maxSize: ${maxValueSize}`)
- this._updateValueCallback = updateValueCallback
- return this.RESULT_SUCCESS
- }
-
- onUnsubscribe () {
- log.debug(`characteristic ${this._description ? this._description : this.uuid} - central unsubscribed`)
- this._updateValueCallback = null
- return this.RESULT_UNLIKELY_ERROR
- }
-}
diff --git a/app/client/components/AppDialog.js b/app/client/components/AppDialog.js
index eab5d2efae..1d4e273a88 100644
--- a/app/client/components/AppDialog.js
+++ b/app/client/components/AppDialog.js
@@ -1,10 +1,9 @@
'use strict'
/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
Component that renders a html dialog
*/
-
import { AppElement, html, css } from './AppElement.js'
import { customElement, property } from 'lit/decorators.js'
import { ref, createRef } from 'lit/directives/ref.js'
@@ -46,10 +45,15 @@ export class AppDialog extends AppElement {
justify-content: center;
align-items: center;
}
- button:hover {
+ button:hover:not(.disabled) {
filter: brightness(150%);
}
+ button.disabled {
+ filter: brightness(50%);
+ pointer: none
+ }
+
fieldset {
border: 0;
margin: unset;
@@ -67,6 +71,8 @@ export class AppDialog extends AppElement {
padding: 0;
}
`
+ @property({ type: Boolean })
+ isValid = true
@property({ type: Boolean, reflect: true })
dialogOpen
@@ -74,13 +80,13 @@ export class AppDialog extends AppElement {
render () {
return html`
@@ -95,6 +101,13 @@ export class AppDialog extends AppElement {
}
}
+ confirm () {
+ if (this.isValid) {
+ this.close({ target: { returnValue: 'confirm' } })
+ this.dialogOpen = false
+ }
+ }
+
firstUpdated () {
this.dialog.value.showModal()
}
diff --git a/app/client/components/AppElement.js b/app/client/components/AppElement.js
index efe6c39454..817492b415 100644
--- a/app/client/components/AppElement.js
+++ b/app/client/components/AppElement.js
@@ -1,27 +1,14 @@
'use strict'
/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
Base Component for all other App Components
*/
import { LitElement } from 'lit'
-import { property } from 'lit/decorators.js'
-import { APP_STATE } from '../store/appState.js'
export * from 'lit'
export class AppElement extends LitElement {
- // this is how we implement a global state: a global state object is passed via properties
- // to child components
- @property({ type: Object })
- appState = APP_STATE
-
- // ..and state changes are send back to the root component of the app by dispatching
- // a CustomEvent
- updateState () {
- this.sendEvent('appStateChanged', this.appState)
- }
-
// a helper to dispatch events to the parent components
sendEvent (eventType, eventData) {
this.dispatchEvent(
diff --git a/app/client/components/BatteryIcon.js b/app/client/components/BatteryIcon.js
index 2321a736c5..7bc62e1e01 100644
--- a/app/client/components/BatteryIcon.js
+++ b/app/client/components/BatteryIcon.js
@@ -1,6 +1,6 @@
'use strict'
/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
Component that renders a battery indicator
*/
diff --git a/app/client/components/DashboardActions.js b/app/client/components/DashboardActions.js
index 8eb53597a4..a24ca248fd 100644
--- a/app/client/components/DashboardActions.js
+++ b/app/client/components/DashboardActions.js
@@ -1,41 +1,57 @@
'use strict'
/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
Component that renders the action buttons of the dashboard
*/
import { AppElement, html, css } from './AppElement.js'
-import { customElement, state } from 'lit/decorators.js'
-import { icon_undo, icon_expand, icon_compress, icon_poweroff, icon_bluetooth, icon_upload } from '../lib/icons.js'
+import { customElement, property, state } from 'lit/decorators.js'
+import { iconUndo, iconExpand, iconCompress, iconPoweroff, iconBluetooth, iconUpload, iconHeartbeat, iconAntplus } from '../lib/icons.js'
import './AppDialog.js'
@customElement('dashboard-actions')
export class DashboardActions extends AppElement {
static styles = css`
button {
+ position: relative;
outline:none;
background-color: var(--theme-button-color);
border: 0;
border-radius: var(--theme-border-radius);
color: var(--theme-font-color);
- margin: 0.2em 0;
+ margin: 0.2em 4px;
font-size: 60%;
text-decoration: none;
display: inline-flex;
- width: 3.5em;
- height: 2.5em;
+ width: 3.2em;
+ min-width: 3.2em;
+ height: 2.2em;
justify-content: center;
align-items: center;
}
+
button:hover {
filter: brightness(150%);
}
+ button > div.text {
+ position: absolute;
+ left: 2px;
+ bottom: 2px;
+ font-size: 40%;
+ }
+
#fullscreen-icon {
display: inline-flex;
}
+ .top-button-group {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ }
+
#windowed-icon {
display: none;
}
@@ -45,7 +61,7 @@ export class DashboardActions extends AppElement {
}
.peripheral-mode {
- font-size: 80%;
+ font-size: 50%;
}
@media (display-mode: fullscreen) {
@@ -58,59 +74,95 @@ export class DashboardActions extends AppElement {
}
`
- @state({ type: Object })
- dialog
+ @property({ type: Object })
+ config = {}
+
+ @state()
+ _appMode = 'BROWSER'
+
+ @state()
+ _dialog
render () {
return html`
-
- ${this.renderOptionalButtons()}
-
-
${this.peripheralMode()}
- ${this.dialog ? this.dialog : ''}
+
+
+
+
${this.blePeripheralMode()}
+
+ ${this._dialog ? this._dialog : ''}
`
}
+ firstUpdated () {
+ switch (new URLSearchParams(window.location.search).get('mode')) {
+ case 'standalone':
+ this._appMode = 'STANDALONE'
+ break
+ case 'kiosk':
+ this._appMode = 'KIOSK'
+ break
+ default:
+ this._appMode = 'BROWSER'
+ }
+ }
+
renderOptionalButtons () {
const buttons = []
// changing to fullscreen mode only makes sence when the app is openend in a regular
// webbrowser (kiosk and standalone mode are always in fullscreen view) and if the
// browser supports this feature
- if (this.appState?.appMode === 'BROWSER' && document.documentElement.requestFullscreen) {
+ if (this._appMode === 'BROWSER' && document.documentElement.requestFullscreen) {
buttons.push(html`
`)
}
// add a button to power down the device, if browser is running on the device in kiosk mode
// and the shutdown feature is enabled
// (might also make sence to enable this for all clients but then we would need visual feedback)
- if (this.appState?.appMode === 'KIOSK' && this.appState?.config?.shutdownEnabled) {
+ if (this._appMode === 'KIOSK' && this.config?.shutdownEnabled) {
buttons.push(html`
-
+
`)
}
- if (this.appState?.config?.stravaUploadEnabled) {
+ if (this.config?.uploadEnabled) {
buttons.push(html`
-
+
`)
}
return buttons
}
- peripheralMode () {
- const value = this.appState?.config?.peripheralMode
- if (value === 'PM5') {
- return 'C2 PM5'
- } else if (value === 'FTMSBIKE') {
- return 'FTMS Bike'
- } else if (value === 'FTMS') {
- return 'FTMS Rower'
- } else {
- return ''
+ blePeripheralMode () {
+ const value = this.config?.blePeripheralMode
+ switch (value) {
+ case 'PM5':
+ return 'C2 PM5'
+ case 'FTMSBIKE':
+ return 'FTMS Bike'
+ case 'CSC':
+ return 'Bike Speed + Cadence'
+ case 'CPS':
+ return 'Bike Power'
+ case 'FTMS':
+ return 'FTMS Rower'
+ default:
+ return 'Off'
}
}
@@ -129,34 +181,42 @@ export class DashboardActions extends AppElement {
this.sendEvent('triggerAction', { command: 'reset' })
}
- switchPeripheralMode () {
- this.sendEvent('triggerAction', { command: 'switchPeripheralMode' })
+ switchBlePeripheralMode () {
+ this.sendEvent('triggerAction', { command: 'switchBlePeripheralMode' })
+ }
+
+ switchAntPeripheralMode () {
+ this.sendEvent('triggerAction', { command: 'switchAntPeripheralMode' })
+ }
+
+ switchHrmPeripheralMode () {
+ this.sendEvent('triggerAction', { command: 'switchHrmMode' })
}
uploadTraining () {
- this.dialog = html`
+ this._dialog = html`
-
- Do you want to finish your workout and upload it to Strava?
+
+ Do you want to finish your workout and upload it to webservices (Strava, Intervals.icu and RowsAndAll)?
`
function dialogClosed (event) {
- this.dialog = undefined
+ this._dialog = undefined
if (event.detail === 'confirm') {
- this.sendEvent('triggerAction', { command: 'uploadTraining' })
+ this.sendEvent('triggerAction', { command: 'upload' })
}
}
}
shutdown () {
- this.dialog = html`
+ this._dialog = html`
-
+
Do you want to shutdown the device?
`
function dialogClosed (event) {
- this.dialog = undefined
+ this._dialog = undefined
if (event.detail === 'confirm') {
this.sendEvent('triggerAction', { command: 'shutdown' })
}
diff --git a/app/client/components/DashboardForceCurve.js b/app/client/components/DashboardForceCurve.js
new file mode 100644
index 0000000000..2e57958c7c
--- /dev/null
+++ b/app/client/components/DashboardForceCurve.js
@@ -0,0 +1,125 @@
+'use strict'
+/*
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
+
+ Component that renders a metric of the dashboard
+*/
+
+import { AppElement, html, css } from './AppElement.js'
+import { customElement, property, state } from 'lit/decorators.js'
+import ChartDataLabels from 'chartjs-plugin-datalabels'
+import { Chart, Filler, Legend, LinearScale, LineController, LineElement, PointElement } from 'chart.js'
+
+@customElement('dashboard-force-curve')
+export class DashboardForceCurve extends AppElement {
+ static styles = css`
+ canvas {
+ margin-top: 24px;
+ }
+ `
+
+ constructor () {
+ super()
+ Chart.register(ChartDataLabels, Legend, Filler, LinearScale, LineController, PointElement, LineElement)
+ }
+
+ @property({ type: Object })
+ value = []
+
+ @state()
+ _chart
+
+ firstUpdated () {
+ const ctx = this.renderRoot.querySelector('#chart').getContext('2d')
+ this._chart = new Chart(
+ ctx,
+ {
+ type: 'line',
+ data: {
+ datasets: [
+ {
+ fill: true,
+ data: this.value?.map((data, index) => ({ y: data, x: index })),
+ pointRadius: 1,
+ borderColor: 'rgb(255,255,255)',
+ backgroundColor: 'rgb(220,220,220)'
+ }
+ ]
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: {
+ datalabels: {
+ anchor: 'center',
+ align: 'top',
+ formatter: (value) => `Peak: ${Math.round(value.y)}`,
+ display: (ctx) => Math.max(
+ ...ctx.dataset.data.map((point) => point.y)
+ ) === ctx.dataset.data[ctx.dataIndex].y,
+ font: {
+ size: 16
+ },
+ color: 'rgb(255,255,255)'
+ },
+ legend: {
+ title: {
+ display: true,
+ text: 'Force Curve',
+ color: 'rgb(255,255,255)',
+ font: {
+ size: 32
+ },
+ padding: {
+ }
+ },
+ labels: {
+ boxWidth: 0,
+ font: {
+ size: 0
+ }
+ }
+ }
+ },
+ scales: {
+ x: {
+ type: 'linear',
+ display: false
+ },
+ y: {
+ ticks: {
+ color: 'rgb(255,255,255)'
+ }
+ }
+ },
+ animations: {
+ tension: {
+ duration: 200,
+ easing: 'easeInQuad'
+ },
+ y: {
+ duration: 200,
+ easing: 'easeInQuad'
+ },
+ x: {
+ duration: 200,
+ easing: 'easeInQuad'
+ }
+ }
+ }
+ }
+ )
+ }
+
+ render () {
+ if (this._chart?.data) {
+ this._chart.data.datasets[0].data = this.value?.map((data, index) => ({ y: data, x: index }))
+ this.forceCurve = this.value
+ this._chart.update()
+ }
+
+ return html`
+
+ `
+ }
+}
diff --git a/app/client/components/DashboardMetric.js b/app/client/components/DashboardMetric.js
index 185c89f470..0301874fee 100644
--- a/app/client/components/DashboardMetric.js
+++ b/app/client/components/DashboardMetric.js
@@ -1,6 +1,6 @@
'use strict'
/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
Component that renders a metric of the dashboard
*/
@@ -35,19 +35,21 @@ export class DashboardMetric extends AppElement {
`
@property({ type: Object })
- icon
+ icon = ''
@property({ type: String })
unit = ''
@property({ type: String })
- value = ''
+ value
render () {
return html`
- ${this.icon}
+
- ${this.value !== undefined ? this.value : '--'}
+ ${this.value !== undefined ? this.value : '--'}
${this.unit}
diff --git a/app/client/components/PerformanceDashboard.js b/app/client/components/PerformanceDashboard.js
index 0d04013f78..7d0e932eb3 100644
--- a/app/client/components/PerformanceDashboard.js
+++ b/app/client/components/PerformanceDashboard.js
@@ -1,17 +1,15 @@
'use strict'
/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
Component that renders the dashboard
*/
import { AppElement, html, css } from './AppElement.js'
-import { APP_STATE } from '../store/appState.js'
-import { customElement, property } from 'lit/decorators.js'
-import './DashboardMetric.js'
-import './DashboardActions.js'
-import './BatteryIcon.js'
-import { icon_route, icon_stopwatch, icon_bolt, icon_paddle, icon_heartbeat, icon_fire, icon_clock } from '../lib/icons.js'
+import { customElement, property, state } from 'lit/decorators.js'
+import './SettingsDialog.js'
+import { iconSettings } from '../lib/icons.js'
+import { DASHBOARD_METRICS } from '../store/dashboardMetrics.js'
@customElement('performance-dashboard')
export class PerformanceDashboard extends AppElement {
@@ -22,7 +20,6 @@ export class PerformanceDashboard extends AppElement {
padding: 1vw;
grid-gap: 1vw;
grid-template-columns: repeat(4, minmax(0, 1fr));
- grid-template-rows: repeat(2, minmax(0, 1fr));
}
@media (orientation: portrait) {
@@ -32,7 +29,7 @@ export class PerformanceDashboard extends AppElement {
}
}
- dashboard-metric, dashboard-actions {
+ dashboard-metric, dashboard-actions, dashboard-force-curve {
background: var(--theme-widget-color);
text-align: center;
position: relative;
@@ -43,64 +40,70 @@ export class PerformanceDashboard extends AppElement {
dashboard-actions {
padding: 0.5em 0 0 0;
}
+
+ .settings {
+ padding: 0.1em 0;
+ position: absolute;
+ bottom: 0;
+ right: 0;
+ z-index: 20;
+ }
+
+ .settings .icon {
+ cursor: pointer;
+ height: 1em;
+ }
+
+ .settings:hover .icon {
+ filter: brightness(150%);
+ }
`
+ @property()
+ appState = {}
+
+ @state()
+ _dialog
- @property({ type: Object })
- metrics
+ dashboardMetricComponentsFactory = (appState) => {
+ const metrics = appState.metrics
+ const configs = appState.config
- @property({ type: Object })
- appState = APP_STATE
+ const dashboardMetricComponents = Object.keys(DASHBOARD_METRICS).reduce((dashboardMetrics, key) => {
+ dashboardMetrics[key] = DASHBOARD_METRICS[key].template(metrics, configs)
+
+ return dashboardMetrics
+ }, {})
+
+ return dashboardMetricComponents
+ }
render () {
- const metrics = this.calculateFormattedMetrics(this.appState.metrics)
+ const metricConfig = [...new Set(this.appState.config.guiConfigs.dashboardMetrics)].reduce((prev, metricName) => {
+ prev.push(this.dashboardMetricComponentsFactory(this.appState)[metricName])
+ return prev
+ }, [])
+
return html`
-
-
-
-
- ${metrics?.heartrate?.value
- ? html`
-
- ${metrics?.heartrateBatteryLevel?.value
- ? html`
-
- `
- : ''
- }
- `
- : html``}
-
-
-
+
+
+ ${iconSettings}
+ ${this._dialog ? this._dialog : ''}
+
+
+ ${metricConfig}
`
}
- // todo: so far this is just a port of the formatter from the initial proof of concept client
- // we could split this up to make it more readable and testable
- calculateFormattedMetrics (metrics) {
- const fieldFormatter = {
- distanceTotal: (value) => value >= 10000
- ? { value: (value / 1000).toFixed(1), unit: 'km' }
- : { value: Math.round(value), unit: 'm' },
- caloriesTotal: (value) => Math.round(value),
- power: (value) => Math.round(value),
- strokesPerMinute: (value) => Math.round(value)
- }
+ openSettings () {
+ this._dialog = html``
- const formattedMetrics = {}
- for (const [key, value] of Object.entries(metrics)) {
- const valueFormatted = fieldFormatter[key] ? fieldFormatter[key](value) : value
- if (valueFormatted.value !== undefined && valueFormatted.unit !== undefined) {
- formattedMetrics[key] = {
- value: valueFormatted.value,
- unit: valueFormatted.unit
- }
- } else {
- formattedMetrics[key] = {
- value: valueFormatted
- }
- }
+ /* eslint-disable-next-line no-unused-vars -- Standard construct?? */
+ function dialogClosed (event) {
+ this._dialog = undefined
}
- return formattedMetrics
}
}
diff --git a/app/client/components/SettingsDialog.js b/app/client/components/SettingsDialog.js
new file mode 100644
index 0000000000..d0471cef88
--- /dev/null
+++ b/app/client/components/SettingsDialog.js
@@ -0,0 +1,253 @@
+'use strict'
+/*
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
+
+ Component that renders the action buttons of the dashboard
+*/
+
+import { AppElement, html, css } from './AppElement.js'
+import { customElement, property, query, queryAll, state } from 'lit/decorators.js'
+import { iconSettings } from '../lib/icons.js'
+import './AppDialog.js'
+import { DASHBOARD_METRICS } from '../store/dashboardMetrics.js'
+
+@customElement('settings-dialog')
+export class DashboardActions extends AppElement {
+ static styles = css`
+ .metric-selector-feedback{
+ font-size: 0.5em;
+ padding-top: 8px;
+ }
+
+ .settings-dialog>div.metric-selector{
+ display: grid;
+ grid-template-columns: repeat(3,max-content);
+ gap: 8px;
+ }
+
+ .experimental-settings {
+ display: flex;
+ flex-direction: column;
+ }
+
+ .experimental-settings label {
+ width: fit-content;
+ margin-top: 8px;
+ font-size: 0.7em;
+ }
+
+ .experimental-settings label>input {
+ font-size: 0.7em;
+ }
+
+ .settings-dialog>div>label{
+ font-size: 0.6em;
+ width: fit-content;
+ }
+
+ input[type="checkbox"]{
+ cursor: pointer;
+ align-self: center;
+ width: 1.5em;
+ height: 1.5em;
+ }
+
+ label>span {
+ cursor: pointer;
+ -webkit-user-select: none;
+ user-select: none;
+ }
+
+ .icon {
+ height: 1.6em;
+ }
+
+ legend{
+ text-align: center;
+ }
+
+ table {
+ min-height: 70px;
+ margin-top: 8px;
+ width: 100%;
+ }
+
+ table, th, td {
+ font-size: 0.9em;
+ border: 1px solid white;
+ border-collapse: collapse;
+ }
+
+ tr {
+ height: 50%;
+ }
+
+ th, td {
+ padding: 8px;
+ text-align: center;
+ background-color: var(--theme-widget-color);
+ }
+
+ .show-icons-selector {
+ display: flex;
+ gap: 8px;
+ }
+
+ app-dialog > *:last-child {
+ margin-bottom: -24px;
+ }
+ `
+
+ @property({ type: Object })
+ config = {}
+
+ @queryAll('.metric-selector input')
+ _inputs
+
+ @query('input[name="showIcons"]')
+ _showIconInput
+
+ @query('input[name="maxNumberOfTiles"]')
+ _maxNumberOfTilesInput
+
+ @state()
+ _selectedMetrics = []
+
+ @state()
+ _sumSelectedSlots = 0
+
+ @state()
+ _isValid = false
+
+ @state()
+ _showIcons = true
+
+ @state()
+ _maxNumberOfTiles = 8
+
+ render () {
+ return html`
+
+
+
+ Select metrics to be shown:
+
+ ${this.renderAvailableMetricList()}
+
+ Slots remaining: ${this._maxNumberOfTiles - this._sumSelectedSlots}
+
+ ${this.renderSelectedMetrics()}
+
+
+
+
+
+
+ Experimental settings:
+
+
+
+ `
+ }
+
+ firstUpdated () {
+ this._selectedMetrics = [...this.config.dashboardMetrics]
+ this._sumSelectedSlots = this._selectedMetrics.length
+ this._showIcons = this.config.showIcons
+ this._maxNumberOfTiles = this.config.maxNumberOfTiles
+ if (this._sumSelectedSlots === this._maxNumberOfTiles) {
+ this._isValid = true
+ } else {
+ this._isValid = false
+ }
+ [...this._inputs].forEach(input => {
+ input.checked = this._selectedMetrics.find(metric => metric === input.name) !== undefined
+ })
+ this._showIconInput.checked = this._showIcons
+ this._maxNumberOfTilesInput.checked = this._maxNumberOfTiles === 12
+ }
+
+ renderAvailableMetricList () {
+ return Object.keys(DASHBOARD_METRICS).map(key => html`
+
+ `)
+ }
+
+ renderSelectedMetrics () {
+ const selectedMetrics = [html`${[0, 1, 2, 3].map(index => html`| ${this._selectedMetrics[index]} | `)}
`]
+ selectedMetrics.push(html`${[4, 5, 6, 7].map(index => html`| ${this._selectedMetrics[index]} | `)}
`)
+ if (this._maxNumberOfTiles === 12) {
+ selectedMetrics.push(html`${[8, 9, 10, 11].map(index => html`| ${this._selectedMetrics[index]} | `)}
`)
+ }
+
+ return selectedMetrics
+ }
+
+ toggleCheck (e) {
+ if (e.target.checked && ((this._selectedMetrics.length % 4 === 3 && e.target.size > 1) || (this._sumSelectedSlots + e.target.size > this._maxNumberOfTiles))) {
+ this._isValid = this.isFormValid()
+ e.target.checked = false
+ return
+ }
+
+ if (e.target.checked) {
+ for (let index = 0; index < e.target.size; index++) {
+ this._selectedMetrics = [...this._selectedMetrics, e.target.name]
+ }
+ } else {
+ for (let index = 0; index < e.target.size; index++) {
+ this._selectedMetrics.splice(this._selectedMetrics.findIndex(metric => metric === e.target.name), 1)
+ this._selectedMetrics = [...this._selectedMetrics]
+ }
+ }
+
+ this._sumSelectedSlots = this._selectedMetrics.length
+ if (this.isFormValid()) {
+ this._isValid = true
+ } else {
+ this._isValid = false
+ }
+ }
+
+ toggleIcons (e) {
+ this._showIcons = e.target.checked
+ }
+
+ toggleMaxTiles (e) {
+ this._maxNumberOfTiles = e.target.checked ? 12 : 8
+ this._isValid = this.isFormValid()
+ }
+
+ isFormValid () {
+ return this._sumSelectedSlots === this._maxNumberOfTiles && this._selectedMetrics[3] !== this._selectedMetrics[4] && this._selectedMetrics[7] !== this._selectedMetrics?.[8]
+ }
+
+ close (event) {
+ this.dispatchEvent(new CustomEvent('close'))
+ if (event.detail === 'confirm') {
+ this.sendEvent('changeGuiSetting', {
+ dashboardMetrics: this._selectedMetrics,
+ showIcons: this._showIcons,
+ maxNumberOfTiles: this._maxNumberOfTiles
+ })
+ }
+ }
+}
diff --git a/app/client/index.js b/app/client/index.js
index b26dfcd4e6..4bfc50271d 100644
--- a/app/client/index.js
+++ b/app/client/index.js
@@ -1,6 +1,6 @@
'use strict'
/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
Main Initialization Component of the Web Component App
*/
@@ -14,10 +14,7 @@ import './components/PerformanceDashboard.js'
@customElement('web-app')
export class App extends LitElement {
@state()
- appState = APP_STATE
-
- @state()
- metrics
+ _appState = APP_STATE
constructor () {
super()
@@ -28,6 +25,11 @@ export class App extends LitElement {
// todo: we also want a mechanism here to get notified of state changes
})
+ const config = this._appState.config.guiConfigs
+ Object.keys(config).forEach(key => {
+ config[key] = JSON.parse(localStorage.getItem(key)) ?? config[key]
+ })
+
// this is how we implement changes to the global state:
// once any child component sends this CustomEvent we update the global state according
// to the changes that were passed to us
@@ -39,20 +41,28 @@ export class App extends LitElement {
this.addEventListener('triggerAction', (event) => {
this.app.handleAction(event.detail)
})
+
+ // notify the app about the triggered action
+ this.addEventListener('changeGuiSetting', (event) => {
+ Object.keys(event.detail).forEach(key => {
+ localStorage.setItem(key, JSON.stringify(event.detail[key]))
+ })
+ this.updateState({ config: { ...this._appState.config, guiConfigs: { ...event.detail } } })
+ })
}
// the global state is updated by replacing the appState with a copy of the new state
// todo: maybe it is more convenient to just pass the state elements that should be changed?
// i.e. do something like this.appState = { ..this.appState, ...newState }
updateState = (newState) => {
- this.appState = { ...newState }
+ this._appState = { ...this._appState, ...newState }
}
// return a deep copy of the state to other components to minimize risk of side effects
getState = () => {
// could use structuredClone once the browser support is wider
// https://developer.mozilla.org/en-US/docs/Web/API/structuredClone
- return JSON.parse(JSON.stringify(this.appState))
+ return JSON.parse(JSON.stringify(this._appState))
}
// once we have multiple views, then we would rather reference some kind of router here
@@ -60,8 +70,7 @@ export class App extends LitElement {
render () {
return html`
`
}
diff --git a/app/client/lib/app.js b/app/client/lib/app.js
index 90f58019fe..011ae1e7de 100644
--- a/app/client/lib/app.js
+++ b/app/client/lib/app.js
@@ -1,49 +1,29 @@
'use strict'
/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
Initialization file of the Open Rowing Monitor App
*/
-
+/* eslint-disable no-console -- This runs client side, so I guess we have no logging capabilities? */
import NoSleep from 'nosleep.js'
import { filterObjectByKeys } from './helper.js'
-const rowingMetricsFields = ['strokesTotal', 'distanceTotal', 'caloriesTotal', 'power', 'heartrate',
- 'heartrateBatteryLevel', 'splitFormatted', 'strokesPerMinute', 'durationTotalFormatted']
-
export function createApp (app) {
- const urlParameters = new URLSearchParams(window.location.search)
- const mode = urlParameters.get('mode')
- const appMode = mode === 'standalone' ? 'STANDALONE' : mode === 'kiosk' ? 'KIOSK' : 'BROWSER'
- app.updateState({ ...app.getState(), appMode })
-
- const stravaAuthorizationCode = urlParameters.get('code')
-
let socket
initWebsocket()
resetFields()
requestWakeLock()
- function websocketOpened () {
- if (stravaAuthorizationCode) {
- handleStravaAuthorization(stravaAuthorizationCode)
- }
- }
-
- function handleStravaAuthorization (stravaAuthorizationCode) {
- if (socket)socket.send(JSON.stringify({ command: 'stravaAuthorizationCode', data: stravaAuthorizationCode }))
- }
-
let initialWebsocketOpenend = true
function initWebsocket () {
// use the native websocket implementation of browser to communicate with backend
socket = new WebSocket(`ws://${location.host}/websocket`)
+ /* eslint-disable-next-line no-unused-vars -- Standard construct?? */
socket.addEventListener('open', (event) => {
console.log('websocket opened')
if (initialWebsocketOpenend) {
- websocketOpened()
initialWebsocketOpenend = false
}
})
@@ -53,6 +33,7 @@ export function createApp (app) {
socket.close()
})
+ /* eslint-disable-next-line no-unused-vars -- Standard construct?? */
socket.addEventListener('close', (event) => {
console.log('websocket closed, attempting reconnect')
setTimeout(() => {
@@ -71,27 +52,15 @@ export function createApp (app) {
const data = message.data
switch (message.type) {
case 'config': {
- app.updateState({ ...app.getState(), config: data })
+ app.updateState({ ...app.getState(), config: { ...app.getState().config, ...data } })
break
}
case 'metrics': {
- let activeFields = rowingMetricsFields
- // if we are in reset state only update heart rate
- if (data.strokesTotal === 0) {
- activeFields = ['heartrate', 'heartrateBatteryLevel']
- }
-
- const filteredData = filterObjectByKeys(data, activeFields)
- app.updateState({ ...app.getState(), metrics: filteredData })
- break
- }
- case 'authorizeStrava': {
- const currentUrl = encodeURIComponent(window.location.href)
- window.location.href = `https://www.strava.com/oauth/authorize?client_id=${data.stravaClientId}&response_type=code&redirect_uri=${currentUrl}&approval_prompt=force&scope=activity:write`
+ app.updateState({ ...app.getState(), metrics: data })
break
}
default: {
- console.error(`unknown message type: ${message.type}`, message.data)
+ console.error('unknown message type: %s', message.type, message.data)
}
}
} catch (err) {
@@ -118,27 +87,38 @@ export function createApp (app) {
function resetFields () {
const appState = app.getState()
// drop all metrics except heartrate
- appState.metrics = filterObjectByKeys(appState.metrics, ['heartrate', 'heartrateBatteryLevel'])
- app.updateState(appState)
+ app.updateState({ ...appState, metrics: { ...filterObjectByKeys(appState.metrics, ['heartrate', 'heartRateBatteryLevel']) } })
}
function handleAction (action) {
+ if (!socket) {
+ console.error('no socket available for communication!')
+ return
+ }
switch (action.command) {
- case 'switchPeripheralMode': {
- if (socket)socket.send(JSON.stringify({ command: 'switchPeripheralMode' }))
+ case 'switchBlePeripheralMode': {
+ socket.send(JSON.stringify({ command: 'switchBlePeripheralMode' }))
+ break
+ }
+ case 'switchAntPeripheralMode': {
+ socket.send(JSON.stringify({ command: 'switchAntPeripheralMode' }))
+ break
+ }
+ case 'switchHrmMode': {
+ socket.send(JSON.stringify({ command: 'switchHrmMode' }))
break
}
case 'reset': {
resetFields()
- if (socket)socket.send(JSON.stringify({ command: 'reset' }))
+ socket.send(JSON.stringify({ command: 'reset' }))
break
}
- case 'uploadTraining': {
- if (socket)socket.send(JSON.stringify({ command: 'uploadTraining' }))
+ case 'upload': {
+ socket.send(JSON.stringify({ command: 'upload' }))
break
}
case 'shutdown': {
- if (socket)socket.send(JSON.stringify({ command: 'shutdown' }))
+ socket.send(JSON.stringify({ command: 'shutdown' }))
break
}
default: {
diff --git a/app/client/lib/helper.js b/app/client/lib/helper.js
index 16bae1394f..e249ea6298 100644
--- a/app/client/lib/helper.js
+++ b/app/client/lib/helper.js
@@ -1,6 +1,6 @@
'use strict'
/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
Helper functions
*/
@@ -14,3 +14,43 @@ export function filterObjectByKeys (object, keys) {
return obj
}, {})
}
+
+/**
+ * Pipe for converting seconds to a human readable time format 00:00
+ * @param {number} timeInSeconds The actual time in seconds.
+*/
+export function secondsToTimeString (timeInSeconds) {
+ if (timeInSeconds === undefined || timeInSeconds === null || isNaN(timeInSeconds)) { return '--' }
+ if (timeInSeconds === Infinity) { return '∞' }
+ const timeInRoundedSeconds = Math.round(timeInSeconds)
+ const hours = Math.floor(timeInRoundedSeconds / 3600)
+ const minutes = Math.floor(timeInRoundedSeconds / 60) - (hours * 60)
+ const seconds = Math.floor(timeInRoundedSeconds % 60)
+ if (hours > 0) {
+ return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
+ } else {
+ return `${minutes}:${seconds.toString().padStart(2, '0')}`
+ }
+}
+
+/**
+ * Pipe for formatting distance in meters with units
+ * @param {number} value The distance in meters.
+*/
+export function formatDistance (value) {
+ return value >= 99999.5 ?
+ { distance: formatNumber((value / 1000), 2), unit: 'km' } :
+ { distance: formatNumber(value), unit: 'm' }
+}
+
+/**
+ * Pipe for formatting numbers to specific decimal
+ * @param {number} value The number.
+ * @param {number} decimalPlaces The number of decimal places to round to (default: 0).
+*/
+export function formatNumber (value, decimalPlaces = 0) {
+ const decimal = Math.pow(10, decimalPlaces)
+ if (value === undefined || value === null || value === Infinity || isNaN(value) || value === 0) { return '--' }
+
+ return Math.round(value * decimal) / decimal
+}
diff --git a/app/client/lib/helper.test.js b/app/client/lib/helper.test.js
index 42fd02c692..96578a3b7e 100644
--- a/app/client/lib/helper.test.js
+++ b/app/client/lib/helper.test.js
@@ -1,6 +1,6 @@
'use strict'
/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
*/
import { test } from 'uvu'
import * as assert from 'uvu/assert'
diff --git a/app/client/lib/icons.js b/app/client/lib/icons.js
index 23b9a75668..85da58ae7a 100644
--- a/app/client/lib/icons.js
+++ b/app/client/lib/icons.js
@@ -1,27 +1,41 @@
'use strict'
/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
SVG Icons that are used by the Application
*/
import { svg } from 'lit'
-export const icon_route = svg``
-export const icon_stopwatch = svg``
-export const icon_bolt = svg``
-export const icon_paddle = svg`
+export const iconRoute = svg``
+export const iconStopwatch = svg``
+export const iconBolt = svg``
+export const iconPaddle = svg`
`
-export const icon_heartbeat = svg``
-export const icon_fire = svg``
-export const icon_clock = svg``
-export const icon_undo = svg``
-export const icon_poweroff = svg``
-export const icon_expand = svg``
-export const icon_compress = svg``
-export const icon_bluetooth = svg``
-export const icon_upload = svg``
+export const iconHeartbeat = svg``
+export const iconFire = svg``
+export const iconClock = svg``
+export const iconAlarmclock = svg`
+
+ `
+export const iconUndo = svg``
+export const iconPoweroff = svg``
+export const iconExpand = svg``
+export const iconCompress = svg``
+export const iconBluetooth = svg``
+export const iconUpload = svg``
+
+export const iconAntplus = svg``
+export const iconSettings = svg``
+export const rowerIcon = svg``
diff --git a/app/client/store/appState.js b/app/client/store/appState.js
index 3a93f55ebb..341d023405 100644
--- a/app/client/store/appState.js
+++ b/app/client/store/appState.js
@@ -1,21 +1,28 @@
'use strict'
/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
Defines the global state of the app
*/
export const APP_STATE = {
- // currently can be STANDALONE (Mobile Home Screen App), KIOSK (Raspberry Pi deployment) or '' (default)
- appMode: '',
// contains all the rowing metrics that are delivered from the backend
metrics: {},
config: {
- // currently can be FTMS, FTMSBIKE or PM5
- peripheralMode: '',
- // true if upload to strava is enabled
- stravaUploadEnabled: false,
+ // currently can be FTMS, FTMSBIKE, PM5, CSC, CPS, OFF
+ blePeripheralMode: '',
+ // currently can be ANT, BLE, OFF
+ hrmPeripheralMode: '',
+ // currently can be FE, OFF
+ antPeripheralMode: '',
+ // true if manual upload to strava, intervals or rowsandall is enabled
+ uploadEnabled: false,
// true if remote device shutdown is enabled
- shutdownEnabled: false
+ shutdownEnabled: false,
+ guiConfigs: {
+ dashboardMetrics: ['distance', 'timer', 'pace', 'power', 'stkRate', 'totalStk', 'calories', 'actions'],
+ showIcons: true,
+ maxNumberOfTiles: 8
+ }
}
}
diff --git a/app/client/store/dashboardMetrics.js b/app/client/store/dashboardMetrics.js
new file mode 100644
index 0000000000..ef677fa3c3
--- /dev/null
+++ b/app/client/store/dashboardMetrics.js
@@ -0,0 +1,110 @@
+'use strict'
+/*
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
+*/
+
+import { html } from 'lit'
+import { formatDistance, formatNumber, secondsToTimeString } from '../lib/helper'
+import { iconBolt, iconClock, iconAlarmclock, iconFire, iconHeartbeat, iconPaddle, iconRoute, iconStopwatch, rowerIcon } from '../lib/icons'
+import '../components/DashboardForceCurve.js'
+import '../components/DashboardActions.js'
+import '../components/DashboardMetric.js'
+import '../components/BatteryIcon.js'
+
+export const DASHBOARD_METRICS = {
+ distance: {
+ displayName: 'Distance',
+ size: 1,
+ template: (metrics, config) => {
+ let distance
+ switch (true) {
+ case (metrics?.interval.type === 'rest' && metrics?.pauseCountdownTime > 0):
+ distance = 0
+ break
+ case (metrics?.interval.type === 'distance'):
+ distance = Math.max(metrics?.interval.distance.toEnd, 0)
+ break
+ default:
+ distance = Math.max(metrics?.interval.distance.fromStart, 0)
+ }
+ const linearDistance = formatDistance(distance ?? 0)
+
+ return simpleMetricFactory(linearDistance.distance, linearDistance.unit, config.guiConfigs.showIcons ? iconRoute : '')
+ }
+ },
+
+ pace: { displayName: 'Pace/500', size: 1, template: (metrics, config) => simpleMetricFactory(secondsToTimeString(metrics?.cyclePace), '/500m', config.guiConfigs.showIcons ? iconStopwatch : '') },
+
+ power: { displayName: 'Power', size: 1, template: (metrics, config) => simpleMetricFactory(formatNumber(metrics?.cyclePower), 'watt', config.guiConfigs.showIcons ? iconBolt : '') },
+
+ stkRate: { displayName: 'Stroke rate', size: 1, template: (metrics, config) => simpleMetricFactory(formatNumber(metrics?.cycleStrokeRate), '/min', config.guiConfigs.showIcons ? iconPaddle : '') },
+ heartRate: {
+ displayName: 'Heart rate',
+ size: 1,
+ template: (metrics, config) => html`
+ ${metrics?.heartRateBatteryLevel > 0 ?
+ html`` :
+ ''}
+ `
+ },
+
+ totalStk: { displayName: 'Total strokes', size: 1, template: (metrics, config) => simpleMetricFactory(metrics?.totalNumberOfStrokes, 'stk', config.guiConfigs.showIcons ? iconPaddle : '') },
+
+ calories: {
+ displayName: 'Calories',
+ size: 1,
+ template: (metrics, config) => {
+ const calories = metrics?.interval.type === 'Calories' ? Math.max(metrics?.interval.TargetCalories - metrics?.interval.Calories, 0) : metrics?.totalCalories
+
+ return simpleMetricFactory(formatNumber(calories ?? 0), 'kcal', config.guiConfigs.showIcons ? iconFire : '')
+ }
+ },
+
+ timer: {
+ displayName: 'Timer',
+ size: 1,
+ template: (metrics, config) => {
+ let time
+ let icon
+ switch (true) {
+ case (metrics?.interval.type === 'rest' && metrics?.pauseCountdownTime > 0):
+ time = metrics?.pauseCountdownTime
+ icon = iconAlarmclock
+ break
+ case (metrics?.interval.type === 'time'):
+ time = Math.max(metrics?.interval.movingTime.toEnd, 0)
+ icon = iconClock
+ break
+ default:
+ time = Math.max(metrics?.interval.movingTime.sinceStart, 0)
+ icon = iconClock
+ }
+
+ return simpleMetricFactory(secondsToTimeString(time ?? 0), '', config.guiConfigs.showIcons ? icon : '')
+ }
+ },
+
+ distancePerStk: { displayName: 'Dist per Stroke', size: 1, template: (metrics, config) => simpleMetricFactory(formatNumber(metrics?.cycleDistance, 1), 'm', config.guiConfigs.showIcons ? rowerIcon : '') },
+
+ dragFactor: { displayName: 'Drag factor', size: 1, template: (metrics, config) => simpleMetricFactory(formatNumber(metrics?.dragFactor), '', config.guiConfigs.showIcons ? 'Drag' : '') },
+
+ driveLength: { displayName: 'Drive length', size: 1, template: (metrics, config) => simpleMetricFactory(formatNumber(metrics?.driveLength, 2), 'm', config.guiConfigs.showIcons ? 'Drive' : '') },
+
+ driveDuration: { displayName: 'Drive duration', size: 1, template: (metrics, config) => simpleMetricFactory(formatNumber(metrics?.driveDuration, 2), 'sec', config.guiConfigs.showIcons ? 'Drive' : '') },
+
+ recoveryDuration: { displayName: 'Recovery duration', size: 1, template: (metrics, config) => simpleMetricFactory(formatNumber(metrics?.recoveryDuration, 2), 'sec', config.guiConfigs.showIcons ? 'Recovery' : '') },
+
+ forceCurve: { displayName: 'Force curve', size: 2, template: (metrics) => html`` },
+
+ actions: { displayName: 'Actions', size: 1, template: (_, config) => html`` }
+}
+
+/**
+ * Helper function to create a simple metric tile
+ * @param {string | number} value The metric to show
+ * @param {string} unit The unit of the metric.
+ * @param {string | import('lit').TemplateResult<2>} icon The number of decimal places to round to (default: 0).
+*/
+function simpleMetricFactory (value = '--', unit = '', icon = '') {
+ return html``
+}
diff --git a/app/engine/Flywheel.js b/app/engine/Flywheel.js
new file mode 100644
index 0000000000..e2024e3c95
--- /dev/null
+++ b/app/engine/Flywheel.js
@@ -0,0 +1,376 @@
+'use strict'
+/*
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
+
+ This models the flywheel with all of its attributes, which we can also test for being powered
+
+ All times and distances are defined as being before the beginning of the flank, as RowingEngine's metrics
+ solely depend on times and angular positions before the flank (as they are to be certain to belong to a specific
+ drive or recovery phase).
+
+ Please note: The array contains a buffer of flankLenght measured currentDt's, BEFORE they are actually processed
+
+ Please note2: This implements Linear regression to obtain the drag factor. We deliberatly DO NOT include the flank data
+ as we don't know wether they will belong to a Drive or Recovery phase. So we include things which we know for certain that
+ are part of a specific phase, i.e. dirtyDataPoints[flankLength], which will be eliminated from the flank
+
+ The calculation of angular velocity and acceleration is based on Quadratic Regression, as the second derivative tends to be
+ quite fragile when small errors are thrown in the mix. The math behind this approach can be found in https://physics.info/motion-equations/
+ which is intended for simple linear motion, but the formula are identical when applied to angular distances, velocities and
+ accelerations.
+*/
+
+import loglevel from 'loglevel'
+import { createStreamFilter } from './utils/StreamFilter.js'
+import { createTSLinearSeries } from './utils/FullTSLinearSeries.js'
+import { createTSQuadraticSeries } from './utils/FullTSQuadraticSeries.js'
+import { createWeighedSeries } from './utils/WeighedSeries.js'
+
+const log = loglevel.getLogger('RowingEngine')
+
+export function createFlywheel (rowerSettings) {
+ const angularDisplacementPerImpulse = (2.0 * Math.PI) / rowerSettings.numOfImpulsesPerRevolution
+ const flankLength = rowerSettings.flankLength
+ const minimumDragFactorSamples = Math.floor(rowerSettings.minimumRecoveryTime / rowerSettings.maximumTimeBetweenImpulses)
+ const minimumAngularVelocity = angularDisplacementPerImpulse / rowerSettings.maximumTimeBetweenImpulses
+ const minimumTorqueBeforeStroke = rowerSettings.minimumForceBeforeStroke * (rowerSettings.sprocketRadius / 100)
+ const currentDt = createStreamFilter(rowerSettings.smoothing, rowerSettings.maximumTimeBetweenImpulses)
+ const _deltaTime = createTSLinearSeries(flankLength)
+ const _angularDistance = createTSQuadraticSeries(flankLength)
+ const drag = createWeighedSeries(rowerSettings.dragFactorSmoothing, (rowerSettings.dragFactor / 1000000))
+ const recoveryDeltaTime = createTSLinearSeries()
+ const strokedetectionMinimalGoodnessOfFit = rowerSettings.minimumStrokeQuality
+ const minimumRecoverySlope = createWeighedSeries(rowerSettings.dragFactorSmoothing, rowerSettings.minimumRecoverySlope)
+ let _angularVelocityMatrix = []
+ let _angularAccelerationMatrix = []
+ let _deltaTimeBeforeFlank
+ let _angularVelocityAtBeginFlank
+ let _angularVelocityBeforeFlank
+ let _angularAccelerationAtBeginFlank
+ let _angularAccelerationBeforeFlank
+ let _torqueAtBeginFlank
+ let _torqueBeforeFlank
+ let inRecoveryPhase
+ let maintainMetrics
+ let totalNumberOfImpulses
+ let totalTimeSpinning
+ let currentCleanTime
+ let currentRawTime
+ let currentAngularDistance
+ reset()
+
+ /* eslint-disable max-statements -- we need to maintain a lot of metrics in the main loop, nothing we can do about that */
+ function pushValue (dataPoint) {
+ if (isNaN(dataPoint) || dataPoint < 0 || dataPoint > rowerSettings.maximumStrokeTimeBeforePause) {
+ // This typicaly happends after a pause, we need to fix this as it throws off all time calculations
+ log.debug(`*** WARNING: currentDt of ${dataPoint} sec isn't between 0 and maximumStrokeTimeBeforePause (${rowerSettings.maximumStrokeTimeBeforePause} sec), value skipped`)
+ return
+ }
+
+ if (dataPoint > rowerSettings.maximumTimeBetweenImpulses && maintainMetrics) {
+ // This shouldn't happen, but let's log it to clarify there is some issue going on here
+ log.debug(`*** WARNING: currentDt of ${dataPoint} sec is above maximumTimeBetweenImpulses (${rowerSettings.maximumTimeBetweenImpulses} sec)`)
+ }
+
+ if (dataPoint < rowerSettings.minimumTimeBetweenImpulses) {
+ if (_deltaTime.length() >= flankLength && maintainMetrics) {
+ // We are in a normal operational mode, so this shouldn't happen, but let's log it to clarify there is some issue going on here, but accept the value as the TS estimator can handle it
+ log.debug(`*** WARNING: currentDt of ${dataPoint} sec is below minimumTimeBetweenImpulses (${rowerSettings.minimumTimeBetweenImpulses} sec)`)
+ } else {
+ // This is probably due to the start-up noise of a slow but accelerating flywheel as the flink isn't filled or we aren't maintaining metrics
+ log.debug(`*** WARNING: currentDt of ${dataPoint} sec is below minimumTimeBetweenImpulses (${rowerSettings.minimumTimeBetweenImpulses} sec) in a startup phase, value skipped, consider udjusting the gpio debounce filter`)
+ return
+ }
+ }
+
+ currentDt.push(dataPoint)
+
+ if (maintainMetrics && (_deltaTime.length() >= flankLength)) {
+ // If we maintain metrics, update the angular position, spinning time of the flywheel and the associated metrics,
+ // Also we nend feed the Drag calculation. We need to do this, BEFORE the array shifts, as the valueAtSeriesBeginvalue
+ // value before the shift is certain to be part of a specific rowing phase (i.e. Drive or Recovery), once the buffer is filled completely
+ totalNumberOfImpulses += 1
+ _deltaTimeBeforeFlank = _deltaTime.Y.atSeriesBegin()
+ totalTimeSpinning += _deltaTimeBeforeFlank
+ _angularVelocityBeforeFlank = _angularVelocityAtBeginFlank
+ _angularAccelerationBeforeFlank = _angularAccelerationAtBeginFlank
+ _torqueBeforeFlank = _torqueAtBeginFlank
+
+ // Feed the drag calculation, as we didn't reset the Semaphore in the previous cycle based on the current flank
+ if (inRecoveryPhase) {
+ recoveryDeltaTime.push(totalTimeSpinning, _deltaTimeBeforeFlank)
+ }
+ } else {
+ _deltaTimeBeforeFlank = 0
+ _angularVelocityBeforeFlank = 0
+ _angularAccelerationBeforeFlank = 0
+ _torqueBeforeFlank = 0
+ }
+
+ // Let's feed the stroke detection algorithm
+ // Please note that deltaTime MUST use dirty data to be ale to use the regression algorithms effictively (Otherwise the Goodness of Fit can't be used as a filter!)
+ currentRawTime += currentDt.raw()
+ currentAngularDistance += angularDisplacementPerImpulse
+ _deltaTime.push(currentRawTime, currentDt.raw())
+
+ // Next are the metrics that are needed for more advanced metrics, like the foce curve
+ currentCleanTime += currentDt.clean()
+ _angularDistance.push(currentCleanTime, currentAngularDistance)
+
+ // Let's update the matrix and calculate the angular velocity and acceleration
+ if (_angularVelocityMatrix.length >= flankLength) {
+ // The angularVelocityMatrix has reached its maximum length
+ _angularVelocityMatrix.shift()
+ _angularAccelerationMatrix.shift()
+ }
+
+ // Let's make room for a new set of values for angular velocity and acceleration
+ _angularVelocityMatrix[_angularVelocityMatrix.length] = createWeighedSeries(flankLength, 0)
+ _angularAccelerationMatrix[_angularAccelerationMatrix.length] = createWeighedSeries(flankLength, 0)
+
+ let i = 0
+
+ while (i < _angularVelocityMatrix.length) {
+ _angularVelocityMatrix[i].push(_angularDistance.firstDerivativeAtPosition(i), _angularDistance.goodnessOfFit())
+ _angularAccelerationMatrix[i].push(_angularDistance.secondDerivativeAtPosition(i), _angularDistance.goodnessOfFit())
+ i++
+ }
+
+ _angularVelocityAtBeginFlank = _angularVelocityMatrix[0].weighedAverage()
+ _angularAccelerationAtBeginFlank = _angularAccelerationMatrix[0].weighedAverage()
+
+ // And finally calculate the torque
+ _torqueAtBeginFlank = (rowerSettings.flywheelInertia * _angularAccelerationAtBeginFlank + drag.weighedAverage() * Math.pow(_angularVelocityAtBeginFlank, 2))
+ }
+ /* eslint-enable max-statements */
+
+ function maintainStateOnly () {
+ maintainMetrics = false
+ }
+
+ function maintainStateAndMetrics () {
+ maintainMetrics = true
+ }
+
+ function markRecoveryPhaseStart () {
+ inRecoveryPhase = true
+ recoveryDeltaTime.reset()
+ }
+
+ function markRecoveryPhaseCompleted () {
+ // Completion of the recovery phase
+ inRecoveryPhase = false
+
+ // Calculation of the drag-factor
+ if (rowerSettings.autoAdjustDragFactor && recoveryDeltaTime.length() > minimumDragFactorSamples && recoveryDeltaTime.slope() > 0 && (!drag.reliable() || recoveryDeltaTime.goodnessOfFit() >= rowerSettings.minimumDragQuality)) {
+ drag.push(slopeToDrag(recoveryDeltaTime.slope()), recoveryDeltaTime.goodnessOfFit())
+
+ log.debug(`*** Calculated drag factor: ${(slopeToDrag(recoveryDeltaTime.slope()) * 1000000).toFixed(4)}, no. samples: ${recoveryDeltaTime.length()}, Goodness of Fit: ${recoveryDeltaTime.goodnessOfFit().toFixed(4)}`)
+ if (rowerSettings.autoAdjustRecoverySlope) {
+ // We are allowed to autoadjust stroke detection slope as well, so let's do that
+ minimumRecoverySlope.push((1 - rowerSettings.autoAdjustRecoverySlopeMargin) * recoveryDeltaTime.slope(), recoveryDeltaTime.goodnessOfFit())
+ log.debug(`*** Calculated recovery slope: ${recoveryDeltaTime.slope().toFixed(6)}, Goodness of Fit: ${recoveryDeltaTime.goodnessOfFit().toFixed(4)}`)
+ } else {
+ // We aren't allowed to adjust the slope, let's report the slope to help help the user configure it
+ log.debug(`*** Calculated recovery slope: ${recoveryDeltaTime.slope().toFixed(6)}, Goodness of Fit: ${recoveryDeltaTime.goodnessOfFit().toFixed(4)}, not used as autoAdjustRecoverySlope isn't set to true`)
+ }
+ } else {
+ if (!rowerSettings.autoAdjustDragFactor) {
+ // autoAdjustDampingConstant = false, thus the update is skipped, but let's log the dragfactor anyway
+ log.debug(`*** Calculated drag factor: ${(slopeToDrag(recoveryDeltaTime.slope()) * 1000000).toFixed(4)}, slope: ${recoveryDeltaTime.slope().toFixed(8)}, not used because autoAdjustDragFactor is not true`)
+ } else {
+ log.debug(`*** Calculated drag factor: ${(slopeToDrag(recoveryDeltaTime.slope()) * 1000000).toFixed(4)}, not used because reliability was too low. no. samples: ${recoveryDeltaTime.length()}, fit: ${recoveryDeltaTime.goodnessOfFit().toFixed(4)}`)
+ }
+ }
+ }
+
+ function spinningTime () {
+ // This function returns the time the flywheel is spinning in seconds BEFORE the beginning of the flank
+ return totalTimeSpinning
+ }
+
+ function deltaTime () {
+ return _deltaTimeBeforeFlank
+ }
+
+ function angularPosition () {
+ // This function returns the absolute angular position of the flywheel in Radians BEFORE the beginning of the flank
+ return totalNumberOfImpulses * angularDisplacementPerImpulse
+ }
+
+ function angularVelocity () {
+ // This function returns the angular velocity of the flywheel in Radians/sec BEFORE the flank
+ if (maintainMetrics && (_deltaTime.length() >= flankLength)) {
+ return Math.max(0, _angularVelocityBeforeFlank)
+ } else {
+ return 0
+ }
+ }
+
+ function angularAcceleration () {
+ // This function returns the angular acceleration of the flywheel in Radians/sec^2 BEFORE the flanl
+ if (maintainMetrics && (_deltaTime.length() >= flankLength)) {
+ return _angularAccelerationBeforeFlank
+ } else {
+ return 0
+ }
+ }
+
+ function torque () {
+ if (maintainMetrics && (_deltaTime.length() >= flankLength)) {
+ return _torqueBeforeFlank
+ } else {
+ return 0
+ }
+ }
+
+ function dragFactor () {
+ // This function returns the current dragfactor of the flywheel
+ return drag.weighedAverage()
+ }
+
+ function dragFactorIsReliable () {
+ // This returns whether the dragfactor is considered reliable, based on measurements instead of a default value
+ // We can't use reliable() as a filter on the dragFactor() function as Rower.js always needs some dragfactor for most calculations
+ if (rowerSettings.autoAdjustDragFactor) {
+ return drag.reliable()
+ } else {
+ return true
+ }
+ }
+
+ function isDwelling () {
+ // Check if the flywheel is spinning down beyond a recovery phase indicating that the rower has stopped rowing
+ // We conclude this based on
+ // * The angular velocity at the begin of the flank is above the minimum angular velocity (dependent on maximumTimeBetweenImpulses)
+ // * The entire flank has a positive trend, i.e. the flywheel is decelerating consistent with the dragforce being present
+ if (_angularVelocityAtBeginFlank < minimumAngularVelocity && deltaTimeSlopeAbove(minimumRecoverySlope.weighedAverage())) {
+ return true
+ } else {
+ return false
+ }
+ }
+
+ function isAboveMinimumSpeed () {
+ // Check if the flywheel has reached its minimum speed, and that it isn't flywheel noise. We conclude this based on the first element in the flank
+ // as this angular velocity is created by all curves that are in that flank and having an acceleration in the rest of the flank
+ if ((_angularVelocityAtBeginFlank >= minimumAngularVelocity) && (_deltaTime.Y.atSeriesBegin() <= rowerSettings.maximumTimeBetweenImpulses) && (_deltaTime.Y.atSeriesBegin() > rowerSettings.minimumTimeBetweenImpulses)) {
+ return true
+ } else {
+ return false
+ }
+ }
+
+ function isUnpowered () {
+ // We consider the flywheel unpowered when there is an acceleration consistent with the drag being the only forces AND no torque being seen
+ // As in the first stroke drag is unreliable for automatic drag updating machines, torque can't be used when drag indicates it is unreliable for these machines
+ if (deltaTimeSlopeAbove(minimumRecoverySlope.weighedAverage()) && (torqueAbsent() || (rowerSettings.autoAdjustDragFactor && !drag.reliable()))) {
+ return true
+ } else {
+ return false
+ }
+ }
+
+ function isPowered () {
+ if (deltaTimeSlopeBelow(minimumRecoverySlope.weighedAverage()) && torquePresent()) {
+ return true
+ } else {
+ return false
+ }
+ }
+
+ function deltaTimeSlopeBelow (threshold) {
+ // This is a typical indication that the flywheel is accelerating. We use the slope of successive currentDt's
+ // A (more) negative slope indicates a powered flywheel. When set to 0, it determines whether the DeltaT's are decreasing
+ // When set to a value below 0, it will become more stringent. In automatic, a percentage of the current slope (i.e. dragfactor) is used
+ // Please note, as this acceleration isn't linear, _deltaTime.goodnessOfFit() will not be good by definition, so we need omit it
+ if (_deltaTime.slope() < threshold && _deltaTime.length() >= flankLength) {
+ return true
+ } else {
+ return false
+ }
+ }
+
+ function deltaTimeSlopeAbove (threshold) {
+ // This is a typical indication that the flywheel is deccelerating. We use the slope of successive currentDt's
+ // A (more) positive slope indicates a unpowered flywheel. When set to 0, it determines whether the DeltaT's are increasing
+ // When set to a value below 0, it will become more stringent as it will detect a power inconsistent with the drag
+ // Typically, a percentage of the current slope (i.e. dragfactor) is use
+ if (_deltaTime.slope() >= threshold && _deltaTime.goodnessOfFit() >= strokedetectionMinimalGoodnessOfFit && _deltaTime.length() >= flankLength) {
+ return true
+ } else {
+ return false
+ }
+ }
+
+ function torquePresent () {
+ // This is a typical indication that the flywheel is accelerating: the torque is above a certain threshold (so a force is present on the handle)
+ if (_torqueAtBeginFlank >= minimumTorqueBeforeStroke) {
+ return true
+ } else {
+ return false
+ }
+ }
+
+ function torqueAbsent () {
+ // This is a typical indication that the flywheel is decelerating: the torque is below a certain threshold (so a force is absent on the handle)
+ // We need to consider the situation rowerSettings.autoAdjustDragFactor && !drag.reliable() as a high default dragfactor (as set via config) blocks the
+ // detection of the first recovery based on Torque, and thus the calculation of the true dragfactor in that setting.
+ // This let the recovery detection fall back onto slope-based stroke detection only for the first stroke (until drag is calculated reliably)
+ if (_torqueAtBeginFlank < minimumTorqueBeforeStroke) {
+ return true
+ } else {
+ return false
+ }
+ }
+
+ function slopeToDrag (slope) {
+ return ((slope * rowerSettings.flywheelInertia) / angularDisplacementPerImpulse)
+ }
+
+ function reset () {
+ maintainMetrics = false
+ inRecoveryPhase = false
+ drag.reset()
+ recoveryDeltaTime.reset()
+ _deltaTime.reset()
+ _angularDistance.reset()
+ totalNumberOfImpulses = 0
+ totalTimeSpinning = 0
+ currentCleanTime = 0
+ currentRawTime = 0
+ currentAngularDistance = 0
+ _angularVelocityMatrix = null
+ _angularVelocityMatrix = []
+ _angularAccelerationMatrix = null
+ _angularAccelerationMatrix = []
+ _deltaTime.push(0, 0)
+ _angularDistance.push(0, 0)
+ _deltaTimeBeforeFlank = 0
+ _angularVelocityBeforeFlank = 0
+ _angularAccelerationBeforeFlank = 0
+ _torqueAtBeginFlank = 0
+ _torqueBeforeFlank = 0
+ }
+
+ return {
+ pushValue,
+ maintainStateOnly,
+ maintainStateAndMetrics,
+ markRecoveryPhaseStart,
+ markRecoveryPhaseCompleted,
+ spinningTime,
+ deltaTime,
+ angularPosition,
+ angularVelocity,
+ angularAcceleration,
+ torque,
+ dragFactor,
+ dragFactorIsReliable,
+ isDwelling,
+ isAboveMinimumSpeed,
+ isUnpowered,
+ isPowered,
+ reset
+ }
+}
diff --git a/app/engine/Flywheel.test.js b/app/engine/Flywheel.test.js
new file mode 100644
index 0000000000..0fa485eb45
--- /dev/null
+++ b/app/engine/Flywheel.test.js
@@ -0,0 +1,370 @@
+'use strict'
+/*
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
+*/
+import { test } from 'uvu'
+import * as assert from 'uvu/assert'
+import { deepMerge } from '../tools/Helper.js'
+import { replayRowingSession } from '../recorders/RowingReplayer.js'
+import rowerProfiles from '../../config/rowerProfiles.js'
+
+import { createFlywheel } from './Flywheel.js'
+
+const baseConfig = { // Based on Concept 2 settings, as this is the validation system
+ numOfImpulsesPerRevolution: 6,
+ sprocketRadius: 1.4,
+ maximumStrokeTimeBeforePause: 6.0,
+ dragFactor: 110,
+ autoAdjustDragFactor: true,
+ minimumDragQuality: 0.95,
+ dragFactorSmoothing: 3,
+ minimumTimeBetweenImpulses: 0.005,
+ maximumTimeBetweenImpulses: 0.020,
+ flankLength: 12,
+ smoothing: 1,
+ minimumStrokeQuality: 0.36,
+ minimumForceBeforeStroke: 10,
+ minimumRecoverySlope: 0.00070,
+ autoAdjustRecoverySlope: true,
+ autoAdjustRecoverySlopeMargin: 0.15,
+ minimumDriveTime: 0.40,
+ minimumRecoveryTime: 0.90,
+ flywheelInertia: 0.1031,
+ magicConstant: 2.8
+}
+
+// Test behaviour for no datapoints
+test('Correct Flywheel behaviour at initialisation', () => {
+ const flywheel = createFlywheel(baseConfig)
+ testDeltaTime(flywheel, 0)
+ testSpinningTime(flywheel, 0)
+ testAngularPosition(flywheel, 0)
+ testAngularVelocity(flywheel, 0)
+ testAngularAcceleration(flywheel, 0)
+ testTorque(flywheel, 0)
+ testDragFactor(flywheel, 0.00011)
+ testIsDwelling(flywheel, false)
+ testIsUnpowered(flywheel, false)
+ testIsPowered(flywheel, false)
+})
+
+// Test behaviour for one datapoint
+
+// Test behaviour for perfect upgoing flank
+
+// Test behaviour for perfect downgoing flank
+
+// Test behaviour for perfect stroke
+test('Correct Flywheel behaviour for a noisefree stroke', () => {
+ const flywheel = createFlywheel(baseConfig)
+ flywheel.maintainStateAndMetrics()
+ testDeltaTime(flywheel, 0)
+ testSpinningTime(flywheel, 0)
+ testAngularPosition(flywheel, 0)
+ testAngularVelocity(flywheel, 0)
+ testAngularAcceleration(flywheel, 0)
+ testTorque(flywheel, 0)
+ testDragFactor(flywheel, 0.00011)
+ testIsDwelling(flywheel, false)
+ testIsUnpowered(flywheel, false)
+ testIsPowered(flywheel, false)
+ flywheel.pushValue(0.011221636)
+ flywheel.pushValue(0.011175504)
+ flywheel.pushValue(0.01116456)
+ flywheel.pushValue(0.011130263)
+ flywheel.pushValue(0.011082613)
+ flywheel.pushValue(0.011081761)
+ flywheel.pushValue(0.011062297)
+ flywheel.pushValue(0.011051853)
+ flywheel.pushValue(0.010973313)
+ flywheel.pushValue(0.010919756)
+ flywheel.pushValue(0.01086431)
+ flywheel.pushValue(0.010800864)
+ flywheel.pushValue(0.010956987)
+ flywheel.pushValue(0.010653396)
+ flywheel.pushValue(0.010648619)
+ flywheel.pushValue(0.010536818)
+ flywheel.pushValue(0.010526151)
+ flywheel.pushValue(0.010511225)
+ flywheel.pushValue(0.010386684)
+ testDeltaTime(flywheel, 0.011062297)
+ testSpinningTime(flywheel, 0.077918634)
+ testAngularPosition(flywheel, 8.377580409572781)
+ testAngularVelocity(flywheel, 94.77498684553687)
+ testAngularAcceleration(flywheel, 28.980405331480235)
+ testTorque(flywheel, 3.975932584148498)
+ testDragFactor(flywheel, 0.00011)
+ testIsDwelling(flywheel, false)
+ testIsUnpowered(flywheel, false)
+ testIsPowered(flywheel, true)
+ flywheel.pushValue(0.010769)
+ flywheel.pushValue(0.010707554)
+ flywheel.pushValue(0.010722165)
+ flywheel.pushValue(0.01089567)
+ flywheel.pushValue(0.010917504)
+ flywheel.pushValue(0.010997969)
+ flywheel.pushValue(0.011004655)
+ flywheel.pushValue(0.011013618)
+ flywheel.pushValue(0.011058193)
+ flywheel.pushValue(0.010807149)
+ flywheel.pushValue(0.0110626)
+ flywheel.pushValue(0.011090787)
+ flywheel.pushValue(0.011099509)
+ flywheel.pushValue(0.011131862)
+ flywheel.pushValue(0.011209919)
+ testDeltaTime(flywheel, 0.010722165)
+ testSpinningTime(flywheel, 0.23894732900000007)
+ testAngularPosition(flywheel, 24.085543677521745)
+ testAngularVelocity(flywheel, 97.12541571421204)
+ testAngularAcceleration(flywheel, -29.657604177526746)
+ testTorque(flywheel, -2.0200308891605716)
+ testDragFactor(flywheel, 0.00011)
+ testIsDwelling(flywheel, false)
+ testIsUnpowered(flywheel, true)
+ testIsPowered(flywheel, false)
+ flywheel.pushValue(0.020769)
+ flywheel.pushValue(0.020707554)
+ flywheel.pushValue(0.020722165)
+ flywheel.pushValue(0.02089567)
+ flywheel.pushValue(0.020917504)
+ flywheel.pushValue(0.020997969)
+ flywheel.pushValue(0.021004655)
+ flywheel.pushValue(0.021013618)
+ flywheel.pushValue(0.021058193)
+ flywheel.pushValue(0.020807149)
+ flywheel.pushValue(0.0210626)
+ flywheel.pushValue(0.021090787)
+ flywheel.pushValue(0.021099509)
+ flywheel.pushValue(0.021131862)
+ flywheel.pushValue(0.021209919)
+ testDeltaTime(flywheel, 0.020722165)
+ testSpinningTime(flywheel, 0.43343548300000007)
+ testAngularPosition(flywheel, 39.79350694547071)
+ testAngularVelocity(flywheel, 50.85265548983507)
+ testAngularAcceleration(flywheel, -159.89027501034317)
+ testTorque(flywheel, -16.20022817082592)
+ testDragFactor(flywheel, 0.00011)
+ testIsDwelling(flywheel, true)
+ testIsUnpowered(flywheel, true)
+ testIsPowered(flywheel, false)
+})
+
+// Test behaviour for noisy upgoing flank
+
+// Test behaviour for noisy downgoing flank
+
+// Test behaviour for noisy stroke
+
+// Test drag factor calculation
+
+// Test Dynamic stroke detection
+
+// Test behaviour for not maintaining metrics
+test('Correct Flywheel behaviour at maintainStateOnly', () => {
+ const flywheel = createFlywheel(baseConfig)
+ flywheel.maintainStateAndMetrics()
+ testDeltaTime(flywheel, 0)
+ testSpinningTime(flywheel, 0)
+ testAngularPosition(flywheel, 0)
+ testAngularVelocity(flywheel, 0)
+ testAngularAcceleration(flywheel, 0)
+ testTorque(flywheel, 0)
+ testDragFactor(flywheel, 0.00011)
+ testIsDwelling(flywheel, false)
+ testIsUnpowered(flywheel, false)
+ testIsPowered(flywheel, false)
+ flywheel.maintainStateOnly()
+ flywheel.pushValue(0.011221636)
+ flywheel.pushValue(0.011175504)
+ flywheel.pushValue(0.01116456)
+ flywheel.pushValue(0.011130263)
+ flywheel.pushValue(0.011082613)
+ flywheel.pushValue(0.011081761)
+ flywheel.pushValue(0.011062297)
+ flywheel.pushValue(0.011051853)
+ flywheel.pushValue(0.010973313)
+ flywheel.pushValue(0.010919756)
+ flywheel.pushValue(0.01086431)
+ flywheel.pushValue(0.010800864)
+ flywheel.pushValue(0.010956987)
+ flywheel.pushValue(0.010653396)
+ flywheel.pushValue(0.010648619)
+ flywheel.pushValue(0.010536818)
+ flywheel.pushValue(0.010526151)
+ flywheel.pushValue(0.010511225)
+ flywheel.pushValue(0.010386684)
+ testDeltaTime(flywheel, 0)
+ testSpinningTime(flywheel, 0)
+ testAngularPosition(flywheel, 0)
+ testAngularVelocity(flywheel, 0)
+ testAngularAcceleration(flywheel, 0)
+ testTorque(flywheel, 0)
+ testDragFactor(flywheel, 0.00011)
+ testIsDwelling(flywheel, false)
+ testIsUnpowered(flywheel, false)
+ testIsPowered(flywheel, true)
+ flywheel.pushValue(0.010769)
+ flywheel.pushValue(0.010707554)
+ flywheel.pushValue(0.010722165)
+ flywheel.pushValue(0.01089567)
+ flywheel.pushValue(0.010917504)
+ flywheel.pushValue(0.010997969)
+ flywheel.pushValue(0.011004655)
+ flywheel.pushValue(0.011013618)
+ flywheel.pushValue(0.011058193)
+ flywheel.pushValue(0.010807149)
+ flywheel.pushValue(0.0110626)
+ flywheel.pushValue(0.011090787)
+ flywheel.pushValue(0.011099509)
+ flywheel.pushValue(0.011131862)
+ flywheel.pushValue(0.011209919)
+ testDeltaTime(flywheel, 0)
+ testSpinningTime(flywheel, 0)
+ testAngularPosition(flywheel, 0)
+ testAngularVelocity(flywheel, 0)
+ testAngularAcceleration(flywheel, 0)
+ testTorque(flywheel, 0)
+ testDragFactor(flywheel, 0.00011)
+ testIsDwelling(flywheel, false)
+ testIsUnpowered(flywheel, true)
+ testIsPowered(flywheel, false)
+})
+
+test('Correct Flywheel behaviour with a SportsTech WRX700', async () => {
+ const flywheel = createFlywheel(deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Sportstech_WRX700))
+ testSpinningTime(flywheel, 0)
+ testAngularPosition(flywheel, 0)
+ testDragFactor(flywheel, (rowerProfiles.Sportstech_WRX700.dragFactor / 1000000))
+ flywheel.maintainStateAndMetrics()
+
+ // Inject 16 strokes
+ await replayRowingSession(flywheel.pushValue, { filename: 'recordings/WRX700_2magnets.csv', realtime: false, loop: false })
+ testSpinningTime(flywheel, 46.302522627)
+ testAngularPosition(flywheel, 741.4158662471912)
+ testDragFactor(flywheel, (rowerProfiles.Sportstech_WRX700.dragFactor / 1000000))
+})
+
+test('Correct Flywheel behaviour with a DKN R-320', async () => {
+ const flywheel = createFlywheel(deepMerge(rowerProfiles.DEFAULT, rowerProfiles.DKN_R320))
+ testSpinningTime(flywheel, 0)
+ testAngularPosition(flywheel, 0)
+ testDragFactor(flywheel, (rowerProfiles.DKN_R320.dragFactor / 1000000))
+ flywheel.maintainStateAndMetrics()
+
+ // Inject 10 strokes
+ await replayRowingSession(flywheel.pushValue, { filename: 'recordings/DKNR320.csv', realtime: false, loop: false })
+
+ testSpinningTime(flywheel, 22.249536391000003)
+ testAngularPosition(flywheel, 496.37163926718733)
+ // As dragfactor is static, it should remain the same
+ testDragFactor(flywheel, (rowerProfiles.DKN_R320.dragFactor / 1000000))
+})
+
+test('Correct Flywheel behaviour with a NordicTrack RX800', async () => {
+ const flywheel = createFlywheel(deepMerge(rowerProfiles.DEFAULT, rowerProfiles.NordicTrack_RX800))
+ testSpinningTime(flywheel, 0)
+ testAngularPosition(flywheel, 0)
+ testDragFactor(flywheel, (rowerProfiles.NordicTrack_RX800.dragFactor / 1000000))
+ flywheel.maintainStateAndMetrics()
+
+ // Inject 10 strokes
+ await replayRowingSession(flywheel.pushValue, { filename: 'recordings/RX800.csv', realtime: false, loop: false })
+
+ testSpinningTime(flywheel, 22.612226401999987)
+ testAngularPosition(flywheel, 1443.5618243245099)
+ // As we don't detect strokes here (this is a function of Rower.js, the dragcalculation shouldn't be triggered
+ testDragFactor(flywheel, (rowerProfiles.NordicTrack_RX800.dragFactor / 1000000))
+})
+
+test('Correct Flywheel behaviour with a full session on a SportsTech WRX700', async () => {
+ const flywheel = createFlywheel(deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Sportstech_WRX700))
+ testSpinningTime(flywheel, 0)
+ testAngularPosition(flywheel, 0)
+ testDragFactor(flywheel, (rowerProfiles.Sportstech_WRX700.dragFactor / 1000000))
+ flywheel.maintainStateAndMetrics()
+
+ // Inject 846 strokes
+ await replayRowingSession(flywheel.pushValue, { filename: 'recordings/WRX700_2magnets_session.csv', realtime: false, loop: false })
+ testSpinningTime(flywheel, 2340.0100514160117)
+ testAngularPosition(flywheel, 37325.26231730033)
+ // The dragfactor should remain static
+ testDragFactor(flywheel, (rowerProfiles.Sportstech_WRX700.dragFactor / 1000000))
+})
+
+test('A full session for a Concept2 Model C should produce plausible results', async () => {
+ const flywheel = createFlywheel(deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Concept2_Model_C))
+ testSpinningTime(flywheel, 0)
+ testAngularPosition(flywheel, 0)
+ testDragFactor(flywheel, (rowerProfiles.Concept2_Model_C.dragFactor / 1000000))
+ flywheel.maintainStateAndMetrics()
+
+ await replayRowingSession(flywheel.pushValue, { filename: 'recordings/Concept2_Model_C.csv', realtime: false, loop: false })
+
+ testSpinningTime(flywheel, 181.47141999999985)
+ testAngularPosition(flywheel, 15636.753834467596)
+ // As we don't detect strokes here (this is a function of Rower.js, the dragcalculation shouldn't be triggered
+ testDragFactor(flywheel, (rowerProfiles.Concept2_Model_C.dragFactor / 1000000))
+})
+
+test('A full session for a Concept2 RowErg should produce plausible results', async () => {
+ const flywheel = createFlywheel(deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Concept2_RowErg))
+ testSpinningTime(flywheel, 0)
+ testAngularPosition(flywheel, 0)
+ testDragFactor(flywheel, (rowerProfiles.Concept2_RowErg.dragFactor / 1000000))
+ flywheel.maintainStateAndMetrics()
+
+ await replayRowingSession(flywheel.pushValue, { filename: 'recordings/Concept2_RowErg_Session_2000meters.csv', realtime: false, loop: false })
+
+ testSpinningTime(flywheel, 591.0432650000008)
+ testAngularPosition(flywheel, 65961.92655232249)
+ // As we don't detect strokes here (this is a function of Rower.js, the dragcalculation shouldn't be triggered
+ testDragFactor(flywheel, (rowerProfiles.Concept2_RowErg.dragFactor / 1000000))
+})
+
+// Test behaviour after reset
+
+function testDeltaTime (flywheel, expectedValue) {
+ assert.ok(flywheel.deltaTime() === expectedValue, `deltaTime should be ${expectedValue} sec at ${flywheel.spinningTime()} sec, is ${flywheel.deltaTime()}`)
+}
+
+function testSpinningTime (flywheel, expectedValue) {
+ assert.ok(flywheel.spinningTime() === expectedValue, `spinningTime should be ${expectedValue} sec at ${flywheel.spinningTime()} sec, is ${flywheel.spinningTime()}`)
+}
+
+function testAngularPosition (flywheel, expectedValue) {
+ assert.ok(flywheel.angularPosition() === expectedValue, `angularPosition should be ${expectedValue} Radians at ${flywheel.spinningTime()} sec, is ${flywheel.angularPosition()}`)
+}
+
+function testAngularVelocity (flywheel, expectedValue) {
+ assert.ok(flywheel.angularVelocity() === expectedValue, `angularVelocity should be ${expectedValue} Radians/sec at ${flywheel.spinningTime()} sec, is ${flywheel.angularVelocity()}`)
+}
+
+function testAngularAcceleration (flywheel, expectedValue) {
+ assert.ok(flywheel.angularAcceleration() === expectedValue, `angularAcceleration should be ${expectedValue} Radians/sec^2 at ${flywheel.spinningTime()} sec, is ${flywheel.angularAcceleration()}`)
+}
+
+function testTorque (flywheel, expectedValue) {
+ assert.ok(flywheel.torque() === expectedValue, `Torque should be ${expectedValue} N/M at ${flywheel.spinningTime()} sec, is ${flywheel.torque()}`)
+}
+
+function testDragFactor (flywheel, expectedValue) {
+ assert.ok(flywheel.dragFactor() === expectedValue, `Drag Factor should be ${expectedValue} N*m*s^2 at ${flywheel.spinningTime()} sec, is ${flywheel.dragFactor()}`)
+}
+
+function testIsDwelling (flywheel, expectedValue) {
+ assert.ok(flywheel.isDwelling() === expectedValue, `isDwelling should be ${expectedValue} at ${flywheel.spinningTime()} sec, is ${flywheel.isDwelling()}`)
+}
+
+function testIsUnpowered (flywheel, expectedValue) {
+ assert.ok(flywheel.isUnpowered() === expectedValue, `isUnpowered should be ${expectedValue} at ${flywheel.spinningTime()} sec, is ${flywheel.isUnpowered()}`)
+}
+
+function testIsPowered (flywheel, expectedValue) {
+ assert.ok(flywheel.isPowered() === expectedValue, `isPowered should be ${expectedValue} at ${flywheel.spinningTime()} sec, is ${flywheel.isPowered()}`)
+}
+
+function reportAll (flywheel) { // eslint-disable-line no-unused-vars
+ assert.ok(0, `deltaTime: ${flywheel.deltaTime()}, spinningTime: ${flywheel.spinningTime()}, ang. pos: ${flywheel.angularPosition()}, ang. vel: ${flywheel.angularVelocity()}, Ang. acc: ${flywheel.angularAcceleration()}, Torque: ${flywheel.torque()}, DF: ${flywheel.dragFactor()}`)
+}
+
+test.run()
diff --git a/app/engine/MovingFlankDetector.js b/app/engine/MovingFlankDetector.js
deleted file mode 100644
index 3454eb70e0..0000000000
--- a/app/engine/MovingFlankDetector.js
+++ /dev/null
@@ -1,193 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- A Detector used to test for up-going and down-going flanks
-
- Please note: The array contains flankLength + 1 measured currentDt's, thus flankLength number of flanks between them
- They are arranged that dataPoints[0] is the youngest, and dataPoints[flankLength] the oldest
-*/
-import loglevel from 'loglevel'
-import { createMovingAverager } from './averager/MovingAverager.js'
-const log = loglevel.getLogger('RowingEngine')
-
-function createMovingFlankDetector (rowerSettings) {
- const angularDisplacementPerImpulse = (2.0 * Math.PI) / rowerSettings.numOfImpulsesPerRevolution
- const dirtyDataPoints = new Array(rowerSettings.flankLength + 1)
- dirtyDataPoints.fill(rowerSettings.maximumTimeBetweenImpulses)
- const cleanDataPoints = new Array(rowerSettings.flankLength + 1)
- cleanDataPoints.fill(rowerSettings.maximumTimeBetweenImpulses)
- const angularVelocity = new Array(rowerSettings.flankLength + 1)
- angularVelocity.fill(angularDisplacementPerImpulse / rowerSettings.minimumTimeBetweenImpulses)
- const angularAcceleration = new Array(rowerSettings.flankLength + 1)
- angularAcceleration.fill(0.1)
- const movingAverage = createMovingAverager(rowerSettings.smoothing, rowerSettings.maximumTimeBetweenImpulses)
- let numberOfSequentialCorrections = 0
- const maxNumberOfSequentialCorrections = (rowerSettings.smoothing >= 2 ? rowerSettings.smoothing : 2)
-
- function pushValue (dataPoint) {
- // add the new dataPoint to the array, we have to move data points starting at the oldest ones
- let i = rowerSettings.flankLength
- while (i > 0) {
- // older data points are moved toward the higher numbers
- dirtyDataPoints[i] = dirtyDataPoints[i - 1]
- cleanDataPoints[i] = cleanDataPoints[i - 1]
- angularVelocity[i] = angularVelocity[i - 1]
- angularAcceleration[i] = angularAcceleration[i - 1]
- i = i - 1
- }
- dirtyDataPoints[0] = dataPoint
-
- // reduce noise in the measurements by applying some sanity checks
- // noise filter on the value of dataPoint: it should be within sane levels and should not deviate too much from the previous reading
- if (dataPoint < rowerSettings.minimumTimeBetweenImpulses || dataPoint > rowerSettings.maximumTimeBetweenImpulses) {
- // impulseTime is outside plausible ranges, so we assume it is close to the previous clean one
- log.debug(`noise filter corrected currentDt, ${dataPoint} was not between minimumTimeBetweenImpulses and maximumTimeBetweenImpulses, changed to ${cleanDataPoints[1]}`)
- dataPoint = cleanDataPoints[1]
- }
-
- // lets test if pushing this value would fit the curve we are looking for
- movingAverage.pushValue(dataPoint)
-
- if (movingAverage.getAverage() > (rowerSettings.maximumDownwardChange * cleanDataPoints[1]) && movingAverage.getAverage() < (rowerSettings.maximumUpwardChange * cleanDataPoints[1])) {
- numberOfSequentialCorrections = 0
- } else {
- // impulses are outside plausible ranges
- if (numberOfSequentialCorrections <= maxNumberOfSequentialCorrections) {
- // We haven't made too many corrections, so we assume it is close to the previous one
- log.debug(`noise filter corrected currentDt, ${dataPoint} was too much of an accelleration/decelleration with respect to ${movingAverage.getAverage()}, changed to previous value, ${cleanDataPoints[1]}`)
- movingAverage.replaceLastPushedValue(cleanDataPoints[1])
- } else {
- // We made too many corrections (typically, one currentDt is too long, the next is to short or vice versa), let's allow the algorithm to pick it up otherwise we might get stuck
- log.debug(`noise filter wanted to corrected currentDt (${dataPoint} sec), but it had already made ${numberOfSequentialCorrections} corrections, filter temporarily disabled`)
- }
- numberOfSequentialCorrections = numberOfSequentialCorrections + 1
- }
-
- // determine the moving average, to reduce noise
- cleanDataPoints[0] = movingAverage.getAverage()
-
- // determine the derived data
- if (cleanDataPoints[0] > 0) {
- angularVelocity[0] = angularDisplacementPerImpulse / cleanDataPoints[0]
- angularAcceleration[0] = (angularVelocity[0] - angularVelocity[1]) / cleanDataPoints[0]
- } else {
- log.error('Impuls of 0 seconds encountered, this should not be possible (division by 0 prevented)')
- angularVelocity[0] = 0
- angularAcceleration[0] = 0
- }
- }
-
- function isFlywheelUnpowered () {
- let numberOfErrors = 0
- if (rowerSettings.naturalDeceleration < 0) {
- // A valid natural deceleration of the flywheel has been provided, this has to be maintained for a flank length
- // to count as an indication for an unpowered flywheel
- // Please note that angularAcceleration[] contains flank-information already, so we need to check from
- // rowerSettings.flankLength -1 until 0 flanks
- let i = rowerSettings.flankLength - 1
- while (i >= 0) {
- if (angularAcceleration[i] > rowerSettings.naturalDeceleration) {
- // There seems to be some power present, so we detected an error
- numberOfErrors = numberOfErrors + 1
- }
- i = i - 1
- }
- } else {
- // No valid natural deceleration has been provided, we rely on pure deceleration for recovery detection
- let i = rowerSettings.flankLength
- while (i > 0) {
- if (cleanDataPoints[i] >= cleanDataPoints[i - 1]) {
- // Oldest interval (dataPoints[i]) is larger than the younger one (datapoint[i-1], as the distance is
- // fixed, we are accelerating
- numberOfErrors = numberOfErrors + 1
- }
- i = i - 1
- }
- }
- if (numberOfErrors > rowerSettings.numberOfErrorsAllowed) {
- return false
- } else {
- return true
- }
- }
-
- function isFlywheelPowered () {
- let numberOfErrors = 0
- if (rowerSettings.naturalDeceleration < 0) {
- // A valid natural deceleration of the flywheel has been provided, this has to be consistently encountered
- // for a flank length to count as an indication of a powered flywheel
- // Please note that angularAcceleration[] contains flank-information already, so we need to check from
- // rowerSettings.flankLength -1 until 0 flanks
- let i = rowerSettings.flankLength - 1
- while (i >= 0) {
- if (angularAcceleration[i] < rowerSettings.naturalDeceleration) {
- // Some deceleration is below the natural deceleration, so we detected an error
- numberOfErrors = numberOfErrors + 1
- }
- i = i - 1
- }
- } else {
- // No valid natural deceleration of the flywheel has been provided, we rely on pure acceleration for stroke detection
- let i = rowerSettings.flankLength
- while (i > 1) {
- if (cleanDataPoints[i] < cleanDataPoints[i - 1]) {
- // Oldest interval (dataPoints[i]) is shorter than the younger one (datapoint[i-1], as the distance is fixed, we
- // discovered a deceleration
- numberOfErrors = numberOfErrors + 1
- }
- i = i - 1
- }
- if (cleanDataPoints[1] <= cleanDataPoints[0]) {
- // We handle the last measurement more specifically: at least the youngest measurement must be really accelerating
- // This prevents when the currentDt "flatlines" (i.e. error correction kicks in) a ghost-stroke is detected
- numberOfErrors = numberOfErrors + 1
- }
- }
- if (numberOfErrors > rowerSettings.numberOfErrorsAllowed) {
- return false
- } else {
- return true
- }
- }
-
- function timeToBeginOfFlank () {
- // We expect the curve to bend between dirtyDataPoints[rowerSettings.flankLength] and dirtyDataPoints[rowerSettings.flankLength+1],
- // as acceleration FOLLOWS the start of the pulling the handle, we assume it must have started before that
- let i = rowerSettings.flankLength
- let total = 0.0
- while (i >= 0) {
- total += dirtyDataPoints[i]
- i = i - 1
- }
- return total
- }
-
- function noImpulsesToBeginFlank () {
- return rowerSettings.flankLength
- }
-
- function impulseLengthAtBeginFlank () {
- // As this is fed into the speed calculation where small changes have big effects, and we typically use it when
- // the curve is in a plateau, we return the cleaned data and not the dirty data
- // Regardless of the way to determine the acceleration, cleanDataPoints[rowerSettings.flankLength] is always the
- // impulse at the beginning of the flank being investigated
- return cleanDataPoints[rowerSettings.flankLength]
- }
-
- function accelerationAtBeginOfFlank () {
- return angularAcceleration[rowerSettings.flankLength - 1]
- }
-
- return {
- pushValue,
- isFlywheelUnpowered,
- isFlywheelPowered,
- timeToBeginOfFlank,
- noImpulsesToBeginFlank,
- impulseLengthAtBeginFlank,
- accelerationAtBeginOfFlank
- }
-}
-
-export { createMovingFlankDetector }
diff --git a/app/engine/Rower.js b/app/engine/Rower.js
new file mode 100644
index 0000000000..6857fb7f55
--- /dev/null
+++ b/app/engine/Rower.js
@@ -0,0 +1,455 @@
+'use strict'
+/*
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
+
+ The Rowing Engine models the physics of a real rowing boat.
+ It takes impulses from the flywheel of a rowing machine and estimates
+ parameters such as energy, stroke rates and movement.
+
+ This implementation uses concepts that are described here:
+ Physics of Rowing by Anu Dudhia: http://eodg.atm.ox.ac.uk/user/dudhia/rowing/physics
+ Also Dave Vernooy has some good explanations here: https://dvernooy.github.io/projects/ergware
+*/
+/* eslint-disable max-lines -- There is a lot of state machine dependent math going on here. Hard to keep short while maintaining readability */
+import loglevel from 'loglevel'
+import { createFlywheel } from './Flywheel.js'
+import { createCurveMetrics } from './utils/curveMetrics.js'
+
+const log = loglevel.getLogger('RowingEngine')
+
+export function createRower (rowerSettings) {
+ const flywheel = createFlywheel(rowerSettings)
+ const sprocketRadius = rowerSettings.sprocketRadius / 100
+ const driveHandleForce = createCurveMetrics()
+ const driveHandleVelocity = createCurveMetrics()
+ const driveHandlePower = createCurveMetrics()
+ let _strokeState = 'WaitingForDrive'
+ let _totalNumberOfStrokes = -1.0
+ let recoveryPhaseStartTime = 0.0
+ let _recoveryDuration
+ let drivePhaseStartTime = 0.0
+ let _driveDuration
+ let drivePhaseStartAngularPosition = 0.0
+ let drivePhaseAngularDisplacement = 0.0
+ let _driveLinearDistance = 0.0
+ let recoveryPhaseStartAngularPosition = 0.0
+ let recoveryPhaseAngularDisplacement = 0.0
+ let _recoveryLinearDistance = 0.0
+ const minimumCycleDuration = rowerSettings.minimumDriveTime + rowerSettings.minimumRecoveryTime
+ let _cycleDuration
+ let _cycleLinearVelocity
+ let _cyclePower
+ let totalLinearDistance = 0.0
+ let preliminaryTotalLinearDistance = 0.0
+ let _driveLength = 0.0
+
+ flywheel.maintainStateOnly()
+
+ // called if the sensor detected an impulse, currentDt is an interval in seconds
+ function handleRotationImpulse (currentDt) {
+ // Provide the flywheel with new data
+ flywheel.pushValue(currentDt)
+
+ // This is the core of the finite state machine that defines all state transitions
+ switch (true) {
+ case (_strokeState === 'Stopped'):
+ // We are in a stopped state, so don't do anything
+ break
+ case (_strokeState === 'WaitingForDrive' && flywheel.isAboveMinimumSpeed() && flywheel.isPowered()):
+ // We are above the minimum speed, so we can leave the WaitingForDrive state
+ // As we are not certain what caused the "WaitingForDrive", we explicitly start the flywheel maintaining metrics again
+ flywheel.maintainStateAndMetrics()
+ // We change into the "Drive" phase since were waiting for a drive phase, and we see a clear force exerted on the flywheel
+ log.debug(`*** Rowing (re)started with a DRIVE phase at time: ${flywheel.spinningTime().toFixed(4)} sec`)
+ _strokeState = 'Drive'
+ startDrivePhase()
+ break
+ case (_strokeState === 'WaitingForDrive' && flywheel.isAboveMinimumSpeed() && flywheel.isUnpowered()):
+ // We are above the minimum speed, so we can leave the WaitingForDrive state
+ // As we are not certain what caused the "WaitingForDrive", we explicitly start the flywheel maintaining metrics again
+ flywheel.maintainStateAndMetrics()
+ // We change into the "REcovery" phase, as somehow there is a force exerted on the flywheel consistent with a dragforce
+ // We need to update the _totalNumberOfStrokes manually as startDrivePhase() normally does this
+ log.debug(`*** Rowing (re)started with a RECOVERY phase at time: ${flywheel.spinningTime().toFixed(4)} sec`)
+ _totalNumberOfStrokes++
+ _strokeState = 'Recovery'
+ startRecoveryPhase()
+ break
+ case (_strokeState === 'WaitingForDrive'):
+ // We can't change into the "Drive" phase since we are waiting for a drive phase, but there isn't a clear force exerted on the flywheel. So, there is nothing more to do
+ break
+ case (_strokeState === 'Drive' && ((flywheel.spinningTime() - drivePhaseStartTime) >= rowerSettings.minimumDriveTime || _totalNumberOfStrokes < 1) && flywheel.isUnpowered()):
+ // We change into the "Recovery" phase since we have been long enough in the Drive phase, and we see a clear lack of power exerted on the flywheel
+ // In the first stroke, we might not exceed the minimumdrivetime in the first stroke, so we shouldn't allow it to limit us.
+ log.debug(`*** RECOVERY phase started at time: ${flywheel.spinningTime().toFixed(4)} sec`)
+ _strokeState = 'Recovery'
+ endDrivePhase()
+ startRecoveryPhase()
+ break
+ case (_strokeState === 'Drive' && flywheel.isUnpowered()):
+ // We seem to have lost power to the flywheel, but it is too early according to the settings. We stay in the Drive Phase
+ log.debug(`Time: ${flywheel.spinningTime().toFixed(4)} sec: Delta Time trend is upwards, suggests no power, but waiting for drive phase length (${(flywheel.spinningTime() - drivePhaseStartTime).toFixed(4)} sec) to exceed minimumDriveTime (${rowerSettings.minimumDriveTime} sec)`)
+ updateDrivePhase()
+ break
+ case (_strokeState === 'Drive'):
+ // We stay in the "Drive" phase as the decceleration is lacking
+ updateDrivePhase()
+ break
+ case (_strokeState === 'Recovery' && ((flywheel.spinningTime() - drivePhaseStartTime) >= rowerSettings.maximumStrokeTimeBeforePause) && flywheel.isDwelling()):
+ // The Flywheel is spinning too slowly to create valid CurrentDt's and the last Drive started over maximumStrokeTime ago, we consider it a pause
+ log.debug(`*** PAUSED rowing at time: ${flywheel.spinningTime().toFixed(4)} sec, rower hasn't moved in ${(flywheel.spinningTime() - drivePhaseStartTime).toFixed(4)} seconds and flywheel is dwelling`)
+ flywheel.maintainStateOnly()
+ _strokeState = 'WaitingForDrive'
+ endRecoveryPhase()
+ break
+ case (_strokeState === 'Recovery' && ((flywheel.spinningTime() - recoveryPhaseStartTime) >= rowerSettings.minimumRecoveryTime) && flywheel.isPowered()):
+ // We change into the "Drive" phase since we have been long enough in the Recovery phase, and we see a clear force
+ // exerted on the flywheel
+ log.debug(`*** DRIVE phase started at time: ${flywheel.spinningTime().toFixed(4)} sec`)
+ _strokeState = 'Drive'
+ endRecoveryPhase()
+ startDrivePhase()
+ break
+ case (_strokeState === 'Recovery' && flywheel.isPowered()):
+ // We see a force, but the "Recovery" phase has been too short, we stay in the "Recovery" phase
+ log.debug(`Time: ${flywheel.spinningTime().toFixed(4)} sec: Delta Time trend is downwards, suggesting power, but waiting for recovery phase length (${(flywheel.spinningTime() - recoveryPhaseStartTime).toFixed(4)} sec) to exceed minimumRecoveryTime (${rowerSettings.minimumRecoveryTime} sec)`)
+ updateRecoveryPhase()
+ break
+ case (_strokeState === 'Recovery'):
+ // No force on the flywheel, let's continue the "Recovery" phase of the stroke
+ updateRecoveryPhase()
+ break
+ default:
+ log.error(`Time: ${flywheel.spinningTime().toFixed(4)} sec, state ${_strokeState} found in the Rowing Engine, which is not captured by Finite State Machine`)
+ }
+ }
+
+ function startDrivePhase () {
+ // Next, we start the Drive Phase
+ _totalNumberOfStrokes++
+ drivePhaseStartTime = flywheel.spinningTime()
+ drivePhaseStartAngularPosition = flywheel.angularPosition()
+ driveHandleForce.reset()
+ const forceOnHandle = flywheel.torque() / sprocketRadius
+ driveHandleForce.push(flywheel.deltaTime(), forceOnHandle)
+ driveHandleVelocity.reset()
+ const velocityOfHandle = flywheel.angularVelocity() * sprocketRadius
+ driveHandleVelocity.push(flywheel.deltaTime(), velocityOfHandle)
+ driveHandlePower.reset()
+ const powerOnHandle = flywheel.torque() * flywheel.angularVelocity()
+ driveHandlePower.push(flywheel.deltaTime(), powerOnHandle)
+ }
+
+ function updateDrivePhase () {
+ // Update the key metrics on each impulse
+ drivePhaseAngularDisplacement = flywheel.angularPosition() - drivePhaseStartAngularPosition
+ _driveLinearDistance = calculateLinearDistance(drivePhaseAngularDisplacement, (flywheel.spinningTime() - drivePhaseStartTime))
+ preliminaryTotalLinearDistance = totalLinearDistance + _driveLinearDistance
+ const forceOnHandle = flywheel.torque() / sprocketRadius
+ driveHandleForce.push(flywheel.deltaTime(), forceOnHandle)
+ const velocityOfHandle = flywheel.angularVelocity() * sprocketRadius
+ driveHandleVelocity.push(flywheel.deltaTime(), velocityOfHandle)
+ const powerOnHandle = flywheel.torque() * flywheel.angularVelocity()
+ driveHandlePower.push(flywheel.deltaTime(), powerOnHandle)
+ }
+
+ function endDrivePhase () {
+ // Here, we conclude the Drive Phase
+ // The FSM guarantees that we have a credible driveDuration and cycletime in normal operation, but NOT at the start
+ _driveDuration = flywheel.spinningTime() - drivePhaseStartTime
+ drivePhaseAngularDisplacement = flywheel.angularPosition() - drivePhaseStartAngularPosition
+ _driveLength = drivePhaseAngularDisplacement * sprocketRadius
+ _driveLinearDistance = calculateLinearDistance(drivePhaseAngularDisplacement, _driveDuration)
+ totalLinearDistance += _driveLinearDistance
+ preliminaryTotalLinearDistance = totalLinearDistance
+ if (_driveDuration >= rowerSettings.minimumDriveTime && _recoveryDuration >= rowerSettings.minimumRecoveryTime) {
+ _cycleDuration = _recoveryDuration + _driveDuration
+ _cycleLinearVelocity = calculateLinearVelocity(drivePhaseAngularDisplacement + recoveryPhaseAngularDisplacement, _cycleDuration)
+ _cyclePower = calculateCyclePower()
+ } else {
+ _cycleDuration = undefined
+ _cycleLinearVelocity = undefined
+ _cyclePower = undefined
+ }
+ }
+
+ function startRecoveryPhase () {
+ // Next, we start the Recovery Phase
+ recoveryPhaseStartTime = flywheel.spinningTime()
+ recoveryPhaseStartAngularPosition = flywheel.angularPosition()
+ flywheel.markRecoveryPhaseStart()
+ }
+
+ function updateRecoveryPhase () {
+ // Update the key metrics on each impulse
+ recoveryPhaseAngularDisplacement = flywheel.angularPosition() - recoveryPhaseStartAngularPosition
+ _recoveryLinearDistance = calculateLinearDistance(recoveryPhaseAngularDisplacement, (flywheel.spinningTime() - recoveryPhaseStartTime))
+ preliminaryTotalLinearDistance = totalLinearDistance + _recoveryLinearDistance
+ }
+
+ function endRecoveryPhase () {
+ // First, we conclude the recovery phase
+ // The FSM guarantees that we have a credible recoveryDuration and cycletime in normal operation, but NOT at the start
+ flywheel.markRecoveryPhaseCompleted() // This MUST be executed before the dragfactor is used in any calculation here!
+ _recoveryDuration = flywheel.spinningTime() - recoveryPhaseStartTime
+ recoveryPhaseAngularDisplacement = flywheel.angularPosition() - recoveryPhaseStartAngularPosition
+ _recoveryLinearDistance = calculateLinearDistance(recoveryPhaseAngularDisplacement, _recoveryDuration)
+ totalLinearDistance += _recoveryLinearDistance
+ preliminaryTotalLinearDistance = totalLinearDistance
+ if (_driveDuration >= rowerSettings.minimumDriveTime && _recoveryDuration >= rowerSettings.minimumRecoveryTime) {
+ _cycleDuration = _recoveryDuration + _driveDuration
+ _cycleLinearVelocity = calculateLinearVelocity(drivePhaseAngularDisplacement + recoveryPhaseAngularDisplacement, _cycleDuration)
+ _cyclePower = calculateCyclePower()
+ } else {
+ _cycleDuration = undefined
+ _cycleLinearVelocity = undefined
+ _cyclePower = undefined
+ }
+ }
+
+ function calculateLinearDistance (baseAngularDisplacement, baseTime) {
+ if (baseAngularDisplacement >= 0) {
+ return Math.pow((flywheel.dragFactor() / rowerSettings.magicConstant), 1.0 / 3.0) * baseAngularDisplacement
+ } else {
+ log.error(`Time: ${flywheel.spinningTime().toFixed(4)} sec: calculateLinearDistance error: Angular Displacement of ${baseAngularDisplacement} was not credible, baseTime = ${baseTime}`)
+ return 0
+ }
+ }
+
+ function calculateLinearVelocity (baseAngularDisplacement, baseTime) {
+ // Here we calculate the AVERAGE speed for the displays, NOT the topspeed of the stroke
+ const prevLinearVelocity = _cycleLinearVelocity
+ if (baseAngularDisplacement > 0 && baseTime > 0) {
+ // let's prevent division's by zero and make sure data is credible
+ const baseAngularVelocity = baseAngularDisplacement / baseTime
+ return Math.pow((flywheel.dragFactor() / rowerSettings.magicConstant), 1.0 / 3.0) * baseAngularVelocity
+ } else {
+ log.error(`Time: ${flywheel.spinningTime().toFixed(4)} sec: calculateLinearVelocity error, Angular Displacement = ${baseAngularDisplacement}, baseTime = ${baseTime}`)
+ return prevLinearVelocity
+ }
+ }
+
+ function calculateCyclePower () {
+ // Here we calculate the AVERAGE power for the displays, NOT the top power of the stroke
+ const prevCyclePower = _cyclePower
+ if (_driveDuration >= rowerSettings.minimumDriveTime && _cycleDuration >= minimumCycleDuration) {
+ // let's prevent division's by zero and make sure data is credible
+ return flywheel.dragFactor() * Math.pow((recoveryPhaseAngularDisplacement + drivePhaseAngularDisplacement) / _cycleDuration, 3.0)
+ } else {
+ log.error(`Time: ${flywheel.spinningTime().toFixed(4)} sec: calculateCyclePower error: driveDuration = ${_driveDuration.toFixed(4)} sec, _cycleDuration = ${_cycleDuration.toFixed(4)} sec`)
+ return prevCyclePower
+ }
+ }
+
+ function strokeState () {
+ return _strokeState
+ }
+
+ function totalNumberOfStrokes () {
+ return _totalNumberOfStrokes
+ }
+
+ function totalMovingTimeSinceStart () {
+ return flywheel.spinningTime()
+ }
+
+ function driveLastStartTime () {
+ return drivePhaseStartTime
+ }
+
+ function totalLinearDistanceSinceStart () {
+ return Math.max(preliminaryTotalLinearDistance, totalLinearDistance)
+ }
+
+ function cycleDuration () {
+ if (_driveDuration >= rowerSettings.minimumDriveTime && _recoveryDuration >= rowerSettings.minimumRecoveryTime) {
+ return _cycleDuration
+ } else {
+ return undefined
+ }
+ }
+
+ function cycleLinearDistance () {
+ if (_driveDuration >= rowerSettings.minimumDriveTime && _recoveryDuration >= rowerSettings.minimumRecoveryTime) {
+ return _driveLinearDistance + _recoveryLinearDistance
+ } else {
+ return undefined
+ }
+ }
+
+ function cycleLinearVelocity () {
+ if (_driveDuration >= rowerSettings.minimumDriveTime && _recoveryDuration >= rowerSettings.minimumRecoveryTime) {
+ return _cycleLinearVelocity
+ } else {
+ return undefined
+ }
+ }
+
+ function cyclePower () {
+ if (_driveDuration >= rowerSettings.minimumDriveTime && _recoveryDuration >= rowerSettings.minimumRecoveryTime) {
+ return _cyclePower
+ } else {
+ return undefined
+ }
+ }
+ function driveDuration () {
+ if (_driveDuration >= rowerSettings.minimumDriveTime) {
+ return _driveDuration
+ } else {
+ return undefined
+ }
+ }
+
+ function driveLinearDistance () {
+ if (_driveDuration >= rowerSettings.minimumDriveTime) {
+ return _driveLinearDistance
+ } else {
+ return undefined
+ }
+ }
+
+ function driveLength () {
+ if (_driveDuration >= rowerSettings.minimumDriveTime) {
+ return _driveLength
+ } else {
+ return undefined
+ }
+ }
+
+ function driveAverageHandleForce () {
+ if (_driveDuration >= rowerSettings.minimumDriveTime) {
+ return driveHandleForce.average()
+ } else {
+ return undefined
+ }
+ }
+
+ function drivePeakHandleForce () {
+ if (_driveDuration >= rowerSettings.minimumDriveTime) {
+ return driveHandleForce.peak()
+ } else {
+ return undefined
+ }
+ }
+
+ function driveHandleForceCurve () {
+ if (_driveDuration >= rowerSettings.minimumDriveTime) {
+ return driveHandleForce.curve()
+ } else {
+ return undefined
+ }
+ }
+
+ function driveHandleVelocityCurve () {
+ if (_driveDuration >= rowerSettings.minimumDriveTime) {
+ return driveHandleVelocity.curve()
+ } else {
+ return undefined
+ }
+ }
+
+ function driveHandlePowerCurve () {
+ if (_driveDuration >= rowerSettings.minimumDriveTime) {
+ return driveHandlePower.curve()
+ } else {
+ return undefined
+ }
+ }
+
+ function recoveryDuration () {
+ if (_recoveryDuration >= rowerSettings.minimumRecoveryTime) {
+ return _recoveryDuration
+ } else {
+ return undefined
+ }
+ }
+
+ function recoveryDragFactor () {
+ if (flywheel.dragFactorIsReliable()) {
+ return flywheel.dragFactor() * 1000000
+ } else {
+ return undefined
+ }
+ }
+
+ function instantHandlePower () {
+ if (_strokeState === 'Drive') {
+ return flywheel.torque() * flywheel.angularVelocity()
+ } else {
+ return 0
+ }
+ }
+
+ function allowMovement () {
+ if (_strokeState === 'Stopped') {
+ // We have to check whether there actually was a stop/pause, in order to prevent weird behaviour from the state machine
+ log.debug(`*** ALLOW MOVEMENT command by RowingEngine recieved at time: ${flywheel.spinningTime().toFixed(4)} sec`)
+ _strokeState = 'WaitingForDrive'
+ }
+ }
+
+ function pauseMoving () {
+ log.debug(`*** PAUSE MOVING command recieved by RowingEngine at time: ${flywheel.spinningTime().toFixed(4)} sec, distance: ${preliminaryTotalLinearDistance.toFixed(2)} meters`)
+ flywheel.maintainStateOnly()
+ _strokeState = 'WaitingForDrive'
+ }
+
+ function stopMoving () {
+ log.debug(`*** STOP MOVING command recieved by RowingEngine at time: ${flywheel.spinningTime().toFixed(4)} sec, distance: ${preliminaryTotalLinearDistance.toFixed(2)} meters`)
+ flywheel.maintainStateOnly()
+ _strokeState = 'Stopped'
+ }
+
+ function reset () {
+ _strokeState = 'WaitingForDrive'
+ flywheel.reset()
+ driveHandleForce.reset()
+ driveHandleVelocity.reset()
+ driveHandlePower.reset()
+ _totalNumberOfStrokes = -1.0
+ drivePhaseStartTime = 0.0
+ drivePhaseStartAngularPosition = 0.0
+ _driveDuration = 0.0
+ drivePhaseAngularDisplacement = 0.0
+ _driveLinearDistance = 0.0
+ recoveryPhaseStartTime = 0.0
+ _recoveryDuration = 0.0
+ recoveryPhaseStartAngularPosition = 0.0
+ recoveryPhaseAngularDisplacement = 0.0
+ _recoveryLinearDistance = 0.0
+ _cycleDuration = 0.0
+ _cycleLinearVelocity = 0.0
+ totalLinearDistance = 0.0
+ preliminaryTotalLinearDistance = 0.0
+ _cyclePower = 0.0
+ _driveLength = 0.0
+ }
+
+ return {
+ handleRotationImpulse,
+ allowMovement,
+ pauseMoving,
+ stopMoving,
+ strokeState,
+ totalNumberOfStrokes,
+ driveLastStartTime,
+ totalMovingTimeSinceStart,
+ totalLinearDistanceSinceStart,
+ cycleDuration,
+ cycleLinearDistance,
+ cycleLinearVelocity,
+ cyclePower,
+ driveDuration,
+ driveLinearDistance,
+ driveLength,
+ driveAverageHandleForce,
+ drivePeakHandleForce,
+ driveHandleForceCurve,
+ driveHandleVelocityCurve,
+ driveHandlePowerCurve,
+ recoveryDuration,
+ recoveryDragFactor,
+ instantHandlePower,
+ reset
+ }
+}
diff --git a/app/engine/Rower.test.js b/app/engine/Rower.test.js
new file mode 100644
index 0000000000..2f59c6f90c
--- /dev/null
+++ b/app/engine/Rower.test.js
@@ -0,0 +1,508 @@
+'use strict'
+/*
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
+*/
+/**
+ * This test is a test of the Rower object, that tests wether this object fills all fields correctly, given one validated rower, (the
+ * Concept2 RowErg) using a validated cycle of strokes. This thoroughly tests the raw physics of the translation of Angular physics
+ * to Linear physics. The combination with all possible known rowers is tested when testing the above function RowingStatistics, as
+ * these statistics are dependent on these settings as well.
+ */
+import { test } from 'uvu'
+import * as assert from 'uvu/assert'
+import rowerProfiles from '../../config/rowerProfiles.js'
+import { replayRowingSession } from '../recorders/RowingReplayer.js'
+import { deepMerge } from '../tools/Helper.js'
+
+import { createRower } from './Rower.js'
+
+const baseConfig = { // Based on Concept 2 settings, as this is the validation system
+ numOfImpulsesPerRevolution: 6,
+ sprocketRadius: 1.4,
+ maximumStrokeTimeBeforePause: 0.3, // Modification to standard settings to shorten test cases
+ dragFactor: 110,
+ autoAdjustDragFactor: true,
+ minimumDragQuality: 0.95,
+ dragFactorSmoothing: 3,
+ minimumTimeBetweenImpulses: 0.005,
+ maximumTimeBetweenImpulses: 0.017,
+ flankLength: 12,
+ smoothing: 1,
+ minimumStrokeQuality: 0.36,
+ minimumForceBeforeStroke: 20, // Modification to standard settings to shorten test cases
+ minimumRecoverySlope: 0.00070,
+ autoAdjustRecoverySlope: false, // Modification to standard settings to shorten test cases
+ autoAdjustRecoverySlopeMargin: 0.04,
+ minimumDriveTime: 0.04, // Modification to standard settings to shorten test cases
+ minimumRecoveryTime: 0.09, // Modification to standard settings to shorten test cases
+ flywheelInertia: 0.10138,
+ magicConstant: 2.8
+}
+
+// Test behaviour for no datapoints
+test('Correct rower behaviour at initialisation', () => {
+ const rower = createRower(baseConfig)
+ testStrokeState(rower, 'WaitingForDrive')
+ testTotalMovingTimeSinceStart(rower, 0)
+ testTotalNumberOfStrokes(rower, 0)
+ testTotalLinearDistanceSinceStart(rower, 0)
+ testCycleDuration(rower, undefined) // Default value
+ testCycleLinearDistance(rower, undefined)
+ testCycleLinearVelocity(rower, undefined)
+ testCyclePower(rower, undefined)
+ testDriveDuration(rower, undefined)
+ testDriveLinearDistance(rower, undefined)
+ testDriveLength(rower, undefined)
+ testDriveAverageHandleForce(rower, undefined)
+ testDrivePeakHandleForce(rower, undefined)
+ testRecoveryDuration(rower, undefined)
+ testRecoveryDragFactor(rower, undefined)
+ testInstantHandlePower(rower, 0)
+})
+
+// Test behaviour for one datapoint
+
+// Test behaviour for three perfect identical strokes, including settingling behaviour of metrics
+test('Test behaviour for three perfect identical strokes, including settingling behaviour of metrics', () => {
+ const rower = createRower(baseConfig)
+ testStrokeState(rower, 'WaitingForDrive')
+ testTotalMovingTimeSinceStart(rower, 0)
+ testTotalLinearDistanceSinceStart(rower, 0)
+ testTotalNumberOfStrokes(rower, 0)
+ testCycleDuration(rower, undefined) // Default value
+ testCycleLinearDistance(rower, undefined)
+ testCycleLinearVelocity(rower, undefined)
+ testCyclePower(rower, undefined)
+ testDriveDuration(rower, undefined)
+ testDriveLinearDistance(rower, undefined)
+ testDriveLength(rower, undefined)
+ testDriveAverageHandleForce(rower, undefined)
+ testDrivePeakHandleForce(rower, undefined)
+ testRecoveryDuration(rower, undefined)
+ testRecoveryDragFactor(rower, undefined)
+ testInstantHandlePower(rower, 0)
+ // Drive initial stroke starts here
+ rower.handleRotationImpulse(0.011221636)
+ rower.handleRotationImpulse(0.011175504)
+ rower.handleRotationImpulse(0.01116456)
+ rower.handleRotationImpulse(0.011130263)
+ rower.handleRotationImpulse(0.011082613)
+ rower.handleRotationImpulse(0.011081761)
+ rower.handleRotationImpulse(0.011062297)
+ rower.handleRotationImpulse(0.011051853)
+ rower.handleRotationImpulse(0.010973313)
+ rower.handleRotationImpulse(0.010919756)
+ rower.handleRotationImpulse(0.01086431)
+ rower.handleRotationImpulse(0.010800864)
+ rower.handleRotationImpulse(0.010956987)
+ rower.handleRotationImpulse(0.010653396)
+ rower.handleRotationImpulse(0.010648619)
+ rower.handleRotationImpulse(0.010536818)
+ rower.handleRotationImpulse(0.010526151)
+ rower.handleRotationImpulse(0.010511225)
+ rower.handleRotationImpulse(0.010386684)
+ testStrokeState(rower, 'Drive')
+ testTotalMovingTimeSinceStart(rower, 0.077918634)
+ testTotalLinearDistanceSinceStart(rower, 0.2491943602992768)
+ testTotalNumberOfStrokes(rower, 1)
+ testCycleDuration(rower, undefined) // still default value
+ testCycleLinearDistance(rower, undefined)
+ testCycleLinearVelocity(rower, undefined) // This isn't filled after the first drive, as we haven't survived a complete cycle yet
+ testCyclePower(rower, undefined) // This isn't filled after the first drive, as we haven't survived a complete cycle yet
+ testDriveDuration(rower, undefined) // This isn't filled after the first drive as it is too short
+ testDriveLinearDistance(rower, undefined)
+ testDriveLength(rower, undefined)
+ testDriveAverageHandleForce(rower, undefined)
+ testDrivePeakHandleForce(rower, undefined)
+ testRecoveryDuration(rower, undefined)
+ testRecoveryDragFactor(rower, undefined)
+ testInstantHandlePower(rower, 372.09477620281604)
+ // Recovery initial stroke starts here
+ rower.handleRotationImpulse(0.010769)
+ rower.handleRotationImpulse(0.010707554)
+ rower.handleRotationImpulse(0.010722165)
+ rower.handleRotationImpulse(0.01089567)
+ rower.handleRotationImpulse(0.010917504)
+ rower.handleRotationImpulse(0.010997969)
+ rower.handleRotationImpulse(0.011004655)
+ rower.handleRotationImpulse(0.011013618)
+ rower.handleRotationImpulse(0.011058193)
+ rower.handleRotationImpulse(0.010807149)
+ rower.handleRotationImpulse(0.0110626)
+ rower.handleRotationImpulse(0.011090787)
+ rower.handleRotationImpulse(0.011099509)
+ rower.handleRotationImpulse(0.011131862)
+ rower.handleRotationImpulse(0.011209919)
+ testStrokeState(rower, 'Recovery')
+ testTotalMovingTimeSinceStart(rower, 0.23894732900000007)
+ testTotalLinearDistanceSinceStart(rower, 0.7831822752262985)
+ testTotalNumberOfStrokes(rower, 1)
+ testCycleDuration(rower, undefined)
+ testCycleLinearDistance(rower, undefined)
+ testCycleLinearVelocity(rower, undefined)
+ testCyclePower(rower, undefined)
+ testDriveDuration(rower, 0.143485717)
+ testDriveLinearDistance(rower, 0.46278952627008546)
+ testDriveLength(rower, 0.19058995431778075)
+ testDriveAverageHandleForce(rower, 276.20193475035796)
+ testDrivePeakHandleForce(rower, 325.1619554833936)
+ testRecoveryDuration(rower, undefined)
+ testRecoveryDragFactor(rower, undefined)
+ testInstantHandlePower(rower, 0)
+ // Drive second stroke starts here
+ rower.handleRotationImpulse(0.011221636)
+ rower.handleRotationImpulse(0.011175504)
+ rower.handleRotationImpulse(0.01116456)
+ rower.handleRotationImpulse(0.011130263)
+ rower.handleRotationImpulse(0.011082613)
+ rower.handleRotationImpulse(0.011081761)
+ rower.handleRotationImpulse(0.011062297)
+ rower.handleRotationImpulse(0.011051853)
+ rower.handleRotationImpulse(0.010973313)
+ rower.handleRotationImpulse(0.010919756)
+ rower.handleRotationImpulse(0.01086431)
+ rower.handleRotationImpulse(0.010800864)
+ rower.handleRotationImpulse(0.010956987)
+ rower.handleRotationImpulse(0.010653396)
+ rower.handleRotationImpulse(0.010648619)
+ rower.handleRotationImpulse(0.010536818)
+ rower.handleRotationImpulse(0.010526151)
+ rower.handleRotationImpulse(0.010511225)
+ rower.handleRotationImpulse(0.010386684)
+ testStrokeState(rower, 'Drive')
+ testTotalMovingTimeSinceStart(rower, 0.44915539800000004)
+ testTotalLinearDistanceSinceStart(rower, 1.828822466846578)
+ testTotalNumberOfStrokes(rower, 2)
+ testCycleDuration(rower, 0.34889498300000005)
+ testCycleLinearDistance(rower, 1.3660329405764926)
+ testCycleLinearVelocity(rower, 4.474643028948317)
+ testCyclePower(rower, 250.86103806520188)
+ testDriveDuration(rower, 0.143485717)
+ testDriveLinearDistance(rower, 0.43908201661387253)
+ testDriveLength(rower, 0.19058995431778075)
+ testDriveAverageHandleForce(rower, 236.59556700196183)
+ testDrivePeakHandleForce(rower, 380.1396336099103)
+ testRecoveryDuration(rower, 0.20540926600000003)
+ testRecoveryDragFactor(rower, 283.12720365097886)
+ testInstantHandlePower(rower, 504.63602120716615)
+ // Recovery second stroke starts here
+ rower.handleRotationImpulse(0.010769)
+ rower.handleRotationImpulse(0.010707554)
+ rower.handleRotationImpulse(0.010722165)
+ rower.handleRotationImpulse(0.01089567)
+ rower.handleRotationImpulse(0.010917504)
+ rower.handleRotationImpulse(0.010997969)
+ rower.handleRotationImpulse(0.011004655)
+ rower.handleRotationImpulse(0.011013618)
+ rower.handleRotationImpulse(0.011058193)
+ rower.handleRotationImpulse(0.010807149)
+ rower.handleRotationImpulse(0.0110626)
+ rower.handleRotationImpulse(0.011090787)
+ rower.handleRotationImpulse(0.011099509)
+ rower.handleRotationImpulse(0.011131862)
+ rower.handleRotationImpulse(0.011209919)
+ testStrokeState(rower, 'Recovery')
+ testTotalMovingTimeSinceStart(rower, 0.6101840930000001)
+ testTotalLinearDistanceSinceStart(rower, 2.5606258278697)
+ testTotalNumberOfStrokes(rower, 2)
+ testCycleDuration(rower, 0.44526865700000007)
+ testCycleLinearDistance(rower, 1.1708853776369939)
+ testCycleLinearVelocity(rower, 4.492259872066099)
+ testCyclePower(rower, 253.83566752220193)
+ testDriveDuration(rower, 0.23985939100000003)
+ testDriveLinearDistance(rower, 1.0733115961672441)
+ testDriveLength(rower, 0.322536845768552)
+ testDriveAverageHandleForce(rower, 285.0923064376231)
+ testDrivePeakHandleForce(rower, 439.7407274840117)
+ testRecoveryDuration(rower, 0.20540926600000003)
+ testRecoveryDragFactor(rower, 283.12720365097886) // As we decelerate the flywheel quite fast, this is expected
+ testInstantHandlePower(rower, 0)
+ // Drive third stroke starts here
+ rower.handleRotationImpulse(0.011221636)
+ rower.handleRotationImpulse(0.011175504)
+ rower.handleRotationImpulse(0.01116456)
+ rower.handleRotationImpulse(0.011130263)
+ rower.handleRotationImpulse(0.011082613)
+ rower.handleRotationImpulse(0.011081761)
+ rower.handleRotationImpulse(0.011062297)
+ rower.handleRotationImpulse(0.011051853)
+ rower.handleRotationImpulse(0.010973313)
+ rower.handleRotationImpulse(0.010919756)
+ rower.handleRotationImpulse(0.01086431)
+ rower.handleRotationImpulse(0.010800864)
+ rower.handleRotationImpulse(0.010956987)
+ rower.handleRotationImpulse(0.010653396)
+ rower.handleRotationImpulse(0.010648619)
+ rower.handleRotationImpulse(0.010536818)
+ rower.handleRotationImpulse(0.010526151)
+ rower.handleRotationImpulse(0.010511225)
+ rower.handleRotationImpulse(0.010386684)
+ testStrokeState(rower, 'Drive')
+ testTotalMovingTimeSinceStart(rower, 0.8203921620000004)
+ testTotalLinearDistanceSinceStart(rower, 3.4875767518323193)
+ testTotalNumberOfStrokes(rower, 3)
+ testCycleDuration(rower, 0.3379838680000002)
+ testCycleLinearDistance(rower, 1.0245247054323694)
+ testCycleLinearVelocity(rower, 4.4747508859834575)
+ testCyclePower(rower, 250.8791788061379)
+ testDriveDuration(rower, 0.23985939100000003)
+ testDriveLinearDistance(rower, 0.5854426888184969)
+ testDriveLength(rower, 0.322536845768552)
+ testDriveAverageHandleForce(rower, 194.28476369698888)
+ testDrivePeakHandleForce(rower, 380.1396336085015)
+ testRecoveryDuration(rower, 0.09812447700000015)
+ testRecoveryDragFactor(rower, 283.12720365097886)
+ testInstantHandlePower(rower, 504.63602120535336)
+ // Recovery third stroke starts here
+ rower.handleRotationImpulse(0.010769)
+ rower.handleRotationImpulse(0.010707554)
+ rower.handleRotationImpulse(0.010722165)
+ rower.handleRotationImpulse(0.01089567)
+ rower.handleRotationImpulse(0.010917504)
+ rower.handleRotationImpulse(0.010997969)
+ rower.handleRotationImpulse(0.011004655)
+ rower.handleRotationImpulse(0.011013618)
+ rower.handleRotationImpulse(0.011058193)
+ rower.handleRotationImpulse(0.010807149)
+ rower.handleRotationImpulse(0.0110626)
+ rower.handleRotationImpulse(0.011090787)
+ rower.handleRotationImpulse(0.011099509)
+ rower.handleRotationImpulse(0.011131862)
+ rower.handleRotationImpulse(0.011209919)
+ testStrokeState(rower, 'Recovery')
+ testTotalMovingTimeSinceStart(rower, 0.9814208570000005)
+ testTotalLinearDistanceSinceStart(rower, 4.219380112855441)
+ testTotalNumberOfStrokes(rower, 3)
+ testCycleDuration(rower, 0.3712367640000004)
+ testCycleLinearDistance(rower, 1.3172460498416183)
+ testCycleLinearVelocity(rower, 4.46818431211662)
+ testCyclePower(rower, 249.77632391313173)
+ testDriveDuration(rower, 0.27311228700000023)
+ testDriveLinearDistance(rower, 1.2196722683718688)
+ testDriveLength(rower, 0.3665191429188092)
+ testDriveAverageHandleForce(rower, 254.91449219500532)
+ testDrivePeakHandleForce(rower, 439.74072748282515)
+ testRecoveryDuration(rower, 0.09812447700000015)
+ testRecoveryDragFactor(rower, 283.12720365097886)
+ testInstantHandlePower(rower, 0)
+ // Dwelling state starts here
+ rower.handleRotationImpulse(0.020769)
+ rower.handleRotationImpulse(0.020707554)
+ rower.handleRotationImpulse(0.020722165)
+ rower.handleRotationImpulse(0.02089567)
+ rower.handleRotationImpulse(0.020917504)
+ rower.handleRotationImpulse(0.020997969)
+ rower.handleRotationImpulse(0.021004655)
+ rower.handleRotationImpulse(0.021013618)
+ rower.handleRotationImpulse(0.021058193)
+ rower.handleRotationImpulse(0.020807149)
+ rower.handleRotationImpulse(0.0210626)
+ rower.handleRotationImpulse(0.021090787)
+ rower.handleRotationImpulse(0.021099509)
+ rower.handleRotationImpulse(0.021131862)
+ rower.handleRotationImpulse(0.021209919)
+ testStrokeState(rower, 'WaitingForDrive')
+ testTotalMovingTimeSinceStart(rower, 1.1344792920000004)
+ testTotalNumberOfStrokes(rower, 3)
+ testTotalLinearDistanceSinceStart(rower, 4.8536096924088135)
+ testCycleDuration(rower, 0.4476004410000002)
+ testCycleLinearDistance(rower, 1.9514756293949902)
+ testCycleLinearVelocity(rower, 4.359860828186694)
+ testCyclePower(rower, 232.0469744651364)
+ testDriveDuration(rower, 0.27311228700000023)
+ testDriveLinearDistance(rower, 1.2196722683718688)
+ testDriveLength(rower, 0.3665191429188092)
+ testDriveAverageHandleForce(rower, 254.91449219500532)
+ testDrivePeakHandleForce(rower, 439.74072748282515)
+ testRecoveryDuration(rower, 0.17448815399999995)
+ testRecoveryDragFactor(rower, 283.12720365097886)
+ testInstantHandlePower(rower, 0)
+})
+
+// Test behaviour for noisy upgoing flank
+
+// Test behaviour for noisy downgoing flank
+
+// Test behaviour for noisy stroke
+
+// Test behaviour after reset
+
+// Test behaviour for one datapoint
+
+// Test behaviour for noisy stroke
+
+// Test drag factor calculation
+
+// Test Dynamic stroke detection
+
+// Test behaviour after reset
+
+// Test behaviour with real-life data
+
+test('sample data for Sportstech WRX700 should produce plausible results', async () => {
+ const rower = createRower(deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Sportstech_WRX700))
+ testTotalMovingTimeSinceStart(rower, 0)
+ testTotalLinearDistanceSinceStart(rower, 0)
+ testTotalNumberOfStrokes(rower, 0)
+ testRecoveryDragFactor(rower, rowerProfiles.Sportstech_WRX700.dragFactor)
+
+ await replayRowingSession(rower.handleRotationImpulse, { filename: 'recordings/WRX700_2magnets.csv', realtime: false, loop: false })
+
+ testTotalMovingTimeSinceStart(rower, 46.302522627)
+ testTotalLinearDistanceSinceStart(rower, 166.29596716416734)
+ testTotalNumberOfStrokes(rower, 16)
+ // As dragFactor is static, it should remain in place
+ testRecoveryDragFactor(rower, rowerProfiles.Sportstech_WRX700.dragFactor)
+})
+
+test('sample data for DKN R-320 should produce plausible results', async () => {
+ const rower = createRower(deepMerge(rowerProfiles.DEFAULT, rowerProfiles.DKN_R320))
+ testTotalMovingTimeSinceStart(rower, 0)
+ testTotalLinearDistanceSinceStart(rower, 0)
+ testTotalNumberOfStrokes(rower, 0)
+ // As dragFactor is static, it should be known at initialisation
+ testRecoveryDragFactor(rower, rowerProfiles.DKN_R320.dragFactor)
+
+ await replayRowingSession(rower.handleRotationImpulse, { filename: 'recordings/DKNR320.csv', realtime: false, loop: false })
+
+ testTotalMovingTimeSinceStart(rower, 21.701535821)
+ testTotalLinearDistanceSinceStart(rower, 70.11298001986664)
+ testTotalNumberOfStrokes(rower, 10)
+ // As dragFactor is static, it should remain in place
+ testRecoveryDragFactor(rower, rowerProfiles.DKN_R320.dragFactor)
+})
+
+test('sample data for NordicTrack RX800 should produce plausible results', async () => {
+ const rower = createRower(deepMerge(rowerProfiles.DEFAULT, rowerProfiles.NordicTrack_RX800))
+ testTotalMovingTimeSinceStart(rower, 0)
+ testTotalLinearDistanceSinceStart(rower, 0)
+ testTotalNumberOfStrokes(rower, 0)
+ testRecoveryDragFactor(rower, undefined)
+
+ await replayRowingSession(rower.handleRotationImpulse, { filename: 'recordings/RX800.csv', realtime: false, loop: false })
+
+ testTotalMovingTimeSinceStart(rower, 21.97324892)
+ testTotalLinearDistanceSinceStart(rower, 80.42009355207885)
+ testTotalNumberOfStrokes(rower, 10)
+ // As dragFactor is dynamic, it should have changed
+ testRecoveryDragFactor(rower, 494.92868774518126)
+})
+
+test('A full session for SportsTech WRX700 should produce plausible results', async () => {
+ const rower = createRower(deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Sportstech_WRX700))
+ testTotalMovingTimeSinceStart(rower, 0)
+ testTotalLinearDistanceSinceStart(rower, 0)
+ testTotalNumberOfStrokes(rower, 0)
+ testRecoveryDragFactor(rower, rowerProfiles.Sportstech_WRX700.dragFactor)
+
+ await replayRowingSession(rower.handleRotationImpulse, { filename: 'recordings/WRX700_2magnets_session.csv', realtime: false, loop: false })
+
+ testTotalMovingTimeSinceStart(rower, 2340.0100514160117)
+ testTotalLinearDistanceSinceStart(rower, 8406.791871958883)
+ testTotalNumberOfStrokes(rower, 846)
+ // As dragFactor is static, it should remain in place
+ testRecoveryDragFactor(rower, rowerProfiles.Sportstech_WRX700.dragFactor)
+})
+
+test('A full session for a Concept2 Model C should produce plausible results', async () => {
+ const rower = createRower(deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Concept2_Model_C))
+ testTotalMovingTimeSinceStart(rower, 0)
+ testTotalLinearDistanceSinceStart(rower, 0)
+ testTotalNumberOfStrokes(rower, 0)
+ testRecoveryDragFactor(rower, undefined)
+
+ await replayRowingSession(rower.handleRotationImpulse, { filename: 'recordings/Concept2_Model_C.csv', realtime: false, loop: false })
+
+ testTotalMovingTimeSinceStart(rower, 181.47141999999985)
+ testTotalLinearDistanceSinceStart(rower, 552.0863658667265)
+ testTotalNumberOfStrokes(rower, 84)
+ // As dragFactor isn't static, it should have changed
+ testRecoveryDragFactor(rower, 123.82587294279575)
+})
+
+test('A full session for a Concept2 RowErg should produce plausible results', async () => {
+ const rower = createRower(deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Concept2_RowErg))
+ testTotalMovingTimeSinceStart(rower, 0)
+ testTotalLinearDistanceSinceStart(rower, 0)
+ testTotalNumberOfStrokes(rower, 0)
+ testRecoveryDragFactor(rower, undefined)
+
+ await replayRowingSession(rower.handleRotationImpulse, { filename: 'recordings/Concept2_RowErg_Session_2000meters.csv', realtime: false, loop: false })
+
+ testTotalMovingTimeSinceStart(rower, 590.111937)
+ testTotalLinearDistanceSinceStart(rower, 2027.493082238415)
+ testTotalNumberOfStrokes(rower, 206)
+ // As dragFactor isn't static, it should have changed
+ testRecoveryDragFactor(rower, 80.60573080009686)
+})
+
+function testStrokeState (rower, expectedValue) {
+ assert.ok(rower.strokeState() === expectedValue, `strokeState should be ${expectedValue} at ${rower.totalMovingTimeSinceStart()} sec, is ${rower.strokeState()}`)
+}
+
+function testTotalMovingTimeSinceStart (rower, expectedValue) {
+ assert.ok(rower.totalMovingTimeSinceStart() === expectedValue, `totalMovingTimeSinceStart should be ${expectedValue} sec at ${rower.totalMovingTimeSinceStart()} sec, is ${rower.totalMovingTimeSinceStart()}`)
+}
+
+function testTotalNumberOfStrokes (rower, expectedValue) {
+ // Please note there is a stroke 0
+ assert.ok(rower.totalNumberOfStrokes() + 1 === expectedValue, `totalNumberOfStrokes should be ${expectedValue} at ${rower.totalMovingTimeSinceStart()} sec, is ${rower.totalNumberOfStrokes() + 1}`)
+}
+
+function testTotalLinearDistanceSinceStart (rower, expectedValue) {
+ assert.ok(rower.totalLinearDistanceSinceStart() === expectedValue, `totalLinearDistanceSinceStart should be ${expectedValue} meters at ${rower.totalMovingTimeSinceStart()} sec, is ${rower.totalLinearDistanceSinceStart()}`)
+}
+
+function testCycleDuration (rower, expectedValue) {
+ assert.ok(rower.cycleDuration() === expectedValue, `cycleDuration should be ${expectedValue} sec at ${rower.totalMovingTimeSinceStart()} sec, is ${rower.cycleDuration()}`)
+}
+
+function testCycleLinearDistance (rower, expectedValue) {
+ assert.ok(rower.cycleLinearDistance() === expectedValue, `cycleLinearDistance should be ${expectedValue} meters at ${rower.totalMovingTimeSinceStart()} sec, is ${rower.cycleLinearDistance()}`)
+}
+
+function testCycleLinearVelocity (rower, expectedValue) {
+ assert.ok(rower.cycleLinearVelocity() === expectedValue, `cycleLinearVelocity should be ${expectedValue} m/s at ${rower.totalMovingTimeSinceStart()} sec, is ${rower.cycleLinearVelocity()}`)
+}
+
+function testCyclePower (rower, expectedValue) {
+ assert.ok(rower.cyclePower() === expectedValue, `cyclePower should be ${expectedValue} Watt at ${rower.totalMovingTimeSinceStart()} sec, is ${rower.cyclePower()}`)
+}
+
+function testDriveDuration (rower, expectedValue) {
+ assert.ok(rower.driveDuration() === expectedValue, `driveDuration should be ${expectedValue} sec at ${rower.totalMovingTimeSinceStart()} sec, is ${rower.driveDuration()}`)
+}
+
+function testDriveLinearDistance (rower, expectedValue) {
+ assert.ok(rower.driveLinearDistance() === expectedValue, `driveLinearDistance should be ${expectedValue} meters at ${rower.totalMovingTimeSinceStart()} sec, is ${rower.driveLinearDistance()}`)
+}
+
+function testDriveLength (rower, expectedValue) {
+ assert.ok(rower.driveLength() === expectedValue, `driveLength should be ${expectedValue} meters at ${rower.totalMovingTimeSinceStart()} sec, is ${rower.driveLength()}`)
+}
+
+function testDriveAverageHandleForce (rower, expectedValue) {
+ assert.ok(rower.driveAverageHandleForce() === expectedValue, `driveAverageHandleForce should be ${expectedValue} N at ${rower.totalMovingTimeSinceStart()} sec, is ${rower.driveAverageHandleForce()}`)
+}
+
+function testDrivePeakHandleForce (rower, expectedValue) {
+ assert.ok(rower.drivePeakHandleForce() === expectedValue, `drivePeakHandleForce should be ${expectedValue} N at ${rower.totalMovingTimeSinceStart()} sec, is ${rower.drivePeakHandleForce()}`)
+}
+
+function testRecoveryDuration (rower, expectedValue) {
+ assert.ok(rower.recoveryDuration() === expectedValue, `recoveryDuration should be ${expectedValue} sec at ${rower.totalMovingTimeSinceStart()} sec, is ${rower.recoveryDuration()}`)
+}
+
+function testRecoveryDragFactor (rower, expectedValue) {
+ assert.ok(rower.recoveryDragFactor() === expectedValue, `recoveryDragFactor should be ${expectedValue} N*m*s^2 at ${rower.totalMovingTimeSinceStart()} sec, is ${rower.recoveryDragFactor()}`)
+}
+
+function testInstantHandlePower (rower, expectedValue) {
+ assert.ok(rower.instantHandlePower() === expectedValue, `instantHandlePower should be ${expectedValue} Watt at ${rower.totalMovingTimeSinceStart()} sec, is ${rower.instantHandlePower()}`)
+}
+
+function reportAll (rower) { // eslint-disable-line no-unused-vars
+ assert.ok(0, `time: ${rower.totalMovingTimeSinceStart()}, state ${rower.strokeState()}, No Strokes: ${rower.totalNumberOfStrokes() + 1}, Lin Distance: ${rower.totalLinearDistanceSinceStart()}, cycle dur: ${rower.cycleDuration()}, cycle Lin Dist: ${rower.cycleLinearDistance()}, Lin Velocity: ${rower.cycleLinearVelocity()}, Power: ${rower.cyclePower()}, Drive Dur: ${rower.driveDuration()}, Drive Lin. Dist. ${rower.driveLinearDistance()}, Drive Length: ${rower.driveLength()}, Av. Handle Force: ${rower.driveAverageHandleForce()}, Peak Handle Force: ${rower.drivePeakHandleForce()}, Rec. Dur: ${rower.recoveryDuration()}, Dragfactor: ${rower.recoveryDragFactor()}, Inst Handle Power: ${rower.instantHandlePower()}`)
+}
+
+test.run()
diff --git a/app/engine/RowingEngine.js b/app/engine/RowingEngine.js
deleted file mode 100644
index d471abdecc..0000000000
--- a/app/engine/RowingEngine.js
+++ /dev/null
@@ -1,342 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- The Rowing Engine models the physics of a real rowing boat.
- It takes impulses from the flywheel of a rowing machine and estimates
- parameters such as energy, stroke rates and movement.
-
- This implementation uses concepts that are described here:
- Physics of Rowing by Anu Dudhia: http://eodg.atm.ox.ac.uk/user/dudhia/rowing/physics
- Also Dave Vernooy has some good explanations here: https://dvernooy.github.io/projects/ergware
-*/
-import loglevel from 'loglevel'
-import { createMovingAverager } from './averager/MovingAverager.js'
-import { createMovingFlankDetector } from './MovingFlankDetector.js'
-
-const log = loglevel.getLogger('RowingEngine')
-
-function createRowingEngine (rowerSettings) {
- let workoutHandler
- const flankDetector = createMovingFlankDetector(rowerSettings)
- const angularDisplacementPerImpulse = (2.0 * Math.PI) / rowerSettings.numOfImpulsesPerRevolution
- const movingDragAverage = createMovingAverager(rowerSettings.dampingConstantSmoothing, rowerSettings.dragFactor / 1000000)
- const dragFactorMaxUpwardChange = 1 + rowerSettings.dampingConstantMaxChange
- const dragFactorMaxDownwardChange = 1 - rowerSettings.dampingConstantMaxChange
- const minimumCycleLength = rowerSettings.minimumDriveTime + rowerSettings.minimumRecoveryTime
- let cyclePhase
- let totalTime
- let totalNumberOfImpulses
- let strokeNumber
- let drivePhaseStartTime
- let drivePhaseStartAngularDisplacement
- let drivePhaseLength
- let drivePhaseAngularDisplacement
- let driveLinearDistance
- let recoveryPhaseStartTime
- let recoveryPhaseAngularDisplacement
- let recoveryPhaseStartAngularDisplacement
- let recoveryPhaseLength
- let recoveryStartAngularVelocity
- let recoveryEndAngularVelocity
- let recoveryLinearDistance
- let currentDragFactor
- let dragFactor
- let cycleLength
- let linearCycleVelocity
- let totalLinearDistance
- let averagedCyclePower
- let currentTorque
- let previousAngularVelocity
- let currentAngularVelocity
- // we use the reset function to initialize the variables above
- reset()
-
- // called if the sensor detected an impulse, currentDt is an interval in seconds
- function handleRotationImpulse (currentDt) {
- // impulses that take longer than maximumImpulseTimeBeforePause seconds are considered a pause
- if (currentDt > rowerSettings.maximumImpulseTimeBeforePause) {
- workoutHandler.handlePause(currentDt)
- return
- }
-
- totalTime += currentDt
- totalNumberOfImpulses++
-
- // detect where we are in the rowing phase (drive or recovery)
- flankDetector.pushValue(currentDt)
-
- // we implement a finite state machine that goes between "Drive" and "Recovery" phases,
- // which allows a phase-change if sufficient time has passed and there is a plausible flank
- if (cyclePhase === 'Drive') {
- // We currently are in the "Drive" phase, lets determine what the next phase is
- if (flankDetector.isFlywheelUnpowered()) {
- // The flank detector detects that the flywheel has no power exerted on it
- drivePhaseLength = (totalTime - flankDetector.timeToBeginOfFlank()) - drivePhaseStartTime
- if (drivePhaseLength >= rowerSettings.minimumDriveTime) {
- // We change into the "Recovery" phase since we have been long enough in the Drive phase, and we see a clear lack of power
- // exerted on the flywheel
- startRecoveryPhase(currentDt)
- cyclePhase = 'Recovery'
- } else {
- // We seem to have lost power to the flywheel, but it is too early according to the settings. We stay in the Drive Phase
- log.debug(`Time: ${totalTime.toFixed(4)} sec, impulse ${totalNumberOfImpulses}: flank suggests no power (${flankDetector.accelerationAtBeginOfFlank().toFixed(1)} rad/s2), but waiting for for recoveryPhaseLength (${recoveryPhaseLength.toFixed(4)} sec) to exceed minimumRecoveryTime (${rowerSettings.minimumRecoveryTime} sec)`)
- updateDrivePhase(currentDt)
- }
- } else {
- // We stay in the "Drive" phase as the acceleration is lacking
- updateDrivePhase(currentDt)
- }
- } else {
- // We currently are in the "Recovery" phase, lets determine what the next phase is
- if (flankDetector.isFlywheelPowered()) {
- // The flank detector consistently detects some force on the flywheel
- recoveryPhaseLength = (totalTime - flankDetector.timeToBeginOfFlank()) - recoveryPhaseStartTime
- if (recoveryPhaseLength >= rowerSettings.minimumRecoveryTime) {
- // We change into the "Drive" phase if we have been long enough in the "Recovery" phase, and we see a consistent force being
- // exerted on the flywheel
- startDrivePhase(currentDt)
- cyclePhase = 'Drive'
- } else {
- // We see a force, but the "Recovery" phase has been too short, we stay in the "Recovery" phase
- log.debug(`Time: ${totalTime.toFixed(4)} sec, impulse ${totalNumberOfImpulses}: flank suggests power (${flankDetector.accelerationAtBeginOfFlank().toFixed(1)} rad/s2), but waiting for recoveryPhaseLength (${recoveryPhaseLength.toFixed(4)} sec) to exceed minimumRecoveryTime (${rowerSettings.minimumRecoveryTime} sec)`)
- updateRecoveryPhase(currentDt)
- }
- } else {
- // No force on the flywheel, let's continue the "Drive" phase
- updateRecoveryPhase(currentDt)
- }
- }
- }
-
- function startDrivePhase (currentDt) {
- // First, we conclude the "Recovery" phase
- log.debug('*** recovery phase completed')
- if (rowerSettings.minimumRecoveryTime <= recoveryPhaseLength && rowerSettings.minimumDriveTime <= drivePhaseLength) {
- // We have a plausible cycle time
- cycleLength = recoveryPhaseLength + drivePhaseLength
- } else {
- log.debug(`CycleLength isn't plausible: recoveryPhaseLength ${recoveryPhaseLength.toFixed(4)} sec, drivePhaseLength = ${drivePhaseLength.toFixed(4)} s, maximumImpulseTimeBeforePause ${rowerSettings.maximumImpulseTimeBeforePause} s`)
- }
- recoveryPhaseAngularDisplacement = ((totalNumberOfImpulses - flankDetector.noImpulsesToBeginFlank()) - recoveryPhaseStartAngularDisplacement) * angularDisplacementPerImpulse
-
- // Calculation of the drag-factor
- if (flankDetector.impulseLengthAtBeginFlank() > 0) {
- recoveryEndAngularVelocity = angularDisplacementPerImpulse / flankDetector.impulseLengthAtBeginFlank()
- if (recoveryPhaseLength >= rowerSettings.minimumRecoveryTime && recoveryStartAngularVelocity > 0 && recoveryEndAngularVelocity > 0) {
- // Prevent division by zero and keep useless data out of our calculations
- currentDragFactor = -1 * rowerSettings.flywheelInertia * ((1 / recoveryStartAngularVelocity) - (1 / recoveryEndAngularVelocity)) / recoveryPhaseLength
- if (rowerSettings.autoAdjustDragFactor) {
- if (currentDragFactor > (movingDragAverage.getAverage() * dragFactorMaxDownwardChange) && currentDragFactor < (movingDragAverage.getAverage() * dragFactorMaxUpwardChange)) {
- // If the calculated drag factor is close to what we expect
- movingDragAverage.pushValue(currentDragFactor)
- dragFactor = movingDragAverage.getAverage()
- log.info(`*** Calculated drag factor: ${(currentDragFactor * 1000000).toFixed(2)}`)
- } else {
- // The calculated drag factor is outside the plausible range
- log.info(`Calculated drag factor: ${(currentDragFactor * 1000000).toFixed(2)}, which is too far off the currently used dragfactor of ${movingDragAverage.getAverage() * 1000000}`)
- log.debug(`Time: ${totalTime.toFixed(4)} sec, impulse ${totalNumberOfImpulses}: recoveryStartAngularVelocity = ${recoveryStartAngularVelocity.toFixed(2)} rad/sec, recoveryEndAngularVelocity = ${recoveryEndAngularVelocity.toFixed(2)} rad/sec, recoveryPhaseLength = ${recoveryPhaseLength.toFixed(4)} sec`)
- if (currentDragFactor < (movingDragAverage.getAverage() * dragFactorMaxDownwardChange)) {
- // The current calculated dragfactor makes an abrupt downward change, let's follow the direction, but limit it to the maximum allowed change
- movingDragAverage.pushValue(movingDragAverage.getAverage() * dragFactorMaxDownwardChange)
- } else {
- // The current calculated dragfactor makes an abrupt upward change, let's follow the direction, but limit it to the maximum allowed change
- movingDragAverage.pushValue(movingDragAverage.getAverage() * dragFactorMaxUpwardChange)
- }
- dragFactor = movingDragAverage.getAverage()
- log.debug(`*** Applied drag factor: ${dragFactor * 1000000}`)
- }
- } else {
- log.info(`*** Calculated drag factor: ${(currentDragFactor * 1000000).toFixed(2)}`)
- }
- } else {
- log.error(`Time: ${totalTime.toFixed(4)} sec, impulse ${totalNumberOfImpulses}: division by 0 prevented, recoveryPhaseLength = ${recoveryPhaseLength} sec, recoveryStartAngularVelocity = ${recoveryStartAngularVelocity} rad/sec, recoveryEndAngularVelocity = ${recoveryEndAngularVelocity} rad/sec`)
- }
- } else {
- log.error(`Time: ${totalTime.toFixed(4)} sec, impulse ${totalNumberOfImpulses}: division by 0 prevented, impulseLengthAtBeginFlank = ${flankDetector.impulseLengthAtBeginFlank()} sec`)
- }
-
- // Calculate the key metrics
- recoveryLinearDistance = Math.pow((dragFactor / rowerSettings.magicConstant), 1.0 / 3.0) * recoveryPhaseAngularDisplacement
- totalLinearDistance += recoveryLinearDistance
- currentTorque = calculateTorque(currentDt)
- linearCycleVelocity = calculateLinearVelocity()
- averagedCyclePower = calculateCyclePower()
-
- // Next, we start the "Drive" Phase
- log.debug(`*** DRIVE phase started at time: ${totalTime.toFixed(4)} sec, impulse number ${totalNumberOfImpulses}`)
- strokeNumber++
- drivePhaseStartTime = totalTime - flankDetector.timeToBeginOfFlank()
- drivePhaseStartAngularDisplacement = totalNumberOfImpulses - flankDetector.noImpulsesToBeginFlank()
-
- // Update the metrics
- if (workoutHandler) {
- workoutHandler.handleRecoveryEnd({
- timeSinceStart: totalTime,
- power: averagedCyclePower,
- duration: cycleLength,
- strokeDistance: driveLinearDistance + recoveryLinearDistance,
- durationDrivePhase: drivePhaseLength,
- speed: linearCycleVelocity,
- distance: totalLinearDistance,
- numberOfStrokes: strokeNumber,
- instantaneousTorque: currentTorque,
- strokeState: 'DRIVING'
- })
- }
- }
-
- function updateDrivePhase (currentDt) {
- // Update the key metrics on each impulse
- drivePhaseAngularDisplacement = ((totalNumberOfImpulses - flankDetector.noImpulsesToBeginFlank()) - drivePhaseStartAngularDisplacement) * angularDisplacementPerImpulse
- driveLinearDistance = Math.pow((dragFactor / rowerSettings.magicConstant), 1.0 / 3.0) * drivePhaseAngularDisplacement
- currentTorque = calculateTorque(currentDt)
- if (workoutHandler) {
- workoutHandler.updateKeyMetrics({
- timeSinceStart: totalTime,
- distance: totalLinearDistance + driveLinearDistance,
- instantaneousTorque: currentTorque
- })
- }
- }
-
- function startRecoveryPhase (currentDt) {
- // First, we conclude the "Drive" Phase
- log.debug('*** drive phase completed')
- if (rowerSettings.minimumRecoveryTime <= recoveryPhaseLength && rowerSettings.minimumDriveTime <= drivePhaseLength) {
- // We have a plausible cycle time
- cycleLength = recoveryPhaseLength + drivePhaseLength
- } else {
- log.debug(`CycleLength wasn't plausible: recoveryPhaseLength ${recoveryPhaseLength.toFixed(4)} sec, drivePhaseLength = ${drivePhaseLength.toFixed(4)} s`)
- }
- drivePhaseAngularDisplacement = ((totalNumberOfImpulses - flankDetector.noImpulsesToBeginFlank()) - drivePhaseStartAngularDisplacement) * angularDisplacementPerImpulse
- // driveEndAngularVelocity = angularDisplacementPerImpulse / flankDetector.impulseLengthAtBeginFlank()
- driveLinearDistance = Math.pow((dragFactor / rowerSettings.magicConstant), 1.0 / 3.0) * drivePhaseAngularDisplacement
- totalLinearDistance += driveLinearDistance
- currentTorque = calculateTorque(currentDt)
- // We display the AVERAGE speed in the display, NOT the top speed of the stroke
- linearCycleVelocity = calculateLinearVelocity()
- averagedCyclePower = calculateCyclePower()
-
- // Next, we start the "Recovery" Phase
- log.debug(`*** RECOVERY phase started at time: ${totalTime.toFixed(4)} sec, impuls number ${totalNumberOfImpulses}`)
- recoveryPhaseStartTime = totalTime - flankDetector.timeToBeginOfFlank()
- recoveryPhaseStartAngularDisplacement = totalNumberOfImpulses - flankDetector.noImpulsesToBeginFlank()
- if (flankDetector.impulseLengthAtBeginFlank() > 0) {
- recoveryStartAngularVelocity = angularDisplacementPerImpulse / flankDetector.impulseLengthAtBeginFlank()
- } else {
- log.error(`Time: ${totalTime.toFixed(4)} sec, impuls ${totalNumberOfImpulses}: division by 0 prevented, flankDetector.impulseLengthAtBeginFlank() is ${flankDetector.impulseLengthAtBeginFlank()} sec`)
- }
-
- // Update the metrics
- if (workoutHandler) {
- workoutHandler.handleDriveEnd({
- timeSinceStart: totalTime,
- power: averagedCyclePower,
- duration: cycleLength,
- strokeDistance: driveLinearDistance + recoveryLinearDistance,
- durationDrivePhase: drivePhaseLength,
- speed: linearCycleVelocity,
- distance: totalLinearDistance,
- instantaneousTorque: currentTorque,
- strokeState: 'RECOVERY'
- })
- }
- }
-
- function updateRecoveryPhase (currentDt) {
- // Update the key metrics on each impulse
- recoveryPhaseAngularDisplacement = ((totalNumberOfImpulses - flankDetector.noImpulsesToBeginFlank()) - recoveryPhaseStartAngularDisplacement) * angularDisplacementPerImpulse
- recoveryLinearDistance = Math.pow((dragFactor / rowerSettings.magicConstant), 1.0 / 3.0) * recoveryPhaseAngularDisplacement
- currentTorque = calculateTorque(currentDt)
- if (workoutHandler) {
- workoutHandler.updateKeyMetrics({
- timeSinceStart: totalTime,
- distance: totalLinearDistance + recoveryLinearDistance,
- instantaneousTorque: currentTorque
- })
- }
- }
-
- function calculateLinearVelocity () {
- // Here we calculate the AVERAGE speed for the displays, NOT the topspeed of the stroke
- let tempLinearVelocity = linearCycleVelocity
- if (drivePhaseLength > rowerSettings.minimumDriveTime && cycleLength > minimumCycleLength) {
- // There is no division by zero and the data data is plausible
- tempLinearVelocity = Math.pow((dragFactor / rowerSettings.magicConstant), 1.0 / 3.0) * ((recoveryPhaseAngularDisplacement + drivePhaseAngularDisplacement) / cycleLength)
- } else {
- log.error(`Time: ${totalTime.toFixed(4)} sec, impuls ${totalNumberOfImpulses}: cycle length was not plausible, CycleLength = ${cycleLength} sec`)
- }
- return tempLinearVelocity
- }
-
- function calculateCyclePower () {
- // Here we calculate the AVERAGE power for the displays, NOT the top power of the stroke
- let cyclePower = averagedCyclePower
- if (drivePhaseLength > rowerSettings.minimumDriveTime && cycleLength > minimumCycleLength) {
- // There is no division by zero and the data data is plausible
- cyclePower = dragFactor * Math.pow((recoveryPhaseAngularDisplacement + drivePhaseAngularDisplacement) / cycleLength, 3.0)
- } else {
- log.error(`Time: ${totalTime.toFixed(4)} sec, impulse ${totalNumberOfImpulses}: cycle length was not plausible, CycleLength = ${cycleLength} sec`)
- }
- return cyclePower
- }
-
- function calculateTorque (currentDt) {
- let torque = currentTorque
- if (currentDt > 0) {
- previousAngularVelocity = currentAngularVelocity
- currentAngularVelocity = angularDisplacementPerImpulse / currentDt
- torque = rowerSettings.flywheelInertia * ((currentAngularVelocity - previousAngularVelocity) / currentDt) + dragFactor * Math.pow(currentAngularVelocity, 2)
- }
- return torque
- }
-
- function reset () {
- // to init displacements with plausible defaults we assume, that one rowing cycle transforms to nine meters of distance...
- const defaultDisplacementForRowingCycle = 8.0 / Math.pow(((rowerSettings.dragFactor / 1000000) / rowerSettings.magicConstant), 1.0 / 3.0)
-
- movingDragAverage.reset()
- cyclePhase = 'Recovery'
- totalTime = 0.0
- totalNumberOfImpulses = 0.0
- strokeNumber = 0.0
- drivePhaseStartTime = 0.0
- drivePhaseStartAngularDisplacement = 0.0
- drivePhaseLength = 2.0 * rowerSettings.minimumDriveTime
- // split defaultDisplacementForRowingCycle to aprox 1/3 for the drive phase
- drivePhaseAngularDisplacement = (1.0 / 3.0) * defaultDisplacementForRowingCycle
- driveLinearDistance = 0.0
- // Make sure that the first CurrentDt will trigger a detected stroke by faking a recovery phase that is long enough
- recoveryPhaseStartTime = -2 * rowerSettings.minimumRecoveryTime
- // and split defaultDisplacementForRowingCycle to aprox 2/3 for the recovery phase
- recoveryPhaseAngularDisplacement = (2.0 / 3.0) * defaultDisplacementForRowingCycle
- // set this to the number of impulses required to generate the angular displacement as assumed above
- recoveryPhaseStartAngularDisplacement = Math.round(-1.0 * (2.0 / 3.0) * defaultDisplacementForRowingCycle / angularDisplacementPerImpulse)
- recoveryPhaseLength = 2.0 * rowerSettings.minimumRecoveryTime
- recoveryStartAngularVelocity = angularDisplacementPerImpulse / rowerSettings.minimumTimeBetweenImpulses
- recoveryEndAngularVelocity = angularDisplacementPerImpulse / rowerSettings.maximumTimeBetweenImpulses
- recoveryLinearDistance = 0.0
- currentDragFactor = rowerSettings.dragFactor / 1000000
- dragFactor = movingDragAverage.getAverage()
- cycleLength = minimumCycleLength
- linearCycleVelocity = 0.0
- totalLinearDistance = 0.0
- averagedCyclePower = 0.0
- currentTorque = 0.0
- previousAngularVelocity = 0.0
- currentAngularVelocity = 0.0
- }
-
- function notify (receiver) {
- workoutHandler = receiver
- }
-
- return {
- handleRotationImpulse,
- reset,
- notify
- }
-}
-
-export { createRowingEngine }
diff --git a/app/engine/RowingEngine.test.js b/app/engine/RowingEngine.test.js
deleted file mode 100644
index 5c4d2240a5..0000000000
--- a/app/engine/RowingEngine.test.js
+++ /dev/null
@@ -1,103 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-*/
-import { test } from 'uvu'
-import * as assert from 'uvu/assert'
-import loglevel from 'loglevel'
-
-import rowerProfiles from '../../config/rowerProfiles.js'
-import { createRowingEngine } from './RowingEngine.js'
-import { replayRowingSession } from '../tools/RowingRecorder.js'
-import { deepMerge } from '../tools/Helper.js'
-
-const log = loglevel.getLogger('RowingEngine.test')
-log.setLevel('warn')
-
-const createWorkoutEvaluator = function () {
- const strokes = []
-
- function handleDriveEnd (stroke) {
- strokes.push(stroke)
- log.info(`stroke: ${strokes.length}, power: ${Math.round(stroke.power)}w, duration: ${stroke.duration.toFixed(2)}s, ` +
- ` drivePhase: ${stroke.durationDrivePhase.toFixed(2)}s, distance: ${stroke.distance.toFixed(2)}m`)
- }
- function updateKeyMetrics () {}
- function handleRecoveryEnd () {}
- function handlePause () {}
- function getNumOfStrokes () {
- return strokes.length
- }
- function getMaxStrokePower () {
- return strokes.map((stroke) => stroke.power).reduce((acc, power) => Math.max(acc, power))
- }
- function getMinStrokePower () {
- return strokes.map((stroke) => stroke.power).reduce((acc, power) => Math.max(acc, power))
- }
- function getDistanceSum () {
- return strokes.map((stroke) => stroke.strokeDistance).reduce((acc, strokeDistance) => acc + strokeDistance)
- }
- function getDistanceTotal () {
- return strokes[strokes.length - 1].distance
- }
-
- return {
- handleDriveEnd,
- handleRecoveryEnd,
- updateKeyMetrics,
- handlePause,
- getNumOfStrokes,
- getMaxStrokePower,
- getMinStrokePower,
- getDistanceSum,
- getDistanceTotal
- }
-}
-
-test('sample data for WRX700 should produce plausible results with rower profile', async () => {
- const rowingEngine = createRowingEngine(deepMerge(rowerProfiles.DEFAULT, rowerProfiles.WRX700))
- const workoutEvaluator = createWorkoutEvaluator()
- rowingEngine.notify(workoutEvaluator)
- await replayRowingSession(rowingEngine.handleRotationImpulse, { filename: 'recordings/WRX700_2magnets.csv' })
- assert.is(workoutEvaluator.getNumOfStrokes(), 16, 'number of strokes does not meet expectation')
- assertPowerRange(workoutEvaluator, 50, 220)
- assertDistanceRange(workoutEvaluator, 165, 168)
- assertStrokeDistanceSumMatchesTotal(workoutEvaluator)
-})
-
-test('sample data for DKNR320 should produce plausible results with rower profile', async () => {
- const rowingEngine = createRowingEngine(deepMerge(rowerProfiles.DEFAULT, rowerProfiles.DKNR320))
- const workoutEvaluator = createWorkoutEvaluator()
- rowingEngine.notify(workoutEvaluator)
- await replayRowingSession(rowingEngine.handleRotationImpulse, { filename: 'recordings/DKNR320.csv' })
- assert.is(workoutEvaluator.getNumOfStrokes(), 10, 'number of strokes does not meet expectation')
- assertPowerRange(workoutEvaluator, 75, 200)
- assertDistanceRange(workoutEvaluator, 71, 73)
- assertStrokeDistanceSumMatchesTotal(workoutEvaluator)
-})
-
-test('sample data for RX800 should produce plausible results with rower profile', async () => {
- const rowingEngine = createRowingEngine(deepMerge(rowerProfiles.DEFAULT, rowerProfiles.RX800))
- const workoutEvaluator = createWorkoutEvaluator()
- rowingEngine.notify(workoutEvaluator)
- await replayRowingSession(rowingEngine.handleRotationImpulse, { filename: 'recordings/RX800.csv' })
- assert.is(workoutEvaluator.getNumOfStrokes(), 10, 'number of strokes does not meet expectation')
- assertPowerRange(workoutEvaluator, 80, 200)
- assertDistanceRange(workoutEvaluator, 70, 80)
- assertStrokeDistanceSumMatchesTotal(workoutEvaluator)
-})
-
-function assertPowerRange (evaluator, minPower, maxPower) {
- assert.ok(evaluator.getMinStrokePower() > minPower, `minimum stroke power should be above ${minPower}w, but is ${evaluator.getMinStrokePower()}w`)
- assert.ok(evaluator.getMaxStrokePower() < maxPower, `maximum stroke power should be below ${maxPower}w, but is ${evaluator.getMaxStrokePower()}w`)
-}
-
-function assertDistanceRange (evaluator, minDistance, maxDistance) {
- assert.ok(evaluator.getDistanceSum() >= minDistance && evaluator.getDistanceSum() <= maxDistance, `distance should be between ${minDistance}m and ${maxDistance}m, but is ${evaluator.getDistanceSum().toFixed(2)}m`)
-}
-
-function assertStrokeDistanceSumMatchesTotal (evaluator) {
- assert.ok(evaluator.getDistanceSum().toFixed(2) === evaluator.getDistanceTotal().toFixed(2), `sum of distance of all strokes is ${evaluator.getDistanceSum().toFixed(2)}m, but total in last stroke is ${evaluator.getDistanceTotal().toFixed(2)}m`)
-}
-
-test.run()
diff --git a/app/engine/RowingStatistics.js b/app/engine/RowingStatistics.js
index f683ddd705..44ff977070 100644
--- a/app/engine/RowingStatistics.js
+++ b/app/engine/RowingStatistics.js
@@ -1,229 +1,283 @@
'use strict'
/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- This Module calculates the training specific metrics.
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
*/
-import { EventEmitter } from 'events'
-import { createMovingIntervalAverager } from './averager/MovingIntervalAverager.js'
-import { createWeightedAverager } from './averager/WeightedAverager.js'
+/**
+ * This Module creates a persistent, consistent and user presentable set of metrics.
+ */
+import { createRower } from './Rower.js'
+import { createOLSLinearSeries } from './utils/OLSLinearSeries.js'
+import { createStreamFilter } from './utils/StreamFilter.js'
+import { createCurveAligner } from './utils/CurveAligner.js'
import loglevel from 'loglevel'
const log = loglevel.getLogger('RowingEngine')
-function createRowingStatistics (config) {
- const numOfDataPointsForAveraging = config.numOfPhasesForAveragingScreenData
- const webUpdateInterval = config.webUpdateInterval
+export function createRowingStatistics (config) {
+ const numOfDataPointsForAveraging = config.numOfPhasesForAveragingScreenData // Used for metrics updated twice per cycle
+ const halfNumOfDataPointsForAveraging = Math.round(numOfDataPointsForAveraging / 2) // Used for metrics updated twice per cycle
+ const rower = createRower(config.rowerSettings)
const minimumStrokeTime = config.rowerSettings.minimumRecoveryTime + config.rowerSettings.minimumDriveTime
- const maximumStrokeTime = config.maximumStrokeTime
- const timeBetweenStrokesBeforePause = maximumStrokeTime * 1000
- const emitter = new EventEmitter()
- const strokeAverager = createWeightedAverager(numOfDataPointsForAveraging)
- const powerAverager = createWeightedAverager(numOfDataPointsForAveraging)
- const speedAverager = createWeightedAverager(numOfDataPointsForAveraging)
- const powerRatioAverager = createWeightedAverager(numOfDataPointsForAveraging)
- const caloriesAveragerMinute = createMovingIntervalAverager(60)
- const caloriesAveragerHour = createMovingIntervalAverager(60 * 60)
- let sessionState = 'waitingForStart'
- let rowingPausedTimer
- let heartrateResetTimer
- let distanceTotal = 0.0
- let durationTotal = 0
- let strokesTotal = 0
- let caloriesTotal = 0.0
- let heartrate = 0
- let heartrateBatteryLevel = 0
- let lastStrokeDuration = 0.0
- let instantaneousTorque = 0.0
- let lastStrokeDistance = 0.0
- let lastStrokeSpeed = 0.0
- let lastStrokeState = 'RECOVERY'
- let lastWebMetrics = {}
-
- // send metrics to the web clients periodically (but only if the data has changed)
- setInterval(emitWebMetrics, webUpdateInterval)
- function emitWebMetrics () {
- const currentWebMetrics = getMetrics()
- if (Object.entries(currentWebMetrics).toString() !== Object.entries(lastWebMetrics).toString()) {
- emitter.emit('webMetricsUpdate', currentWebMetrics)
- lastWebMetrics = currentWebMetrics
- }
+ const maximumStrokeTime = config.rowerSettings.maximumStrokeTimeBeforePause
+ const cycleDuration = createStreamFilter(numOfDataPointsForAveraging, undefined)
+ const cycleDistance = createStreamFilter(numOfDataPointsForAveraging, undefined)
+ const cyclePower = createStreamFilter(numOfDataPointsForAveraging, undefined)
+ const cycleLinearVelocity = createStreamFilter(numOfDataPointsForAveraging, undefined)
+ let metricsContext
+ let totalLinearDistance = 0.0
+ let totalMovingTime = 0
+ let totalNumberOfStrokes = -1
+ let driveLastStartTime = 0
+ let strokeCalories = 0
+ let strokeWork = 0
+ const calories = createOLSLinearSeries()
+ const driveDuration = createStreamFilter(halfNumOfDataPointsForAveraging, undefined)
+ const driveLength = createStreamFilter(halfNumOfDataPointsForAveraging, undefined)
+ const driveDistance = createStreamFilter(halfNumOfDataPointsForAveraging, undefined)
+ const recoveryDuration = createStreamFilter(halfNumOfDataPointsForAveraging, undefined)
+ const driveAverageHandleForce = createStreamFilter(halfNumOfDataPointsForAveraging, undefined)
+ const drivePeakHandleForce = createStreamFilter(halfNumOfDataPointsForAveraging, undefined)
+ const driveHandleForceCurve = createCurveAligner(config.rowerSettings.minimumForceBeforeStroke)
+ const driveHandleVelocityCurve = createCurveAligner(1.0)
+ const driveHandlePowerCurve = createCurveAligner(50)
+ let dragFactor
+ let instantPower = 0.0
+ let lastStrokeState = 'WaitingForDrive'
+
+ resetMetricsContext()
+
+ function allowStartOrResumeTraining () {
+ rower.allowMovement()
}
- // notify bluetooth peripherall each second (even if data did not change)
- // todo: the FTMS protocol also supports that peripherals deliver a preferred update interval
- // we could respect this and set the update rate accordingly
- setInterval(emitPeripheralMetrics, 1000)
- function emitPeripheralMetrics () {
- emitter.emit('peripheralMetricsUpdate', getMetrics())
+ function stopTraining () {
+ rower.stopMoving()
+ lastStrokeState = 'Stopped'
}
- function handleDriveEnd (stroke) {
- // if we do not get a drive for timeBetweenStrokesBeforePause milliseconds we treat this as a rowing pause
- if (rowingPausedTimer)clearInterval(rowingPausedTimer)
- rowingPausedTimer = setTimeout(() => pauseRowing(), timeBetweenStrokesBeforePause)
-
- // based on: http://eodg.atm.ox.ac.uk/user/dudhia/rowing/physics/ergometer.html#section11
- const calories = (4 * powerAverager.getAverage() + 350) * (stroke.duration) / 4200
- durationTotal = stroke.timeSinceStart
- powerAverager.pushValue(stroke.power)
- speedAverager.pushValue(stroke.speed)
- if (stroke.duration < timeBetweenStrokesBeforePause && stroke.duration > minimumStrokeTime) {
- // stroke duration has to be plausible to be accepted
- powerRatioAverager.pushValue(stroke.durationDrivePhase / stroke.duration)
- strokeAverager.pushValue(stroke.duration)
- caloriesAveragerMinute.pushValue(calories, stroke.duration)
- caloriesAveragerHour.pushValue(calories, stroke.duration)
- } else {
- log.debug(`*** Stroke duration of ${stroke.duration} sec is considered unreliable, skipped update stroke statistics`)
- }
+ // clear the metrics in case the user pauses rowing
+ function pauseTraining () {
+ rower.pauseMoving()
+ metricsContext.isMoving = false
+ cycleDuration.reset()
+ cycleDistance.reset()
+ cyclePower.reset()
+ cycleLinearVelocity.reset()
+ lastStrokeState = 'WaitingForDrive'
+ }
- caloriesTotal += calories
- lastStrokeDuration = stroke.duration
- distanceTotal = stroke.distance
- lastStrokeDistance = stroke.strokeDistance
- lastStrokeState = stroke.strokeState
- lastStrokeSpeed = stroke.speed
- instantaneousTorque = stroke.instantaneousTorque
- emitter.emit('driveFinished', getMetrics())
+ function resetTraining () {
+ stopTraining()
+ rower.reset()
+ calories.reset()
+ rower.allowMovement()
+ totalMovingTime = 0
+ totalLinearDistance = 0.0
+ totalNumberOfStrokes = -1
+ driveLastStartTime = 0
+ driveDuration.reset()
+ recoveryDuration.reset()
+ driveLength.reset()
+ driveDistance.reset()
+ driveAverageHandleForce.reset()
+ drivePeakHandleForce.reset()
+ driveHandleForceCurve.reset()
+ driveHandleVelocityCurve.reset()
+ driveHandlePowerCurve.reset()
+ cycleDuration.reset()
+ cycleDistance.reset()
+ cyclePower.reset()
+ strokeCalories = 0
+ strokeWork = 0
+ cycleLinearVelocity.reset()
+ lastStrokeState = 'WaitingForDrive'
+ resetMetricsContext()
}
- // initiated by the rowing engine in case an impulse was not considered
- // because it was too large
- function handlePause (duration) {
- sessionState = 'paused'
- caloriesAveragerMinute.pushValue(0, duration)
- caloriesAveragerHour.pushValue(0, duration)
- emitter.emit('rowingPaused')
+ /**
+ * Calculates the linear metrics based on a currentDt
+ *
+ * @param {float} time between two impulses in seconds
+ *
+ * @see {@link https://github.com/JaapvanEkris/openrowingmonitor/blob/new-ble-api/docs/Architecture.md#rowingstatisticsjs|the architecture description}
+ */
+ function handleRotationImpulse (currentDt) {
+ // Provide the rower with new data
+ rower.handleRotationImpulse(currentDt)
+
+ resetMetricsContext()
+
+ // This is the core of the finite state machine that defines all state transitions
+ switch (true) {
+ case (lastStrokeState === 'WaitingForDrive' && rower.strokeState() === 'Drive'):
+ updateContinousMetrics()
+ metricsContext.isMoving = true
+ metricsContext.isDriveStart = true
+ break
+ case (lastStrokeState === 'WaitingForDrive' && rower.strokeState() === 'Recovery'):
+ updateContinousMetrics()
+ metricsContext.isMoving = true
+ metricsContext.isRecoveryStart = true
+ break
+ case (lastStrokeState === 'WaitingForDrive'):
+ // We can't change into the "Rowing" state since we are waiting for a drive phase that didn't come
+ metricsContext.isMoving = false // This has the disired side-effect that the many of the reported instanous metrics are zero-ed
+ break
+ case (lastStrokeState !== 'Stopped' && rower.strokeState() === 'Stopped'):
+ metricsContext.isMoving = false // This has the disired side-effect that the many of the reported instanous metrics are zero-ed
+ // This is intended behaviour, as the rower/flywheel indicate the rower has stopped somehow. So zero-ing all metrics fits that state better then a last know good state
+ break
+ case (lastStrokeState === 'Stopped'):
+ metricsContext.isMoving = false
+ // We are in a stopped state, so we won't update any metrics
+ // This is a permanent state, regardless of current action of the flywheel
+ break
+ case (lastStrokeState !== 'WaitingForDrive' && rower.strokeState() === 'WaitingForDrive'):
+ metricsContext.isMoving = false // This has the desired side-effect that the many of the reported instanous metrics are zero-ed
+ // Please note, the sessionmanager will trigger a pause based on this condition
+ break
+ // From this point on, we can be certain that the LastStrokeState and rower.strokeState() aren't 'Stopped' or 'WaitingForDrive', so we are processing an active stroke
+ case (lastStrokeState === 'Recovery' && rower.strokeState() === 'Drive'):
+ updateContinousMetrics()
+ updateCycleMetrics()
+ handleRecoveryEnd()
+ metricsContext.isMoving = true
+ metricsContext.isDriveStart = true
+ break
+ case (lastStrokeState === 'Recovery' && rower.strokeState() === 'Recovery'):
+ updateContinousMetrics()
+ metricsContext.isMoving = true
+ break
+ case (lastStrokeState === 'Drive' && rower.strokeState() === 'Recovery'):
+ updateContinousMetrics()
+ updateCycleMetrics()
+ handleDriveEnd()
+ metricsContext.isMoving = true
+ metricsContext.isRecoveryStart = true
+ break
+ case (lastStrokeState === 'Drive' && rower.strokeState() === 'Drive'):
+ updateContinousMetrics()
+ metricsContext.isMoving = true
+ break
+ default:
+ log.error(`Time: ${rower.totalMovingTimeSinceStart()}, combination of last stroke state ${lastStrokeState} and state ${rower.strokeState()} found in the Rowing Statistics, which is not captured by Finite State Machine`)
+ }
+ lastStrokeState = rower.strokeState()
+ return allMetrics()
}
- // initiated when the stroke state changes
- function handleRecoveryEnd (stroke) {
- // todo: we need a better mechanism to communicate strokeState updates
- // this is an initial hacky attempt to see if we can use it for the C2-pm5 protocol
- if (sessionState !== 'rowing') startTraining()
- durationTotal = stroke.timeSinceStart
- powerAverager.pushValue(stroke.power)
- speedAverager.pushValue(stroke.speed)
- if (stroke.duration < timeBetweenStrokesBeforePause && stroke.duration > minimumStrokeTime) {
- // stroke duration has to be plausible to be accepted
- powerRatioAverager.pushValue(stroke.durationDrivePhase / stroke.duration)
- strokeAverager.pushValue(stroke.duration)
- } else {
- log.debug(`*** Stroke duration of ${stroke.duration} sec is considered unreliable, skipped update stroke statistics`)
+ // Basic metricContext structure
+ function resetMetricsContext () {
+ metricsContext = {
+ isMoving: false,
+ isDriveStart: false,
+ isRecoveryStart: false
}
- distanceTotal = stroke.distance
- strokesTotal = stroke.numberOfStrokes
- lastStrokeDistance = stroke.strokeDistance
- lastStrokeState = stroke.strokeState
- lastStrokeSpeed = stroke.speed
- instantaneousTorque = stroke.instantaneousTorque
- emitter.emit('recoveryFinished', getMetrics())
}
// initiated when updating key statistics
- function updateKeyMetrics (stroke) {
- durationTotal = stroke.timeSinceStart
- distanceTotal = stroke.distance
- instantaneousTorque = stroke.instantaneousTorque
+ function updateContinousMetrics () {
+ totalMovingTime = rower.totalMovingTimeSinceStart()
+ totalLinearDistance = rower.totalLinearDistanceSinceStart()
+ instantPower = rower.instantHandlePower()
}
- // initiated when a new heart rate value is received from heart rate sensor
- function handleHeartrateMeasurement (value) {
- // set the heart rate to zero if we did not receive a value for some time
- if (heartrateResetTimer)clearInterval(heartrateResetTimer)
- heartrateResetTimer = setTimeout(() => {
- heartrate = 0
- heartrateBatteryLevel = 0
- }, 6000)
- heartrate = value.heartrate
- heartrateBatteryLevel = value.batteryLevel
+ function updateCycleMetrics () {
+ if (rower.cycleDuration() !== undefined && rower.cycleDuration() < maximumStrokeTime && rower.cycleDuration() > minimumStrokeTime && totalNumberOfStrokes > 0) {
+ // stroke duration has to be credible to be accepted
+ cycleDuration.push(rower.cycleDuration())
+ cycleDistance.push(rower.cycleLinearDistance())
+ cycleLinearVelocity.push(rower.cycleLinearVelocity())
+ cyclePower.push(rower.cyclePower())
+ } else {
+ log.debug(`*** Stroke duration of ${rower.cycleDuration()} sec is considered unreliable, skipped update cycle statistics`)
+ }
}
- function getMetrics () {
- const splitTime = speedAverager.getAverage() !== 0 && lastStrokeSpeed > 0 ? (500.0 / speedAverager.getAverage()) : Infinity
- // todo: due to sanitization we currently do not use a consistent time throughout the engine
- // We will rework this section to use both absolute and sanitized time in the appropriate places.
- // We will also polish up the events for the recovery and drive phase, so we get clean complete strokes from the first stroke onwards.
- const averagedStrokeTime = strokeAverager.getAverage() > minimumStrokeTime && strokeAverager.getAverage() < maximumStrokeTime && lastStrokeSpeed > 0 && sessionState === 'rowing' ? strokeAverager.getAverage() : 0 // seconds
- return {
- sessionState,
- durationTotal,
- durationTotalFormatted: secondsToTimeString(durationTotal),
- strokesTotal,
- distanceTotal: distanceTotal > 0 ? distanceTotal : 0, // meters
- caloriesTotal, // kcal
- caloriesPerMinute: caloriesAveragerMinute.getAverage() > 0 ? caloriesAveragerMinute.getAverage() : 0,
- caloriesPerHour: caloriesAveragerHour.getAverage() > 0 ? caloriesAveragerHour.getAverage() : 0,
- strokeTime: lastStrokeDuration, // seconds
- distance: lastStrokeDistance > 0 && lastStrokeSpeed > 0 && sessionState === 'rowing' ? lastStrokeDistance : 0, // meters
- power: powerAverager.getAverage() > 0 && lastStrokeSpeed > 0 && sessionState === 'rowing' ? powerAverager.getAverage() : 0, // watts
- split: splitTime, // seconds/500m
- splitFormatted: secondsToTimeString(splitTime),
- powerRatio: powerRatioAverager.getAverage() > 0 && lastStrokeSpeed > 0 && sessionState === 'rowing' ? powerRatioAverager.getAverage() : 0,
- instantaneousTorque,
- strokesPerMinute: averagedStrokeTime !== 0 && sessionState === 'rowing' ? (60.0 / averagedStrokeTime) : 0,
- speed: speedAverager.getAverage() > 0 && lastStrokeSpeed > 0 && sessionState === 'rowing' ? (speedAverager.getAverage() * 3.6) : 0, // km/h
- strokeState: lastStrokeState,
- heartrate,
- heartrateBatteryLevel
+ function handleDriveEnd () {
+ if (rower.driveDuration() !== undefined) {
+ driveDuration.push(rower.driveDuration())
+ driveLength.push(rower.driveLength())
+ driveDistance.push(rower.driveLinearDistance())
+ driveAverageHandleForce.push(rower.driveAverageHandleForce())
+ drivePeakHandleForce.push(rower.drivePeakHandleForce())
+ driveHandleForceCurve.push(rower.driveHandleForceCurve())
+ driveHandleVelocityCurve.push(rower.driveHandleVelocityCurve())
+ driveHandlePowerCurve.push(rower.driveHandlePowerCurve())
}
}
- function startTraining () {
- sessionState = 'rowing'
- }
+ // initiated when the stroke state changes
+ function handleRecoveryEnd () {
+ totalNumberOfStrokes = rower.totalNumberOfStrokes()
+ driveLastStartTime = rower.driveLastStartTime()
+ if (rower.recoveryDuration() !== undefined) {
+ recoveryDuration.push(rower.recoveryDuration())
+ }
+ if (rower.recoveryDuration() !== undefined && rower.recoveryDragFactor() !== undefined) {
+ dragFactor = rower.recoveryDragFactor()
+ } else {
+ dragFactor = undefined
+ }
- function stopTraining () {
- sessionState = 'stopped'
- if (rowingPausedTimer)clearInterval(rowingPausedTimer)
+ if (cyclePower.reliable() && cycleDuration.reliable()) {
+ // ToDo: see if this can be made part of the continuousmatrcs as Garmin and Concept2 also have a 'calories' type of training
+ // based on: http://eodg.atm.ox.ac.uk/user/dudhia/rowing/physics/ergometer.html#section11
+ strokeCalories = (4 * cyclePower.clean() + 350) * (cycleDuration.clean()) / 4200
+ strokeWork = cyclePower.clean() * cycleDuration.clean()
+ const totalCalories = calories.Y.atSeriesEnd() + strokeCalories
+ calories.push(totalMovingTime, totalCalories)
+ }
}
- function resetTraining () {
- stopTraining()
- distanceTotal = 0.0
- strokesTotal = 0
- caloriesTotal = 0.0
- durationTotal = 0
- caloriesAveragerMinute.reset()
- caloriesAveragerHour.reset()
- strokeAverager.reset()
- powerAverager.reset()
- speedAverager.reset()
- powerRatioAverager.reset()
- sessionState = 'waitingForStart'
+ /* eslint-disable complexity -- As this is the central metric being delivered to all consumers, who need to accept this at face value, we need a lot of defensive coding */
+ function allMetrics () {
+ const cyclePace = cycleLinearVelocity.clean() !== 0 && cycleLinearVelocity.raw() > 0 && metricsContext.isMoving === true ? (500.0 / cycleLinearVelocity.clean()) : Infinity
+ return {
+ metricsContext,
+ strokeState: rower.strokeState(),
+ totalMovingTime: totalMovingTime > 0 ? totalMovingTime : 0,
+ totalNumberOfStrokes: totalNumberOfStrokes > 0 ? totalNumberOfStrokes : 0,
+ totalLinearDistance: totalLinearDistance > 0 ? totalLinearDistance : 0, // meters
+ strokeCalories: strokeCalories > 0 ? strokeCalories : 0, // kCal
+ strokeWork: strokeWork > 0 ? strokeWork : 0, // Joules
+ totalCalories: calories.Y.atSeriesEnd() > 0 ? calories.Y.atSeriesEnd() : 0, // kcal
+ totalCaloriesPerMinute: totalMovingTime > 60 ? caloriesPerPeriod(totalMovingTime - 60, totalMovingTime) : caloriesPerPeriod(0, 60),
+ totalCaloriesPerHour: totalMovingTime > 3600 ? caloriesPerPeriod(totalMovingTime - 3600, totalMovingTime) : caloriesPerPeriod(0, 3600),
+ cycleDuration: cycleDuration.reliable() && cycleDuration.clean() > minimumStrokeTime && cycleDuration.clean() < maximumStrokeTime && cycleLinearVelocity.raw() > 0 && totalNumberOfStrokes > 0 && metricsContext.isMoving === true ? cycleDuration.clean() : undefined, // seconds
+ cycleStrokeRate: cycleDuration.reliable() && cycleDuration.clean() > minimumStrokeTime && cycleDuration.clean() < maximumStrokeTime && cycleLinearVelocity.raw() > 0 && totalNumberOfStrokes > 0 && metricsContext.isMoving === true ? (60.0 / cycleDuration.clean()) : undefined, // strokeRate in SPM
+ cycleDistance: cycleDistance.reliable() && cycleDistance.raw() > 0 && cycleLinearVelocity.raw() > 0 && metricsContext.isMoving === true ? cycleDistance.clean() : undefined, // meters
+ cycleLinearVelocity: cycleLinearVelocity.reliable() && cycleLinearVelocity.clean() > 0 && cycleLinearVelocity.raw() > 0 && metricsContext.isMoving === true ? cycleLinearVelocity.clean() : undefined, // m/s
+ cyclePace: cycleLinearVelocity.reliable() && cycleLinearVelocity.clean() > 0 && metricsContext.isMoving === true ? cyclePace : Infinity, // seconds/500m
+ cyclePower: cyclePower.reliable() && cyclePower.clean() > 0 && cycleLinearVelocity.raw() > 0 && metricsContext.isMoving === true ? cyclePower.clean() : undefined, // watts
+ driveLastStartTime: driveLastStartTime > 0 ? driveLastStartTime : 0,
+ driveDuration: driveDuration.reliable() && driveDuration.clean() >= config.rowerSettings.minimumDriveTime && totalNumberOfStrokes > 0 && metricsContext.isMoving === true ? driveDuration.clean() : undefined, // seconds
+ driveLength: driveLength.reliable() && driveLength.clean() > 0 && metricsContext.isMoving === true ? driveLength.clean() : undefined, // meters of chain movement
+ driveDistance: driveDistance.reliable() && driveDistance.clean() >= 0 && metricsContext.isMoving === true ? driveDistance.clean() : undefined, // meters
+ driveAverageHandleForce: driveAverageHandleForce.clean() > 0 && metricsContext.isMoving === true ? driveAverageHandleForce.clean() : undefined,
+ drivePeakHandleForce: drivePeakHandleForce.clean() > 0 && metricsContext.isMoving === true ? drivePeakHandleForce.clean() : undefined,
+ driveHandleForceCurve: drivePeakHandleForce.clean() > 0 && metricsContext.isMoving === true ? driveHandleForceCurve.lastCompleteCurve() : [],
+ driveHandleVelocityCurve: drivePeakHandleForce.clean() > 0 && metricsContext.isMoving === true ? driveHandleVelocityCurve.lastCompleteCurve() : [],
+ driveHandlePowerCurve: drivePeakHandleForce.clean() > 0 && metricsContext.isMoving === true ? driveHandlePowerCurve.lastCompleteCurve() : [],
+ recoveryDuration: recoveryDuration.reliable() && recoveryDuration.clean() >= config.rowerSettings.minimumRecoveryTime && totalNumberOfStrokes > 0 && metricsContext.isMoving === true ? recoveryDuration.clean() : undefined, // seconds
+ dragFactor: dragFactor > 0 ? dragFactor : undefined, // Dragfactor
+ instantPower: instantPower > 0 && rower.strokeState() === 'Drive' ? instantPower : 0
+ }
}
+ /* eslint-enable complexity */
- // clear the metrics in case the user pauses rowing
- function pauseRowing () {
- strokeAverager.reset()
- powerAverager.reset()
- speedAverager.reset()
- powerRatioAverager.reset()
- lastStrokeState = 'RECOVERY'
- sessionState = 'paused'
- emitter.emit('rowingPaused')
+ function caloriesPerPeriod (periodBegin, periodEnd) {
+ const beginCalories = calories.projectX(periodBegin)
+ const endCalories = calories.projectX(periodEnd)
+ return (endCalories - beginCalories)
}
- // converts a timestamp in seconds to a human readable hh:mm:ss format
- function secondsToTimeString (secondsTimeStamp) {
- if (secondsTimeStamp === Infinity) return '∞'
- const hours = Math.floor(secondsTimeStamp / 60 / 60)
- const minutes = Math.floor(secondsTimeStamp / 60) - (hours * 60)
- const seconds = Math.floor(secondsTimeStamp % 60)
- let timeString = hours > 0 ? ` ${hours.toString().padStart(2, '0')}:` : ''
- timeString += `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
- return timeString
+ return {
+ handleRotationImpulse,
+ allowStartOrResumeTraining,
+ stopTraining,
+ pauseTraining,
+ resetTraining,
+ getMetrics: allMetrics
}
-
- return Object.assign(emitter, {
- handleDriveEnd,
- handlePause,
- handleHeartrateMeasurement,
- handleRecoveryEnd,
- updateKeyMetrics,
- reset: resetTraining
- })
}
-
-export { createRowingStatistics }
diff --git a/app/engine/RowingStatistics.test.js b/app/engine/RowingStatistics.test.js
new file mode 100644
index 0000000000..ebbc8ed26b
--- /dev/null
+++ b/app/engine/RowingStatistics.test.js
@@ -0,0 +1,569 @@
+'use strict'
+/*
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
+*/
+/**
+ * This test is a test of the Rower object, that tests wether this object fills all fields correctly, given one validated rower, (the
+ * Concept2 RowErg) using a validated cycle of strokes. This thoroughly tests the raw physics of the translation of Angular physics
+ * to Linear physics. The combination with all possible known rowers is tested when testing the above function RowingStatistics, as
+ * these statistics are dependent on these settings as well.
+*/
+// ToDo: test the effects of smoothing parameters
+import { test } from 'uvu'
+import * as assert from 'uvu/assert'
+import rowerProfiles from '../../config/rowerProfiles.js'
+import { replayRowingSession } from '../recorders/RowingReplayer.js'
+import { deepMerge } from '../tools/Helper.js'
+
+import { createRowingStatistics } from './RowingStatistics.js'
+
+const baseConfig = {
+ loglevel: {
+ default: 'silent',
+ RowingEngine: 'silent'
+ },
+ numOfPhasesForAveragingScreenData: 2,
+ rowerSettings: { // Based on Concept 2 settings, as this is the validation system
+ numOfImpulsesPerRevolution: 6,
+ sprocketRadius: 1.4,
+ maximumStrokeTimeBeforePause: 0.3, // Modification to standard settings to shorten test cases
+ dragFactor: 110,
+ autoAdjustDragFactor: true,
+ minimumDragQuality: 0.95,
+ dragFactorSmoothing: 3,
+ minimumTimeBetweenImpulses: 0.005,
+ maximumTimeBetweenImpulses: 0.017,
+ flankLength: 12,
+ smoothing: 1,
+ minimumStrokeQuality: 0.36,
+ minimumForceBeforeStroke: 20, // Modification to standard settings to shorten test cases
+ minimumRecoverySlope: 0.00070,
+ autoAdjustRecoverySlope: false, // Modification to standard settings to shorten test cases
+ autoAdjustRecoverySlopeMargin: 0.04,
+ minimumDriveTime: 0.04, // Modification to standard settings to shorten test cases
+ minimumRecoveryTime: 0.09, // Modification to standard settings to shorten test cases
+ flywheelInertia: 0.10138,
+ magicConstant: 2.8
+ }
+}
+
+// Test behaviour for no datapoints
+test('Correct rower behaviour at initialisation', () => {
+ const rowingStatistics = createRowingStatistics(baseConfig)
+ testStrokeState(rowingStatistics, 'WaitingForDrive')
+ testTotalMovingTime(rowingStatistics, 0)
+ testTotalNumberOfStrokes(rowingStatistics, 0)
+ testTotalLinearDistance(rowingStatistics, 0)
+ testCycleDuration(rowingStatistics, undefined) // Default value
+ testCycleDistance(rowingStatistics, undefined)
+ testCycleLinearVelocity(rowingStatistics, undefined)
+ testCyclePower(rowingStatistics, undefined)
+ testDriveDuration(rowingStatistics, undefined)
+ testDriveDistance(rowingStatistics, undefined)
+ testDriveLength(rowingStatistics, undefined)
+ testDriveAverageHandleForce(rowingStatistics, undefined)
+ testDrivePeakHandleForce(rowingStatistics, undefined)
+ testRecoveryDuration(rowingStatistics, undefined)
+ testDragFactor(rowingStatistics, undefined)
+ testInstantHandlePower(rowingStatistics, undefined)
+})
+
+// Test behaviour for one datapoint
+
+// Test behaviour for three perfect identical strokes, including settingling behaviour of metrics
+test('Test behaviour for three perfect identical strokes, including settingling behaviour of metrics', () => {
+ const rowingStatistics = createRowingStatistics(baseConfig)
+ testStrokeState(rowingStatistics, 'WaitingForDrive')
+ testTotalMovingTime(rowingStatistics, 0)
+ testTotalLinearDistance(rowingStatistics, 0)
+ testTotalNumberOfStrokes(rowingStatistics, 0)
+ testCycleDuration(rowingStatistics, undefined) // Default value
+ testCycleDistance(rowingStatistics, undefined)
+ testCycleLinearVelocity(rowingStatistics, undefined)
+ testCyclePower(rowingStatistics, undefined)
+ testDriveDuration(rowingStatistics, undefined)
+ testDriveDistance(rowingStatistics, undefined)
+ testDriveLength(rowingStatistics, undefined)
+ testDriveAverageHandleForce(rowingStatistics, undefined)
+ testDrivePeakHandleForce(rowingStatistics, undefined)
+ testRecoveryDuration(rowingStatistics, undefined)
+ testDragFactor(rowingStatistics, undefined)
+ testInstantHandlePower(rowingStatistics, undefined)
+ // Drive initial stroke starts here
+ rowingStatistics.handleRotationImpulse(0.011221636)
+ rowingStatistics.handleRotationImpulse(0.011175504)
+ rowingStatistics.handleRotationImpulse(0.01116456)
+ rowingStatistics.handleRotationImpulse(0.011130263)
+ rowingStatistics.handleRotationImpulse(0.011082613)
+ rowingStatistics.handleRotationImpulse(0.011081761)
+ rowingStatistics.handleRotationImpulse(0.011062297)
+ rowingStatistics.handleRotationImpulse(0.011051853)
+ rowingStatistics.handleRotationImpulse(0.010973313)
+ rowingStatistics.handleRotationImpulse(0.010919756)
+ rowingStatistics.handleRotationImpulse(0.01086431)
+ rowingStatistics.handleRotationImpulse(0.010800864)
+ rowingStatistics.handleRotationImpulse(0.010956987)
+ rowingStatistics.handleRotationImpulse(0.010653396)
+ rowingStatistics.handleRotationImpulse(0.010648619)
+ rowingStatistics.handleRotationImpulse(0.010536818)
+ rowingStatistics.handleRotationImpulse(0.010526151)
+ rowingStatistics.handleRotationImpulse(0.010511225)
+ rowingStatistics.handleRotationImpulse(0.010386684)
+ testStrokeState(rowingStatistics, 'Drive')
+ testTotalMovingTime(rowingStatistics, 0.077918634)
+ testTotalLinearDistance(rowingStatistics, 0.2491943602992768)
+ testTotalNumberOfStrokes(rowingStatistics, 0)
+ testCycleDuration(rowingStatistics, undefined) // still default value
+ testCycleDistance(rowingStatistics, undefined)
+ testCycleLinearVelocity(rowingStatistics, undefined) // This isn't filled after the first drive, as we haven't survived a complete cycle yet
+ testCyclePower(rowingStatistics, undefined) // This isn't filled after the first drive, as we haven't survived a complete cycle yet
+ testDriveDuration(rowingStatistics, undefined) // Shouldn't this one be filled after the first drive?
+ testDriveDistance(rowingStatistics, undefined)
+ testDriveLength(rowingStatistics, undefined) // Shouldn't this one be filled after the first drive?
+ testDriveAverageHandleForce(rowingStatistics, undefined)
+ testDrivePeakHandleForce(rowingStatistics, undefined)
+ testRecoveryDuration(rowingStatistics, undefined)
+ testDragFactor(rowingStatistics, undefined)
+ testInstantHandlePower(rowingStatistics, undefined)
+ // Recovery initial stroke starts here
+ rowingStatistics.handleRotationImpulse(0.010769)
+ rowingStatistics.handleRotationImpulse(0.010707554)
+ rowingStatistics.handleRotationImpulse(0.010722165)
+ rowingStatistics.handleRotationImpulse(0.01089567)
+ rowingStatistics.handleRotationImpulse(0.010917504)
+ rowingStatistics.handleRotationImpulse(0.010997969)
+ rowingStatistics.handleRotationImpulse(0.011004655)
+ rowingStatistics.handleRotationImpulse(0.011013618)
+ rowingStatistics.handleRotationImpulse(0.011058193)
+ rowingStatistics.handleRotationImpulse(0.010807149)
+ rowingStatistics.handleRotationImpulse(0.0110626)
+ rowingStatistics.handleRotationImpulse(0.011090787)
+ rowingStatistics.handleRotationImpulse(0.011099509)
+ rowingStatistics.handleRotationImpulse(0.011131862)
+ rowingStatistics.handleRotationImpulse(0.011209919)
+ testStrokeState(rowingStatistics, 'Recovery')
+ testTotalMovingTime(rowingStatistics, 0.23894732900000007)
+ testTotalLinearDistance(rowingStatistics, 0.7831822752262985)
+ testTotalNumberOfStrokes(rowingStatistics, 0)
+ testCycleDuration(rowingStatistics, undefined)
+ testCycleDistance(rowingStatistics, undefined)
+ testCycleLinearVelocity(rowingStatistics, undefined)
+ testCyclePower(rowingStatistics, undefined)
+ testDriveDuration(rowingStatistics, undefined)
+ testDriveDistance(rowingStatistics, 0.46278952627008546)
+ testDriveLength(rowingStatistics, 0.19058995431778075)
+ testDriveAverageHandleForce(rowingStatistics, 276.20193475035796)
+ testDrivePeakHandleForce(rowingStatistics, 325.1619554833936)
+ testRecoveryDuration(rowingStatistics, undefined)
+ testDragFactor(rowingStatistics, undefined)
+ testInstantHandlePower(rowingStatistics, undefined)
+ // Drive second stroke starts here
+ rowingStatistics.handleRotationImpulse(0.011221636)
+ rowingStatistics.handleRotationImpulse(0.011175504)
+ rowingStatistics.handleRotationImpulse(0.01116456)
+ rowingStatistics.handleRotationImpulse(0.011130263)
+ rowingStatistics.handleRotationImpulse(0.011082613)
+ rowingStatistics.handleRotationImpulse(0.011081761)
+ rowingStatistics.handleRotationImpulse(0.011062297)
+ rowingStatistics.handleRotationImpulse(0.011051853)
+ rowingStatistics.handleRotationImpulse(0.010973313)
+ rowingStatistics.handleRotationImpulse(0.010919756)
+ rowingStatistics.handleRotationImpulse(0.01086431)
+ rowingStatistics.handleRotationImpulse(0.010800864)
+ rowingStatistics.handleRotationImpulse(0.010956987)
+ rowingStatistics.handleRotationImpulse(0.010653396)
+ rowingStatistics.handleRotationImpulse(0.010648619)
+ rowingStatistics.handleRotationImpulse(0.010536818)
+ rowingStatistics.handleRotationImpulse(0.010526151)
+ rowingStatistics.handleRotationImpulse(0.010511225)
+ rowingStatistics.handleRotationImpulse(0.010386684)
+ testStrokeState(rowingStatistics, 'Drive')
+ testTotalMovingTime(rowingStatistics, 0.44915539800000004)
+ testTotalLinearDistance(rowingStatistics, 1.828822466846578)
+ testTotalNumberOfStrokes(rowingStatistics, 1)
+ testCycleDuration(rowingStatistics, undefined)
+ testCycleDistance(rowingStatistics, undefined)
+ testCycleLinearVelocity(rowingStatistics, undefined)
+ testCyclePower(rowingStatistics, undefined)
+ testDriveDuration(rowingStatistics, 0.143485717)
+ testDriveDistance(rowingStatistics, 0.46278952627008546)
+ testDriveLength(rowingStatistics, 0.19058995431778075)
+ testDriveAverageHandleForce(rowingStatistics, 276.20193475035796)
+ testDrivePeakHandleForce(rowingStatistics, 325.1619554833936)
+ testRecoveryDuration(rowingStatistics, 0.20540926600000003)
+ testDragFactor(rowingStatistics, 283.12720365097886)
+ testInstantHandlePower(rowingStatistics, undefined)
+ // Recovery second stroke starts here
+ rowingStatistics.handleRotationImpulse(0.010769)
+ rowingStatistics.handleRotationImpulse(0.010707554)
+ rowingStatistics.handleRotationImpulse(0.010722165)
+ rowingStatistics.handleRotationImpulse(0.01089567)
+ rowingStatistics.handleRotationImpulse(0.010917504)
+ rowingStatistics.handleRotationImpulse(0.010997969)
+ rowingStatistics.handleRotationImpulse(0.011004655)
+ rowingStatistics.handleRotationImpulse(0.011013618)
+ rowingStatistics.handleRotationImpulse(0.011058193)
+ rowingStatistics.handleRotationImpulse(0.010807149)
+ rowingStatistics.handleRotationImpulse(0.0110626)
+ rowingStatistics.handleRotationImpulse(0.011090787)
+ rowingStatistics.handleRotationImpulse(0.011099509)
+ rowingStatistics.handleRotationImpulse(0.011131862)
+ rowingStatistics.handleRotationImpulse(0.011209919)
+ testStrokeState(rowingStatistics, 'Recovery')
+ testTotalMovingTime(rowingStatistics, 0.6101840930000001)
+ testTotalLinearDistance(rowingStatistics, 2.5606258278697)
+ testTotalNumberOfStrokes(rowingStatistics, 1)
+ testCycleDuration(rowingStatistics, undefined)
+ testCycleDistance(rowingStatistics, undefined)
+ testCycleLinearVelocity(rowingStatistics, undefined)
+ testCyclePower(rowingStatistics, undefined)
+ testDriveDuration(rowingStatistics, 0.23985939100000003)
+ testDriveDistance(rowingStatistics, 1.0733115961672441)
+ testDriveLength(rowingStatistics, 0.322536845768552)
+ testDriveAverageHandleForce(rowingStatistics, 285.0923064376231)
+ testDrivePeakHandleForce(rowingStatistics, 439.7407274840117)
+ testRecoveryDuration(rowingStatistics, 0.20540926600000003)
+ testDragFactor(rowingStatistics, 283.12720365097886) // As we decelerate the flywheel quite fast, this is expected
+ testInstantHandlePower(rowingStatistics, undefined)
+ // Drive third stroke starts here
+ rowingStatistics.handleRotationImpulse(0.011221636)
+ rowingStatistics.handleRotationImpulse(0.011175504)
+ rowingStatistics.handleRotationImpulse(0.01116456)
+ rowingStatistics.handleRotationImpulse(0.011130263)
+ rowingStatistics.handleRotationImpulse(0.011082613)
+ rowingStatistics.handleRotationImpulse(0.011081761)
+ rowingStatistics.handleRotationImpulse(0.011062297)
+ rowingStatistics.handleRotationImpulse(0.011051853)
+ rowingStatistics.handleRotationImpulse(0.010973313)
+ rowingStatistics.handleRotationImpulse(0.010919756)
+ rowingStatistics.handleRotationImpulse(0.01086431)
+ rowingStatistics.handleRotationImpulse(0.010800864)
+ rowingStatistics.handleRotationImpulse(0.010956987)
+ rowingStatistics.handleRotationImpulse(0.010653396)
+ rowingStatistics.handleRotationImpulse(0.010648619)
+ rowingStatistics.handleRotationImpulse(0.010536818)
+ rowingStatistics.handleRotationImpulse(0.010526151)
+ rowingStatistics.handleRotationImpulse(0.010511225)
+ rowingStatistics.handleRotationImpulse(0.010386684)
+ testStrokeState(rowingStatistics, 'Drive')
+ testTotalMovingTime(rowingStatistics, 0.8203921620000004)
+ testTotalLinearDistance(rowingStatistics, 3.4875767518323193)
+ testTotalNumberOfStrokes(rowingStatistics, 2)
+ testCycleDuration(rowingStatistics, undefined)
+ testCycleDistance(rowingStatistics, undefined)
+ testCycleLinearVelocity(rowingStatistics, undefined)
+ testCyclePower(rowingStatistics, undefined)
+ testDriveDuration(rowingStatistics, 0.23985939100000003)
+ testDriveDistance(rowingStatistics, 1.0733115961672441)
+ testDriveLength(rowingStatistics, 0.322536845768552)
+ testDriveAverageHandleForce(rowingStatistics, 285.0923064376231)
+ testDrivePeakHandleForce(rowingStatistics, 439.7407274840117)
+ testRecoveryDuration(rowingStatistics, 0.09812447700000015)
+ testDragFactor(rowingStatistics, 283.12720365097886)
+ testInstantHandlePower(rowingStatistics, undefined)
+ // Recovery third stroke starts here
+ rowingStatistics.handleRotationImpulse(0.010769)
+ rowingStatistics.handleRotationImpulse(0.010707554)
+ rowingStatistics.handleRotationImpulse(0.010722165)
+ rowingStatistics.handleRotationImpulse(0.01089567)
+ rowingStatistics.handleRotationImpulse(0.010917504)
+ rowingStatistics.handleRotationImpulse(0.010997969)
+ rowingStatistics.handleRotationImpulse(0.011004655)
+ rowingStatistics.handleRotationImpulse(0.011013618)
+ rowingStatistics.handleRotationImpulse(0.011058193)
+ rowingStatistics.handleRotationImpulse(0.010807149)
+ rowingStatistics.handleRotationImpulse(0.0110626)
+ rowingStatistics.handleRotationImpulse(0.011090787)
+ rowingStatistics.handleRotationImpulse(0.011099509)
+ rowingStatistics.handleRotationImpulse(0.011131862)
+ rowingStatistics.handleRotationImpulse(0.011209919)
+ testStrokeState(rowingStatistics, 'Recovery')
+ testTotalMovingTime(rowingStatistics, 0.9814208570000005)
+ testTotalLinearDistance(rowingStatistics, 4.219380112855441)
+ testTotalNumberOfStrokes(rowingStatistics, 2)
+ testCycleDuration(rowingStatistics, undefined)
+ testCycleDistance(rowingStatistics, undefined)
+ testCycleLinearVelocity(rowingStatistics, undefined)
+ testCyclePower(rowingStatistics, undefined)
+ testDriveDuration(rowingStatistics, 0.27311228700000023)
+ testDriveDistance(rowingStatistics, 1.2196722683718688)
+ testDriveLength(rowingStatistics, 0.3665191429188092)
+ testDriveAverageHandleForce(rowingStatistics, 254.91449219500532)
+ testDrivePeakHandleForce(rowingStatistics, 439.74072748282515)
+ testRecoveryDuration(rowingStatistics, 0.09812447700000015)
+ testDragFactor(rowingStatistics, 283.12720365097886)
+ testInstantHandlePower(rowingStatistics, undefined)
+ // Dwelling state starts here
+ rowingStatistics.handleRotationImpulse(0.020769)
+ rowingStatistics.handleRotationImpulse(0.020707554)
+ rowingStatistics.handleRotationImpulse(0.020722165)
+ rowingStatistics.handleRotationImpulse(0.02089567)
+ rowingStatistics.handleRotationImpulse(0.020917504)
+ rowingStatistics.handleRotationImpulse(0.020997969)
+ rowingStatistics.handleRotationImpulse(0.021004655)
+ rowingStatistics.handleRotationImpulse(0.021013618)
+ rowingStatistics.handleRotationImpulse(0.021058193)
+ rowingStatistics.handleRotationImpulse(0.020807149)
+ rowingStatistics.handleRotationImpulse(0.0210626)
+ rowingStatistics.handleRotationImpulse(0.021090787)
+ rowingStatistics.handleRotationImpulse(0.021099509)
+ rowingStatistics.handleRotationImpulse(0.021131862)
+ rowingStatistics.handleRotationImpulse(0.021209919)
+ testStrokeState(rowingStatistics, 'WaitingForDrive')
+ testTotalMovingTime(rowingStatistics, 1.1137102920000004)
+ testTotalNumberOfStrokes(rowingStatistics, 2)
+ testTotalLinearDistance(rowingStatistics, 4.804822801673938)
+ testCycleDuration(rowingStatistics, undefined)
+ testCycleDistance(rowingStatistics, undefined)
+ testCycleLinearVelocity(rowingStatistics, undefined)
+ testCyclePower(rowingStatistics, undefined)
+ testDriveDuration(rowingStatistics, undefined)
+ testDriveDistance(rowingStatistics, undefined)
+ testDriveLength(rowingStatistics, undefined)
+ testDriveAverageHandleForce(rowingStatistics, undefined)
+ testDrivePeakHandleForce(rowingStatistics, undefined)
+ testRecoveryDuration(rowingStatistics, undefined)
+ testDragFactor(rowingStatistics, 283.12720365097886)
+ testInstantHandlePower(rowingStatistics, undefined)
+})
+
+// Test behaviour for noisy upgoing flank
+
+// Test behaviour for noisy downgoing flank
+
+// Test behaviour for noisy stroke
+
+// Test behaviour after reset
+
+// Test behaviour for one datapoint
+
+// Test behaviour for noisy stroke
+
+// Test drag factor calculation
+
+// Test Dynamic stroke detection
+
+// Test behaviour after reset
+
+// Test behaviour with real-life data
+
+test('sample data for Sportstech WRX700 should produce plausible results', async () => {
+ const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Sportstech_WRX700)
+ const testConfig = {
+ loglevel: {
+ default: 'silent',
+ RowingEngine: 'silent'
+ },
+ numOfPhasesForAveragingScreenData: 2,
+ rowerSettings: rowerProfile
+ }
+ const rowingStatistics = createRowingStatistics(testConfig)
+ testTotalMovingTime(rowingStatistics, 0)
+ testTotalLinearDistance(rowingStatistics, 0)
+ testTotalNumberOfStrokes(rowingStatistics, 0)
+ testDragFactor(rowingStatistics, undefined)
+
+ await replayRowingSession(rowingStatistics.handleRotationImpulse, { filename: 'recordings/WRX700_2magnets.csv', realtime: false, loop: false })
+
+ testTotalMovingTime(rowingStatistics, 46.302522627)
+ testTotalLinearDistance(rowingStatistics, 166.29596716416734)
+ testTotalNumberOfStrokes(rowingStatistics, 15)
+ // As dragFactor is static, it should remain in place
+ testDragFactor(rowingStatistics, rowerProfiles.Sportstech_WRX700.dragFactor)
+})
+
+test('sample data for DKN R-320 should produce plausible results', async () => {
+ const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.DKN_R320)
+ const testConfig = {
+ loglevel: {
+ default: 'silent',
+ RowingEngine: 'silent'
+ },
+ numOfPhasesForAveragingScreenData: 2,
+ rowerSettings: rowerProfile
+ }
+ const rowingStatistics = createRowingStatistics(testConfig)
+ testTotalMovingTime(rowingStatistics, 0)
+ testTotalLinearDistance(rowingStatistics, 0)
+ testTotalNumberOfStrokes(rowingStatistics, 0)
+ testDragFactor(rowingStatistics, undefined)
+
+ await replayRowingSession(rowingStatistics.handleRotationImpulse, { filename: 'recordings/DKNR320.csv', realtime: false, loop: false })
+
+ testTotalMovingTime(rowingStatistics, 21.701535821)
+ testTotalLinearDistance(rowingStatistics, 70.11298001986664)
+ testTotalNumberOfStrokes(rowingStatistics, 9)
+ // As dragFactor is static, it should remain in place
+ testDragFactor(rowingStatistics, rowerProfiles.DKN_R320.dragFactor)
+})
+
+test('sample data for NordicTrack RX800 should produce plausible results', async () => {
+ const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.NordicTrack_RX800)
+ const testConfig = {
+ loglevel: {
+ default: 'silent',
+ RowingEngine: 'silent'
+ },
+ numOfPhasesForAveragingScreenData: 2,
+ rowerSettings: rowerProfile
+ }
+ const rowingStatistics = createRowingStatistics(testConfig)
+ testTotalMovingTime(rowingStatistics, 0)
+ testTotalLinearDistance(rowingStatistics, 0)
+ testTotalNumberOfStrokes(rowingStatistics, 0)
+ testDragFactor(rowingStatistics, undefined)
+
+ await replayRowingSession(rowingStatistics.handleRotationImpulse, { filename: 'recordings/RX800.csv', realtime: false, loop: false })
+
+ testTotalMovingTime(rowingStatistics, 21.97324892)
+ testTotalLinearDistance(rowingStatistics, 80.42009355207885)
+ testTotalNumberOfStrokes(rowingStatistics, 9)
+ // As dragFactor is dynamic, it should have changed
+ testDragFactor(rowingStatistics, 494.92868774518126)
+})
+
+test('A full session for SportsTech WRX700 should produce plausible results', async () => {
+ const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Sportstech_WRX700)
+ const testConfig = {
+ loglevel: {
+ default: 'silent',
+ RowingEngine: 'silent'
+ },
+ numOfPhasesForAveragingScreenData: 2,
+ rowerSettings: rowerProfile
+ }
+ const rowingStatistics = createRowingStatistics(testConfig)
+ testTotalMovingTime(rowingStatistics, 0)
+ testTotalLinearDistance(rowingStatistics, 0)
+ testTotalNumberOfStrokes(rowingStatistics, 0)
+ testDragFactor(rowingStatistics, undefined)
+
+ await replayRowingSession(rowingStatistics.handleRotationImpulse, { filename: 'recordings/WRX700_2magnets_session.csv', realtime: false, loop: false })
+
+ testTotalMovingTime(rowingStatistics, 2340.0100514160117)
+ testTotalLinearDistance(rowingStatistics, 8406.791871958883)
+ testTotalNumberOfStrokes(rowingStatistics, 845)
+ // As dragFactor is static, it should remain in place
+ testDragFactor(rowingStatistics, rowerProfiles.Sportstech_WRX700.dragFactor)
+})
+
+test('A full session for a Concept2 Model C should produce plausible results', async () => {
+ const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Concept2_Model_C)
+ const testConfig = {
+ loglevel: {
+ default: 'silent',
+ RowingEngine: 'silent'
+ },
+ numOfPhasesForAveragingScreenData: 2,
+ rowerSettings: rowerProfile
+ }
+ const rowingStatistics = createRowingStatistics(testConfig)
+ testTotalMovingTime(rowingStatistics, 0)
+ testTotalLinearDistance(rowingStatistics, 0)
+ testTotalNumberOfStrokes(rowingStatistics, 0)
+ testDragFactor(rowingStatistics, undefined)
+
+ await replayRowingSession(rowingStatistics.handleRotationImpulse, { filename: 'recordings/Concept2_Model_C.csv', realtime: false, loop: false })
+
+ testTotalMovingTime(rowingStatistics, 181.47141999999985)
+ testTotalLinearDistance(rowingStatistics, 552.0863658667265)
+ testTotalNumberOfStrokes(rowingStatistics, 83)
+ // As dragFactor isn't static, it should have changed
+ testDragFactor(rowingStatistics, 123.82587294279575)
+})
+
+test('A full session for a Concept2 RowErg should produce plausible results', async () => {
+ const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Concept2_RowErg)
+ const testConfig = {
+ loglevel: {
+ default: 'silent',
+ RowingEngine: 'silent'
+ },
+ numOfPhasesForAveragingScreenData: 2,
+ rowerSettings: rowerProfile
+ }
+ const rowingStatistics = createRowingStatistics(testConfig)
+ testTotalMovingTime(rowingStatistics, 0)
+ testTotalLinearDistance(rowingStatistics, 0)
+ testTotalNumberOfStrokes(rowingStatistics, 0)
+ testDragFactor(rowingStatistics, undefined)
+
+ await replayRowingSession(rowingStatistics.handleRotationImpulse, { filename: 'recordings/Concept2_RowErg_Session_2000meters.csv', realtime: false, loop: false })
+
+ testTotalMovingTime(rowingStatistics, 590.111937)
+ testTotalLinearDistance(rowingStatistics, 2027.493082238415)
+ testTotalNumberOfStrokes(rowingStatistics, 205)
+ // As dragFactor isn't static, it should have changed
+ testDragFactor(rowingStatistics, 80.60573080009686)
+})
+
+function testStrokeState (rowingStatistics, expectedValue) {
+ assert.ok(rowingStatistics.getMetrics().strokeState === expectedValue, `strokeState should be ${expectedValue} at ${rowingStatistics.getMetrics().totalMovingTime} sec, is ${rowingStatistics.getMetrics().strokeState}`)
+}
+
+function testTotalMovingTime (rowingStatistics, expectedValue) {
+ assert.ok(rowingStatistics.getMetrics().totalMovingTime === expectedValue, `totalMovingTime should be ${expectedValue} sec at ${rowingStatistics.getMetrics().totalMovingTime} sec, is ${rowingStatistics.getMetrics().totalMovingTime}`)
+}
+
+function testTotalNumberOfStrokes (rowingStatistics, expectedValue) {
+ // Please note there is a stroke 0
+ assert.ok(rowingStatistics.getMetrics().totalNumberOfStrokes === expectedValue, `totalNumberOfStrokes should be ${expectedValue} at ${rowingStatistics.getMetrics().totalMovingTime} sec, is ${rowingStatistics.getMetrics().totalNumberOfStrokes}`)
+}
+
+function testTotalLinearDistance (rowingStatistics, expectedValue) {
+ assert.ok(rowingStatistics.getMetrics().totalLinearDistance === expectedValue, `totalLinearDistance should be ${expectedValue} meters at ${rowingStatistics.getMetrics().totalMovingTime} sec, is ${rowingStatistics.getMetrics().totalLinearDistance}`)
+}
+
+function testCycleDuration (rowingStatistics, expectedValue) {
+ assert.ok(rowingStatistics.getMetrics().cycleDuration === expectedValue, `cycleDuration should be ${expectedValue} sec at ${rowingStatistics.getMetrics().totalMovingTime} sec, is ${rowingStatistics.getMetrics().cycleDuration}`)
+}
+
+function testCycleDistance (rowingStatistics, expectedValue) {
+ assert.ok(rowingStatistics.getMetrics().cycleDistance === expectedValue, `cycleDistance should be ${expectedValue} meters at ${rowingStatistics.getMetrics().totalMovingTime} sec, is ${rowingStatistics.getMetrics().cycleDistance}`)
+}
+
+function testCycleLinearVelocity (rowingStatistics, expectedValue) {
+ assert.ok(rowingStatistics.getMetrics().cycleLinearVelocity === expectedValue, `cycleLinearVelocity should be ${expectedValue} m/s at ${rowingStatistics.getMetrics().totalMovingTime} sec, is ${rowingStatistics.getMetrics().cycleLinearVelocity}`)
+}
+
+function testCyclePower (rowingStatistics, expectedValue) {
+ assert.ok(rowingStatistics.getMetrics().cyclePower === expectedValue, `cyclePower should be ${expectedValue} Watt at ${rowingStatistics.getMetrics().totalMovingTime} sec, is ${rowingStatistics.getMetrics().cyclePower}`)
+}
+
+function testDriveDuration (rowingStatistics, expectedValue) {
+ assert.ok(rowingStatistics.getMetrics().driveDuration === expectedValue, `driveDuration should be ${expectedValue} sec at ${rowingStatistics.getMetrics().totalMovingTime} sec, is ${rowingStatistics.getMetrics().driveDuration}`)
+}
+
+function testDriveDistance (rowingStatistics, expectedValue) {
+ assert.ok(rowingStatistics.getMetrics().driveDistance === expectedValue, `DriveDistance should be ${expectedValue} meters at ${rowingStatistics.getMetrics().totalMovingTime} sec, is ${rowingStatistics.getMetrics().driveDistance}`)
+}
+
+function testDriveLength (rowingStatistics, expectedValue) {
+ assert.ok(rowingStatistics.getMetrics().driveLength === expectedValue, `driveLength should be ${expectedValue} meters at ${rowingStatistics.getMetrics().totalMovingTime} sec, is ${rowingStatistics.getMetrics().driveLength}`)
+}
+
+function testDriveAverageHandleForce (rowingStatistics, expectedValue) {
+ assert.ok(rowingStatistics.getMetrics().driveAverageHandleForce === expectedValue, `driveAverageHandleForce should be ${expectedValue} N at ${rowingStatistics.getMetrics().totalMovingTime} sec, is ${rowingStatistics.getMetrics().driveAverageHandleForce}`)
+}
+
+function testDrivePeakHandleForce (rowingStatistics, expectedValue) {
+ assert.ok(rowingStatistics.getMetrics().drivePeakHandleForce === expectedValue, `drivePeakHandleForce should be ${expectedValue} N at ${rowingStatistics.getMetrics().totalMovingTime} sec, is ${rowingStatistics.getMetrics().drivePeakHandleForce}`)
+}
+
+function testRecoveryDuration (rowingStatistics, expectedValue) {
+ assert.ok(rowingStatistics.getMetrics().recoveryDuration === expectedValue, `recoveryDuration should be ${expectedValue} sec at ${rowingStatistics.getMetrics().totalMovingTime} sec, is ${rowingStatistics.getMetrics().recoveryDuration}`)
+}
+
+function testDragFactor (rowingStatistics, expectedValue) {
+ assert.ok(rowingStatistics.getMetrics().dragFactor === expectedValue, `dragFactor should be ${expectedValue} N*m*s^2 at ${rowingStatistics.getMetrics().totalMovingTime} sec, is ${rowingStatistics.getMetrics().dragFactor}`)
+}
+
+function testInstantHandlePower (rowingStatistics, expectedValue) {
+ assert.ok(rowingStatistics.getMetrics().instantHandlePower === expectedValue, `instantHandlePower should be ${expectedValue} Watt at ${rowingStatistics.getMetrics().totalMovingTime} sec, is ${rowingStatistics.getMetrics().instantHandlePower}`)
+}
+
+function reportAll (rowingStatistics) { // eslint-disable-line no-unused-vars
+ assert.ok(0, `time: ${rowingStatistics.getMetrics().totalMovingTime}, state ${rowingStatistics.getMetrics().strokeState}, No Strokes: ${rowingStatistics.getMetrics().totalNumberOfStrokes + 1}, Lin Distance: ${rowingStatistics.getMetrics().totalLinearDistance}, cycle dur: ${rowingStatistics.getMetrics().cycleDuration}, cycle Lin Dist: ${rowingStatistics.getMetrics().cycleLinearDistance}, Lin Velocity: ${rowingStatistics.getMetrics().cycleLinearVelocity}, Power: ${rowingStatistics.getMetrics().cyclePower}, Drive Dur: ${rowingStatistics.getMetrics().driveDuration}, Drive Lin. Dist. ${rowingStatistics.driveDistance}, Drive Length: ${rowingStatistics.getMetrics().driveLength}, Av. Handle Force: ${rowingStatistics.getMetrics().driveAverageHandleForce}, Peak Handle Force: ${rowingStatistics.getMetrics().drivePeakHandleForce}, Rec. Dur: ${rowingStatistics.getMetrics().recoveryDuration}, Dragfactor: ${rowingStatistics.getMetrics().dragFactor}, Inst Handle Power: ${rowingStatistics.getMetrics().instantHandlePower}`)
+}
+
+test.run()
diff --git a/app/engine/SessionManager.js b/app/engine/SessionManager.js
new file mode 100644
index 0000000000..e098674fb1
--- /dev/null
+++ b/app/engine/SessionManager.js
@@ -0,0 +1,495 @@
+'use strict'
+/*
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
+/*
+/**
+ * This Module calculates the workout, interval and split specific metrics, as well as guards their boundaries
+ * @see {@link https://github.com/JaapvanEkris/openrowingmonitor/blob/main/docs/Architecture.md#sessionmanagerjs|the description}
+ */
+/* eslint-disable max-lines -- This handles quite a complex state machine with three levels of workout segments, not much we can do about it */
+import { EventEmitter } from 'events'
+import { createRowingStatistics } from './RowingStatistics.js'
+import { createWorkoutSegment } from './utils/workoutSegment.js'
+
+import loglevel from 'loglevel'
+const log = loglevel.getLogger('RowingEngine')
+
+export function createSessionManager (config) {
+ const emitter = new EventEmitter()
+ const rowingStatistics = createRowingStatistics(config)
+ const session = createWorkoutSegment(config)
+ const interval = createWorkoutSegment(config)
+ const split = createWorkoutSegment(config)
+ let metrics = {}
+ let lastBroadcastedMetrics = {}
+ let pauseTimer
+ let pauseCountdownTimer = 0
+ let watchdogTimer
+ const watchdogTimout = 1000 * config.rowerSettings.maximumStrokeTimeBeforePause // Pause timeout in miliseconds
+ let sessionState = 'WaitingForStart'
+ let intervalSettings = []
+ let currentIntervalNumber = -1
+ let splitNumber = -1
+
+ metrics = refreshMetrics()
+ // ToDo: replace with activateNextInterval based on justrow, justrow
+ session.setStart(metrics)
+ interval.setStart(metrics)
+ split.setStart(metrics)
+ emitMetrics(metrics)
+ lastBroadcastedMetrics = { ...metrics }
+
+ /**
+ * This function handles all incomming commands. As all commands are broadasted to all managers, we need to filter here what is relevant
+ * for the RowingEngine and what is not
+ *
+ * @param {Command} Name of the command to be executed by the commandhandler
+ * @param {unknown} data for executing the command
+ *
+ * @see {@link https://github.com/JaapvanEkris/openrowingmonitor/blob/main/docs/Architecture.md#command-flow|The command flow documentation}
+ */
+ function handleCommand (commandName, data) {
+ resetMetricsSessionContext(lastBroadcastedMetrics)
+ switch (commandName) {
+ case ('updateIntervalSettings'):
+ if (sessionState !== 'Rowing') {
+ setIntervalParameters(data)
+ } else {
+ log.debug(`SessionManager, time: ${metrics.totalMovingTime}, rejected new interval settings as session was already in progress`)
+ }
+ emitMetrics(lastBroadcastedMetrics)
+ break
+ case ('start'):
+ if (sessionState !== 'Rowing') {
+ clearTimeout(pauseTimer)
+ StartOrResumeTraining()
+ sessionState = 'WaitingForStart'
+ emitMetrics(lastBroadcastedMetrics)
+ }
+ break
+ case ('startOrResume'):
+ if (sessionState !== 'Rowing' && sessionState !== 'WaitingForStart') {
+ clearTimeout(pauseTimer)
+ StartOrResumeTraining()
+ sessionState = 'Paused'
+ lastBroadcastedMetrics.metricsContext.isPauseStart = true
+ emitMetrics(lastBroadcastedMetrics)
+ }
+ break
+ case ('pause'):
+ if (sessionState === 'Rowing') {
+ pauseTraining(lastBroadcastedMetrics)
+ lastBroadcastedMetrics = refreshMetrics() // as the pause button is forced, we need to fetch the zero'ed metrics
+ lastBroadcastedMetrics.metricsContext.isPauseStart = true
+ sessionState = 'Paused'
+ emitMetrics(lastBroadcastedMetrics)
+ }
+ break
+ case ('stop'):
+ if (sessionState === 'Rowing') {
+ clearTimeout(pauseTimer)
+ stopTraining(lastBroadcastedMetrics)
+ lastBroadcastedMetrics.metricsContext.isSessionStop = true
+ sessionState = 'Stopped'
+ emitMetrics(lastBroadcastedMetrics)
+ }
+ break
+ case ('reset'):
+ clearTimeout(pauseTimer)
+ if (sessionState === 'Rowing') {
+ sessionState = 'Stopped'
+ lastBroadcastedMetrics.metricsContext.isSessionStop = true
+ emitMetrics(lastBroadcastedMetrics)
+ }
+ resetTraining(lastBroadcastedMetrics)
+ lastBroadcastedMetrics = refreshMetrics() // as the engine is reset, we need to fetch the zero'ed metrics
+ sessionState = 'WaitingForStart'
+ emitMetrics(lastBroadcastedMetrics)
+ break
+ case 'switchBlePeripheralMode':
+ break
+ case 'switchAntPeripheralMode':
+ break
+ case 'switchHrmMode':
+ break
+ case 'refreshPeripheralConfig':
+ break
+ case 'upload':
+ break
+ case 'shutdown':
+ clearTimeout(pauseTimer)
+ stopTraining(lastBroadcastedMetrics)
+ if (sessionState === 'Rowing') {
+ lastBroadcastedMetrics.metricsContext.isSessionStop = true
+ sessionState = 'Stopped'
+ emitMetrics(lastBroadcastedMetrics)
+ }
+ break
+ default:
+ log.error(`Recieved unknown command: ${commandName}`)
+ }
+ }
+
+ function refreshMetrics () {
+ const baseMetrics = rowingStatistics.getMetrics()
+ resetMetricsSessionContext(baseMetrics)
+ baseMetrics.timestamp = new Date()
+ return baseMetrics
+ }
+
+ function StartOrResumeTraining () {
+ rowingStatistics.allowStartOrResumeTraining()
+ }
+
+ function stopTraining (baseMetrics) {
+ clearTimeout(watchdogTimer)
+ interval.push(baseMetrics)
+ split.push(baseMetrics)
+ rowingStatistics.stopTraining()
+ }
+
+ // clear the metrics in case the user pauses rowing
+ function pauseTraining (baseMetrics) {
+ clearTimeout(watchdogTimer)
+ session.push(baseMetrics)
+ interval.push(baseMetrics)
+ rowingStatistics.pauseTraining()
+ }
+
+ function resetTraining (baseMetrics) {
+ stopTraining(baseMetrics)
+ rowingStatistics.resetTraining()
+ rowingStatistics.allowStartOrResumeTraining()
+ intervalSettings = null
+ intervalSettings = []
+ currentIntervalNumber = -1
+ pauseCountdownTimer = 0
+ splitNumber = -1
+ metrics = refreshMetrics()
+ lastBroadcastedMetrics = { ...metrics }
+ sessionState = 'WaitingForStart'
+ session.reset()
+ interval.reset()
+ split.reset()
+ session.setStart(metrics)
+ interval.setStart(metrics)
+ split.setStart(metrics)
+ emitMetrics(metrics)
+ }
+
+ /**
+ * This function guards the session, interval and split states boundaries
+ *
+ * @param {float} time between two impulses in seconds
+ *
+ * @see {@link https://github.com/JaapvanEkris/openrowingmonitor/blob/main/docs/Architecture.md#session-interval-and-split-boundaries-in-sessionmanagerjs|The session, interval and split setup}
+ * @see {@link https://github.com/JaapvanEkris/openrowingmonitor/blob/main/docs/Architecture.md#sessionstates-in-sessionmanagerjs|The states maintained}
+ * @see {@link https://github.com/JaapvanEkris/openrowingmonitor/blob/main/docs/Architecture.md#rowing-metrics-flow|the flags set}
+ */
+ /* eslint-disable max-statements, complexity -- This handles quite a complex state machine with three levels of workout segments, not much we can do about it */
+ function handleRotationImpulse (currentDt) {
+ let temporaryDatapoint
+
+ // Clear the watchdog as we got a currentDt, we'll set it at the end again
+ clearTimeout(watchdogTimer)
+
+ // Provide the rower with new data
+ metrics = rowingStatistics.handleRotationImpulse(currentDt)
+ resetMetricsSessionContext(metrics)
+ if (sessionState === 'Rowing' && split.getStartTimestamp() !== undefined && split.timeSinceStart(metrics) >= 0) {
+ // If we are moving, timestamps should be based on movingTime as it is more accurate and consistent for the consumers
+ metrics.timestamp = new Date(split.getStartTimestamp().getTime() + (split.timeSinceStart(metrics) * 1000))
+ } else {
+ metrics.timestamp = new Date()
+ }
+
+ if (metrics.metricsContext.isMoving && (metrics.metricsContext.isDriveStart || metrics.metricsContext.isRecoveryStart)) {
+ session.push(metrics)
+ interval.push(metrics)
+ split.push(metrics)
+ }
+
+ // This is the core of the finite state machine that defines all state transitions
+ switch (true) {
+ case (sessionState === 'WaitingForStart' && metrics.metricsContext.isMoving === true):
+ StartOrResumeTraining()
+ sessionState = 'Rowing'
+ metrics.metricsContext.isSessionStart = true
+ // eslint-disable-next-line no-case-declarations -- Code clarity outweighs lint rules
+ const startTimestamp = new Date(metrics.timestamp.getTime() - metrics.totalMovingTime * 1000)
+ session.setStartTimestamp(startTimestamp)
+ interval.setStartTimestamp(startTimestamp)
+ split.setStartTimestamp(startTimestamp)
+ emitMetrics(metrics)
+ break
+ case (sessionState === 'WaitingForStart'):
+ // We can't change into the "Rowing" state since we are waiting for a drive phase that didn't come
+ emitMetrics(metrics)
+ break
+ case (sessionState === 'Paused' && metrics.metricsContext.isMoving === true):
+ StartOrResumeTraining()
+ sessionState = 'Rowing'
+ metrics.metricsContext.isPauseEnd = true
+ if (interval.type() === 'rest') { metrics.metricsContext.isIntervalEnd = true }
+ emitMetrics(metrics)
+ if (interval.type() === 'rest') {
+ // We are leaving a rest interval
+ activateNextIntervalParameters(metrics)
+ } else {
+ // It was a spontanuous pause
+ activateNextSplitParameters(metrics)
+ }
+ break
+ case (sessionState === 'Paused'):
+ // We are in a paused state, and didn't see a drive, so nothing to do here
+ emitMetrics(metrics)
+ break
+ case (sessionState !== 'Stopped' && metrics.strokeState === 'Stopped'):
+ // We do not need to refetch the metrics as RowingStatistics will already have zero-ed the metrics when strokeState = 'Stopped'
+ // This is intended behaviour, as the rower/flywheel indicate the rower has stopped somehow
+ stopTraining(metrics)
+ sessionState = 'Stopped'
+ metrics.metricsContext.isSessionStop = true
+ emitMetrics(metrics)
+ break
+ case (sessionState === 'Stopped'):
+ // We are in a stopped state, and will remain there
+ sessionState = 'Stopped'
+ emitMetrics(metrics)
+ break
+ case (sessionState === 'Rowing' && metrics.strokeState === 'WaitingForDrive'):
+ // We do not need to refetch the metrics as RowingStatistics will already have zero-ed the metrics when strokeState = 'WaitingForDrive'
+ // This is intended behaviour, as the rower/flywheel indicate the rower has paused somehow
+ pauseTraining(metrics)
+ sessionState = 'Paused'
+ metrics.metricsContext.isPauseStart = true
+ metrics.metricsContext.isSplitEnd = true
+ emitMetrics(metrics)
+ activateNextSplitParameters(metrics)
+ break
+ case (sessionState === 'Rowing' && metrics.metricsContext.isMoving && interval.isEndReached(metrics) && isNextIntervalActive()):
+ // The next interval is an active one, so we just keep on going
+ // As we typically overshoot our interval target, we project the intermediate value
+ temporaryDatapoint = interval.interpolateEnd(lastBroadcastedMetrics, metrics)
+ sessionState = 'Rowing'
+ if (temporaryDatapoint.modified) {
+ // The intermediate datapoint is actually different
+ temporaryDatapoint.metricsContext.isIntervalEnd = true
+ temporaryDatapoint.metricsContext.isSplitEnd = true
+ emitMetrics(temporaryDatapoint)
+ activateNextIntervalParameters(temporaryDatapoint)
+ emitMetrics(metrics)
+ } else {
+ metrics.metricsContext.isIntervalEnd = true
+ metrics.metricsContext.isSplitEnd = true
+ emitMetrics(metrics)
+ activateNextIntervalParameters(metrics)
+ }
+ break
+ case (sessionState === 'Rowing' && metrics.metricsContext.isMoving && interval.isEndReached(metrics) && isNextIntervalAvailable()):
+ // There is a next interval, but it is a rest interval, so we forcefully stop the session
+ // As we typically overshoot our interval target, we project the intermediate value
+ sessionState = 'Paused'
+ temporaryDatapoint = interval.interpolateEnd(lastBroadcastedMetrics, metrics)
+ if (temporaryDatapoint.modified) {
+ // The intermediate datapoint is actually different
+ temporaryDatapoint.metricsContext.isIntervalEnd = true
+ temporaryDatapoint.metricsContext.isSplitEnd = true
+ temporaryDatapoint.metricsContext.isPauseStart = true
+ emitMetrics(temporaryDatapoint)
+ activateNextIntervalParameters(temporaryDatapoint)
+ } else {
+ metrics.metricsContext.isIntervalEnd = true
+ metrics.metricsContext.isSplitEnd = true
+ metrics.metricsContext.isPauseStart = true
+ emitMetrics(metrics)
+ activateNextIntervalParameters(metrics)
+ }
+
+ if (interval.timeToEnd(metrics) > 0) {
+ // If a minimal pause timer has been set, we need to make sure the user obeys that
+ pauseCountdownTimer = interval.timeToEnd(temporaryDatapoint)
+ stopTraining(temporaryDatapoint)
+ pauseTimer = setTimeout(onPauseTimer, 100)
+ } else {
+ // No minimal pause time has been set, so we pause the engine. In this state automatically activates the session again upon the next drive
+ pauseCountdownTimer = 0
+ pauseTraining(temporaryDatapoint)
+ }
+ metrics = refreshMetrics() // Here we want to switch to a zero-ed message as the flywheel has stopped
+ break
+ case (sessionState === 'Rowing' && metrics.metricsContext.isMoving && interval.isEndReached(metrics)):
+ // Here we do NOT want zero the metrics, as we want to keep the metrics we had when we crossed the finishline
+ stopTraining(metrics)
+ sessionState = 'Stopped'
+ temporaryDatapoint = interval.interpolateEnd(lastBroadcastedMetrics, metrics)
+ if (temporaryDatapoint.modified) {
+ temporaryDatapoint.metricsContext.isSessionStop = true
+ emitMetrics(temporaryDatapoint)
+ } else {
+ metrics.metricsContext.isSessionStop = true
+ emitMetrics(metrics)
+ }
+ break
+ case (sessionState === 'Rowing' && metrics.metricsContext.isMoving && split.isEndReached(metrics)):
+ sessionState = 'Rowing'
+ temporaryDatapoint = split.interpolateEnd(lastBroadcastedMetrics, metrics)
+ if (temporaryDatapoint.modified) {
+ temporaryDatapoint.metricsContext.isSplitEnd = true
+ emitMetrics(temporaryDatapoint)
+ activateNextSplitParameters(temporaryDatapoint)
+ emitMetrics(metrics)
+ } else {
+ metrics.metricsContext.isSplitEnd = true
+ emitMetrics(metrics)
+ activateNextSplitParameters(metrics)
+ }
+ break
+ case (sessionState === 'Rowing' && metrics.metricsContext.isMoving):
+ sessionState = 'Rowing'
+ emitMetrics(metrics)
+ break
+ default:
+ log.error(`SessionManager: Time: ${metrics.totalMovingTime}, combination of ${sessionState} and state ${metrics.strokeState} is not captured by Finite State Machine`)
+ }
+
+ if (sessionState === 'Rowing' && metrics.metricsContext.isMoving) {
+ watchdogTimer = setTimeout(onWatchdogTimeout, watchdogTimout)
+ }
+ lastBroadcastedMetrics = { ...metrics }
+ }
+ /* eslint-enable max-statements, complexity */
+
+ // Basic metricContext structure
+ function resetMetricsSessionContext (metricsToReset) {
+ metricsToReset.metricsContext.isSessionStart = false
+ metricsToReset.metricsContext.isIntervalEnd = false
+ metricsToReset.metricsContext.isSplitEnd = false
+ metricsToReset.metricsContext.isPauseStart = false
+ metricsToReset.metricsContext.isPauseEnd = false
+ metricsToReset.metricsContext.isSessionStop = false
+ }
+
+ function setIntervalParameters (intervalParameters) {
+ intervalSettings = null
+ intervalSettings = intervalParameters
+ currentIntervalNumber = -1
+ splitNumber = -1
+ if (intervalSettings.length > 0) {
+ log.info(`SessionManager: Workout plan recieved with ${intervalSettings.length} interval(s)`)
+ metrics = refreshMetrics()
+
+ session.setStart(metrics)
+ session.summarize(intervalParameters)
+
+ activateNextIntervalParameters(metrics)
+ emitMetrics(metrics)
+ } else {
+ // intervalParameters were empty, lets log this odd situation
+ log.error('SessionManager: Recieved workout plan containing no intervals')
+ }
+ }
+
+ function isNextIntervalAvailable () {
+ // This function tests whether there is a next interval available
+ if (currentIntervalNumber > -1 && intervalSettings.length > 0 && intervalSettings.length > (currentIntervalNumber + 1)) {
+ return true
+ } else {
+ return false
+ }
+ }
+
+ function isNextIntervalActive () {
+ // This function tests whether there is a next interval available
+ if (currentIntervalNumber > -1 && intervalSettings.length > 0 && intervalSettings.length > (currentIntervalNumber + 1)) {
+ return (intervalSettings[currentIntervalNumber + 1].type !== 'rest')
+ } else {
+ return false
+ }
+ }
+
+ function activateNextIntervalParameters (baseMetrics) {
+ if (intervalSettings.length > 0 && intervalSettings.length > (currentIntervalNumber + 1)) {
+ // This function sets the interval parameters in absolute distances/times
+ // Thus the interval target always is a projected "finishline" from the current position
+ currentIntervalNumber++
+ log.info(`Activating interval settings for interval ${currentIntervalNumber + 1} of ${intervalSettings.length}`)
+ interval.setStart(baseMetrics)
+ interval.setEnd(intervalSettings[currentIntervalNumber])
+
+ // As the interval has changed, we need to reset the split metrics
+ activateNextSplitParameters(baseMetrics)
+ } else {
+ log.error('SessionManager: expected a next interval, but did not find one!')
+ }
+ }
+
+ function activateNextSplitParameters (baseMetrics) {
+ splitNumber++
+ log.error(`Activating split settings for split ${splitNumber + 1}`)
+ split.setStart(baseMetrics)
+ split.setEnd(interval.getSplit())
+ }
+
+ function onPauseTimer () {
+ pauseCountdownTimer = pauseCountdownTimer - 0.1
+ if (pauseCountdownTimer > 0) {
+ // The countdowntimer still has some time left on it
+ pauseTimer = setTimeout(onPauseTimer, 100)
+ lastBroadcastedMetrics.timestamp = new Date()
+ } else {
+ // The timer has run out
+ pauseTraining(lastBroadcastedMetrics)
+ sessionState = 'Paused'
+ lastBroadcastedMetrics = refreshMetrics()
+ pauseCountdownTimer = 0
+ log.debug(`Time: ${lastBroadcastedMetrics.totalMovingTime}, rest interval ended`)
+ }
+ emitMetrics(lastBroadcastedMetrics)
+ }
+
+ function emitMetrics (metricsToEmit) {
+ enrichMetrics(metricsToEmit)
+ emitter.emit('metricsUpdate', metricsToEmit)
+ }
+
+ function enrichMetrics (metricsToEnrich) {
+ metricsToEnrich.sessionState = sessionState
+ metricsToEnrich.pauseCountdownTime = Math.max(pauseCountdownTimer, 0) // Time left on the countdown timer
+ metricsToEnrich.workout = session.metrics(metricsToEnrich)
+ metricsToEnrich.interval = interval.metrics(metricsToEnrich)
+ metricsToEnrich.interval.workoutStepNumber = Math.max(currentIntervalNumber, 0) // Interval number, to keep in sync with the workout plan
+ metricsToEnrich.split = split.metrics(metricsToEnrich)
+ metricsToEnrich.split.number = splitNumber
+ }
+
+ function onWatchdogTimeout () {
+ pauseTraining(lastBroadcastedMetrics)
+ metrics = refreshMetrics()
+ log.error(`Time: ${metrics.totalMovingTime}, Forced a session pause due to unexpeted flywheel stop, exceeding the maximumStrokeTimeBeforePause (i.e. ${watchdogTimout / 1000} seconds) without new datapoints`)
+ sessionState = 'Paused'
+ metrics.metricsContext.isPauseStart = true
+ metrics.metricsContext.isSplitEnd = true
+ session.push(metrics)
+ interval.push(metrics)
+ split.push(metrics)
+ emitMetrics(metrics)
+ activateNextSplitParameters(metrics)
+ lastBroadcastedMetrics = { ...metrics }
+ }
+
+ /**
+ * @returns all metrics in the session manager
+ * @remark FOR TESTING PURPOSSES ONLY!
+ */
+ function getMetrics () {
+ enrichMetrics(metrics)
+ return metrics
+ }
+
+ return Object.assign(emitter, {
+ handleCommand,
+ handleRotationImpulse,
+ getMetrics
+ })
+}
diff --git a/app/engine/SessionManager.test.js b/app/engine/SessionManager.test.js
new file mode 100644
index 0000000000..73d189c5ba
--- /dev/null
+++ b/app/engine/SessionManager.test.js
@@ -0,0 +1,563 @@
+'use strict'
+/*
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
+*/
+/**
+ * This test is a test of the SessionManager, that tests wether this object fills all fields correctly,
+ * and cuts off a session, interval and split decently
+ */
+// ToDo: test the effects of smoothing parameters
+import { test } from 'uvu'
+import * as assert from 'uvu/assert'
+import rowerProfiles from '../../config/rowerProfiles.js'
+import { replayRowingSession } from '../recorders/RowingReplayer.js'
+import { deepMerge } from '../tools/Helper.js'
+
+import { createSessionManager } from './SessionManager.js'
+
+test('sample data for Sportstech WRX700 should produce plausible results for an unlimited run', async () => {
+ const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Sportstech_WRX700)
+ const testConfig = {
+ loglevel: {
+ default: 'silent',
+ RowingEngine: 'silent'
+ },
+ numOfPhasesForAveragingScreenData: 2,
+ rowerSettings: rowerProfile
+ }
+ const sessionManager = createSessionManager(testConfig)
+ testTotalMovingTime(sessionManager, 0)
+ testTotalLinearDistance(sessionManager, 0)
+ testTotalNumberOfStrokes(sessionManager, 0)
+ testDragFactor(sessionManager, undefined)
+
+ await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/WRX700_2magnets.csv', realtime: false, loop: false })
+
+ testTotalMovingTime(sessionManager, 46.302522627)
+ testTotalLinearDistance(sessionManager, 166.29596716416734)
+ testTotalNumberOfStrokes(sessionManager, 15)
+ // As dragFactor is static, it should remain in place
+ testDragFactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+})
+
+test('sample data for Sportstech WRX700 should produce plausible results for a 150 meter session', async () => {
+ const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Sportstech_WRX700)
+ const testConfig = {
+ loglevel: {
+ default: 'silent',
+ RowingEngine: 'silent'
+ },
+ numOfPhasesForAveragingScreenData: 2,
+ rowerSettings: rowerProfile
+ }
+ const sessionManager = createSessionManager(testConfig)
+
+ const intervalSettings = []
+ intervalSettings[0] = {
+ type: 'distance',
+ targetDistance: 150,
+ targetTime: 0
+ }
+ sessionManager.handleCommand('updateIntervalSettings', intervalSettings)
+
+ testTotalMovingTime(sessionManager, 0)
+ testTotalLinearDistance(sessionManager, 0)
+ testTotalNumberOfStrokes(sessionManager, 0)
+ testDragFactor(sessionManager, undefined)
+
+ await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/WRX700_2magnets.csv', realtime: false, loop: false })
+
+ testTotalMovingTime(sessionManager, 41.734896595)
+ testTotalLinearDistance(sessionManager, 150.02019165448286)
+ testTotalNumberOfStrokes(sessionManager, 14)
+ // As dragFactor is static, it should remain in place
+ testDragFactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+})
+
+test('sample data for Sportstech WRX700 should produce plausible results for a 45 seconds session', async () => {
+ const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Sportstech_WRX700)
+ const testConfig = {
+ loglevel: {
+ default: 'silent',
+ RowingEngine: 'silent'
+ },
+ numOfPhasesForAveragingScreenData: 2,
+ rowerSettings: rowerProfile
+ }
+ const sessionManager = createSessionManager(testConfig)
+
+ const intervalSettings = []
+ intervalSettings[0] = {
+ type: 'time',
+ targetDistance: 0,
+ targetTime: 45
+ }
+ sessionManager.handleCommand('updateIntervalSettings', intervalSettings)
+
+ testTotalMovingTime(sessionManager, 0)
+ testTotalLinearDistance(sessionManager, 0)
+ testTotalNumberOfStrokes(sessionManager, 0)
+ testDragFactor(sessionManager, undefined)
+
+ await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/WRX700_2magnets.csv', realtime: false, loop: false })
+
+ testTotalMovingTime(sessionManager, 45.077573161000004)
+ testTotalLinearDistance(sessionManager, 163.46539751030917)
+ testTotalNumberOfStrokes(sessionManager, 15)
+ // As dragFactor is static, it should remain in place
+ testDragFactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+})
+
+test('sample data for DKN R-320 should produce plausible results', async () => {
+ const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.DKN_R320)
+ const testConfig = {
+ loglevel: {
+ default: 'silent',
+ RowingEngine: 'silent'
+ },
+ numOfPhasesForAveragingScreenData: 2,
+ rowerSettings: rowerProfile
+ }
+ const sessionManager = createSessionManager(testConfig)
+ testTotalMovingTime(sessionManager, 0)
+ testTotalLinearDistance(sessionManager, 0)
+ testTotalNumberOfStrokes(sessionManager, 0)
+ testDragFactor(sessionManager, undefined)
+
+ await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/DKNR320.csv', realtime: false, loop: false })
+
+ testTotalMovingTime(sessionManager, 21.701535821)
+ testTotalLinearDistance(sessionManager, 70.11298001986664)
+ testTotalNumberOfStrokes(sessionManager, 9)
+ // As dragFactor is static, it should remain in place
+ testDragFactor(sessionManager, rowerProfiles.DKN_R320.dragFactor)
+})
+
+test('sample data for NordicTrack RX800 should produce plausible results without intervalsettings', async () => {
+ const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.NordicTrack_RX800)
+ const testConfig = {
+ loglevel: {
+ default: 'silent',
+ RowingEngine: 'silent'
+ },
+ numOfPhasesForAveragingScreenData: 2,
+ rowerSettings: rowerProfile
+ }
+ const sessionManager = createSessionManager(testConfig)
+ testTotalMovingTime(sessionManager, 0)
+ testTotalLinearDistance(sessionManager, 0)
+ testTotalNumberOfStrokes(sessionManager, 0)
+ testDragFactor(sessionManager, undefined)
+
+ await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/RX800.csv', realtime: false, loop: false })
+
+ testTotalMovingTime(sessionManager, 21.97324892)
+ testTotalLinearDistance(sessionManager, 80.42009355207885)
+ testTotalNumberOfStrokes(sessionManager, 9)
+ // As dragFactor is dynamic, it should have changed
+ testDragFactor(sessionManager, 494.92868774518126)
+})
+
+test('sample data for NordicTrack RX800 should produce plausible results for a 20 seconds session', async () => {
+ const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.NordicTrack_RX800)
+ const testConfig = {
+ loglevel: {
+ default: 'silent',
+ RowingEngine: 'silent'
+ },
+ numOfPhasesForAveragingScreenData: 2,
+ rowerSettings: rowerProfile
+ }
+ const sessionManager = createSessionManager(testConfig)
+
+ const intervalSettings = []
+ intervalSettings[0] = {
+ type: 'time',
+ targetDistance: 0,
+ targetTime: 20
+ }
+ sessionManager.handleCommand('updateIntervalSettings', intervalSettings)
+
+ testTotalMovingTime(sessionManager, 0)
+ testTotalLinearDistance(sessionManager, 0)
+ testTotalNumberOfStrokes(sessionManager, 0)
+ testDragFactor(sessionManager, undefined)
+
+ await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/RX800.csv', realtime: false, loop: false })
+
+ testTotalMovingTime(sessionManager, 20.017169872999983)
+ testTotalLinearDistance(sessionManager, 73.19136480921375)
+ testTotalNumberOfStrokes(sessionManager, 9)
+ // As dragFactor is dynamic, it should have changed
+ testDragFactor(sessionManager, 494.92868774518126)
+})
+
+test('sample data for NordicTrack RX800 should produce plausible results for a 75 meter session', async () => {
+ const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.NordicTrack_RX800)
+ const testConfig = {
+ loglevel: {
+ default: 'silent',
+ RowingEngine: 'silent'
+ },
+ numOfPhasesForAveragingScreenData: 2,
+ rowerSettings: rowerProfile
+ }
+ const sessionManager = createSessionManager(testConfig)
+
+ const intervalSettings = []
+ intervalSettings[0] = {
+ type: 'distance',
+ targetDistance: 75,
+ targetTime: 0
+ }
+ sessionManager.handleCommand('updateIntervalSettings', intervalSettings)
+
+ testTotalMovingTime(sessionManager, 0)
+ testTotalLinearDistance(sessionManager, 0)
+ testTotalNumberOfStrokes(sessionManager, 0)
+ testDragFactor(sessionManager, undefined)
+
+ await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/RX800.csv', realtime: false, loop: false })
+
+ testTotalMovingTime(sessionManager, 20.52171210899998)
+ testTotalLinearDistance(sessionManager, 75.04262460921579)
+ testTotalNumberOfStrokes(sessionManager, 9)
+ // As dragFactor is dynamic, it should have changed
+ testDragFactor(sessionManager, 494.92868774518126)
+})
+
+test('A full unlimited session for SportsTech WRX700 should produce plausible results', async () => {
+ const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Sportstech_WRX700)
+ const testConfig = {
+ loglevel: {
+ default: 'silent',
+ RowingEngine: 'silent'
+ },
+ numOfPhasesForAveragingScreenData: 2,
+ rowerSettings: rowerProfile
+ }
+ const sessionManager = createSessionManager(testConfig)
+ testTotalMovingTime(sessionManager, 0)
+ testTotalLinearDistance(sessionManager, 0)
+ testTotalNumberOfStrokes(sessionManager, 0)
+ testDragFactor(sessionManager, undefined)
+
+ await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/WRX700_2magnets_session.csv', realtime: false, loop: false })
+
+ testTotalMovingTime(sessionManager, 2340.0100514160117)
+ testTotalLinearDistance(sessionManager, 8406.791871958883)
+ testTotalNumberOfStrokes(sessionManager, 845)
+ // As dragFactor is static, it should remain in place
+ testDragFactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+})
+
+test('A 8000 meter session for SportsTech WRX700 should produce plausible results', async () => {
+ const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Sportstech_WRX700)
+ const testConfig = {
+ loglevel: {
+ default: 'silent',
+ RowingEngine: 'silent'
+ },
+ numOfPhasesForAveragingScreenData: 2,
+ rowerSettings: rowerProfile
+ }
+ const sessionManager = createSessionManager(testConfig)
+
+ const intervalSettings = []
+ intervalSettings[0] = {
+ type: 'distance',
+ targetDistance: 8000,
+ targetTime: 0
+ }
+ sessionManager.handleCommand('updateIntervalSettings', intervalSettings)
+
+ testTotalMovingTime(sessionManager, 0)
+ testTotalLinearDistance(sessionManager, 0)
+ testTotalNumberOfStrokes(sessionManager, 0)
+ testDragFactor(sessionManager, undefined)
+
+ await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/WRX700_2magnets_session.csv', realtime: false, loop: false })
+
+ testTotalMovingTime(sessionManager, 2236.509317727007)
+ testTotalLinearDistance(sessionManager, 8000.605126630236)
+ testTotalNumberOfStrokes(sessionManager, 804)
+ // As dragFactor is static, it should remain in place
+ testDragFactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+})
+
+test('A 2300 sec session for SportsTech WRX700 should produce plausible results', async () => {
+ const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Sportstech_WRX700)
+ const testConfig = {
+ loglevel: {
+ default: 'silent',
+ RowingEngine: 'silent'
+ },
+ numOfPhasesForAveragingScreenData: 2,
+ rowerSettings: rowerProfile
+ }
+ const sessionManager = createSessionManager(testConfig)
+
+ const intervalSettings = []
+ intervalSettings[0] = {
+ type: 'time',
+ targetDistance: 0,
+ targetTime: 2300
+ }
+ sessionManager.handleCommand('updateIntervalSettings', intervalSettings)
+
+ testTotalMovingTime(sessionManager, 0)
+ testTotalLinearDistance(sessionManager, 0)
+ testTotalNumberOfStrokes(sessionManager, 0)
+ testDragFactor(sessionManager, undefined)
+
+ await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/WRX700_2magnets_session.csv', realtime: false, loop: false })
+
+ testTotalMovingTime(sessionManager, 2300.00695516701)
+ testTotalLinearDistance(sessionManager, 8252.525825823619)
+ testTotalNumberOfStrokes(sessionManager, 830)
+ // As dragFactor is static, it should remain in place
+ testDragFactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+})
+
+test('A 2400 sec session for SportsTech WRX700 should produce plausible results', async () => {
+ const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Sportstech_WRX700)
+ const testConfig = {
+ loglevel: {
+ default: 'silent',
+ RowingEngine: 'silent'
+ },
+ numOfPhasesForAveragingScreenData: 2,
+ rowerSettings: rowerProfile
+ }
+ const sessionManager = createSessionManager(testConfig)
+
+ const intervalSettings = []
+ intervalSettings[0] = {
+ type: 'time',
+ targetDistance: 0,
+ targetTime: 2400
+ }
+ sessionManager.handleCommand('updateIntervalSettings', intervalSettings)
+
+ testTotalMovingTime(sessionManager, 0)
+ testTotalLinearDistance(sessionManager, 0)
+ testTotalNumberOfStrokes(sessionManager, 0)
+ testDragFactor(sessionManager, undefined)
+
+ await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/WRX700_2magnets_session.csv', realtime: false, loop: false })
+
+ testTotalMovingTime(sessionManager, 2340.0100514160117)
+ testTotalLinearDistance(sessionManager, 8406.791871958883)
+ testTotalNumberOfStrokes(sessionManager, 845)
+ // As dragFactor is static, it should remain in place
+ testDragFactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+})
+
+test('A full session for a Concept2 Model C should produce plausible results', async () => {
+ const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Concept2_Model_C)
+ const testConfig = {
+ loglevel: {
+ default: 'silent',
+ RowingEngine: 'silent'
+ },
+ numOfPhasesForAveragingScreenData: 2,
+ rowerSettings: rowerProfile
+ }
+ const sessionManager = createSessionManager(testConfig)
+ testTotalMovingTime(sessionManager, 0)
+ testTotalLinearDistance(sessionManager, 0)
+ testTotalNumberOfStrokes(sessionManager, 0)
+ testDragFactor(sessionManager, undefined)
+
+ await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/Concept2_Model_C.csv', realtime: false, loop: false })
+
+ testTotalMovingTime(sessionManager, 181.47141999999985)
+ testTotalLinearDistance(sessionManager, 552.0863658667265)
+ testTotalNumberOfStrokes(sessionManager, 83)
+ // As dragFactor isn't static, it should have changed
+ testDragFactor(sessionManager, 123.82587294279575)
+})
+
+test('A 500 meter session for a Concept2 Model C should produce plausible results', async () => {
+ const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Concept2_Model_C)
+ const testConfig = {
+ loglevel: {
+ default: 'silent',
+ RowingEngine: 'silent'
+ },
+ numOfPhasesForAveragingScreenData: 2,
+ rowerSettings: rowerProfile
+ }
+ const sessionManager = createSessionManager(testConfig)
+
+ const intervalSettings = []
+ intervalSettings[0] = {
+ type: 'distance',
+ targetDistance: 500,
+ targetTime: 0
+ }
+ sessionManager.handleCommand('updateIntervalSettings', intervalSettings)
+
+ testTotalMovingTime(sessionManager, 0)
+ testTotalLinearDistance(sessionManager, 0)
+ testTotalNumberOfStrokes(sessionManager, 0)
+ testDragFactor(sessionManager, undefined)
+
+ await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/Concept2_Model_C.csv', realtime: false, loop: false })
+
+ testTotalMovingTime(sessionManager, 156.87138200000004)
+ testTotalLinearDistance(sessionManager, 500.03019828253076)
+ testTotalNumberOfStrokes(sessionManager, 73)
+ // As dragFactor isn't static, it should have changed
+ testDragFactor(sessionManager, 123.69864738410088)
+})
+
+test('A 3 minute session for a Concept2 Model C should produce plausible results', async () => {
+ const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Concept2_Model_C)
+ const testConfig = {
+ loglevel: {
+ default: 'silent',
+ RowingEngine: 'silent'
+ },
+ numOfPhasesForAveragingScreenData: 2,
+ rowerSettings: rowerProfile
+ }
+ const sessionManager = createSessionManager(testConfig)
+
+ const intervalSettings = []
+ intervalSettings[0] = {
+ type: 'time',
+ targetDistance: 0,
+ targetTime: 180
+ }
+ sessionManager.handleCommand('updateIntervalSettings', intervalSettings)
+
+ testTotalMovingTime(sessionManager, 0)
+ testTotalLinearDistance(sessionManager, 0)
+ testTotalNumberOfStrokes(sessionManager, 0)
+ testDragFactor(sessionManager, undefined)
+
+ await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/Concept2_Model_C.csv', realtime: false, loop: false })
+
+ testTotalMovingTime(sessionManager, 180.96533299999987)
+ testTotalLinearDistance(sessionManager, 551.8641725505744)
+ testTotalNumberOfStrokes(sessionManager, 83)
+ // As dragFactor isn't static, it should have changed
+ testDragFactor(sessionManager, 123.82587294279575)
+})
+
+test('A full session for a Concept2 RowErg should produce plausible results', async () => {
+ const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Concept2_RowErg)
+ const testConfig = {
+ loglevel: {
+ default: 'silent',
+ RowingEngine: 'silent'
+ },
+ numOfPhasesForAveragingScreenData: 2,
+ rowerSettings: rowerProfile
+ }
+ const sessionManager = createSessionManager(testConfig)
+ testTotalMovingTime(sessionManager, 0)
+ testTotalLinearDistance(sessionManager, 0)
+ testTotalNumberOfStrokes(sessionManager, 0)
+ testDragFactor(sessionManager, undefined)
+
+ await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/Concept2_RowErg_Session_2000meters.csv', realtime: false, loop: false })
+
+ testTotalMovingTime(sessionManager, 590.111937)
+ testTotalLinearDistance(sessionManager, 2027.493082238415)
+ testTotalNumberOfStrokes(sessionManager, 205)
+ // As dragFactor isn't static, it should have changed
+ testDragFactor(sessionManager, 80.60573080009686)
+})
+
+test('A 2000 meter session for a Concept2 RowErg should produce plausible results', async () => {
+ const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Concept2_RowErg)
+ const testConfig = {
+ loglevel: {
+ default: 'silent',
+ RowingEngine: 'silent'
+ },
+ numOfPhasesForAveragingScreenData: 2,
+ rowerSettings: rowerProfile
+ }
+ const sessionManager = createSessionManager(testConfig)
+
+ const intervalSettings = []
+ intervalSettings[0] = {
+ type: 'distance',
+ targetDistance: 2000,
+ targetTime: 0
+ }
+ sessionManager.handleCommand('updateIntervalSettings', intervalSettings)
+
+ testTotalMovingTime(sessionManager, 0)
+ testTotalLinearDistance(sessionManager, 0)
+ testTotalNumberOfStrokes(sessionManager, 0)
+ testDragFactor(sessionManager, undefined)
+
+ await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/Concept2_RowErg_Session_2000meters.csv', realtime: false, loop: false })
+
+ testTotalMovingTime(sessionManager, 582.1907659999988)
+ testTotalLinearDistance(sessionManager, 2000.0158938948496)
+ testTotalNumberOfStrokes(sessionManager, 203)
+ // As dragFactor isn't static, it should have changed
+ testDragFactor(sessionManager, 80.55270240035931)
+})
+
+test('A 580 seconds session for a Concept2 RowErg should produce plausible results', async () => {
+ const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Concept2_RowErg)
+ const testConfig = {
+ loglevel: {
+ default: 'silent',
+ RowingEngine: 'silent'
+ },
+ numOfPhasesForAveragingScreenData: 2,
+ rowerSettings: rowerProfile
+ }
+ const sessionManager = createSessionManager(testConfig)
+
+ const intervalSettings = []
+ intervalSettings[0] = {
+ type: 'time',
+ targetDistance: 0,
+ targetTime: 580
+ }
+ sessionManager.handleCommand('updateIntervalSettings', intervalSettings)
+
+ testTotalMovingTime(sessionManager, 0)
+ testTotalLinearDistance(sessionManager, 0)
+ testTotalNumberOfStrokes(sessionManager, 0)
+ testDragFactor(sessionManager, undefined)
+
+ await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/Concept2_RowErg_Session_2000meters.csv', realtime: false, loop: false })
+
+ testTotalMovingTime(sessionManager, 580.0033639999992)
+ testTotalLinearDistance(sessionManager, 1992.6040191024413)
+ testTotalNumberOfStrokes(sessionManager, 202)
+ // As dragFactor isn't static, it should have changed
+ testDragFactor(sessionManager, 80.5946092810885)
+})
+
+function testTotalMovingTime (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().totalMovingTime === expectedValue, `totalMovingTime should be ${expectedValue} sec at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().totalMovingTime}`)
+}
+
+function testTotalNumberOfStrokes (sessionManager, expectedValue) {
+ // Please note there is a stroke 0
+ assert.ok(sessionManager.getMetrics().totalNumberOfStrokes === expectedValue, `totalNumberOfStrokes should be ${expectedValue} at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().totalNumberOfStrokes}`)
+}
+
+function testTotalLinearDistance (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().totalLinearDistance === expectedValue, `totalLinearDistance should be ${expectedValue} meters at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().totalLinearDistance}`)
+}
+
+function testDragFactor (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().dragFactor === expectedValue, `dragFactor should be ${expectedValue} N*m*s^2 at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().dragFactor}`)
+}
+
+function reportAll (sessionManager) { // eslint-disable-line no-unused-vars
+ assert.ok(0, `time: ${sessionManager.getMetrics().totalMovingTime}, state ${sessionManager.getMetrics().strokeState}, No Strokes: ${sessionManager.getMetrics().totalNumberOfStrokes}, Lin Distance: ${sessionManager.getMetrics().totalLinearDistance}, cycle dur: ${sessionManager.getMetrics().cycleDuration}, cycle Lin Dist: ${sessionManager.getMetrics().cycleLinearDistance}, Lin Velocity: ${sessionManager.getMetrics().cycleLinearVelocity}, Power: ${sessionManager.getMetrics().cyclePower}, Drive Dur: ${sessionManager.getMetrics().driveDuration}, Drive Lin. Dist. ${sessionManager.driveDistance}, Drive Length: ${sessionManager.getMetrics().driveLength}, Av. Handle Force: ${sessionManager.getMetrics().driveAverageHandleForce}, Peak Handle Force: ${sessionManager.getMetrics().drivePeakHandleForce}, Rec. Dur: ${sessionManager.getMetrics().recoveryDuration}, Dragfactor: ${sessionManager.getMetrics().dragFactor}, Inst Handle Power: ${sessionManager.getMetrics().instantHandlePower}`)
+}
+
+test.run()
diff --git a/app/engine/Timer.js b/app/engine/Timer.js
deleted file mode 100644
index 3f6cffcfe2..0000000000
--- a/app/engine/Timer.js
+++ /dev/null
@@ -1,36 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- Stopwatch used to measure multiple time intervals
-*/
-function createTimer () {
- const timerMap = new Map()
-
- function start (key) {
- timerMap.set(key, 0.0)
- }
-
- function stop (key) {
- timerMap.delete(key)
- }
-
- function getValue (key) {
- return timerMap.get(key) || 0.0
- }
-
- function updateTimers (currentDt) {
- timerMap.forEach((value, key) => {
- timerMap.set(key, value + currentDt)
- })
- }
-
- return {
- start,
- stop,
- getValue,
- updateTimers
- }
-}
-
-export { createTimer }
diff --git a/app/engine/WorkoutRecorder.js b/app/engine/WorkoutRecorder.js
deleted file mode 100644
index 192b6efecd..0000000000
--- a/app/engine/WorkoutRecorder.js
+++ /dev/null
@@ -1,240 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- This Module captures the metrics of a rowing session and persists them.
-
- Todo: split this into multiple modules
-*/
-import log from 'loglevel'
-import zlib from 'zlib'
-import fs from 'fs/promises'
-import xml2js from 'xml2js'
-import config from '../tools/ConfigManager.js'
-import { promisify } from 'util'
-const gzip = promisify(zlib.gzip)
-
-function createWorkoutRecorder () {
- let strokes = []
- let rotationImpulses = []
- let startTime
-
- function recordRotationImpulse (impulse) {
- if (startTime === undefined) {
- startTime = new Date()
- }
- // impulse recordings a currently only used to create raw data files, so we can skip it
- // if raw data file creation is disabled
- if (config.createRawDataFiles) {
- rotationImpulses.push(impulse)
- }
- }
-
- function recordStroke (stroke) {
- if (startTime === undefined) {
- startTime = new Date()
- }
- strokes.push(stroke)
- }
-
- async function createRawDataFile () {
- const stringifiedStartTime = startTime.toISOString().replace(/T/, '_').replace(/:/g, '-').replace(/\..+/, '')
- const directory = `${config.dataDirectory}/recordings/${startTime.getFullYear()}/${(startTime.getMonth() + 1).toString().padStart(2, '0')}`
- const filename = `${directory}/${stringifiedStartTime}_raw.csv${config.gzipRawDataFiles ? '.gz' : ''}`
- log.info(`saving session as raw data file ${filename}...`)
-
- try {
- await fs.mkdir(directory, { recursive: true })
- } catch (error) {
- if (error.code !== 'EEXIST') {
- log.error(`can not create directory ${directory}`, error)
- }
- }
- await createFile(rotationImpulses.join('\n'), filename, config.gzipRawDataFiles)
- }
-
- async function createTcxFile () {
- const tcxRecord = await activeWorkoutToTcx()
- if (tcxRecord === undefined) {
- log.error('error creating tcx file')
- return
- }
- const directory = `${config.dataDirectory}/recordings/${startTime.getFullYear()}/${(startTime.getMonth() + 1).toString().padStart(2, '0')}`
- const filename = `${directory}/${tcxRecord.filename}${config.gzipTcxFiles ? '.gz' : ''}`
- log.info(`saving session as tcx file ${filename}...`)
-
- try {
- await fs.mkdir(directory, { recursive: true })
- } catch (error) {
- if (error.code !== 'EEXIST') {
- log.error(`can not create directory ${directory}`, error)
- }
- }
-
- await createFile(tcxRecord.tcx, `${filename}`, config.gzipTcxFiles)
- }
-
- async function activeWorkoutToTcx () {
- // we need at least two strokes to generate a valid tcx file
- if (strokes.length < 2) return
- const stringifiedStartTime = startTime.toISOString().replace(/T/, '_').replace(/:/g, '-').replace(/\..+/, '')
- const filename = `${stringifiedStartTime}_rowing.tcx`
-
- const tcx = await workoutToTcx({
- id: startTime.toISOString(),
- startTime,
- strokes
- })
-
- return {
- tcx,
- filename
- }
- }
-
- async function workoutToTcx (workout) {
- let versionArray = process.env.npm_package_version.split('.')
- if (versionArray.length < 3) versionArray = [0, 0, 0]
- const lastStroke = workout.strokes[strokes.length - 1]
-
- const tcxObject = {
- TrainingCenterDatabase: {
- $: { xmlns: 'http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2', 'xmlns:ns2': 'http://www.garmin.com/xmlschemas/ActivityExtension/v2' },
- Activities: {
- Activity: {
- $: { Sport: 'Other' },
- Id: workout.id,
- Lap: [
- {
- $: { StartTime: workout.startTime.toISOString() },
- TotalTimeSeconds: workout.strokes.reduce((acc, stroke) => acc + stroke.strokeTime, 0).toFixed(1),
- DistanceMeters: lastStroke.distanceTotal.toFixed(1),
- // tcx uses meters per second as unit for speed
- MaximumSpeed: (workout.strokes.map((stroke) => stroke.speed).reduce((acc, speed) => Math.max(acc, speed)) / 3.6).toFixed(2),
- Calories: Math.round(lastStroke.caloriesTotal),
- /* todo: calculate heart rate metrics...
- AverageHeartRateBpm: { Value: 76 },
- MaximumHeartRateBpm: { Value: 76 },
- */
- Intensity: 'Active',
- // todo: calculate average SPM
- // Cadence: 20,
- TriggerMethod: 'Manual',
- Track: {
- Trackpoint: (() => {
- let trackPointTime = workout.startTime
-
- return workout.strokes.map((stroke) => {
- trackPointTime = new Date(trackPointTime.getTime() + stroke.strokeTime * 1000)
- const trackpoint = {
- Time: trackPointTime.toISOString(),
- DistanceMeters: stroke.distanceTotal.toFixed(2),
- Cadence: Math.round(stroke.strokesPerMinute),
- Extensions: {
- 'ns2:TPX': {
- // tcx uses meters per second as unit for speed
- 'ns2:Speed': (stroke.speed / 3.6).toFixed(2),
- 'ns2:Watts': Math.round(stroke.power)
- }
- }
- }
- if (stroke.heartrate !== undefined) {
- trackpoint.HeartRateBpm = { Value: stroke.heartrate }
- }
- return trackpoint
- })
- })()
- },
- Extensions: {
- 'ns2:LX': {
- /* todo: calculate these metrics...
- 'ns2:AvgSpeed': 12,
- 'ns2:AvgWatts': 133,
- */
- 'ns2:MaxWatts': Math.round(workout.strokes.map((stroke) => stroke.power).reduce((acc, power) => Math.max(acc, power)))
- }
- }
- }
- ],
- Notes: 'Rowing Session'
- }
- },
- Author: {
- $: { 'xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance', 'xsi:type': 'Application_t' },
- Name: 'Open Rowing Monitor',
- Build: {
- Version: {
- VersionMajor: versionArray[0],
- VersionMinor: versionArray[1],
- BuildMajor: versionArray[2],
- BuildMinor: 0
- },
- LangID: 'en',
- PartNumber: 'OPE-NROWI-NG'
- }
- }
- }
- }
-
- const builder = new xml2js.Builder()
- return builder.buildObject(tcxObject)
- }
-
- async function reset () {
- await createRecordings()
- strokes = []
- rotationImpulses = []
- startTime = undefined
- }
-
- async function createFile (content, filename, compress = false) {
- if (compress) {
- const gzipContent = await gzip(content)
- await fs.writeFile(filename, gzipContent, (err) => { if (err) log.error(err) })
- } else {
- await fs.writeFile(filename, content, (err) => { if (err) log.error(err) })
- }
- }
-
- function handlePause () {
- createRecordings()
- }
-
- async function createRecordings () {
- if (!config.createRawDataFiles && !config.createTcxFiles) {
- return
- }
-
- if (!minimumRecordingTimeHasPassed()) {
- log.debug('workout is shorter than minimum workout time, skipping automatic creation of recordings...')
- return
- }
-
- const parallelCalls = []
-
- if (config.createRawDataFiles) {
- parallelCalls.push(createRawDataFile())
- }
- if (config.createTcxFiles) {
- parallelCalls.push(createTcxFile())
- }
- await Promise.all(parallelCalls)
- }
-
- function minimumRecordingTimeHasPassed () {
- const minimumRecordingTimeInSeconds = 10
- const rotationImpulseTimeTotal = rotationImpulses.reduce((acc, impulse) => acc + impulse, 0)
- const strokeTimeTotal = strokes.reduce((acc, stroke) => acc + stroke.strokeTime, 0)
- return (Math.max(rotationImpulseTimeTotal, strokeTimeTotal) > minimumRecordingTimeInSeconds)
- }
-
- return {
- recordStroke,
- recordRotationImpulse,
- handlePause,
- activeWorkoutToTcx,
- reset
- }
-}
-
-export { createWorkoutRecorder }
diff --git a/app/engine/WorkoutUploader.js b/app/engine/WorkoutUploader.js
deleted file mode 100644
index 1f4e4af3b8..0000000000
--- a/app/engine/WorkoutUploader.js
+++ /dev/null
@@ -1,57 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- Handles uploading workout data to different cloud providers
-*/
-import log from 'loglevel'
-import EventEmitter from 'events'
-import { createStravaAPI } from '../tools/StravaAPI.js'
-import config from '../tools/ConfigManager.js'
-
-function createWorkoutUploader (workoutRecorder) {
- const emitter = new EventEmitter()
-
- let stravaAuthorizationCodeResolver
- let requestingClient
-
- function getStravaAuthorizationCode () {
- return new Promise((resolve) => {
- emitter.emit('authorizeStrava', { stravaClientId: config.stravaClientId }, requestingClient)
- stravaAuthorizationCodeResolver = resolve
- })
- }
-
- const stravaAPI = createStravaAPI(getStravaAuthorizationCode)
-
- function stravaAuthorizationCode (stravaAuthorizationCode) {
- if (stravaAuthorizationCodeResolver) {
- stravaAuthorizationCodeResolver(stravaAuthorizationCode)
- stravaAuthorizationCodeResolver = undefined
- }
- }
-
- async function upload (client) {
- log.debug('uploading workout to strava...')
- try {
- requestingClient = client
- // todo: we might signal back to the client whether we had success or not
- const tcxActivity = await workoutRecorder.activeWorkoutToTcx()
- if (tcxActivity !== undefined) {
- await stravaAPI.uploadActivityTcx(tcxActivity)
- emitter.emit('resetWorkout')
- } else {
- log.error('can not upload an empty workout to strava')
- }
- } catch (error) {
- log.error('can not upload workout to strava:', error.message)
- }
- }
-
- return Object.assign(emitter, {
- upload,
- stravaAuthorizationCode
- })
-}
-
-export { createWorkoutUploader }
diff --git a/app/engine/averager/MovingAverager.js b/app/engine/averager/MovingAverager.js
deleted file mode 100644
index cbdcf3beb9..0000000000
--- a/app/engine/averager/MovingAverager.js
+++ /dev/null
@@ -1,56 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- This Averager can calculate the moving average of a continuous flow of data points
-
- Please note: The array contains flankLength + 1 measured currentDt's, thus flankLength number
- of flanks between them.
- They are arranged that dataPoints[0] is the youngest, and dataPoints[flankLength] the oldest
-*/
-function createMovingAverager (length, initValue) {
- let dataPoints
- reset()
-
- function pushValue (dataPoint) {
- // add the new dataPoint to the array, we have to move data points starting at the oldest ones
- let i = length - 1
- while (i > 0) {
- // older data points are moved towards the higher numbers
- dataPoints[i] = dataPoints[i - 1]
- i = i - 1
- }
- dataPoints[0] = dataPoint
- }
-
- function replaceLastPushedValue (dataPoint) {
- // replace the newest dataPoint in the array, as it was faulty
- dataPoints[0] = dataPoint
- }
-
- function getAverage () {
- let i = length - 1
- let arrayTotal = 0.0
- while (i >= 0) {
- // summarize the value of the moving average
- arrayTotal = arrayTotal + dataPoints[i]
- i = i - 1
- }
- const arrayAverage = arrayTotal / length
- return arrayAverage
- }
-
- function reset () {
- dataPoints = new Array(length)
- dataPoints.fill(initValue)
- }
-
- return {
- pushValue,
- replaceLastPushedValue,
- getAverage,
- reset
- }
-}
-
-export { createMovingAverager }
diff --git a/app/engine/averager/MovingAverager.test.js b/app/engine/averager/MovingAverager.test.js
deleted file mode 100644
index 748d720a9c..0000000000
--- a/app/engine/averager/MovingAverager.test.js
+++ /dev/null
@@ -1,44 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-*/
-import { test } from 'uvu'
-import * as assert from 'uvu/assert'
-
-import { createMovingAverager } from './MovingAverager.js'
-
-test('average should be initValue on empty dataset', () => {
- const movingAverager = createMovingAverager(10, 5.5)
- assert.is(movingAverager.getAverage(), 5.5)
-})
-
-test('an averager of length 1 should return the last added value', () => {
- const movingAverager = createMovingAverager(1, 3)
- movingAverager.pushValue(9)
- assert.is(movingAverager.getAverage(), 9)
-})
-
-test('an averager of length 2 should return average of last 2 added elements', () => {
- const movingAverager = createMovingAverager(2, 3)
- movingAverager.pushValue(9)
- movingAverager.pushValue(4)
- assert.is(movingAverager.getAverage(), 6.5)
-})
-
-test('elements outside of range should not be considered', () => {
- const movingAverager = createMovingAverager(2, 3)
- movingAverager.pushValue(9)
- movingAverager.pushValue(4)
- movingAverager.pushValue(3)
- assert.is(movingAverager.getAverage(), 3.5)
-})
-
-test('replacing the last element should work as expected', () => {
- const movingAverager = createMovingAverager(2, 3)
- movingAverager.pushValue(9)
- movingAverager.pushValue(5)
- movingAverager.replaceLastPushedValue(12)
- assert.is(movingAverager.getAverage(), 10.5)
-})
-
-test.run()
diff --git a/app/engine/averager/MovingIntervalAverager.js b/app/engine/averager/MovingIntervalAverager.js
deleted file mode 100644
index c224ed5175..0000000000
--- a/app/engine/averager/MovingIntervalAverager.js
+++ /dev/null
@@ -1,47 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- This Averager calculates the average forecast for a moving interval of a continuous flow
- of data points for a certain (time) interval
-*/
-function createMovingIntervalAverager (movingDuration) {
- let dataPoints
- let duration
- let sum
- reset()
-
- function pushValue (dataValue, dataDuration) {
- // add the new data point to the front of the array
- dataPoints.unshift({ value: dataValue, duration: dataDuration })
- duration += dataDuration
- sum += dataValue
- while (duration > movingDuration) {
- const removedDataPoint = dataPoints.pop()
- duration -= removedDataPoint.duration
- sum -= removedDataPoint.value
- }
- }
-
- function getAverage () {
- if (duration > 0) {
- return sum / duration * movingDuration
- } else {
- return 0
- }
- }
-
- function reset () {
- dataPoints = []
- duration = 0.0
- sum = 0.0
- }
-
- return {
- pushValue,
- getAverage,
- reset
- }
-}
-
-export { createMovingIntervalAverager }
diff --git a/app/engine/averager/MovingIntervalAverager.test.js b/app/engine/averager/MovingIntervalAverager.test.js
deleted file mode 100644
index 35b7013f90..0000000000
--- a/app/engine/averager/MovingIntervalAverager.test.js
+++ /dev/null
@@ -1,57 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-*/
-import { test } from 'uvu'
-import * as assert from 'uvu/assert'
-
-import { createMovingIntervalAverager } from './MovingIntervalAverager.js'
-
-test('average of a data point with duration of averager is equal to datapoint', () => {
- const movingAverager = createMovingIntervalAverager(10)
- movingAverager.pushValue(5, 10)
- assert.is(movingAverager.getAverage(), 5)
-})
-
-test('average of a data point with half duration of averager is double to datapoint', () => {
- const movingAverager = createMovingIntervalAverager(20)
- movingAverager.pushValue(5, 10)
- assert.is(movingAverager.getAverage(), 10)
-})
-
-test('average of two identical data points with half duration of averager is equal to datapoint sum', () => {
- const movingAverager = createMovingIntervalAverager(20)
- movingAverager.pushValue(5, 10)
- movingAverager.pushValue(5, 10)
- assert.is(movingAverager.getAverage(), 10)
-})
-
-test('average does not consider data points that are outside of duration', () => {
- const movingAverager = createMovingIntervalAverager(20)
- movingAverager.pushValue(10, 10)
- movingAverager.pushValue(5, 10)
- movingAverager.pushValue(5, 10)
- assert.is(movingAverager.getAverage(), 10)
-})
-
-test('average works with lots of values', () => {
- // one hour
- const movingAverager = createMovingIntervalAverager(3000)
- for (let i = 0; i < 1000; i++) {
- movingAverager.pushValue(10, 1)
- }
- for (let i = 0; i < 1000; i++) {
- movingAverager.pushValue(20, 1)
- }
- for (let i = 0; i < 1000; i++) {
- movingAverager.pushValue(30, 2)
- }
- assert.is(movingAverager.getAverage(), 50000)
-})
-
-test('average should return 0 on empty dataset', () => {
- const movingAverager = createMovingIntervalAverager(10)
- assert.is(movingAverager.getAverage(), 0)
-})
-
-test.run()
diff --git a/app/engine/averager/WeightedAverager.js b/app/engine/averager/WeightedAverager.js
deleted file mode 100644
index 4b266b96b2..0000000000
--- a/app/engine/averager/WeightedAverager.js
+++ /dev/null
@@ -1,43 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- This Averager can calculate the weighted average of a continuous flow of data points
-*/
-function createWeightedAverager (maxNumOfDataPoints) {
- let dataPoints = []
-
- function pushValue (dataPoint) {
- // add the new data point to the front of the array
- dataPoints.unshift(dataPoint)
- // ensure that the array does not get longer than maxNumOfDataPoints
- if (dataPoints.length > maxNumOfDataPoints) {
- dataPoints.pop()
- }
- }
-
- function getAverage () {
- const numOfDataPoints = dataPoints.length
- if (numOfDataPoints > 0) {
- const sum = dataPoints
- .map((dataPoint, index) => Math.pow(2, numOfDataPoints - index - 1) * dataPoint)
- .reduce((acc, dataPoint) => acc + dataPoint, 0)
- const weight = Math.pow(2, numOfDataPoints) - 1
- return sum / weight
- } else {
- return 0
- }
- }
-
- function reset () {
- dataPoints = []
- }
-
- return {
- pushValue,
- getAverage,
- reset
- }
-}
-
-export { createWeightedAverager }
diff --git a/app/engine/averager/WeightedAverager.test.js b/app/engine/averager/WeightedAverager.test.js
deleted file mode 100644
index 28e1583e9b..0000000000
--- a/app/engine/averager/WeightedAverager.test.js
+++ /dev/null
@@ -1,45 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-*/
-import { test } from 'uvu'
-import * as assert from 'uvu/assert'
-
-import { createWeightedAverager } from './WeightedAverager.js'
-
-test('average should be 0 on empty dataset', () => {
- const weightedAverager = createWeightedAverager(10)
- assert.is(weightedAverager.getAverage(), 0)
-})
-
-test('average of one value is value', () => {
- const weightedAverager = createWeightedAverager(10)
- weightedAverager.pushValue(13.78)
- assert.is(weightedAverager.getAverage(), 13.78)
-})
-
-test('average of a and b is (2*b + a) / 3', () => {
- const weightedAverager = createWeightedAverager(10)
- weightedAverager.pushValue(5) // a
- weightedAverager.pushValue(2) // b
- assert.is(weightedAverager.getAverage(), 3)
-})
-
-test('average should be 0 after reset', () => {
- const weightedAverager = createWeightedAverager(10)
- weightedAverager.pushValue(5)
- weightedAverager.pushValue(2)
- weightedAverager.reset()
- assert.is(weightedAverager.getAverage(), 0)
-})
-
-test('average should be a after pushing a after a reset', () => {
- const weightedAverager = createWeightedAverager(10)
- weightedAverager.pushValue(5)
- weightedAverager.pushValue(2)
- weightedAverager.reset()
- weightedAverager.pushValue(7)
- assert.is(weightedAverager.getAverage(), 7)
-})
-
-test.run()
diff --git a/app/engine/utils/BinarySearchTree.js b/app/engine/utils/BinarySearchTree.js
new file mode 100644
index 0000000000..2fb7c1bc8a
--- /dev/null
+++ b/app/engine/utils/BinarySearchTree.js
@@ -0,0 +1,361 @@
+'use strict'
+/*
+ Open Rowing Monitor, https://github.com/jaapvanekris/openrowingmonitor
+
+ This creates an ordered series with labels
+ It allows for efficient determining the Median, Number of Above and Below
+*/
+
+export function createLabelledBinarySearchTree () {
+ let tree = null
+
+ function push (label, value) {
+ if (value === undefined || isNaN(value)) { return }
+ if (tree === null) {
+ tree = newNode(label, value)
+ } else {
+ tree = pushInTree(tree, label, value)
+ }
+ }
+
+ function pushInTree (currentTree, label, value) {
+ if (value <= currentTree.value) {
+ // The value should be on the left side of currentTree
+ if (currentTree.leftNode === null) {
+ currentTree.leftNode = newNode(label, value)
+ } else {
+ currentTree.leftNode = pushInTree(currentTree.leftNode, label, value)
+ }
+ } else {
+ // The value should be on the right side of currentTree
+ if (currentTree.rightNode === null) {
+ currentTree.rightNode = newNode(label, value)
+ } else {
+ currentTree.rightNode = pushInTree(currentTree.rightNode, label, value)
+ }
+ }
+ currentTree.numberOfLeafsAndNodes = currentTree.numberOfLeafsAndNodes + 1
+ return currentTree
+ }
+
+ function newNode (label, value) {
+ return {
+ label,
+ value,
+ leftNode: null,
+ rightNode: null,
+ numberOfLeafsAndNodes: 1
+ }
+ }
+
+ function size () {
+ if (tree !== null) {
+ return tree.numberOfLeafsAndNodes
+ } else {
+ return 0
+ }
+ }
+
+ function minimum () {
+ return minimumValueInTree(tree)
+ }
+
+ function minimumValueInTree (subTree) {
+ if (subTree.leftNode === null) {
+ return subTree.value
+ } else {
+ return minimumValueInTree(subTree.leftNode)
+ }
+ }
+
+ function maximum () {
+ return maximumValueInTree(tree)
+ }
+
+ function maximumValueInTree (subTree) {
+ if (subTree.rightNode === null) {
+ return subTree.value
+ } else {
+ return maximumValueInTree(subTree.rightNode)
+ }
+ }
+
+ function numberOfValuesAbove (testedValue) {
+ return countNumberOfValuesAboveInTree(tree, testedValue)
+ }
+
+ function countNumberOfValuesAboveInTree (currentTree, testedValue) {
+ if (currentTree === null) {
+ return 0
+ } else {
+ // We encounter a filled node
+ if (currentTree.value > testedValue) {
+ // testedValue < currentTree.value, so we can find the tested value in the left and right branch
+ return (countNumberOfValuesAboveInTree(currentTree.leftNode, testedValue) + countNumberOfValuesAboveInTree(currentTree.rightNode, testedValue) + 1)
+ } else {
+ // currentTree.value < testedValue, so we need to find values from the right branch
+ return countNumberOfValuesAboveInTree(currentTree.rightNode, testedValue)
+ }
+ }
+ }
+
+ function numberOfValuesEqualOrBelow (testedValue) {
+ return countNumberOfValuesEqualOrBelowInTree(tree, testedValue)
+ }
+
+ function countNumberOfValuesEqualOrBelowInTree (currentTree, testedValue) {
+ if (currentTree === null) {
+ return 0
+ } else {
+ // We encounter a filled node
+ if (currentTree.value <= testedValue) {
+ // testedValue <= currentTree.value, so we can only find the tested value in the left branch
+ return (countNumberOfValuesEqualOrBelowInTree(currentTree.leftNode, testedValue) + countNumberOfValuesEqualOrBelowInTree(currentTree.rightNode, testedValue) + 1)
+ } else {
+ // currentTree.value > testedValue, so we only need to look at the left branch
+ return countNumberOfValuesEqualOrBelowInTree(currentTree.leftNode, testedValue)
+ }
+ }
+ }
+
+ function remove (label) {
+ if (tree !== null) {
+ tree = removeFromTree(tree, label)
+ }
+ }
+
+ function removeFromTree (currentTree, label) {
+ // Clean up the underlying sub-trees first
+ if (currentTree.leftNode !== null) {
+ currentTree.leftNode = removeFromTree(currentTree.leftNode, label)
+ }
+ if (currentTree.rightNode !== null) {
+ currentTree.rightNode = removeFromTree(currentTree.rightNode, label)
+ }
+
+ // Next, handle the situation when we need to remove the node itself
+ if (currentTree.label === label) {
+ // First we need to remove the current node, then we need to investigate the underlying sub-trees to determine how it is resolved
+ // First, release the memory of the current node before we start to rearrange the tree, as this might cause a memory leak
+ currentTree.label = null
+ currentTree.value = null
+ currentTree.numberOfLeafsAndNodes = null
+ switch (true) {
+ case (currentTree.leftNode === null && currentTree.rightNode === null):
+ // As the underlying sub-trees are empty as well, we return an empty tree
+ currentTree = null
+ break
+ case (currentTree.leftNode !== null && currentTree.rightNode === null):
+ // As only the left node contains data, we can simply replace the removed node with the left sub-tree
+ currentTree = currentTree.leftNode
+ break
+ case (currentTree.leftNode === null && currentTree.rightNode !== null):
+ // As only the right node contains data, we can simply replace the removed node with the right sub-tree
+ currentTree = currentTree.rightNode
+ break
+ case (currentTree.leftNode !== null && currentTree.rightNode !== null):
+ // As all underlying sub-trees are filled, we need to move a leaf to the now empty node. Here, we can be a bit smarter
+ // as there are two potential nodes to use, we try to balance the tree a bit more as this increases performance
+ if (currentTree.leftNode.numberOfLeafsAndNodes > currentTree.rightNode.numberOfLeafsAndNodes) {
+ // The left sub-tree is bigger then the right one, lets use the closest predecessor to restore some balance
+ currentTree.value = clostestPredecessor(currentTree.leftNode).value
+ currentTree.label = clostestPredecessor(currentTree.leftNode).label
+ currentTree.leftNode = destroyClostestPredecessor(currentTree.leftNode)
+ } else {
+ // The right sub-tree is smaller then the right one, lets use the closest successor to restore some balance
+ currentTree.value = clostestSuccesor(currentTree.rightNode).value
+ currentTree.label = clostestSuccesor(currentTree.rightNode).label
+ currentTree.rightNode = destroyClostestSuccessor(currentTree.rightNode)
+ }
+ break
+ // no default
+ }
+ }
+
+ // Recalculate the tree size
+ switch (true) {
+ case (currentTree === null):
+ // We are now an empty leaf, nothing to do here
+ break
+ case (currentTree.leftNode === null && currentTree.rightNode === null):
+ // This is a filled leaf
+ currentTree.numberOfLeafsAndNodes = 1
+ break
+ case (currentTree.leftNode !== null && currentTree.rightNode === null):
+ currentTree.numberOfLeafsAndNodes = currentTree.leftNode.numberOfLeafsAndNodes + 1
+ break
+ case (currentTree.leftNode === null && currentTree.rightNode !== null):
+ currentTree.numberOfLeafsAndNodes = currentTree.rightNode.numberOfLeafsAndNodes + 1
+ break
+ case (currentTree.leftNode !== null && currentTree.rightNode !== null):
+ currentTree.numberOfLeafsAndNodes = currentTree.leftNode.numberOfLeafsAndNodes + currentTree.rightNode.numberOfLeafsAndNodes + 1
+ break
+ // no default
+ }
+ return currentTree
+ }
+
+ function clostestPredecessor (currentTree) {
+ // This function finds the maximum value in a tree
+ if (currentTree.rightNode !== null) {
+ // We haven't reached the end of the tree yet
+ return clostestPredecessor(currentTree.rightNode)
+ } else {
+ // We reached the largest value in the tree
+ return {
+ label: currentTree.label,
+ value: currentTree.value
+ }
+ }
+ }
+
+ function destroyClostestPredecessor (currentTree) {
+ // This function finds the maximum value in a tree
+ if (currentTree.rightNode !== null) {
+ // We haven't reached the end of the tree yet
+ currentTree.rightNode = destroyClostestPredecessor(currentTree.rightNode)
+ currentTree.numberOfLeafsAndNodes = currentTree.numberOfLeafsAndNodes - 1
+ return currentTree
+ } else {
+ // We reached the largest value in the tree
+ // First, release the memory of the current node before we start to rearrange the tree, as this might cause a memory leak
+ currentTree.label = null
+ currentTree.value = null
+ currentTree.numberOfLeafsAndNodes = null
+ return currentTree.leftNode
+ }
+ }
+
+ function clostestSuccesor (currentTree) {
+ // This function finds the maximum value in a tree
+ if (currentTree.leftNode !== null) {
+ // We haven't reached the end of the tree yet
+ return clostestSuccesor(currentTree.leftNode)
+ } else {
+ // We reached the smallest value in the tree
+ return {
+ label: currentTree.label,
+ value: currentTree.value
+ }
+ }
+ }
+
+ function destroyClostestSuccessor (currentTree) {
+ // This function finds the maximum value in a tree
+ if (currentTree.leftNode !== null) {
+ // We haven't reached the end of the tree yet
+ currentTree.leftNode = destroyClostestSuccessor(currentTree.leftNode)
+ currentTree.numberOfLeafsAndNodes = currentTree.numberOfLeafsAndNodes - 1
+ return currentTree
+ } else {
+ // We reached the smallest value in the tree
+ // First, release the memory of the current node before we start to rearrange the tree, as this might cause a memory leak
+ currentTree.label = null
+ currentTree.value = null
+ currentTree.numberOfLeafsAndNodes = null
+ return currentTree.rightNode
+ }
+ }
+
+ function median () {
+ if (tree !== null && tree.numberOfLeafsAndNodes > 0) {
+ // BE AWARE, UNLIKE WITH ARRAYS, THE COUNTING OF THE ELEMENTS STARTS WITH 1 !!!!!!!
+ // THIS LOGIC THUS WORKS DIFFERENT THAN MOST ARRAYS FOUND IN ORM!!!!!!!
+ const mid = Math.floor(tree.numberOfLeafsAndNodes / 2)
+ return tree.numberOfLeafsAndNodes % 2 !== 0 ? valueAtInorderPosition(tree, mid + 1) : (valueAtInorderPosition(tree, mid) + valueAtInorderPosition(tree, mid + 1)) / 2
+ } else {
+ return 0
+ }
+ }
+
+ /**
+ * @remark: // BE AWARE TESTING PURPOSSES ONLY
+ */
+ function valueAtInorderPos (position) {
+ if (tree !== null && position >= 1) {
+ return valueAtInorderPosition(tree, position)
+ } else {
+ return undefined
+ }
+ }
+
+ function valueAtInorderPosition (currentTree, position) {
+ let currentNodePosition
+ if (currentTree === null) {
+ // We are now an empty tree, this shouldn't happen
+ return undefined
+ }
+
+ // First we need to find out what the InOrder Postion we currently are at
+ if (currentTree.leftNode !== null) {
+ currentNodePosition = currentTree.leftNode.numberOfLeafsAndNodes + 1
+ } else {
+ currentNodePosition = 1
+ }
+
+ switch (true) {
+ case (position === currentNodePosition):
+ // The current position is the one we are looking for
+ return currentTree.value
+ case (currentTree.leftNode === null):
+ // The current node's left side is empty, but position <> currentNodePosition, so we have no choice but to move downwards
+ return valueAtInorderPosition(currentTree.rightNode, (position - 1))
+ case (currentTree.leftNode !== null && currentNodePosition > position):
+ // The position we look for is in the left side of the currentTree
+ return valueAtInorderPosition(currentTree.leftNode, position)
+ case (currentTree.leftNode !== null && currentNodePosition < position && currentTree.rightNode !== null):
+ // The position we look for is in the right side of the currentTree
+ return valueAtInorderPosition(currentTree.rightNode, (position - currentNodePosition))
+ default:
+ return undefined
+ }
+ }
+
+ function orderedSeries () {
+ return orderedTree(tree)
+ }
+
+ function orderedTree (currentTree) {
+ if (currentTree === null) {
+ return []
+ } else {
+ // We encounter a filled node
+ return [...orderedTree(currentTree.leftNode), currentTree.value, ...orderedTree(currentTree.rightNode)]
+ }
+ }
+
+ function reset () {
+ resetTree(tree)
+ tree = null
+ }
+
+ function resetTree (currentTree) {
+ if (currentTree !== null) {
+ currentTree.label = null
+ currentTree.value = null
+ if (currentTree.leftNode !== null) {
+ resetTree(currentTree.leftNode)
+ currentTree.leftNode = null
+ }
+ if (currentTree.rightNode !== null) {
+ resetTree(currentTree.rightNode)
+ currentTree.rightNode = null
+ }
+ currentTree.numberOfLeafsAndNodes = null
+ }
+ }
+
+ return {
+ push,
+ remove,
+ size,
+ numberOfValuesAbove,
+ numberOfValuesEqualOrBelow,
+ minimum,
+ maximum,
+ median,
+ valueAtInorderPos,
+ orderedSeries,
+ reset
+ }
+}
diff --git a/app/engine/utils/BinarySearchTree.test.js b/app/engine/utils/BinarySearchTree.test.js
new file mode 100644
index 0000000000..e7b8e541ae
--- /dev/null
+++ b/app/engine/utils/BinarySearchTree.test.js
@@ -0,0 +1,207 @@
+'use strict'
+/*
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
+
+ As this object is fundamental for most other utility objects, we must test its behaviour quite thoroughly
+*/
+import { test } from 'uvu'
+import * as assert from 'uvu/assert'
+
+import { createLabelledBinarySearchTree } from './BinarySearchTree.js'
+
+test('Series behaviour with an empty tree', () => {
+ const dataTree = createLabelledBinarySearchTree()
+ testSize(dataTree, 0)
+ testNumberOfValuesAbove(dataTree, 0, 0)
+ testNumberOfValuesEqualOrBelow(dataTree, 0, 0)
+ testNumberOfValuesAbove(dataTree, 10, 0)
+ testNumberOfValuesEqualOrBelow(dataTree, 10, 0)
+ testMedian(dataTree, 0)
+})
+
+test('Tree behaviour with a single pushed value. Tree = [9]', () => {
+ const dataTree = createLabelledBinarySearchTree()
+ dataTree.push(1, 9)
+ testOrderedSeries(dataTree, [9])
+ testSize(dataTree, 1)
+ testValueAtInorderPos(dataTree, 1, 9)
+ testNumberOfValuesAbove(dataTree, 0, 1)
+ testNumberOfValuesEqualOrBelow(dataTree, 0, 0)
+ testNumberOfValuesAbove(dataTree, 10, 0)
+ testNumberOfValuesEqualOrBelow(dataTree, 10, 1)
+ testMedian(dataTree, 9)
+})
+
+test('Tree behaviour with a second pushed value. Tree = [9, 3]', () => {
+ const dataTree = createLabelledBinarySearchTree()
+ dataTree.push(1, 9)
+ dataTree.push(2, 3)
+ testOrderedSeries(dataTree, [3, 9])
+ testSize(dataTree, 2)
+ testValueAtInorderPos(dataTree, 1, 3)
+ testValueAtInorderPos(dataTree, 2, 9)
+ testNumberOfValuesAbove(dataTree, 0, 2)
+ testNumberOfValuesEqualOrBelow(dataTree, 0, 0)
+ testNumberOfValuesAbove(dataTree, 10, 0)
+ testNumberOfValuesEqualOrBelow(dataTree, 10, 2)
+ testMedian(dataTree, 6)
+})
+
+test('Tree behaviour with a third pushed value. Tree = [9, 3, 6]', () => {
+ const dataTree = createLabelledBinarySearchTree()
+ dataTree.push(1, 9)
+ dataTree.push(2, 3)
+ dataTree.push(3, 6)
+ testOrderedSeries(dataTree, [3, 6, 9])
+ testSize(dataTree, 3)
+ testValueAtInorderPos(dataTree, 1, 3)
+ testValueAtInorderPos(dataTree, 2, 6)
+ testValueAtInorderPos(dataTree, 3, 9)
+ testNumberOfValuesAbove(dataTree, 0, 3)
+ testNumberOfValuesEqualOrBelow(dataTree, 0, 0)
+ testNumberOfValuesAbove(dataTree, 10, 0)
+ testNumberOfValuesEqualOrBelow(dataTree, 10, 3)
+ testMedian(dataTree, 6)
+})
+
+test('Tree behaviour with a fourth pushed value. Tree = [3, 6, 12]', () => {
+ const dataTree = createLabelledBinarySearchTree()
+ dataTree.push(1, 9)
+ dataTree.push(2, 3)
+ dataTree.push(3, 6)
+ dataTree.remove(1)
+ dataTree.push(4, 12)
+ testOrderedSeries(dataTree, [3, 6, 12])
+ testSize(dataTree, 3)
+ testValueAtInorderPos(dataTree, 1, 3)
+ testValueAtInorderPos(dataTree, 2, 6)
+ testValueAtInorderPos(dataTree, 3, 12)
+ testNumberOfValuesAbove(dataTree, 0, 3)
+ testNumberOfValuesEqualOrBelow(dataTree, 0, 0)
+ testNumberOfValuesAbove(dataTree, 10, 1)
+ testNumberOfValuesEqualOrBelow(dataTree, 10, 2)
+ testMedian(dataTree, 6)
+})
+
+test('Tree behaviour with a fifth pushed value. Series = [6, 12, -3]', () => {
+ const dataTree = createLabelledBinarySearchTree()
+ dataTree.push(1, 9)
+ dataTree.push(2, 3)
+ dataTree.push(3, 6)
+ dataTree.remove(1)
+ dataTree.push(4, 12)
+ dataTree.remove(2)
+ dataTree.push(5, -3)
+ testOrderedSeries(dataTree, [-3, 6, 12])
+ testSize(dataTree, 3)
+ testValueAtInorderPos(dataTree, 1, -3)
+ testValueAtInorderPos(dataTree, 2, 6)
+ testValueAtInorderPos(dataTree, 3, 12)
+ testNumberOfValuesAbove(dataTree, 0, 2)
+ testNumberOfValuesEqualOrBelow(dataTree, 0, 1)
+ testNumberOfValuesAbove(dataTree, 10, 1)
+ testNumberOfValuesEqualOrBelow(dataTree, 10, 2)
+ testMedian(dataTree, 6)
+})
+
+test('Tree behaviour with complex removals. Series = [9, 6, 5, 8, 7, 9, 12, 10, 11]', () => {
+ const dataTree = createLabelledBinarySearchTree()
+ dataTree.push(1, 9)
+ dataTree.push(2, 6)
+ dataTree.push(3, 5)
+ dataTree.push(4, 8)
+ dataTree.push(5, 7)
+ dataTree.push(6, 9)
+ dataTree.push(7, 12)
+ dataTree.push(8, 10)
+ dataTree.push(9, 11)
+ testOrderedSeries(dataTree, [5, 6, 7, 8, 9, 9, 10, 11, 12])
+ testSize(dataTree, 9)
+ testValueAtInorderPos(dataTree, 5, 9)
+ testMedian(dataTree, 9)
+ dataTree.remove(1)
+ testOrderedSeries(dataTree, [5, 6, 7, 8, 9, 10, 11, 12])
+ testSize(dataTree, 8)
+ testValueAtInorderPos(dataTree, 4, 8)
+ testValueAtInorderPos(dataTree, 5, 9)
+ testMedian(dataTree, 8.5)
+ dataTree.remove(3)
+ testOrderedSeries(dataTree, [6, 7, 8, 9, 10, 11, 12])
+ testSize(dataTree, 7)
+ testValueAtInorderPos(dataTree, 4, 9)
+ testMedian(dataTree, 9)
+})
+
+// Test based on https://levelup.gitconnected.com/deletion-in-binary-search-tree-with-javascript-fded82e1791c
+test('Tree behaviour with complex removals. Series = [50, 30, 70, 20, 40, 60, 80]', () => {
+ const dataTree = createLabelledBinarySearchTree()
+ dataTree.push(1, 50)
+ dataTree.push(2, 30)
+ dataTree.push(3, 70)
+ dataTree.push(4, 20)
+ dataTree.push(5, 40)
+ dataTree.push(6, 60)
+ dataTree.push(7, 80)
+ testOrderedSeries(dataTree, [20, 30, 40, 50, 60, 70, 80])
+ testSize(dataTree, 7)
+ testValueAtInorderPos(dataTree, 4, 50)
+ dataTree.remove(4)
+ testOrderedSeries(dataTree, [30, 40, 50, 60, 70, 80])
+ testSize(dataTree, 6)
+ testValueAtInorderPos(dataTree, 3, 50)
+ testValueAtInorderPos(dataTree, 4, 60)
+ testMedian(dataTree, 55)
+ dataTree.remove(2)
+ testOrderedSeries(dataTree, [40, 50, 60, 70, 80])
+ testSize(dataTree, 5)
+ testValueAtInorderPos(dataTree, 3, 60)
+ testMedian(dataTree, 60)
+ dataTree.remove(1)
+ testOrderedSeries(dataTree, [40, 60, 70, 80])
+ testSize(dataTree, 4)
+ testValueAtInorderPos(dataTree, 2, 60)
+ testValueAtInorderPos(dataTree, 3, 70)
+ testMedian(dataTree, 65)
+})
+
+test('Tree behaviour with a five pushed values followed by a reset, Tree = []', () => {
+ const dataTree = createLabelledBinarySearchTree()
+ dataTree.push(1, 9)
+ dataTree.push(2, 3)
+ dataTree.push(3, 6)
+ dataTree.push(4, 12)
+ dataTree.push(5, -3)
+ dataTree.reset()
+ testSize(dataTree, 0)
+ testNumberOfValuesAbove(dataTree, 0, 0)
+ testNumberOfValuesEqualOrBelow(dataTree, 0, 0)
+ testNumberOfValuesAbove(dataTree, 10, 0)
+ testNumberOfValuesEqualOrBelow(dataTree, 10, 0)
+ testMedian(dataTree, 0)
+})
+
+function testSize (tree, expectedValue) {
+ assert.ok(tree.size() === expectedValue, `Expected size should be ${expectedValue}, encountered ${tree.size()}`)
+}
+
+function testNumberOfValuesAbove (tree, cutoff, expectedValue) {
+ assert.ok(tree.numberOfValuesAbove(cutoff) === expectedValue, `Expected numberOfValuesAbove(${cutoff}) to be ${expectedValue}, encountered ${tree.numberOfValuesAbove(cutoff)}`)
+}
+
+function testNumberOfValuesEqualOrBelow (tree, cutoff, expectedValue) {
+ assert.ok(tree.numberOfValuesEqualOrBelow(cutoff) === expectedValue, `Expected numberOfValuesEqualOrBelow(${cutoff}) to be ${expectedValue}, encountered ${tree.numberOfValuesEqualOrBelow(cutoff)}`)
+}
+
+function testOrderedSeries (tree, expectedValue) {
+ assert.ok(tree.orderedSeries().toString() === expectedValue.toString(), `Expected ordered series to be ${expectedValue}, encountered ${tree.orderedSeries()}`)
+}
+
+function testValueAtInorderPos (tree, position, expectedValue) {
+ assert.ok(tree.valueAtInorderPos(position) === expectedValue, `Expected valueAtInorderPos(${position}) to be ${expectedValue}, encountered ${tree.valueAtInorderPos(position)}`)
+}
+
+function testMedian (tree, expectedValue) {
+ assert.ok(tree.median() === expectedValue, `Expected median to be ${expectedValue}, encountered ${tree.median()}`)
+}
+
+test.run()
diff --git a/app/engine/utils/CurveAligner.js b/app/engine/utils/CurveAligner.js
new file mode 100644
index 0000000000..11109a8aba
--- /dev/null
+++ b/app/engine/utils/CurveAligner.js
@@ -0,0 +1,42 @@
+'use strict'
+/*
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
+
+ This keeps an array, for ForceMetrics, and cleans it up
+*/
+
+export function createCurveAligner (minimumValue) {
+ let _lastCompleteCurve = []
+
+ function push (curve) {
+ // First, remove all unneccessary leading zero's
+ while (curve.length > 5 && (curve[0] < minimumValue || curve[1] < minimumValue || curve[2] < minimumValue || curve[3] < minimumValue || curve[4] < minimumValue)) {
+ curve.shift()
+ }
+
+ // Next, we clean up the trailing noise in the tail of the array
+ while (curve.length > 5 && (curve[curve.length - 1] < minimumValue || curve[curve.length - 2] < minimumValue || curve[curve.length - 3] < minimumValue || curve[curve.length - 4] < minimumValue || curve[curve.length - 5] < minimumValue)) {
+ curve.pop()
+ }
+ _lastCompleteCurve = Array.from(curve)
+ }
+
+ function lastCompleteCurve () {
+ if (_lastCompleteCurve.length > 0) {
+ return _lastCompleteCurve
+ } else {
+ return []
+ }
+ }
+
+ function reset () {
+ _lastCompleteCurve = null
+ _lastCompleteCurve = []
+ }
+
+ return {
+ push,
+ lastCompleteCurve,
+ reset
+ }
+}
diff --git a/app/engine/utils/FullTSLinearSeries.js b/app/engine/utils/FullTSLinearSeries.js
new file mode 100644
index 0000000000..3cef184821
--- /dev/null
+++ b/app/engine/utils/FullTSLinearSeries.js
@@ -0,0 +1,217 @@
+'use strict'
+/*
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
+
+ The TSLinearSeries is a datatype that represents a Linear Series. It allows
+ values to be retrieved (like a FiFo buffer, or Queue) but it also includes
+ a Theil-Sen estimator Linear Regressor to determine the slope of this timeseries.
+
+ At creation its length is determined. After it is filled, the oldest will be pushed
+ out of the queue) automatically. This is a property of the Series object
+
+ A key constraint is to prevent heavy calculations at the end (due to large
+ array based curve fitting), which might happen on a Pi zero
+
+ In order to prevent unneccessary calculations, this implementation uses lazy evaluation,
+ so it will calculate the intercept and goodnessOfFit only when needed, as many uses only
+ (first) need the slope.
+
+ This implementation uses concepts that are described here:
+ https://en.wikipedia.org/wiki/Theil%E2%80%93Sen_estimator
+
+ The array is ordered such that x[0] is the oldest, and x[x.length-1] is the youngest
+*/
+
+import { createSeries } from './Series.js'
+import { createLabelledBinarySearchTree } from './BinarySearchTree.js'
+
+import loglevel from 'loglevel'
+const log = loglevel.getLogger('RowingEngine')
+
+export function createTSLinearSeries (maxSeriesLength = 0) {
+ const X = createSeries(maxSeriesLength)
+ const Y = createSeries(maxSeriesLength)
+ const A = createLabelledBinarySearchTree()
+
+ let _A = 0
+ let _B = 0
+ let _goodnessOfFit = 0
+
+ function push (x, y) {
+ // Invariant: A contains all a's (as in the general formula y = a * x + b)
+ // Where the a's are labeled in the Binary Search Tree with their xi when they BEGIN in the point (xi, yi)
+ if (x === undefined || isNaN(x) || y === undefined || isNaN(y)) { return }
+
+ if (maxSeriesLength > 0 && X.length() >= maxSeriesLength) {
+ // The maximum of the array has been reached, so when pushing the x,y the array gets shifted,
+ // thus we have to remove the a's belonging to the current position X0 as well before this value is trashed
+ A.remove(X.get(0))
+ }
+
+ X.push(x)
+ Y.push(y)
+
+ // Calculate all the slopes of the newly added point
+ if (X.length() > 1) {
+ // There are at least two points in the X and Y arrays, so let's add the new datapoint
+ let i = 0
+ while (i < X.length() - 1) {
+ // Calculate the slope with all preceeding datapoints and X.length() - 1'th datapoint (as the array starts at zero)
+ A.push(X.get(i), calculateSlope(i, X.length() - 1))
+ i++
+ }
+ }
+
+ // Calculate the median of the slopes
+ if (X.length() > 1) {
+ _A = A.median()
+ } else {
+ _A = 0
+ }
+
+ // Invalidate the previously calculated intercept and goodnessOfFit. We'll only calculate them if we need them
+ _B = null
+ _goodnessOfFit = null
+ }
+
+ function slope () {
+ return _A
+ }
+
+ function intercept () {
+ calculateIntercept()
+ return _B
+ }
+
+ function coefficientA () {
+ // For testing purposses only!
+ return _A
+ }
+
+ function coefficientB () {
+ // For testing purposses only!
+ calculateIntercept()
+ return _B
+ }
+
+ function length () {
+ return X.length()
+ }
+
+ function goodnessOfFit () {
+ // This function returns the R^2 as a goodness of fit indicator
+ // It will automatically recalculate the _goodnessOfFit when it isn't defined
+ // This lazy approach is intended to prevent unneccesary calculations
+ let i = 0
+ let sse = 0
+ let sst = 0
+ if (_goodnessOfFit === null) {
+ if (X.length() >= 2) {
+ while (i < X.length()) {
+ sse += Math.pow((Y.get(i) - projectX(X.get(i))), 2)
+ sst += Math.pow((Y.get(i) - Y.average()), 2)
+ i++
+ }
+ switch (true) {
+ case (sse === 0):
+ _goodnessOfFit = 1
+ break
+ case (sse > sst):
+ // This is a pretty bad fit as the error is bigger than just using the line for the average y as intercept
+ _goodnessOfFit = 0
+ break
+ case (sst !== 0):
+ _goodnessOfFit = 1 - (sse / sst)
+ break
+ default:
+ // When SST = 0, R2 isn't defined
+ _goodnessOfFit = 0
+ }
+ } else {
+ _goodnessOfFit = 0
+ }
+ }
+ return _goodnessOfFit
+ }
+
+ function projectX (x) {
+ if (X.length() >= 2) {
+ calculateIntercept()
+ return (_A * x) + _B
+ } else {
+ return 0
+ }
+ }
+
+ function projectY (y) {
+ if (X.length() >= 2 && _A !== 0) {
+ calculateIntercept()
+ return ((y - _B) / _A)
+ } else {
+ log.error('TS Linear Regressor, attempted a Y-projection while slope was zero!')
+ return 0
+ }
+ }
+
+ function calculateSlope (pointOne, pointTwo) {
+ if (pointOne !== pointTwo && X.get(pointOne) !== X.get(pointTwo)) {
+ return ((Y.get(pointTwo) - Y.get(pointOne)) / (X.get(pointTwo) - X.get(pointOne)))
+ } else {
+ log.error('TS Linear Regressor, Division by zero prevented!')
+ return 0
+ }
+ }
+
+ function calculateIntercept () {
+ // Calculate all the intercepts for the newly added point and the newly calculated A, when needed
+ // This function is only called when an intercept is really needed, as this saves a lot of CPU cycles when only a slope suffices
+ const B = createLabelledBinarySearchTree()
+ if (_B === null) {
+ if (X.length() > 1) {
+ // There are at least two points in the X and Y arrays, so let's calculate the intercept
+ let i = 0
+ while (i < X.length()) {
+ // Please note , as we need to recreate the B-tree for each newly added datapoint anyway, the label i isn't relevant
+ B.push(i, (Y.get(i) - (_A * X.get(i))))
+ i++
+ }
+ _B = B.median()
+ } else {
+ _B = 0
+ }
+ }
+ B.reset()
+ }
+
+ function reliable () {
+ return (X.length() >= 2)
+ }
+
+ function reset () {
+ if (X.length() > 0) {
+ // There is something to reset
+ X.reset()
+ Y.reset()
+ A.reset()
+ _A = 0
+ _B = 0
+ _goodnessOfFit = 0
+ }
+ }
+
+ return {
+ push,
+ X,
+ Y,
+ slope,
+ intercept,
+ coefficientA,
+ coefficientB,
+ length,
+ goodnessOfFit,
+ projectX,
+ projectY,
+ reliable,
+ reset
+ }
+}
diff --git a/app/engine/utils/FullTSLinearSeries.test.js b/app/engine/utils/FullTSLinearSeries.test.js
new file mode 100644
index 0000000000..b0c29955c2
--- /dev/null
+++ b/app/engine/utils/FullTSLinearSeries.test.js
@@ -0,0 +1,268 @@
+'use strict'
+/*
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
+*/
+import { test } from 'uvu'
+import * as assert from 'uvu/assert'
+
+import { createTSLinearSeries } from './FullTSLinearSeries.js'
+
+test('Correct behaviour of a series after initialisation', () => {
+ const dataSeries = createTSLinearSeries(3)
+ testLength(dataSeries, 0)
+ testXAtSeriesBegin(dataSeries, 0)
+ testYAtSeriesBegin(dataSeries, 0)
+ testXAtSeriesEnd(dataSeries, 0)
+ testYAtSeriesEnd(dataSeries, 0)
+ testNumberOfXValuesAbove(dataSeries, 0, 0)
+ testNumberOfYValuesAbove(dataSeries, 0, 0)
+ testNumberOfXValuesEqualOrBelow(dataSeries, 0, 0)
+ testNumberOfYValuesEqualOrBelow(dataSeries, 0, 0)
+ testNumberOfXValuesAbove(dataSeries, 10, 0)
+ testNumberOfYValuesAbove(dataSeries, 10, 0)
+ testNumberOfXValuesEqualOrBelow(dataSeries, 10, 0)
+ testNumberOfYValuesEqualOrBelow(dataSeries, 10, 0)
+ testXSum(dataSeries, 0)
+ testYSum(dataSeries, 0)
+ testSlopeEquals(dataSeries, 0)
+ testInterceptEquals(dataSeries, 0)
+ testGoodnessOfFitEquals(dataSeries, 0)
+})
+
+test('Correct behaviour of a series after several puhed values, function y = 3x + 6, noisefree, 1 datapoint', () => {
+ const dataSeries = createTSLinearSeries(3)
+ testLength(dataSeries, 0)
+ dataSeries.push(5, 9)
+ testLength(dataSeries, 1)
+ testXAtSeriesBegin(dataSeries, 5)
+ testYAtSeriesBegin(dataSeries, 9)
+ testXAtSeriesEnd(dataSeries, 5)
+ testYAtSeriesEnd(dataSeries, 9)
+ testNumberOfXValuesAbove(dataSeries, 0, 1)
+ testNumberOfYValuesAbove(dataSeries, 0, 1)
+ testNumberOfXValuesEqualOrBelow(dataSeries, 0, 0)
+ testNumberOfYValuesEqualOrBelow(dataSeries, 0, 0)
+ testNumberOfXValuesAbove(dataSeries, 10, 0)
+ testNumberOfYValuesAbove(dataSeries, 10, 0)
+ testNumberOfXValuesEqualOrBelow(dataSeries, 10, 1)
+ testNumberOfYValuesEqualOrBelow(dataSeries, 10, 1)
+ testXSum(dataSeries, 5)
+ testYSum(dataSeries, 9)
+ testSlopeEquals(dataSeries, 0)
+ testInterceptEquals(dataSeries, 0)
+ testGoodnessOfFitEquals(dataSeries, 0)
+})
+
+test('Correct behaviour of a series after several puhed values, function y = 3x + 6, noisefree, 2 datapoints', () => {
+ const dataSeries = createTSLinearSeries(3)
+ dataSeries.push(5, 9)
+ dataSeries.push(3, 3)
+ testLength(dataSeries, 2)
+ testXAtSeriesBegin(dataSeries, 5)
+ testYAtSeriesBegin(dataSeries, 9)
+ testXAtSeriesEnd(dataSeries, 3)
+ testYAtSeriesEnd(dataSeries, 3)
+ testNumberOfXValuesAbove(dataSeries, 0, 2)
+ testNumberOfYValuesAbove(dataSeries, 0, 2)
+ testNumberOfXValuesEqualOrBelow(dataSeries, 0, 0)
+ testNumberOfYValuesEqualOrBelow(dataSeries, 0, 0)
+ testNumberOfXValuesAbove(dataSeries, 10, 0)
+ testNumberOfYValuesAbove(dataSeries, 10, 0)
+ testNumberOfXValuesEqualOrBelow(dataSeries, 10, 2)
+ testNumberOfYValuesEqualOrBelow(dataSeries, 10, 2)
+ testXSum(dataSeries, 8)
+ testYSum(dataSeries, 12)
+ testSlopeEquals(dataSeries, 3)
+ testInterceptEquals(dataSeries, -6)
+ testGoodnessOfFitEquals(dataSeries, 1)
+})
+
+test('Correct behaviour of a series after several puhed values, function y = 3x + 6, noisefree, 3 datapoints', () => {
+ const dataSeries = createTSLinearSeries(3)
+ dataSeries.push(5, 9)
+ dataSeries.push(3, 3)
+ dataSeries.push(4, 6)
+ testLength(dataSeries, 3)
+ testXAtSeriesBegin(dataSeries, 5)
+ testYAtSeriesBegin(dataSeries, 9)
+ testXAtSeriesEnd(dataSeries, 4)
+ testYAtSeriesEnd(dataSeries, 6)
+ testNumberOfXValuesAbove(dataSeries, 0, 3)
+ testNumberOfYValuesAbove(dataSeries, 0, 3)
+ testNumberOfXValuesEqualOrBelow(dataSeries, 0, 0)
+ testNumberOfYValuesEqualOrBelow(dataSeries, 0, 0)
+ testNumberOfXValuesAbove(dataSeries, 10, 0)
+ testNumberOfYValuesAbove(dataSeries, 10, 0)
+ testNumberOfXValuesEqualOrBelow(dataSeries, 10, 3)
+ testNumberOfYValuesEqualOrBelow(dataSeries, 10, 3)
+ testXSum(dataSeries, 12)
+ testYSum(dataSeries, 18)
+ testSlopeEquals(dataSeries, 3)
+ testInterceptEquals(dataSeries, -6)
+ testGoodnessOfFitEquals(dataSeries, 1)
+})
+
+test('Correct behaviour of a series after several puhed values, function y = 3x + 6, noisefree, 4 datapoints', () => {
+ const dataSeries = createTSLinearSeries(3)
+ dataSeries.push(5, 9)
+ dataSeries.push(3, 3)
+ dataSeries.push(4, 6)
+ dataSeries.push(6, 12)
+ testLength(dataSeries, 3)
+ testXAtSeriesBegin(dataSeries, 3)
+ testYAtSeriesBegin(dataSeries, 3)
+ testXAtSeriesEnd(dataSeries, 6)
+ testYAtSeriesEnd(dataSeries, 12)
+ testNumberOfXValuesAbove(dataSeries, 0, 3)
+ testNumberOfYValuesAbove(dataSeries, 0, 3)
+ testNumberOfXValuesEqualOrBelow(dataSeries, 0, 0)
+ testNumberOfYValuesEqualOrBelow(dataSeries, 0, 0)
+ testNumberOfXValuesAbove(dataSeries, 10, 0)
+ testNumberOfYValuesAbove(dataSeries, 10, 1)
+ testNumberOfXValuesEqualOrBelow(dataSeries, 10, 3)
+ testNumberOfYValuesEqualOrBelow(dataSeries, 10, 2)
+ testXSum(dataSeries, 13)
+ testYSum(dataSeries, 21)
+ testSlopeEquals(dataSeries, 3)
+ testInterceptEquals(dataSeries, -6)
+ testGoodnessOfFitEquals(dataSeries, 1)
+})
+
+test('Correct behaviour of a series after several puhed values, function y = 3x + 6, noisefree, 5 datapoints', () => {
+ const dataSeries = createTSLinearSeries(3)
+ dataSeries.push(5, 9)
+ dataSeries.push(3, 3)
+ dataSeries.push(4, 6)
+ dataSeries.push(6, 12)
+ dataSeries.push(1, -3)
+ testLength(dataSeries, 3)
+ testXAtSeriesBegin(dataSeries, 4)
+ testYAtSeriesBegin(dataSeries, 6)
+ testXAtSeriesEnd(dataSeries, 1)
+ testYAtSeriesEnd(dataSeries, -3)
+ testNumberOfXValuesAbove(dataSeries, 0, 3)
+ testNumberOfYValuesAbove(dataSeries, 0, 2)
+ testNumberOfXValuesEqualOrBelow(dataSeries, 0, 0)
+ testNumberOfYValuesEqualOrBelow(dataSeries, 0, 1)
+ testNumberOfXValuesAbove(dataSeries, 10, 0)
+ testNumberOfYValuesAbove(dataSeries, 10, 1)
+ testNumberOfXValuesEqualOrBelow(dataSeries, 10, 3)
+ testNumberOfYValuesEqualOrBelow(dataSeries, 10, 2)
+ testXSum(dataSeries, 11)
+ testYSum(dataSeries, 15)
+ testSlopeEquals(dataSeries, 3)
+ testInterceptEquals(dataSeries, -6)
+ testGoodnessOfFitEquals(dataSeries, 1)
+})
+
+test('Correct behaviour of a series after several puhed values, function y = 3x + 6, noisefree, 4 datapoints and a reset', () => {
+ const dataSeries = createTSLinearSeries(3)
+ dataSeries.push(5, 9)
+ dataSeries.push(3, 3)
+ dataSeries.push(4, 6)
+ dataSeries.push(6, 12)
+ dataSeries.reset()
+ testLength(dataSeries, 0)
+ testXAtSeriesBegin(dataSeries, 0)
+ testYAtSeriesBegin(dataSeries, 0)
+ testXAtSeriesEnd(dataSeries, 0)
+ testYAtSeriesEnd(dataSeries, 0)
+ testNumberOfXValuesAbove(dataSeries, 0, 0)
+ testNumberOfYValuesAbove(dataSeries, 0, 0)
+ testNumberOfXValuesEqualOrBelow(dataSeries, 0, 0)
+ testNumberOfYValuesEqualOrBelow(dataSeries, 0, 0)
+ testNumberOfXValuesAbove(dataSeries, 10, 0)
+ testNumberOfYValuesAbove(dataSeries, 10, 0)
+ testNumberOfXValuesEqualOrBelow(dataSeries, 10, 0)
+ testNumberOfYValuesEqualOrBelow(dataSeries, 10, 0)
+ testXSum(dataSeries, 0)
+ testYSum(dataSeries, 0)
+ testSlopeEquals(dataSeries, 0)
+ testInterceptEquals(dataSeries, 0)
+ testGoodnessOfFitEquals(dataSeries, 0)
+})
+
+test('Series with 5 elements, with 2 noisy datapoints', () => {
+ const dataSeries = createTSLinearSeries(5)
+ dataSeries.push(5, 9)
+ dataSeries.push(3, 2)
+ dataSeries.push(4, 7)
+ dataSeries.push(6, 12)
+ dataSeries.push(1, -3)
+ testSlopeBetween(dataSeries, 2.9, 3.1)
+ testInterceptBetween(dataSeries, -6.3, -5.8)
+ testGoodnessOfFitBetween(dataSeries, 0.9, 1.0)
+})
+
+function testLength (series, expectedValue) {
+ assert.ok(series.length() === expectedValue, `Expected length should be ${expectedValue}, encountered a ${series.length()}`)
+}
+
+function testXAtSeriesBegin (series, expectedValue) {
+ assert.ok(series.X.atSeriesBegin() === expectedValue, `Expected X.atSeriesBegin to be ${expectedValue}, encountered a ${series.X.atSeriesBegin()}`)
+}
+
+function testYAtSeriesBegin (series, expectedValue) {
+ assert.ok(series.Y.atSeriesBegin() === expectedValue, `Expected Y.atSeriesBegin to be ${expectedValue}, encountered a ${series.Y.atSeriesBegin()}`)
+}
+
+function testXAtSeriesEnd (series, expectedValue) {
+ assert.ok(series.X.atSeriesEnd() === expectedValue, `Expected X.atSeriesEnd to be ${expectedValue}, encountered a ${series.X.atSeriesEnd()}`)
+}
+
+function testYAtSeriesEnd (series, expectedValue) {
+ assert.ok(series.Y.atSeriesEnd() === expectedValue, `Expected Y.atSeriesEnd to be ${expectedValue}, encountered a ${series.Y.atSeriesEnd()}`)
+}
+
+function testNumberOfXValuesAbove (series, cutoff, expectedValue) {
+ assert.ok(series.X.numberOfValuesAbove(cutoff) === expectedValue, `Expected X.numberOfValuesAbove(${cutoff}) to be ${expectedValue}, encountered a ${series.X.numberOfValuesAbove(cutoff)}`)
+}
+
+function testNumberOfYValuesAbove (series, cutoff, expectedValue) {
+ assert.ok(series.Y.numberOfValuesAbove(cutoff) === expectedValue, `Expected Y.numberOfValuesAbove(${cutoff}) to be ${expectedValue}, encountered a ${series.Y.numberOfValuesAbove(cutoff)}`)
+}
+
+function testNumberOfXValuesEqualOrBelow (series, cutoff, expectedValue) {
+ assert.ok(series.X.numberOfValuesEqualOrBelow(cutoff) === expectedValue, `Expected X.numberOfValuesEqualOrBelow(${cutoff}) to be ${expectedValue}, encountered a ${series.X.numberOfValuesEqualOrBelow(cutoff)}`)
+}
+
+function testNumberOfYValuesEqualOrBelow (series, cutoff, expectedValue) {
+ assert.ok(series.Y.numberOfValuesEqualOrBelow(cutoff) === expectedValue, `Expected Y.numberOfValuesEqualOrBelow(${cutoff}) to be ${expectedValue}, encountered a ${series.Y.numberOfValuesEqualOrBelow(cutoff)}`)
+}
+
+function testXSum (series, expectedValue) {
+ assert.ok(series.X.sum() === expectedValue, `Expected X.sum to be ${expectedValue}, encountered a ${series.X.sum()}`)
+}
+
+function testYSum (series, expectedValue) {
+ assert.ok(series.Y.sum() === expectedValue, `Expected Y.sum to be ${expectedValue}, encountered a ${series.Y.sum()}`)
+}
+
+function testSlopeEquals (series, expectedValue) {
+ assert.ok(series.slope() === expectedValue, `Expected slope to be ${expectedValue}, encountered a ${series.slope()}`)
+}
+
+function testSlopeBetween (series, expectedValueAbove, expectedValueBelow) {
+ assert.ok(series.slope() > expectedValueAbove, `Expected slope to be above ${expectedValueAbove}, encountered a ${series.slope()}`)
+ assert.ok(series.slope() < expectedValueBelow, `Expected slope to be below ${expectedValueBelow}, encountered a ${series.slope()}`)
+}
+
+function testInterceptEquals (series, expectedValue) {
+ assert.ok(series.intercept() === expectedValue, `Expected intercept to be ${expectedValue}, encountered ${series.intercept()}`)
+}
+
+function testInterceptBetween (series, expectedValueAbove, expectedValueBelow) {
+ assert.ok(series.intercept() > expectedValueAbove, `Expected intercept to be above ${expectedValueAbove}, encountered ${series.intercept()}`)
+ assert.ok(series.intercept() < expectedValueBelow, `Expected intercept to be below ${expectedValueBelow}, encountered ${series.intercept()}`)
+}
+
+function testGoodnessOfFitEquals (series, expectedValue) {
+ assert.ok(series.goodnessOfFit() === expectedValue, `Expected goodnessOfFit to be ${expectedValue}, encountered ${series.goodnessOfFit()}`)
+}
+
+function testGoodnessOfFitBetween (series, expectedValueAbove, expectedValueBelow) {
+ assert.ok(series.goodnessOfFit() > expectedValueAbove, `Expected goodnessOfFit to be above ${expectedValueAbove}, encountered ${series.goodnessOfFit()}`)
+ assert.ok(series.goodnessOfFit() < expectedValueBelow, `Expected goodnessOfFit to be below ${expectedValueBelow}, encountered ${series.goodnessOfFit()}`)
+}
+
+test.run()
diff --git a/app/engine/utils/FullTSQuadraticSeries.js b/app/engine/utils/FullTSQuadraticSeries.js
new file mode 100644
index 0000000000..138a899715
--- /dev/null
+++ b/app/engine/utils/FullTSQuadraticSeries.js
@@ -0,0 +1,272 @@
+'use strict'
+/*
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
+
+ The FullTSQuadraticSeries is a datatype that represents a Quadratic Series. It allows
+ values to be retrieved (like a FiFo buffer, or Queue) but it also includes
+ a Theil-Sen Quadratic Regressor to determine the coefficients of this dataseries.
+
+ At creation its length is determined. After it is filled, the oldest will be pushed
+ out of the queue) automatically.
+
+ A key constraint is to prevent heavy calculations at the end of a stroke (due to large
+ array based curve fitting), which might be performed on a Pi zero or Zero 2W
+
+ In order to prevent unneccessary calculations, this implementation uses lazy evaluation,
+ so it will calculate the B, C and goodnessOfFit only when needed, as many uses only
+ (first) need the first and second direvative.
+
+ The Theil-Senn implementation uses concepts that are described here:
+ https://stats.stackexchange.com/questions/317777/theil-sen-estimator-for-polynomial,
+
+ The determination of the coefficients is based on the Lagrange interpolation, which is descirbed here:
+ https://www.quora.com/How-do-I-find-a-quadratic-equation-from-points/answer/Robert-Paxson,
+ https://www.physicsforums.com/threads/quadratic-equation-from-3-points.404174/
+*/
+
+import { createSeries } from './Series.js'
+import { createTSLinearSeries } from './FullTSLinearSeries.js'
+import { createLabelledBinarySearchTree } from './BinarySearchTree.js'
+
+import loglevel from 'loglevel'
+const log = loglevel.getLogger('RowingEngine')
+
+export function createTSQuadraticSeries (maxSeriesLength = 0) {
+ const X = createSeries(maxSeriesLength)
+ const Y = createSeries(maxSeriesLength)
+ const A = createLabelledBinarySearchTree()
+ const linearResidu = createTSLinearSeries(maxSeriesLength)
+ let _A = 0
+ let _B = 0
+ let _C = 0
+ let _goodnessOfFit = 0
+
+ function push (x, y) {
+ // Invariant: A contains all a's (as in the general formula y = a * x^2 + b * x + c)
+ // Where the a's are labeled in the Binary Search Tree with their Xi when they BEGIN in the point (Xi, Yi)
+ if (x === undefined || isNaN(x) || y === undefined || isNaN(y)) { return }
+
+ if (maxSeriesLength > 0 && X.length() >= maxSeriesLength) {
+ // The maximum of the array has been reached, so when pushing the new datapoint (x,y), the array will get shifted,
+ // thus we have to remove all the A's that start with the old position X0 BEFORE this value gets thrown away
+ A.remove(X.get(0))
+ }
+
+ X.push(x)
+ Y.push(y)
+
+ // Calculate the coefficient a for the new interval by adding the newly added datapoint
+ let i = 0
+ let j = 0
+
+ switch (true) {
+ case (X.length() >= 3):
+ // There are now at least three datapoints in the X and Y arrays, so let's calculate the A portion belonging for the new datapoint via Quadratic Theil-Sen regression
+ // First we calculate the A for the formula
+ while (i < X.length() - 2) {
+ j = i + 1
+ while (j < X.length() - 1) {
+ A.push(X.get(i), calculateA(i, j, X.length() - 1))
+ j++
+ }
+ i++
+ }
+ _A = A.median()
+
+ // We invalidate the linearResidu, B, C, and goodnessOfFit, as this will trigger a recalculate when they are needed
+ linearResidu.reset()
+ _B = null
+ _C = null
+ _goodnessOfFit = null
+ break
+ default:
+ _A = 0
+ _B = 0
+ _C = 0
+ _goodnessOfFit = 0
+ }
+ }
+
+ function firstDerivativeAtPosition (position) {
+ if (X.length() >= 3 && position < X.length()) {
+ calculateB()
+ return ((_A * 2 * X.get(position)) + _B)
+ } else {
+ return 0
+ }
+ }
+
+ function secondDerivativeAtPosition (position) {
+ if (X.length() >= 3 && position < X.length()) {
+ return (_A * 2)
+ } else {
+ return 0
+ }
+ }
+
+ function slope (x) {
+ if (X.length() >= 3) {
+ calculateB()
+ return ((_A * 2 * x) + _B)
+ } else {
+ return 0
+ }
+ }
+
+ function coefficientA () {
+ // For testing purposses only!
+ return _A
+ }
+
+ function coefficientB () {
+ // For testing purposses only!
+ calculateB()
+ return _B
+ }
+
+ function coefficientC () {
+ // For testing purposses only!
+ calculateB()
+ calculateC()
+ return _C
+ }
+
+ function intercept () {
+ calculateB()
+ calculateC()
+ return _C
+ }
+
+ function length () {
+ return X.length()
+ }
+
+ function goodnessOfFit () {
+ // This function returns the R^2 as a goodness of fit indicator
+ let i = 0
+ let sse = 0
+ let sst = 0
+ if (_goodnessOfFit === null) {
+ if (X.length() >= 3) {
+ while (i < X.length()) {
+ sse += Math.pow((Y.get(i) - projectX(X.get(i))), 2)
+ sst += Math.pow((Y.get(i) - Y.average()), 2)
+ i++
+ }
+ switch (true) {
+ case (sse === 0):
+ _goodnessOfFit = 1
+ break
+ case (sse > sst):
+ // This is a pretty bad fit as the error is bigger than just using the line for the average y as intercept
+ _goodnessOfFit = 0
+ break
+ case (sst !== 0):
+ _goodnessOfFit = 1 - (sse / sst)
+ break
+ default:
+ // When SST = 0, R2 isn't defined
+ _goodnessOfFit = 0
+ }
+ } else {
+ _goodnessOfFit = 0
+ }
+ }
+ return _goodnessOfFit
+ }
+
+ function projectX (x) {
+ if (X.length() >= 3) {
+ calculateB()
+ calculateC()
+ return ((_A * x * x) + (_B * x) + _C)
+ } else {
+ return 0
+ }
+ }
+
+ function calculateA (pointOne, pointTwo, pointThree) {
+ let result = 0
+ if (X.get(pointOne) !== X.get(pointTwo) && X.get(pointOne) !== X.get(pointThree) && X.get(pointTwo) !== X.get(pointThree)) {
+ // For the underlying math, see https://www.quora.com/How-do-I-find-a-quadratic-equation-from-points/answer/Robert-Paxson
+ result = (X.get(pointOne) * (Y.get(pointThree) - Y.get(pointTwo)) + Y.get(pointOne) * (X.get(pointTwo) - X.get(pointThree)) + (X.get(pointThree) * Y.get(pointTwo) - X.get(pointTwo) * Y.get(pointThree))) / ((X.get(pointOne) - X.get(pointTwo)) * (X.get(pointOne) - X.get(pointThree)) * (X.get(pointTwo) - X.get(pointThree)))
+ return result
+ } else {
+ log.error('TS Quadratic Regressor, Division by zero prevented in CalculateA!')
+ return 0
+ }
+ }
+
+ function calculateB () {
+ // Calculate all the linear slope for the newly added point and the newly calculated A
+ // This function is only called when a linear slope is really needed, as this saves a lot of CPU cycles when only a slope suffices
+ if (_B === null) {
+ if (X.length() >= 3) {
+ fillLinearResidu()
+ _B = linearResidu.slope()
+ } else {
+ _B = 0
+ }
+ }
+ }
+
+ function calculateC () {
+ // Calculate all the intercept for the newly added point and the newly calculated A
+ // This function is only called when a linear intercept is really needed, as this saves a lot of CPU cycles when only a slope suffices
+ if (_C === null) {
+ if (X.length() >= 3) {
+ fillLinearResidu()
+ _C = linearResidu.intercept()
+ } else {
+ _C = 0
+ }
+ }
+ }
+
+ function fillLinearResidu () {
+ // To calculate the B and C via Linear regression over the residu, we need to fill it if empty
+ if (linearResidu.length() === 0) {
+ let i = 0
+ while (i < X.length()) {
+ linearResidu.push(X.get(i), Y.get(i) - (_A * Math.pow(X.get(i), 2)))
+ i++
+ }
+ }
+ }
+
+ function reliable () {
+ return (X.length() >= 3)
+ }
+
+ function reset () {
+ if (X.length() > 0) {
+ // There is something to reset
+ X.reset()
+ Y.reset()
+ A.reset()
+ linearResidu.reset()
+ _A = 0
+ _B = 0
+ _C = 0
+ _goodnessOfFit = 0
+ }
+ }
+
+ return {
+ push,
+ X,
+ Y,
+ firstDerivativeAtPosition,
+ secondDerivativeAtPosition,
+ slope,
+ coefficientA,
+ coefficientB,
+ coefficientC,
+ intercept,
+ length,
+ goodnessOfFit,
+ projectX,
+ reliable,
+ reset
+ }
+}
diff --git a/app/engine/utils/FullTSQuadraticSeries.test.js b/app/engine/utils/FullTSQuadraticSeries.test.js
new file mode 100644
index 0000000000..211dc1450c
--- /dev/null
+++ b/app/engine/utils/FullTSQuadraticSeries.test.js
@@ -0,0 +1,626 @@
+'use strict'
+/*
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
+*/
+/**
+ * This tests the Quadratic Theil-Senn Regression algorithm. As regression is an estimation and methods have biasses,
+ * we need to accept some slack with respect to real-life examples
+ */
+import { test } from 'uvu'
+import * as assert from 'uvu/assert'
+
+import { createTSQuadraticSeries } from './FullTSQuadraticSeries.js'
+
+test('Quadratic Approximation startup behaviour', () => {
+ const dataSeries = createTSQuadraticSeries(10)
+ testCoefficientA(dataSeries, 0)
+ testCoefficientB(dataSeries, 0)
+ testCoefficientC(dataSeries, 0)
+ dataSeries.push(-1, 2)
+ testCoefficientA(dataSeries, 0)
+ testCoefficientB(dataSeries, 0)
+ testCoefficientC(dataSeries, 0)
+ dataSeries.push(0, 2)
+ testCoefficientA(dataSeries, 0)
+ testCoefficientB(dataSeries, 0)
+ testCoefficientC(dataSeries, 0)
+ dataSeries.push(1, 6)
+ testCoefficientA(dataSeries, 2)
+ testCoefficientB(dataSeries, 2)
+ testCoefficientC(dataSeries, 2)
+})
+
+test('Quadratic Approximation on a perfect noisefree function y = 2 * Math.pow(x, 2) + 2 * x + 2, 21 datapoints', () => {
+ // Data based on 2 x^2 + 2 x + 2
+ const dataSeries = createTSQuadraticSeries(21)
+ dataSeries.push(-10, 182)
+ dataSeries.push(-9, 146)
+ dataSeries.push(-8, 114)
+ dataSeries.push(-7, 86)
+ dataSeries.push(-6, 62)
+ dataSeries.push(-5, 42)
+ dataSeries.push(-4, 26)
+ dataSeries.push(-3, 14) // Pi ;)
+ dataSeries.push(-2, 6)
+ dataSeries.push(-1, 2)
+ dataSeries.push(0, 2)
+ dataSeries.push(1, 6)
+ dataSeries.push(2, 14)
+ dataSeries.push(3, 26)
+ dataSeries.push(4, 42)
+ dataSeries.push(5, 62)
+ dataSeries.push(6, 86)
+ dataSeries.push(7, 114)
+ dataSeries.push(8, 146)
+ dataSeries.push(9, 182)
+ dataSeries.push(10, 222)
+ testCoefficientA(dataSeries, 2)
+ testCoefficientB(dataSeries, 2)
+ testCoefficientC(dataSeries, 2)
+ testGoodnessOfFitEquals(dataSeries, 1)
+})
+
+test('Quadratic Approximation on a perfect noisefree function y = 2 * Math.pow(x, 2) + 2 * x + 2, with 10 datapoints and some shifting in the series', () => {
+ // Data based on 2 x^2 + 2 x + 2, split the dataset in two to see its behaviour when it is around the Vertex
+ const dataSeries = createTSQuadraticSeries(10)
+ dataSeries.push(-10, 182)
+ dataSeries.push(-9, 146)
+ dataSeries.push(-8, 114)
+ dataSeries.push(-7, 86)
+ dataSeries.push(-6, 62)
+ dataSeries.push(-5, 42)
+ dataSeries.push(-4, 26)
+ dataSeries.push(-3, 14) // Pi ;)
+ dataSeries.push(-2, 6)
+ dataSeries.push(-1, 2)
+ dataSeries.push(0, 2)
+ testCoefficientA(dataSeries, 2)
+ testCoefficientB(dataSeries, 2)
+ testCoefficientC(dataSeries, 2)
+ testGoodnessOfFitEquals(dataSeries, 1)
+ dataSeries.push(1, 6)
+ dataSeries.push(2, 14)
+ dataSeries.push(3, 26)
+ dataSeries.push(4, 42)
+ dataSeries.push(5, 62)
+ dataSeries.push(6, 86)
+ dataSeries.push(7, 114)
+ dataSeries.push(8, 146)
+ dataSeries.push(9, 182)
+ dataSeries.push(10, 222)
+ testCoefficientA(dataSeries, 2)
+ testCoefficientB(dataSeries, 2)
+ testCoefficientC(dataSeries, 2)
+ testGoodnessOfFitEquals(dataSeries, 1)
+})
+
+test('Quadratic Approximation on function y = 4 * Math.pow(x, 2) + 4 * x + 4, noisefree', () => {
+ // Data based on 4 x^2 + 4 x + 4
+ const dataSeries = createTSQuadraticSeries(11)
+ dataSeries.push(-11, 444)
+ dataSeries.push(-10, 364)
+ dataSeries.push(-9, 292)
+ testCoefficientA(dataSeries, 4)
+ testCoefficientB(dataSeries, 4)
+ testCoefficientC(dataSeries, 4)
+ testGoodnessOfFitEquals(dataSeries, 1)
+ dataSeries.push(-8, 228)
+ testCoefficientA(dataSeries, 4)
+ testCoefficientB(dataSeries, 4)
+ testCoefficientC(dataSeries, 4)
+ testGoodnessOfFitEquals(dataSeries, 1)
+ dataSeries.push(-7, 172)
+ testCoefficientA(dataSeries, 4)
+ testCoefficientB(dataSeries, 4)
+ testCoefficientC(dataSeries, 4)
+ testGoodnessOfFitEquals(dataSeries, 1)
+ dataSeries.push(-6, 124)
+ testCoefficientA(dataSeries, 4)
+ testCoefficientB(dataSeries, 4)
+ testCoefficientC(dataSeries, 4)
+ testGoodnessOfFitEquals(dataSeries, 1)
+ dataSeries.push(-5, 84)
+ testCoefficientA(dataSeries, 4)
+ testCoefficientB(dataSeries, 4)
+ testCoefficientC(dataSeries, 4)
+ testGoodnessOfFitEquals(dataSeries, 1)
+ dataSeries.push(-4, 52)
+ testCoefficientA(dataSeries, 4)
+ testCoefficientB(dataSeries, 4)
+ testCoefficientC(dataSeries, 4)
+ testGoodnessOfFitEquals(dataSeries, 1)
+ dataSeries.push(-3, 28)
+ testCoefficientA(dataSeries, 4)
+ testCoefficientB(dataSeries, 4)
+ testCoefficientC(dataSeries, 4)
+ testGoodnessOfFitEquals(dataSeries, 1)
+ dataSeries.push(-2, 12)
+ testCoefficientA(dataSeries, 4)
+ testCoefficientB(dataSeries, 4)
+ testCoefficientC(dataSeries, 4)
+ testGoodnessOfFitEquals(dataSeries, 1)
+ dataSeries.push(-1, 4)
+ testCoefficientA(dataSeries, 4)
+ testCoefficientB(dataSeries, 4)
+ testCoefficientC(dataSeries, 4)
+ testGoodnessOfFitEquals(dataSeries, 1)
+ dataSeries.push(0, 4)
+ testCoefficientA(dataSeries, 4)
+ testCoefficientB(dataSeries, 4)
+ testCoefficientC(dataSeries, 4)
+ testGoodnessOfFitEquals(dataSeries, 1)
+ dataSeries.push(1, 12)
+ testCoefficientA(dataSeries, 4)
+ testCoefficientB(dataSeries, 4)
+ testCoefficientC(dataSeries, 4)
+ testGoodnessOfFitEquals(dataSeries, 1)
+ dataSeries.push(2, 28)
+ testCoefficientA(dataSeries, 4)
+ testCoefficientB(dataSeries, 4)
+ testCoefficientC(dataSeries, 4)
+ testGoodnessOfFitEquals(dataSeries, 1)
+ dataSeries.push(3, 52)
+ testCoefficientA(dataSeries, 4)
+ testCoefficientB(dataSeries, 4)
+ testCoefficientC(dataSeries, 4)
+ testGoodnessOfFitEquals(dataSeries, 1)
+ dataSeries.push(4, 84)
+ testCoefficientA(dataSeries, 4)
+ testCoefficientB(dataSeries, 4)
+ testCoefficientC(dataSeries, 4)
+ testGoodnessOfFitEquals(dataSeries, 1)
+ dataSeries.push(5, 124)
+ testCoefficientA(dataSeries, 4)
+ testCoefficientB(dataSeries, 4)
+ testCoefficientC(dataSeries, 4)
+ testGoodnessOfFitEquals(dataSeries, 1)
+ dataSeries.push(6, 172)
+ testCoefficientA(dataSeries, 4)
+ testCoefficientB(dataSeries, 4)
+ testCoefficientC(dataSeries, 4)
+ testGoodnessOfFitEquals(dataSeries, 1)
+ dataSeries.push(7, 228)
+ testCoefficientA(dataSeries, 4)
+ testCoefficientB(dataSeries, 4)
+ testCoefficientC(dataSeries, 4)
+ testGoodnessOfFitEquals(dataSeries, 1)
+ dataSeries.push(8, 292)
+ testCoefficientA(dataSeries, 4)
+ testCoefficientB(dataSeries, 4)
+ testCoefficientC(dataSeries, 4)
+ testGoodnessOfFitEquals(dataSeries, 1)
+ dataSeries.push(9, 364)
+ testCoefficientA(dataSeries, 4)
+ testCoefficientB(dataSeries, 4)
+ testCoefficientC(dataSeries, 4)
+ testGoodnessOfFitEquals(dataSeries, 1)
+ dataSeries.push(10, 444)
+ testCoefficientA(dataSeries, 4)
+ testCoefficientB(dataSeries, 4)
+ testCoefficientC(dataSeries, 4)
+ testGoodnessOfFitEquals(dataSeries, 1)
+})
+
+test('Quadratic Approximation on function y = 4 * Math.pow(x, 2) + 4 * x + 4, with some noise (+/- 1)', () => {
+ // Data based on 4 x^2 + 4 x + 4
+ const dataSeries = createTSQuadraticSeries(11)
+ dataSeries.push(-11, 443)
+ dataSeries.push(-10, 365)
+ dataSeries.push(-9, 291)
+ testCoefficientA(dataSeries, 2)
+ testCoefficientB(dataSeries, -36)
+ testCoefficientC(dataSeries, -195)
+ testGoodnessOfFitEquals(dataSeries, 1)
+ dataSeries.push(-8, 229)
+ testCoefficientA(dataSeries, 4)
+ testCoefficientB(dataSeries, 4.333333333333334)
+ testCoefficientC(dataSeries, 7.166666666666671)
+ testGoodnessOfFitEquals(dataSeries, 0.9998746217034155)
+ dataSeries.push(-7, 171)
+ testCoefficientA(dataSeries, 3.3333333333333335)
+ testCoefficientB(dataSeries, -7.999999999999991)
+ testCoefficientC(dataSeries, -48.33333333333328)
+ testGoodnessOfFitEquals(dataSeries, 0.9998468647471163)
+ dataSeries.push(-6, 125)
+ testCoefficientA(dataSeries, 4)
+ testCoefficientB(dataSeries, 4)
+ testCoefficientC(dataSeries, 4)
+ testGoodnessOfFitEquals(dataSeries, 0.9999165499911914)
+ dataSeries.push(-5, 83)
+ testCoefficientA(dataSeries, 3.8666666666666667)
+ testCoefficientB(dataSeries, 1.8666666666666671)
+ testCoefficientC(dataSeries, -4.333333333333336) // This is quite acceptable as ORM ignores the C
+ testGoodnessOfFitEquals(dataSeries, 0.9999366117119067)
+ dataSeries.push(-4, 53)
+ testCoefficientA(dataSeries, 4)
+ testCoefficientB(dataSeries, 4)
+ testCoefficientC(dataSeries, 4)
+ testGoodnessOfFitEquals(dataSeries, 0.9999402806808002)
+ dataSeries.push(-3, 27)
+ testCoefficientA(dataSeries, 4)
+ testCoefficientB(dataSeries, 4)
+ testCoefficientC(dataSeries, 3)
+ testGoodnessOfFitEquals(dataSeries, 0.9999042318865254)
+ dataSeries.push(-2, 13)
+ testCoefficientA(dataSeries, 4)
+ testCoefficientB(dataSeries, 4)
+ testCoefficientC(dataSeries, 4)
+ testGoodnessOfFitEquals(dataSeries, 0.9999495097395712)
+ dataSeries.push(-1, 3)
+ testCoefficientA(dataSeries, 4)
+ testCoefficientB(dataSeries, 4)
+ testCoefficientC(dataSeries, 3)
+ testGoodnessOfFitEquals(dataSeries, 0.9999117149452151)
+ dataSeries.push(0, 5)
+ testCoefficientA(dataSeries, 4)
+ testCoefficientB(dataSeries, 4)
+ testCoefficientC(dataSeries, 5)
+ testGoodnessOfFitEquals(dataSeries, 0.9998721709098177)
+ dataSeries.push(1, 11)
+ testCoefficientA(dataSeries, 4)
+ testCoefficientB(dataSeries, 4)
+ testCoefficientC(dataSeries, 3)
+ testGoodnessOfFitEquals(dataSeries, 0.9997996371611135)
+ dataSeries.push(2, 29)
+ testCoefficientA(dataSeries, 4)
+ testCoefficientB(dataSeries, 4)
+ testCoefficientC(dataSeries, 5)
+ testGoodnessOfFitEquals(dataSeries, 0.9996545703483187)
+ dataSeries.push(3, 51)
+ testCoefficientA(dataSeries, 4)
+ testCoefficientB(dataSeries, 4)
+ testCoefficientC(dataSeries, 3)
+ testGoodnessOfFitEquals(dataSeries, 0.9993201651380683)
+ dataSeries.push(4, 85)
+ testCoefficientA(dataSeries, 4)
+ testCoefficientB(dataSeries, 4)
+ testCoefficientC(dataSeries, 5)
+ testGoodnessOfFitEquals(dataSeries, 0.9987227718173796)
+ dataSeries.push(5, 123)
+ testCoefficientA(dataSeries, 4)
+ testCoefficientB(dataSeries, 4)
+ testCoefficientC(dataSeries, 3)
+ testGoodnessOfFitEquals(dataSeries, 0.9986961263098004)
+ dataSeries.push(6, 173)
+ testCoefficientA(dataSeries, 4)
+ testCoefficientB(dataSeries, 4)
+ testCoefficientC(dataSeries, 5)
+ testGoodnessOfFitEquals(dataSeries, 0.9993274803746546)
+ dataSeries.push(7, 227)
+ testCoefficientA(dataSeries, 4)
+ testCoefficientB(dataSeries, 4)
+ testCoefficientC(dataSeries, 3)
+ testGoodnessOfFitEquals(dataSeries, 0.9996526505917571)
+ dataSeries.push(8, 293)
+ testCoefficientA(dataSeries, 4)
+ testCoefficientB(dataSeries, 4)
+ testCoefficientC(dataSeries, 5)
+ testGoodnessOfFitEquals(dataSeries, 0.9998002774328024)
+ dataSeries.push(9, 363)
+ testCoefficientA(dataSeries, 4)
+ testCoefficientB(dataSeries, 4)
+ testCoefficientC(dataSeries, 3) // We get a 3 instead of 4, which is quite acceptable (especially since ORM ignores the C)
+ testGoodnessOfFitEquals(dataSeries, 0.9998719089295779)
+ dataSeries.push(10, 444)
+ testCoefficientA(dataSeries, 4)
+ testCoefficientB(dataSeries, 4)
+ testCoefficientC(dataSeries, 4)
+ testGoodnessOfFitEquals(dataSeries, 0.9999558104799866)
+})
+
+test('Quadratic Approximation on function y = 4 * Math.pow(x, 2) + 4 * x + 4, with some noise (+/- 1) and spikes (+/- 9)', () => {
+ // Data based on 4 x^2 + 4 x + 4
+ const dataSeries = createTSQuadraticSeries(11)
+ dataSeries.push(-11, 443)
+ dataSeries.push(-10, 365)
+ dataSeries.push(-9, 291)
+ dataSeries.push(-8, 229)
+ dataSeries.push(-7, 171)
+ dataSeries.push(-6, 125)
+ dataSeries.push(-5, 83)
+ dataSeries.push(-4, 53)
+ testCoefficientA(dataSeries, 4)
+ testCoefficientB(dataSeries, 4)
+ testCoefficientC(dataSeries, 4)
+ testGoodnessOfFitEquals(dataSeries, 0.9999402806808002)
+ dataSeries.push(-3, 37) // FIRST SPIKE +9
+ testCoefficientA(dataSeries, 4.215277777777778)
+ testCoefficientB(dataSeries, 7.694940476190471)
+ testCoefficientC(dataSeries, 18.816964285714235)
+ testGoodnessOfFitEquals(dataSeries, 0.9997971509015441)
+ dataSeries.push(-2, 3) // SECOND SPIKE -9
+ testCoefficientA(dataSeries, 3.9714285714285715)
+ testCoefficientB(dataSeries, 3.6000000000000036) // Coefficient B seems to take a hit anyway
+ testCoefficientC(dataSeries, 2.842857142857163) // We get a 2.8 instead of 4, which is quite acceptable (especially since ORM ignores the C)
+ testGoodnessOfFitEquals(dataSeries, 0.9991656951087963)
+ dataSeries.push(-1, 3)
+ testCoefficientA(dataSeries, 3.9555555555555557)
+ testCoefficientB(dataSeries, 3.37777777777778)
+ testCoefficientC(dataSeries, 2.4222222222222243)
+ testGoodnessOfFitEquals(dataSeries, 0.9992769580376006)
+ dataSeries.push(0, 5)
+ testCoefficientA(dataSeries, 4)
+ testCoefficientB(dataSeries, 4)
+ testCoefficientC(dataSeries, 5)
+ testGoodnessOfFitEquals(dataSeries, 0.9988530568930122)
+ dataSeries.push(1, 11)
+ testCoefficientA(dataSeries, 4)
+ testCoefficientB(dataSeries, 4)
+ testCoefficientC(dataSeries, 3)
+ testGoodnessOfFitEquals(dataSeries, 0.9982053643291688)
+ dataSeries.push(2, 29)
+ testCoefficientA(dataSeries, 4)
+ testCoefficientB(dataSeries, 4)
+ testCoefficientC(dataSeries, 5)
+ testGoodnessOfFitEquals(dataSeries, 0.9969166946967148)
+ dataSeries.push(3, 51)
+ testCoefficientA(dataSeries, 4)
+ testCoefficientB(dataSeries, 4)
+ testCoefficientC(dataSeries, 3)
+ testGoodnessOfFitEquals(dataSeries, 0.9939797134586851)
+ dataSeries.push(4, 85)
+ testCoefficientA(dataSeries, 4)
+ testCoefficientB(dataSeries, 4)
+ testCoefficientC(dataSeries, 5)
+ testGoodnessOfFitEquals(dataSeries, 0.9888468297958631)
+ dataSeries.push(5, 123)
+ testCoefficientA(dataSeries, 4)
+ testCoefficientB(dataSeries, 4)
+ testCoefficientC(dataSeries, 3)
+ testGoodnessOfFitEquals(dataSeries, 0.9886212128178015)
+ dataSeries.push(6, 173)
+ testCoefficientA(dataSeries, 4.044444444444444)
+ testCoefficientB(dataSeries, 3.822222222222223)
+ testCoefficientC(dataSeries, 3.577777777777783)
+ testGoodnessOfFitEquals(dataSeries, 0.9945681627011398)
+ dataSeries.push(7, 227)
+ testCoefficientA(dataSeries, 4)
+ testCoefficientB(dataSeries, 4)
+ testCoefficientC(dataSeries, 3)
+ testGoodnessOfFitEquals(dataSeries, 0.9968997006175546)
+ dataSeries.push(8, 293)
+ testCoefficientA(dataSeries, 3.9047619047619047)
+ testCoefficientB(dataSeries, 4.888888888888889)
+ testCoefficientC(dataSeries, 2.9682539682539684) // This is quite acceptable as ORM ignores the C
+ testGoodnessOfFitEquals(dataSeries, 0.9995034675221599)
+ dataSeries.push(9, 363)
+ testCoefficientA(dataSeries, 4) // These results match up 100% with the previous test, showing that a spike has no carry over effects
+ testCoefficientB(dataSeries, 4)
+ testCoefficientC(dataSeries, 3)
+ testGoodnessOfFitEquals(dataSeries, 0.9998719089295779)
+ dataSeries.push(10, 444)
+ testCoefficientA(dataSeries, 4)
+ testCoefficientB(dataSeries, 4)
+ testCoefficientC(dataSeries, 4)
+ testGoodnessOfFitEquals(dataSeries, 0.9999558104799866)
+})
+
+test('Quadratic TS Estimation should be decent for standard real-life example from MathBits with some noise', () => {
+ // Data based on https://mathbits.com/MathBits/TISection/Statistics2/quadratic.html
+ const dataSeries = createTSQuadraticSeries(13)
+ dataSeries.push(10, 115.6)
+ dataSeries.push(15, 157.2)
+ dataSeries.push(20, 189.2)
+ dataSeries.push(24, 220.8)
+ dataSeries.push(30, 253.8)
+ dataSeries.push(34, 269.2)
+ dataSeries.push(40, 284.8)
+ dataSeries.push(45, 285.0)
+ dataSeries.push(48, 277.4)
+ dataSeries.push(50, 269.2)
+ dataSeries.push(58, 244.2)
+ dataSeries.push(60, 231.4)
+ dataSeries.push(64, 180.4)
+ testCoefficientA(dataSeries, -0.17702838827838824) // In the example, the TI084 results in -0.1737141137, which we consider acceptably close
+ testCoefficientB(dataSeries, 14.929144536019532) // In the example, the TI084 results in 14.52117133, which we consider acceptably close
+ testCoefficientC(dataSeries, -31.325531135531037) // In the example, the TI084 results in -21.89774466, which we consider acceptably close
+ testGoodnessOfFitEquals(dataSeries, 0.9781087883163964)
+})
+
+test('Quadratic TS Estimation should be decent for standard real-life example from VarsityTutors with some noise', () => {
+ // Test based on https://www.varsitytutors.com/hotmath/hotmath_help/topics/quadratic-regression
+ const dataSeries = createTSQuadraticSeries(7)
+ dataSeries.push(-3, 7.5)
+ dataSeries.push(-2, 3)
+ dataSeries.push(-1, 0.5)
+ dataSeries.push(0, 1)
+ dataSeries.push(1, 3)
+ dataSeries.push(2, 6)
+ dataSeries.push(3, 14)
+ testCoefficientA(dataSeries, 1.0833333333333333) // The example results in 1.1071 for OLS, which we consider acceptably close
+ testCoefficientB(dataSeries, 1.0833333333333333) // The example results in 1 for OLS, which we consider acceptably close
+ testCoefficientC(dataSeries, 0.8333333333333335) // The example results in 0.5714 for OLS, which we consider acceptably close
+ testGoodnessOfFitEquals(dataSeries, 0.9851153039832286)
+})
+
+test('Quadratic TS Estimation should be decent for standard example from VTUPulse with some noise, without the vertex being part of the dataset', () => {
+ // Test based on https://www.vtupulse.com/machine-learning/quadratic-polynomial-regression-model-solved-example/
+ const dataSeries = createTSQuadraticSeries(5)
+ dataSeries.push(3, 2.5)
+ dataSeries.push(4, 3.3)
+ dataSeries.push(5, 3.8)
+ dataSeries.push(6, 6.5)
+ dataSeries.push(7, 11.5)
+ testCoefficientA(dataSeries, 0.8583333333333334) // The example results in 0.7642857 for OLS, which we consider acceptably close given the small sample size
+ testCoefficientB(dataSeries, -6.420833333333334) // The example results in -5.5128571 for OLS, which we consider acceptably close given the small sample size
+ testCoefficientC(dataSeries, 14.387500000000003) // The example results in 12.4285714 for OLS, which we consider acceptably close given the small sample size
+ testGoodnessOfFitEquals(dataSeries, 0.9825283785404673)
+})
+
+test('Quadratic TS Estimation should be decent for standard real-life example from Uni Berlin with some noise without the vertex being part of the dataset', () => {
+ // Test based on https://www.geo.fu-berlin.de/en/v/soga/Basics-of-statistics/Linear-Regression/Polynomial-Regression/Polynomial-Regression---An-example/index.html
+ const dataSeries = createTSQuadraticSeries(25)
+ dataSeries.push(0.001399613, -0.23436656)
+ dataSeries.push(0.971629779, 0.64689524)
+ dataSeries.push(0.579119475, -0.92635765)
+ dataSeries.push(0.335693937, 0.13000706)
+ dataSeries.push(0.736736086, -0.89294863)
+ dataSeries.push(0.492572335, 0.33854780)
+ dataSeries.push(0.737133774, -1.24171910)
+ dataSeries.push(0.563693769, -0.22523318)
+ dataSeries.push(0.877603280, -0.12962722)
+ dataSeries.push(0.141426545, 0.37632006)
+ dataSeries.push(0.307203910, 0.30299077)
+ dataSeries.push(0.024509308, -0.21162739)
+ dataSeries.push(0.843665029, -0.76468719)
+ dataSeries.push(0.771206067, -0.90455412)
+ dataSeries.push(0.149670258, 0.77097952)
+ dataSeries.push(0.359605608, 0.56466366)
+ dataSeries.push(0.049612895, 0.18897607)
+ dataSeries.push(0.409898906, 0.32531750)
+ dataSeries.push(0.935457898, -0.78703491)
+ dataSeries.push(0.149476207, 0.80585375)
+ dataSeries.push(0.234315216, 0.62944986)
+ dataSeries.push(0.455297119, 0.02353327)
+ dataSeries.push(0.102696671, 0.27621694)
+ dataSeries.push(0.715372314, -1.20379729)
+ dataSeries.push(0.681745393, -0.83059624)
+ testCoefficientA(dataSeries, -2.030477132951317)
+ testCoefficientB(dataSeries, 0.5976858995201227)
+ testCoefficientC(dataSeries, 0.17630021024409503)
+ testGoodnessOfFitEquals(dataSeries, 0.23921110548689295)
+})
+
+test('Quadratic TS Estimation should be decent for standard real-life example from Statology.org with some noise and chaotic X values', () => {
+ // Test based on https://www.statology.org/quadratic-regression-r/
+ const dataSeries = createTSQuadraticSeries(11)
+ dataSeries.push(6, 14)
+ dataSeries.push(9, 28)
+ dataSeries.push(12, 50)
+ dataSeries.push(14, 70)
+ dataSeries.push(30, 89)
+ dataSeries.push(35, 94)
+ dataSeries.push(40, 90)
+ dataSeries.push(47, 75)
+ dataSeries.push(51, 59)
+ dataSeries.push(55, 44)
+ dataSeries.push(60, 27)
+ testCoefficientA(dataSeries, -0.10119047619047619) // The example results in -0.1012 for R after two rounds, which we consider acceptably close
+ testCoefficientB(dataSeries, 6.801190476190477) // The example results in 6.7444 for R after two rounds, which we consider acceptably close
+ testCoefficientC(dataSeries, -21.126190476190516) // The example results in 18.2536 for R after two rounds, but for ORM, this factor is irrelevant
+ testGoodnessOfFitEquals(dataSeries, 0.9571127392718894)
+})
+
+test('Quadratic TS Estimation should be decent for standard real-life example from StatsDirect.com with some noise and chaotic X values', () => {
+ // Test based on https://www.statsdirect.com/help/regression_and_correlation/polynomial.htm
+ const dataSeries = createTSQuadraticSeries(10)
+ dataSeries.push(1290, 1182)
+ dataSeries.push(1350, 1172)
+ dataSeries.push(1470, 1264)
+ dataSeries.push(1600, 1493)
+ dataSeries.push(1710, 1571)
+ dataSeries.push(1840, 1711)
+ dataSeries.push(1980, 1804)
+ dataSeries.push(2230, 1840)
+ dataSeries.push(2400, 1956)
+ dataSeries.push(2930, 1954)
+ testCoefficientA(dataSeries, -0.00046251263566907585) // The example results in -0.00045 through QR decomposition by Givens rotations, which we consider acceptably close
+ testCoefficientB(dataSeries, 2.441798780934297) // The example results in 2.39893 for QR decomposition by Givens rotations, which we consider acceptably close
+ testCoefficientC(dataSeries, -1235.044997485239) // The example results in -1216.143887 for QR decomposition by Givens rotations, but for ORM, this factor is irrelevant
+ testGoodnessOfFitEquals(dataSeries, 0.9790379024208455)
+})
+
+test('Quadratic Approximation with a clean function and a reset', () => {
+ // Data based on 2 x^2 + 2 x + 2
+ const dataSeries = createTSQuadraticSeries(10)
+ dataSeries.push(-10, 182)
+ dataSeries.push(-9, 146)
+ dataSeries.push(-8, 114)
+ dataSeries.push(-7, 86)
+ dataSeries.push(-6, 62)
+ dataSeries.push(-5, 42)
+ testCoefficientA(dataSeries, 2)
+ testCoefficientB(dataSeries, 2)
+ testCoefficientC(dataSeries, 2)
+ testGoodnessOfFitEquals(dataSeries, 1)
+ dataSeries.push(-4, 26)
+ dataSeries.push(-3, 14) // Pi ;)
+ dataSeries.push(-2, 6)
+ dataSeries.push(-1, 2)
+ dataSeries.push(0, 2)
+ dataSeries.push(1, 6)
+ dataSeries.push(2, 14)
+ testCoefficientA(dataSeries, 2)
+ testCoefficientB(dataSeries, 2)
+ testCoefficientC(dataSeries, 2)
+ testGoodnessOfFitEquals(dataSeries, 1)
+ dataSeries.push(3, 26)
+ dataSeries.push(4, 42)
+ dataSeries.push(5, 62)
+ dataSeries.push(6, 86)
+ dataSeries.push(7, 114)
+ dataSeries.push(8, 146)
+ dataSeries.push(9, 182)
+ dataSeries.push(10, 222)
+ testCoefficientA(dataSeries, 2)
+ testCoefficientB(dataSeries, 2)
+ testCoefficientC(dataSeries, 2)
+ testGoodnessOfFitEquals(dataSeries, 1)
+ dataSeries.reset()
+ testCoefficientA(dataSeries, 0)
+ testCoefficientB(dataSeries, 0)
+ testCoefficientC(dataSeries, 0)
+ testGoodnessOfFitEquals(dataSeries, 0)
+ dataSeries.push(-1, 2)
+ testCoefficientA(dataSeries, 0)
+ testCoefficientB(dataSeries, 0)
+ testCoefficientC(dataSeries, 0)
+ testGoodnessOfFitEquals(dataSeries, 0)
+ dataSeries.push(0, 2)
+ testCoefficientA(dataSeries, 0)
+ testCoefficientB(dataSeries, 0)
+ testCoefficientC(dataSeries, 0)
+ testGoodnessOfFitEquals(dataSeries, 0)
+ dataSeries.push(1, 6)
+ testCoefficientA(dataSeries, 2)
+ testCoefficientB(dataSeries, 2)
+ testCoefficientC(dataSeries, 2)
+ testGoodnessOfFitEquals(dataSeries, 1)
+})
+
+test('Quadratic TS Estimation should result in a straight line for function y = x', () => {
+ // As ORM will encounter straight lines (when forces are balanced on the flywheel, there is no acceleration/deceleration), so we need to test this as well
+ const dataSeries = createTSQuadraticSeries(7)
+ dataSeries.push(0, 0)
+ dataSeries.push(1, 1)
+ dataSeries.push(2, 2)
+ dataSeries.push(3, 3)
+ dataSeries.push(4, 4)
+ dataSeries.push(5, 5)
+ dataSeries.push(6, 6)
+ testCoefficientA(dataSeries, 0)
+ testCoefficientB(dataSeries, 1)
+ testCoefficientC(dataSeries, 0)
+ testGoodnessOfFitEquals(dataSeries, 1)
+})
+
+function testCoefficientA (series, expectedValue) {
+ assert.ok(series.coefficientA() === expectedValue, `Expected value for coefficientA at X-position ${series.X.atSeriesEnd()} is ${expectedValue}, encountered a ${series.coefficientA()}`)
+}
+
+function testCoefficientB (series, expectedValue) {
+ assert.ok(series.coefficientB() === expectedValue, `Expected value for coefficientB at X-position ${series.X.atSeriesEnd()} is ${expectedValue}, encountered a ${series.coefficientB()}`)
+}
+
+function testCoefficientC (series, expectedValue) {
+ assert.ok(series.coefficientC() === expectedValue, `Expected value for coefficientC at X-position ${series.X.atSeriesEnd()} is ${expectedValue}, encountered a ${series.coefficientC()}`)
+}
+
+function testGoodnessOfFitEquals (series, expectedValue) {
+ assert.ok(series.goodnessOfFit() === expectedValue, `Expected goodnessOfFit at X-position ${series.X.atSeriesEnd()} is ${expectedValue}, encountered ${series.goodnessOfFit()}`)
+}
+
+function testGoodnessOfFitBetween (series, expectedValueAbove, expectedValueBelow) { // eslint-disable-line no-unused-vars
+ assert.ok(series.goodnessOfFit() > expectedValueAbove, `Expected goodnessOfFit at X-position ${series.X.atSeriesEnd()} above ${expectedValueAbove}, encountered ${series.goodnessOfFit()}`)
+ assert.ok(series.goodnessOfFit() < expectedValueBelow, `Expected goodnessOfFit at X-position ${series.X.atSeriesEnd()} below ${expectedValueBelow}, encountered ${series.goodnessOfFit()}`)
+}
+
+function testSlope (series, position, expectedValue) { // eslint-disable-line no-unused-vars
+ assert.ok(series.slope(position) === expectedValue, `Expected value for Slope-${position} at X-position ${series.X.atSeriesEnd()} (slope at X-position ${series.X.atPosition(position)}) is ${expectedValue}, encountered a ${series.slope(position)}`)
+}
+
+function reportAll (series) { // eslint-disable-line no-unused-vars
+ assert.ok(series.coefficientA() === 99, `time: ${series.X.atSeriesEnd()}, coefficientA: ${series.coefficientA()}, coefficientB: ${series.coefficientB()}, coefficientC: ${series.coefficientC()}, Slope-10: ${series.slope(10)}, Slope-9: ${series.slope(9)}, Slope-8: ${series.slope(8)}, Slope-7: ${series.slope(7)}, Slope-6: ${series.slope(6)}, Slope-5: ${series.slope(5)}, Slope-4: ${series.slope(4)}, Slope-3: ${series.slope(3)}, Slope-2: ${series.slope(2)}, Slope-1: ${series.slope(1)}, Slope-0: ${series.slope(0)}`)
+}
+
+test.run()
diff --git a/app/engine/utils/OLSLinearSeries.js b/app/engine/utils/OLSLinearSeries.js
new file mode 100644
index 0000000000..6d0c26541e
--- /dev/null
+++ b/app/engine/utils/OLSLinearSeries.js
@@ -0,0 +1,129 @@
+'use strict'
+/*
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
+
+ The LinearSeries is a datatype that represents a Linear Series. It allows
+ values to be retrieved (like a FiFo buffer, or Queue) but it also includes
+ a Linear Regressor to determine the slope, intercept and R^2 of this timeseries
+ of x any y coordinates through Simple Linear Regression.
+
+ At creation it can be determined that the Time Series is limited (i.e. after it
+ is filled, the oldest will be pushed out of the queue) or that the the time series
+ is unlimited (will only expand). The latter is activated by calling the creation with
+ an empty argument.
+
+ please note that for unlimited series it is up to the calling function to handle resetting
+ the Linear Series when needed through the reset() call.
+
+ A key constraint is to prevent heavy calculations at the end (due to large
+ array based curve fitting) as this function is also used to calculate
+ drag at the end of the recovery phase, which might happen on a Pi zero
+
+ This implementation uses concepts that are described here:
+ https://www.colorado.edu/amath/sites/default/files/attached-files/ch12_0.pdf
+*/
+
+import { createSeries } from './Series.js'
+
+import loglevel from 'loglevel'
+const log = loglevel.getLogger('RowingEngine')
+
+export function createOLSLinearSeries (maxSeriesLength = 0) {
+ const X = createSeries(maxSeriesLength)
+ const XX = createSeries(maxSeriesLength)
+ const Y = createSeries(maxSeriesLength)
+ const YY = createSeries(maxSeriesLength)
+ const XY = createSeries(maxSeriesLength)
+ let _slope = 0
+ let _intercept = 0
+ let _goodnessOfFit = 0
+
+ function push (x, y) {
+ if (x === undefined || isNaN(x) || y === undefined || isNaN(y)) { return }
+ X.push(x)
+ XX.push(x * x)
+ Y.push(y)
+ YY.push(y * y)
+ XY.push(x * y)
+
+ // Let's approximate the line through OLS
+ if (X.length() >= 2 && X.sum() > 0) {
+ _slope = (X.length() * XY.sum() - X.sum() * Y.sum()) / (X.length() * XX.sum() - X.sum() * X.sum())
+ _intercept = (Y.sum() - (_slope * X.sum())) / X.length()
+ const sse = YY.sum() - (_intercept * Y.sum()) - (_slope * XY.sum())
+ const sst = YY.sum() - (Math.pow(Y.sum(), 2) / X.length())
+ _goodnessOfFit = 1 - (sse / sst)
+ } else {
+ _slope = 0
+ _intercept = 0
+ _goodnessOfFit = 0
+ }
+ }
+
+ function slope () {
+ return _slope
+ }
+
+ function intercept () {
+ return _intercept
+ }
+
+ function length () {
+ return X.length()
+ }
+
+ function goodnessOfFit () {
+ // This function returns the R^2 as a goodness of fit indicator
+ if (X.length() >= 2) {
+ return _goodnessOfFit
+ } else {
+ return 0
+ }
+ }
+
+ function projectX (x) {
+ if (X.length() >= 2) {
+ return (_slope * x) + _intercept
+ } else {
+ return 0
+ }
+ }
+
+ function projectY (y) {
+ if (X.length() >= 2 && _slope !== 0) {
+ return ((y - _intercept) / _slope)
+ } else {
+ log.error('OLS Regressor, attempted a Y-projection while slope was zero!')
+ return 0
+ }
+ }
+
+ function reliable () {
+ return (X.length() >= 2 && _slope !== 0)
+ }
+
+ function reset () {
+ X.reset()
+ XX.reset()
+ Y.reset()
+ YY.reset()
+ XY.reset()
+ _slope = 0
+ _intercept = 0
+ _goodnessOfFit = 0
+ }
+
+ return {
+ push,
+ X,
+ Y,
+ slope,
+ intercept,
+ length,
+ goodnessOfFit,
+ projectX,
+ projectY,
+ reliable,
+ reset
+ }
+}
diff --git a/app/engine/utils/OLSLinearSeries.test.js b/app/engine/utils/OLSLinearSeries.test.js
new file mode 100644
index 0000000000..9bf25cc3c0
--- /dev/null
+++ b/app/engine/utils/OLSLinearSeries.test.js
@@ -0,0 +1,268 @@
+'use strict'
+/*
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
+*/
+import { test } from 'uvu'
+import * as assert from 'uvu/assert'
+
+import { createOLSLinearSeries } from './OLSLinearSeries.js'
+
+test('Correct behaviour of a series after initialisation', () => {
+ const dataSeries = createOLSLinearSeries(3)
+ testLength(dataSeries, 0)
+ testXAtSeriesBegin(dataSeries, 0)
+ testYAtSeriesBegin(dataSeries, 0)
+ testXAtSeriesEnd(dataSeries, 0)
+ testYAtSeriesEnd(dataSeries, 0)
+ testNumberOfXValuesAbove(dataSeries, 0, 0)
+ testNumberOfYValuesAbove(dataSeries, 0, 0)
+ testNumberOfXValuesEqualOrBelow(dataSeries, 0, 0)
+ testNumberOfYValuesEqualOrBelow(dataSeries, 0, 0)
+ testNumberOfXValuesAbove(dataSeries, 10, 0)
+ testNumberOfYValuesAbove(dataSeries, 10, 0)
+ testNumberOfXValuesEqualOrBelow(dataSeries, 10, 0)
+ testNumberOfYValuesEqualOrBelow(dataSeries, 10, 0)
+ testXSum(dataSeries, 0)
+ testYSum(dataSeries, 0)
+ testSlopeEquals(dataSeries, 0)
+ testInterceptEquals(dataSeries, 0)
+ testGoodnessOfFitEquals(dataSeries, 0)
+})
+
+test('Correct behaviour of a series after several puhed values, function y = 3x + 6, noisefree, 1 datapoint', () => {
+ const dataSeries = createOLSLinearSeries(3)
+ testLength(dataSeries, 0)
+ dataSeries.push(5, 9)
+ testLength(dataSeries, 1)
+ testXAtSeriesBegin(dataSeries, 5)
+ testYAtSeriesBegin(dataSeries, 9)
+ testXAtSeriesEnd(dataSeries, 5)
+ testYAtSeriesEnd(dataSeries, 9)
+ testNumberOfXValuesAbove(dataSeries, 0, 1)
+ testNumberOfYValuesAbove(dataSeries, 0, 1)
+ testNumberOfXValuesEqualOrBelow(dataSeries, 0, 0)
+ testNumberOfYValuesEqualOrBelow(dataSeries, 0, 0)
+ testNumberOfXValuesAbove(dataSeries, 10, 0)
+ testNumberOfYValuesAbove(dataSeries, 10, 0)
+ testNumberOfXValuesEqualOrBelow(dataSeries, 10, 1)
+ testNumberOfYValuesEqualOrBelow(dataSeries, 10, 1)
+ testXSum(dataSeries, 5)
+ testYSum(dataSeries, 9)
+ testSlopeEquals(dataSeries, 0)
+ testInterceptEquals(dataSeries, 0)
+ testGoodnessOfFitEquals(dataSeries, 0)
+})
+
+test('Correct behaviour of a series after several puhed values, function y = 3x + 6, noisefree, 2 datapoints', () => {
+ const dataSeries = createOLSLinearSeries(3)
+ dataSeries.push(5, 9)
+ dataSeries.push(3, 3)
+ testLength(dataSeries, 2)
+ testXAtSeriesBegin(dataSeries, 5)
+ testYAtSeriesBegin(dataSeries, 9)
+ testXAtSeriesEnd(dataSeries, 3)
+ testYAtSeriesEnd(dataSeries, 3)
+ testNumberOfXValuesAbove(dataSeries, 0, 2)
+ testNumberOfYValuesAbove(dataSeries, 0, 2)
+ testNumberOfXValuesEqualOrBelow(dataSeries, 0, 0)
+ testNumberOfYValuesEqualOrBelow(dataSeries, 0, 0)
+ testNumberOfXValuesAbove(dataSeries, 10, 0)
+ testNumberOfYValuesAbove(dataSeries, 10, 0)
+ testNumberOfXValuesEqualOrBelow(dataSeries, 10, 2)
+ testNumberOfYValuesEqualOrBelow(dataSeries, 10, 2)
+ testXSum(dataSeries, 8)
+ testYSum(dataSeries, 12)
+ testSlopeEquals(dataSeries, 3)
+ testInterceptEquals(dataSeries, -6)
+ testGoodnessOfFitEquals(dataSeries, 1)
+})
+
+test('Correct behaviour of a series after several puhed values, function y = 3x + 6, noisefree, 3 datapoints', () => {
+ const dataSeries = createOLSLinearSeries(3)
+ dataSeries.push(5, 9)
+ dataSeries.push(3, 3)
+ dataSeries.push(4, 6)
+ testLength(dataSeries, 3)
+ testXAtSeriesBegin(dataSeries, 5)
+ testYAtSeriesBegin(dataSeries, 9)
+ testXAtSeriesEnd(dataSeries, 4)
+ testYAtSeriesEnd(dataSeries, 6)
+ testNumberOfXValuesAbove(dataSeries, 0, 3)
+ testNumberOfYValuesAbove(dataSeries, 0, 3)
+ testNumberOfXValuesEqualOrBelow(dataSeries, 0, 0)
+ testNumberOfYValuesEqualOrBelow(dataSeries, 0, 0)
+ testNumberOfXValuesAbove(dataSeries, 10, 0)
+ testNumberOfYValuesAbove(dataSeries, 10, 0)
+ testNumberOfXValuesEqualOrBelow(dataSeries, 10, 3)
+ testNumberOfYValuesEqualOrBelow(dataSeries, 10, 3)
+ testXSum(dataSeries, 12)
+ testYSum(dataSeries, 18)
+ testSlopeEquals(dataSeries, 3)
+ testInterceptEquals(dataSeries, -6)
+ testGoodnessOfFitEquals(dataSeries, 1)
+})
+
+test('Correct behaviour of a series after several puhed values, function y = 3x + 6, noisefree, 4 datapoints', () => {
+ const dataSeries = createOLSLinearSeries(3)
+ dataSeries.push(5, 9)
+ dataSeries.push(3, 3)
+ dataSeries.push(4, 6)
+ dataSeries.push(6, 12)
+ testLength(dataSeries, 3)
+ testXAtSeriesBegin(dataSeries, 3)
+ testYAtSeriesBegin(dataSeries, 3)
+ testXAtSeriesEnd(dataSeries, 6)
+ testYAtSeriesEnd(dataSeries, 12)
+ testNumberOfXValuesAbove(dataSeries, 0, 3)
+ testNumberOfYValuesAbove(dataSeries, 0, 3)
+ testNumberOfXValuesEqualOrBelow(dataSeries, 0, 0)
+ testNumberOfYValuesEqualOrBelow(dataSeries, 0, 0)
+ testNumberOfXValuesAbove(dataSeries, 10, 0)
+ testNumberOfYValuesAbove(dataSeries, 10, 1)
+ testNumberOfXValuesEqualOrBelow(dataSeries, 10, 3)
+ testNumberOfYValuesEqualOrBelow(dataSeries, 10, 2)
+ testXSum(dataSeries, 13)
+ testYSum(dataSeries, 21)
+ testSlopeEquals(dataSeries, 3)
+ testInterceptEquals(dataSeries, -6)
+ testGoodnessOfFitEquals(dataSeries, 1)
+})
+
+test('Correct behaviour of a series after several puhed values, function y = 3x + 6, noisefree, 5 datapoints', () => {
+ const dataSeries = createOLSLinearSeries(3)
+ dataSeries.push(5, 9)
+ dataSeries.push(3, 3)
+ dataSeries.push(4, 6)
+ dataSeries.push(6, 12)
+ dataSeries.push(1, -3)
+ testLength(dataSeries, 3)
+ testXAtSeriesBegin(dataSeries, 4)
+ testYAtSeriesBegin(dataSeries, 6)
+ testXAtSeriesEnd(dataSeries, 1)
+ testYAtSeriesEnd(dataSeries, -3)
+ testNumberOfXValuesAbove(dataSeries, 0, 3)
+ testNumberOfYValuesAbove(dataSeries, 0, 2)
+ testNumberOfXValuesEqualOrBelow(dataSeries, 0, 0)
+ testNumberOfYValuesEqualOrBelow(dataSeries, 0, 1)
+ testNumberOfXValuesAbove(dataSeries, 10, 0)
+ testNumberOfYValuesAbove(dataSeries, 10, 1)
+ testNumberOfXValuesEqualOrBelow(dataSeries, 10, 3)
+ testNumberOfYValuesEqualOrBelow(dataSeries, 10, 2)
+ testXSum(dataSeries, 11)
+ testYSum(dataSeries, 15)
+ testSlopeEquals(dataSeries, 3)
+ testInterceptEquals(dataSeries, -6)
+ testGoodnessOfFitEquals(dataSeries, 1)
+})
+
+test('Correct behaviour of a series after several puhed values, function y = 3x + 6, noisefree, 4 datapoints and a reset', () => {
+ const dataSeries = createOLSLinearSeries(3)
+ dataSeries.push(5, 9)
+ dataSeries.push(3, 3)
+ dataSeries.push(4, 6)
+ dataSeries.push(6, 12)
+ dataSeries.reset()
+ testLength(dataSeries, 0)
+ testXAtSeriesBegin(dataSeries, 0)
+ testYAtSeriesBegin(dataSeries, 0)
+ testXAtSeriesEnd(dataSeries, 0)
+ testYAtSeriesEnd(dataSeries, 0)
+ testNumberOfXValuesAbove(dataSeries, 0, 0)
+ testNumberOfYValuesAbove(dataSeries, 0, 0)
+ testNumberOfXValuesEqualOrBelow(dataSeries, 0, 0)
+ testNumberOfYValuesEqualOrBelow(dataSeries, 0, 0)
+ testNumberOfXValuesAbove(dataSeries, 10, 0)
+ testNumberOfYValuesAbove(dataSeries, 10, 0)
+ testNumberOfXValuesEqualOrBelow(dataSeries, 10, 0)
+ testNumberOfYValuesEqualOrBelow(dataSeries, 10, 0)
+ testXSum(dataSeries, 0)
+ testYSum(dataSeries, 0)
+ testSlopeEquals(dataSeries, 0)
+ testInterceptEquals(dataSeries, 0)
+ testGoodnessOfFitEquals(dataSeries, 0)
+})
+
+test('Series with 5 elements, with 2 noisy datapoints', () => {
+ const dataSeries = createOLSLinearSeries(5)
+ dataSeries.push(5, 9)
+ dataSeries.push(3, 2)
+ dataSeries.push(4, 7)
+ dataSeries.push(6, 12)
+ dataSeries.push(1, -3)
+ testSlopeBetween(dataSeries, 2.9, 3.1)
+ testInterceptBetween(dataSeries, -6.3, -5.8)
+ testGoodnessOfFitBetween(dataSeries, 0.9, 1.0)
+})
+
+function testLength (series, expectedValue) {
+ assert.ok(series.length() === expectedValue, `Expected length should be ${expectedValue}, encountered a ${series.length()}`)
+}
+
+function testXAtSeriesBegin (series, expectedValue) {
+ assert.ok(series.X.atSeriesBegin() === expectedValue, `Expected X.atSeriesBegin to be ${expectedValue}, encountered a ${series.X.atSeriesBegin()}`)
+}
+
+function testYAtSeriesBegin (series, expectedValue) {
+ assert.ok(series.Y.atSeriesBegin() === expectedValue, `Expected Y.atSeriesBegin to be ${expectedValue}, encountered a ${series.Y.atSeriesBegin()}`)
+}
+
+function testXAtSeriesEnd (series, expectedValue) {
+ assert.ok(series.X.atSeriesEnd() === expectedValue, `Expected X.atSeriesEnd to be ${expectedValue}, encountered a ${series.X.atSeriesEnd()}`)
+}
+
+function testYAtSeriesEnd (series, expectedValue) {
+ assert.ok(series.Y.atSeriesEnd() === expectedValue, `Expected Y.atSeriesEnd to be ${expectedValue}, encountered a ${series.Y.atSeriesEnd()}`)
+}
+
+function testNumberOfXValuesAbove (series, cutoff, expectedValue) {
+ assert.ok(series.X.numberOfValuesAbove(cutoff) === expectedValue, `Expected X.numberOfValuesAbove(${cutoff}) to be ${expectedValue}, encountered a ${series.X.numberOfValuesAbove(cutoff)}`)
+}
+
+function testNumberOfYValuesAbove (series, cutoff, expectedValue) {
+ assert.ok(series.Y.numberOfValuesAbove(cutoff) === expectedValue, `Expected Y.numberOfValuesAbove(${cutoff}) to be ${expectedValue}, encountered a ${series.Y.numberOfValuesAbove(cutoff)}`)
+}
+
+function testNumberOfXValuesEqualOrBelow (series, cutoff, expectedValue) {
+ assert.ok(series.X.numberOfValuesEqualOrBelow(cutoff) === expectedValue, `Expected X.numberOfValuesEqualOrBelow(${cutoff}) to be ${expectedValue}, encountered a ${series.X.numberOfValuesEqualOrBelow(cutoff)}`)
+}
+
+function testNumberOfYValuesEqualOrBelow (series, cutoff, expectedValue) {
+ assert.ok(series.Y.numberOfValuesEqualOrBelow(cutoff) === expectedValue, `Expected Y.numberOfValuesEqualOrBelow(${cutoff}) to be ${expectedValue}, encountered a ${series.Y.numberOfValuesEqualOrBelow(cutoff)}`)
+}
+
+function testXSum (series, expectedValue) {
+ assert.ok(series.X.sum() === expectedValue, `Expected X.sum to be ${expectedValue}, encountered a ${series.X.sum()}`)
+}
+
+function testYSum (series, expectedValue) {
+ assert.ok(series.Y.sum() === expectedValue, `Expected y.Sum to be ${expectedValue}, encountered a ${series.Y.sum()}`)
+}
+
+function testSlopeEquals (series, expectedValue) {
+ assert.ok(series.slope() === expectedValue, `Expected slope to be ${expectedValue}, encountered a ${series.slope()}`)
+}
+
+function testSlopeBetween (series, expectedValueAbove, expectedValueBelow) {
+ assert.ok(series.slope() > expectedValueAbove, `Expected slope to be above ${expectedValueAbove}, encountered a ${series.slope()}`)
+ assert.ok(series.slope() < expectedValueBelow, `Expected slope to be below ${expectedValueBelow}, encountered a ${series.slope()}`)
+}
+
+function testInterceptEquals (series, expectedValue) {
+ assert.ok(series.intercept() === expectedValue, `Expected intercept to be ${expectedValue}, encountered ${series.intercept()}`)
+}
+
+function testInterceptBetween (series, expectedValueAbove, expectedValueBelow) {
+ assert.ok(series.intercept() > expectedValueAbove, `Expected intercept to be above ${expectedValueAbove}, encountered ${series.intercept()}`)
+ assert.ok(series.intercept() < expectedValueBelow, `Expected intercept to be below ${expectedValueBelow}, encountered ${series.intercept()}`)
+}
+
+function testGoodnessOfFitEquals (series, expectedValue) {
+ assert.ok(series.goodnessOfFit() === expectedValue, `Expected goodnessOfFit to be ${expectedValue}, encountered ${series.goodnessOfFit()}`)
+}
+
+function testGoodnessOfFitBetween (series, expectedValueAbove, expectedValueBelow) {
+ assert.ok(series.goodnessOfFit() > expectedValueAbove, `Expected goodnessOfFit to be above ${expectedValueAbove}, encountered ${series.goodnessOfFit()}`)
+ assert.ok(series.goodnessOfFit() < expectedValueBelow, `Expected goodnessOfFit to be below ${expectedValueBelow}, encountered ${series.goodnessOfFit()}`)
+}
+
+test.run()
diff --git a/app/engine/utils/Series.js b/app/engine/utils/Series.js
new file mode 100644
index 0000000000..15a67fd9d0
--- /dev/null
+++ b/app/engine/utils/Series.js
@@ -0,0 +1,233 @@
+'use strict'
+/*
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
+*/
+/**
+ * This creates a series with a maximum number of values. It allows for determining the Average, Median, Number of Positive, number of Negative
+ * @remark BE AWARE: The median function is extremely CPU intensive for larger series. Use the BinarySearchTree for that situation instead!
+ *
+ * @param {number} [maxSeriesLength] The maximum length of the series (0 for unlimited)
+ */
+export function createSeries (maxSeriesLength = 0) {
+ /**
+ * @type {Array}
+ */
+ let seriesArray = []
+ let seriesSum = 0
+ let numPos = 0
+ let numNeg = 0
+ let min = undefined
+ let max = undefined
+
+ /**
+ * @param {float} value to be added to the series
+ */
+ function push (value) {
+ if (value === undefined || isNaN(value)) { return }
+
+ if (min !== undefined) { min = Math.min(min, value) }
+ if (max !== undefined) { max = Math.max(max, value) }
+
+ if (maxSeriesLength > 0 && seriesArray.length >= maxSeriesLength) {
+ // The maximum of the array has been reached, we have to create room by removing the first
+ // value from the array
+ seriesSum -= seriesArray[0]
+ if (seriesArray[0] > 0) {
+ numPos--
+ } else {
+ numNeg--
+ }
+ if (min === seriesArray[0]) {
+ min = undefined
+ }
+ if (max === seriesArray[0]) {
+ max = undefined
+ }
+ seriesArray.shift()
+ }
+ seriesArray.push(value)
+ seriesSum += value
+ if (value > 0) {
+ numPos++
+ } else {
+ numNeg++
+ }
+ }
+
+ /**
+ * @output {number} length of the series
+ */
+ function length () {
+ return seriesArray.length
+ }
+
+ /**
+ * @output {float} value at the head of the series (i.e. the one first added)
+ */
+ function atSeriesBegin () {
+ if (seriesArray.length > 0) {
+ return seriesArray[0]
+ } else {
+ return 0
+ }
+ }
+
+ /**
+ * @output {float} value at the tail of the series (i.e. the one last added)
+ */
+ function atSeriesEnd () {
+ if (seriesArray.length > 0) {
+ return seriesArray[seriesArray.length - 1]
+ } else {
+ return 0
+ }
+ }
+
+ /**
+ * @param {number} position
+ * @output {float} value at a specific postion, starting at 0
+ */
+ function get (position) {
+ if (position >= 0 && position < seriesArray.length) {
+ return seriesArray[position]
+ } else {
+ return undefined
+ }
+ }
+
+ /**
+ * @param {number} testedValue
+ * @output {number} number of values in the series above the tested value
+ */
+ function numberOfValuesAbove (testedValue) {
+ if (testedValue === 0) {
+ return numPos
+ } else {
+ let i = seriesArray.length - 1
+ let count = 0
+ while (i >= 0) {
+ if (seriesArray[i] > testedValue) {
+ count++
+ }
+ i--
+ }
+ return count
+ }
+ }
+
+ /**
+ * @param {number} testedValue
+ * @output {number} number of values in the series below or equal to the tested value
+ */
+ function numberOfValuesEqualOrBelow (testedValue) {
+ if (testedValue === 0) {
+ return numNeg
+ } else {
+ let i = seriesArray.length - 1
+ let count = 0
+ while (i >= 0) {
+ if (seriesArray[i] <= testedValue) {
+ count++
+ }
+ i--
+ }
+ return count
+ }
+ }
+
+ /**
+ * @output {float} sum of the entire series
+ */
+ function sum () {
+ return seriesSum
+ }
+
+ /**
+ * @output {float} average of the entire series
+ */
+ function average () {
+ if (seriesArray.length > 0) {
+ return seriesSum / seriesArray.length
+ } else {
+ return 0
+ }
+ }
+
+ /**
+ * @output {float} smallest element in the series
+ */
+ function minimum () {
+ if (seriesArray.length > 0) {
+ if (isNaN(min)) { min = Math.min(...seriesArray) }
+ return min
+ } else {
+ return 0
+ }
+ }
+
+ /**
+ * @output {float} largest value in the series
+ */
+ function maximum () {
+ if (seriesArray.length > 0) {
+ if (isNaN(max)) { max = Math.max(...seriesArray) }
+ return max
+ } else {
+ return 0
+ }
+ }
+
+ /**
+ * @output {float} median of the series (DO NOT USE FOR LARGE SERIES!)
+ */
+ function median () {
+ if (seriesArray.length > 0) {
+ const mid = Math.floor(seriesArray.length / 2)
+ const sortedArray = [...seriesArray].sort((a, b) => a - b)
+ return seriesArray.length % 2 !== 0 ? sortedArray[mid] : (sortedArray[mid - 1] + sortedArray[mid]) / 2
+ } else {
+ return 0
+ }
+ }
+
+ /**
+ * @output {array} returns the entire series
+ */
+ function series () {
+ if (seriesArray.length > 0) {
+ return seriesArray
+ } else {
+ return []
+ }
+ }
+
+ /**
+ * Resets the series to its initial state
+ */
+ function reset () {
+ seriesArray = /** @type {Array} */(/** @type {unknown} */(null))
+ seriesArray = []
+ seriesSum = 0
+ numPos = 0
+ numNeg = 0
+ min = undefined
+ max = undefined
+ }
+
+ return {
+ push,
+ length,
+ atSeriesBegin,
+ atSeriesEnd,
+ get,
+ numberOfValuesAbove,
+ numberOfValuesEqualOrBelow,
+ sum,
+ average,
+ minimum,
+ maximum,
+ median,
+ series,
+ reset
+ }
+}
diff --git a/app/engine/utils/Series.test.js b/app/engine/utils/Series.test.js
new file mode 100644
index 0000000000..1d9962c3ec
--- /dev/null
+++ b/app/engine/utils/Series.test.js
@@ -0,0 +1,214 @@
+'use strict'
+/*
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
+*/
+/**
+ * As this object is fundamental for most other utility objects, we must test its behaviour quite thoroughly
+ */
+import { test } from 'uvu'
+import * as assert from 'uvu/assert'
+
+import { createSeries } from './Series.js'
+
+test('Series behaviour with an empty series', () => {
+ const dataSeries = createSeries(3)
+ testLength(dataSeries, 0)
+ testatSeriesBegin(dataSeries, 0)
+ testAtSeriesEnd(dataSeries, 0)
+ testNumberOfValuesAbove(dataSeries, 0, 0)
+ testNumberOfValuesEqualOrBelow(dataSeries, 0, 0)
+ testNumberOfValuesAbove(dataSeries, 10, 0)
+ testNumberOfValuesEqualOrBelow(dataSeries, 10, 0)
+ testSum(dataSeries, 0)
+ testAverage(dataSeries, 0)
+ testMedian(dataSeries, 0)
+ testMinimum(dataSeries, 0)
+ testMaximum(dataSeries, 0)
+})
+
+test('Series behaviour with a single pushed value. Series = [9]', () => {
+ const dataSeries = createSeries(3)
+ dataSeries.push(9)
+ testLength(dataSeries, 1)
+ testatSeriesBegin(dataSeries, 9)
+ testAtSeriesEnd(dataSeries, 9)
+ testNumberOfValuesAbove(dataSeries, 0, 1)
+ testNumberOfValuesEqualOrBelow(dataSeries, 0, 0)
+ testNumberOfValuesAbove(dataSeries, 10, 0)
+ testNumberOfValuesEqualOrBelow(dataSeries, 10, 1)
+ testSum(dataSeries, 9)
+ testAverage(dataSeries, 9)
+ testMedian(dataSeries, 9)
+ testMinimum(dataSeries, 9)
+ testMaximum(dataSeries, 9)
+})
+
+test('Series behaviour with a second pushed value. Series = [9, 3]', () => {
+ const dataSeries = createSeries(3)
+ dataSeries.push(9)
+ dataSeries.push(3)
+ testLength(dataSeries, 2)
+ testatSeriesBegin(dataSeries, 9)
+ testAtSeriesEnd(dataSeries, 3)
+ testNumberOfValuesAbove(dataSeries, 0, 2)
+ testNumberOfValuesEqualOrBelow(dataSeries, 0, 0)
+ testNumberOfValuesAbove(dataSeries, 10, 0)
+ testNumberOfValuesEqualOrBelow(dataSeries, 10, 2)
+ testSum(dataSeries, 12)
+ testAverage(dataSeries, 6)
+ testMedian(dataSeries, 6)
+ testMinimum(dataSeries, 3)
+ testMaximum(dataSeries, 9)
+})
+
+test('Series behaviour with a third pushed value. Series = [9, 3, 6]', () => {
+ const dataSeries = createSeries(3)
+ dataSeries.push(9)
+ dataSeries.push(3)
+ dataSeries.push(6)
+ testLength(dataSeries, 3)
+ testatSeriesBegin(dataSeries, 9)
+ testAtSeriesEnd(dataSeries, 6)
+ testNumberOfValuesAbove(dataSeries, 0, 3)
+ testNumberOfValuesEqualOrBelow(dataSeries, 0, 0)
+ testNumberOfValuesAbove(dataSeries, 10, 0)
+ testNumberOfValuesEqualOrBelow(dataSeries, 10, 3)
+ testSum(dataSeries, 18)
+ testAverage(dataSeries, 6)
+ testMedian(dataSeries, 6)
+ testMinimum(dataSeries, 3)
+ testMaximum(dataSeries, 9)
+})
+
+test('Series behaviour with a fourth pushed value. Series = [3, 6, 12]', () => {
+ const dataSeries = createSeries(3)
+ dataSeries.push(9)
+ dataSeries.push(3)
+ dataSeries.push(6)
+ dataSeries.push(12)
+ testLength(dataSeries, 3)
+ testatSeriesBegin(dataSeries, 3)
+ testAtSeriesEnd(dataSeries, 12)
+ testNumberOfValuesAbove(dataSeries, 0, 3)
+ testNumberOfValuesEqualOrBelow(dataSeries, 0, 0)
+ testNumberOfValuesAbove(dataSeries, 10, 1)
+ testNumberOfValuesEqualOrBelow(dataSeries, 10, 2)
+ testSum(dataSeries, 21)
+ testAverage(dataSeries, 7)
+ testMedian(dataSeries, 6)
+ testMinimum(dataSeries, 3)
+ testMaximum(dataSeries, 12)
+})
+
+test('Series behaviour with a fifth pushed value. Series = [6, 12, -3]', () => {
+ const dataSeries = createSeries(3)
+ dataSeries.push(9)
+ dataSeries.push(3)
+ dataSeries.push(6)
+ dataSeries.push(12)
+ dataSeries.push(-3)
+ testLength(dataSeries, 3)
+ testatSeriesBegin(dataSeries, 6)
+ testAtSeriesEnd(dataSeries, -3)
+ testNumberOfValuesAbove(dataSeries, 0, 2)
+ testNumberOfValuesEqualOrBelow(dataSeries, 0, 1)
+ testNumberOfValuesAbove(dataSeries, 10, 1)
+ testNumberOfValuesEqualOrBelow(dataSeries, 10, 2)
+ testSum(dataSeries, 15)
+ testAverage(dataSeries, 5)
+ testMedian(dataSeries, 6)
+ testMinimum(dataSeries, -3)
+ testMaximum(dataSeries, 12)
+})
+
+test('Series behaviour pushing out the min and max value and forcing a recalculate of min/max via the array.', () => {
+ const dataSeries = createSeries(3)
+ dataSeries.push(9)
+ dataSeries.push(3)
+ dataSeries.push(6)
+ testMinimum(dataSeries, 3)
+ testMaximum(dataSeries, 9)
+ dataSeries.push(6)
+ testMinimum(dataSeries, 3)
+ testMaximum(dataSeries, 6)
+ dataSeries.push(6)
+ testMinimum(dataSeries, 6)
+ testMaximum(dataSeries, 6)
+})
+
+test('Series behaviour pushing out the min and max value, replacing them just in time.', () => {
+ const dataSeries = createSeries(3)
+ dataSeries.push(9)
+ dataSeries.push(3)
+ dataSeries.push(6)
+ testMinimum(dataSeries, 3)
+ testMaximum(dataSeries, 9)
+ dataSeries.push(12)
+ testMinimum(dataSeries, 3)
+ testMaximum(dataSeries, 12)
+ dataSeries.push(1)
+ testMinimum(dataSeries, 1)
+ testMaximum(dataSeries, 12)
+})
+
+test('Series behaviour with a five pushed values followed by a reset, Series = []', () => {
+ const dataSeries = createSeries(3)
+ dataSeries.push(9)
+ dataSeries.push(3)
+ dataSeries.push(6)
+ dataSeries.push(12)
+ dataSeries.push(-3)
+ dataSeries.reset()
+ testLength(dataSeries, 0)
+ testatSeriesBegin(dataSeries, 0)
+ testAtSeriesEnd(dataSeries, 0)
+ testNumberOfValuesAbove(dataSeries, 0, 0)
+ testNumberOfValuesEqualOrBelow(dataSeries, 0, 0)
+ testNumberOfValuesAbove(dataSeries, 10, 0)
+ testNumberOfValuesEqualOrBelow(dataSeries, 10, 0)
+ testSum(dataSeries, 0)
+ testAverage(dataSeries, 0)
+ testMedian(dataSeries, 0)
+})
+
+function testLength (series, expectedValue) {
+ assert.ok(series.length() === expectedValue, `Expected length should be ${expectedValue}, encountered ${series.length()}`)
+}
+
+function testatSeriesBegin (series, expectedValue) {
+ assert.ok(series.atSeriesBegin() === expectedValue, `Expected atSeriesBegin to be ${expectedValue}, encountered ${series.atSeriesBegin()}`)
+}
+
+function testAtSeriesEnd (series, expectedValue) {
+ assert.ok(series.atSeriesEnd() === expectedValue, `Expected atSeriesEnd to be ${expectedValue}, encountered ${series.atSeriesEnd()}`)
+}
+
+function testNumberOfValuesAbove (series, cutoff, expectedValue) {
+ assert.ok(series.numberOfValuesAbove(cutoff) === expectedValue, `Expected numberOfValuesAbove(${cutoff}) to be ${expectedValue}, encountered ${series.numberOfValuesAbove(cutoff)}`)
+}
+
+function testNumberOfValuesEqualOrBelow (series, cutoff, expectedValue) {
+ assert.ok(series.numberOfValuesEqualOrBelow(cutoff) === expectedValue, `Expected numberOfValuesEqualOrBelow(${cutoff}) to be ${expectedValue}, encountered ${series.numberOfValuesEqualOrBelow(cutoff)}`)
+}
+
+function testSum (series, expectedValue) {
+ assert.ok(series.sum() === expectedValue, `Expected sum to be ${expectedValue}, encountered ${series.sum()}`)
+}
+
+function testAverage (series, expectedValue) {
+ assert.ok(series.average() === expectedValue, `Expected average to be ${expectedValue}, encountered ${series.average()}`)
+}
+
+function testMedian (series, expectedValue) {
+ assert.ok(series.median() === expectedValue, `Expected median to be ${expectedValue}, encountered ${series.median()}`)
+}
+
+function testMinimum (series, expectedValue) {
+ assert.ok(series.minimum() === expectedValue, `Expected minimum to be ${expectedValue}, encountered ${series.minimum()}`)
+}
+
+function testMaximum (series, expectedValue) {
+ assert.ok(series.maximum() === expectedValue, `Expected maximum to be ${expectedValue}, encountered ${series.maximum()}`)
+}
+
+test.run()
diff --git a/app/engine/utils/StreamFilter.js b/app/engine/utils/StreamFilter.js
new file mode 100644
index 0000000000..6f77c68664
--- /dev/null
+++ b/app/engine/utils/StreamFilter.js
@@ -0,0 +1,62 @@
+'use strict'
+/*
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
+*/
+/**
+ * This keeps a series of specified length, which we can ask for an moving median
+ *
+ */
+import { createLabelledBinarySearchTree } from './BinarySearchTree.js'
+
+export function createStreamFilter (maxLength, defaultValue) {
+ let lastRawDatapoint = defaultValue
+ let cleanDatapoint = defaultValue
+ let position = 0
+ let bst = createLabelledBinarySearchTree()
+
+ function push (dataPoint) {
+ if (dataPoint !== undefined && !isNaN(dataPoint)) {
+ lastRawDatapoint = dataPoint
+ if (maxLength > 0) {
+ position = (position + 1) % maxLength
+ bst.remove(position)
+ bst.push(position, dataPoint)
+ } else {
+ bst.push(position, dataPoint)
+ }
+ cleanDatapoint = bst.median()
+ }
+ }
+
+ function raw () {
+ return lastRawDatapoint
+ }
+
+ function clean () {
+ if (bst.size() > 0) {
+ // The series contains sufficient values to be valid
+ return cleanDatapoint
+ } else {
+ // The array isn't sufficiently filled
+ return defaultValue
+ }
+ }
+
+ function reliable () {
+ return bst.size() > 0
+ }
+
+ function reset () {
+ bst.reset()
+ lastRawDatapoint = defaultValue
+ cleanDatapoint = defaultValue
+ }
+
+ return {
+ push,
+ raw,
+ clean,
+ reliable,
+ reset
+ }
+}
diff --git a/app/engine/utils/StreamFilter.test.js b/app/engine/utils/StreamFilter.test.js
new file mode 100644
index 0000000000..a5dbc9c8e9
--- /dev/null
+++ b/app/engine/utils/StreamFilter.test.js
@@ -0,0 +1,69 @@
+'use strict'
+/*
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
+*/
+import { test } from 'uvu'
+import * as assert from 'uvu/assert'
+
+import { createStreamFilter } from './StreamFilter.js'
+
+test('average should be initValue on empty dataset', () => {
+ const datapoints = createStreamFilter(3, 5.5)
+ testReliable(datapoints, false)
+ testClean(datapoints, 5.5)
+})
+
+test('an averager of length 1 should return the last added value', () => {
+ const datapoints = createStreamFilter(3, 5.5)
+ datapoints.push(9)
+ testReliable(datapoints, true)
+ testClean(datapoints, 9)
+})
+
+test('a median of length 2 should return average of the 2 added elements', () => {
+ const datapoints = createStreamFilter(3, 5.5)
+ datapoints.push(9)
+ datapoints.push(4)
+ testReliable(datapoints, true)
+ testClean(datapoints, 6.5)
+})
+
+test('a median of three values should deliver the middle element', () => {
+ const datapoints = createStreamFilter(3, 5.5)
+ datapoints.push(9)
+ datapoints.push(4)
+ datapoints.push(3)
+ testReliable(datapoints, true)
+ testClean(datapoints, 4)
+})
+
+test('elements outside of range should not be considered', () => {
+ const datapoints = createStreamFilter(3, 5.5)
+ datapoints.push(9)
+ datapoints.push(4)
+ datapoints.push(3)
+ datapoints.push(1)
+ testReliable(datapoints, true)
+ testClean(datapoints, 3)
+})
+
+test('elements outside of range should not be considered', () => {
+ const datapoints = createStreamFilter(3, 5.5)
+ datapoints.push(9)
+ datapoints.push(4)
+ datapoints.push(3)
+ datapoints.push(1)
+ datapoints.reset()
+ testReliable(datapoints, false)
+ testClean(datapoints, 5.5)
+})
+
+function testClean (series, expectedValue) {
+ assert.ok(series.clean() === expectedValue, `Expected clean datapoint should be ${expectedValue}, encountered ${series.clean()}`)
+}
+
+function testReliable (series, expectedValue) {
+ assert.ok(series.reliable() === expectedValue, `Expected clean datapoint should be ${expectedValue}, encountered ${series.reliable()}`)
+}
+
+test.run()
diff --git a/app/engine/utils/WeighedSeries.js b/app/engine/utils/WeighedSeries.js
new file mode 100644
index 0000000000..8581597f5b
--- /dev/null
+++ b/app/engine/utils/WeighedSeries.js
@@ -0,0 +1,113 @@
+'use strict'
+/*
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
+
+ This creates a series with a maximum number of values
+ It allows for determining the Average, Median, Number of Positive, number of Negative
+*/
+
+import { createSeries } from './Series.js'
+
+export function createWeighedSeries (maxSeriesLength, defaultValue) {
+ const dataArray = createSeries(maxSeriesLength)
+ const weightArray = createSeries(maxSeriesLength)
+ const weightedArray = createSeries(maxSeriesLength)
+
+ function push (value, weight) {
+ if (value === undefined || isNaN(value) || weight === undefined || isNaN(weight)) { return }
+ dataArray.push(value)
+ weightArray.push(weight)
+ weightedArray.push(value * weight)
+ }
+
+ function length () {
+ return dataArray.length()
+ }
+
+ function atSeriesBegin () {
+ return dataArray.atSeriesBegin()
+ }
+
+ function atSeriesEnd () {
+ return dataArray.atSeriesEnd()
+ }
+
+ function get (position) {
+ return dataArray.get(position)
+ }
+
+ function numberOfValuesAbove (testedValue) {
+ return dataArray.numberOfValuesAbove(testedValue)
+ }
+
+ function numberOfValuesEqualOrBelow (testedValue) {
+ return dataArray.numberOfValuesEqualOrBelow(testedValue)
+ }
+
+ function sum () {
+ return dataArray.sum()
+ }
+
+ function average () {
+ if (dataArray.length() > 0) {
+ // The series contains sufficient values to be valid
+ return dataArray.average()
+ } else {
+ // The array isn't sufficiently filled
+ return defaultValue
+ }
+ }
+
+ function weighedAverage () {
+ if (dataArray.length() > 0 && weightArray.sum() !== 0) {
+ return (weightedArray.sum() / weightArray.sum())
+ } else {
+ return defaultValue
+ }
+ }
+
+ function minimum () {
+ return dataArray.minimum()
+ }
+
+ function maximum () {
+ return dataArray.maximum()
+ }
+
+ function median () {
+ return dataArray.median()
+ }
+
+ function reliable () {
+ return dataArray.length() > 0
+ }
+
+ function series () {
+ return dataArray.series()
+ }
+
+ function reset () {
+ dataArray.reset()
+ weightArray.reset()
+ weightedArray.reset()
+ }
+
+ return {
+ push,
+ length,
+ atSeriesBegin,
+ atSeriesEnd,
+ get,
+ numberOfValuesAbove,
+ numberOfValuesEqualOrBelow,
+ sum,
+ average,
+ weighedAverage,
+ minimum,
+ maximum,
+ median,
+ series,
+ reliable,
+ reset
+ }
+}
diff --git a/app/engine/utils/curveMetrics.js b/app/engine/utils/curveMetrics.js
new file mode 100644
index 0000000000..746a15562c
--- /dev/null
+++ b/app/engine/utils/curveMetrics.js
@@ -0,0 +1,71 @@
+'use strict'
+/*
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
+
+ This keeps an array, for all in-stroke metrics
+*/
+import { createSeries } from './Series.js'
+
+export function createCurveMetrics () {
+ const _curve = createSeries()
+ let _max = 0
+ let totalInputXTime = 0
+ let totaltime = 0
+
+ function push (deltaTime, inputValue) {
+ // add the new dataPoint to the array, we have to move datapoints starting at the oldst ones
+ if (inputValue > 0) {
+ _curve.push(inputValue)
+ _max = Math.max(_max, inputValue)
+ totalInputXTime += deltaTime * inputValue
+ totaltime += deltaTime
+ } else {
+ // Let's skip negative and zero values with 0's as they are not relevant
+ _curve.push(0)
+ }
+ }
+
+ function peak () {
+ if (_max > 0) {
+ return _max
+ } else {
+ return 0
+ }
+ }
+
+ function average () {
+ if (totaltime > 0 && totalInputXTime > 0) {
+ return totalInputXTime / totaltime
+ } else {
+ return 0
+ }
+ }
+
+ function curve () {
+ if (_curve.length() > 0) {
+ return Array.from(_curve.series())
+ } else {
+ return []
+ }
+ }
+
+ function length () {
+ return _curve.length()
+ }
+
+ function reset () {
+ _curve.reset()
+ _max = 0
+ totalInputXTime = 0
+ totaltime = 0
+ }
+
+ return {
+ push,
+ peak,
+ average,
+ curve,
+ length,
+ reset
+ }
+}
diff --git a/app/engine/utils/metrics.interface.js b/app/engine/utils/metrics.interface.js
new file mode 100644
index 0000000000..b95d8786a6
--- /dev/null
+++ b/app/engine/utils/metrics.interface.js
@@ -0,0 +1,77 @@
+/**
+ * @typedef {{isMoving: boolean,
+ * isDriveStart: boolean,
+ * isRecoveryStart: boolean,
+ * isSessionStart: boolean,
+ * isIntervalStart: boolean,
+ * isSplitEnd: boolean,
+ * isPauseStart: boolean,
+ * isPauseEnd: boolean,
+ * isSessionStop: boolean
+ * }} MetricsContext
+ */
+/**
+ * @typedef {'justrow'|
+ * 'time'|
+ * 'distance'|
+ * 'calories'|
+ * 'rest'
+ * } SessionType
+ */
+/**
+ * @typedef {'WaitingForStart'|
+ * 'Rowing'|
+ * 'Paused'|
+ * 'Stopped'
+ * } SessionState
+ */
+/**
+ * @typedef {'WaitingForDrive'|
+ * 'Drive'|
+ * 'Recovery'|
+ * 'Stopped'
+ * } StrokeState
+ */
+/**
+ * @typedef {{
+ * metricsContext: MetricsContext,
+ * sessionStatus: SessionState,
+ * strokeState: StrokeState,
+ * timestamp: number,
+ * cyclePower: number,
+ * totalLinearDistance: number,
+ * totalMovingTime: number,
+ * totalNumberOfStrokes: number,
+ * driveLastStartTime: number,
+ * driveLength: number,
+ * driveDuration: number,
+ * driveHandleForceCurve: Array,
+ * driveHandleVelocityCurve: Array,
+ * driveHandlePowerCurve: Array,
+ * drivePeakHandleForce: number,
+ * driveAverageHandleForce: number,
+ * cycleStrokeRate: number,
+ * cyclePace: number,
+ * cycleLinearVelocity: number,
+ * cycleDistance: number,
+ * cycleDuration: number,
+ * cycleProjectedEndTime: number,
+ * cycleProjectedEndLinearDistance: number
+ * recoveryDuration: number,
+ * strokeCalories: number,
+ * totalCalories: number,
+ * totalCaloriesPerHour: number,
+ * totalCaloriesPerMinute: number,
+ * strokeWork: number,
+ * dragFactor: number,
+ * heartrate?: number,
+ * heartRateBatteryLevel?: number
+ * splitNumber: number
+ * }} Metrics
+ */
+/**
+ * @typedef {{
+ * totalMovingTime: number,
+ * totalLinearDistance: number
+ * }} SplitTimeDistanceData
+ */
diff --git a/app/engine/utils/workoutSegment.js b/app/engine/utils/workoutSegment.js
new file mode 100644
index 0000000000..d650fda338
--- /dev/null
+++ b/app/engine/utils/workoutSegment.js
@@ -0,0 +1,540 @@
+'use strict'
+/*
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
+*/
+/**
+ * This Module supports the creation and use of workoutSegment
+ * @see {@link https://github.com/JaapvanEkris/openrowingmonitor/blob/main/docs/Architecture.md#session-interval-and-split-boundaries-in-sessionmanagerjs|the description of the concepts used}
+ */
+/* eslint-disable max-lines -- This contains a lot of defensive programming, so it is long */
+import { createOLSLinearSeries } from './OLSLinearSeries.js'
+import { createSeries } from './Series.js'
+import loglevel from 'loglevel'
+const log = loglevel.getLogger('RowingEngine')
+
+export function createWorkoutSegment (config) {
+ const numOfDataPointsForAveraging = config.numOfPhasesForAveragingScreenData
+ const distanceOverTime = createOLSLinearSeries(Math.min(4, numOfDataPointsForAveraging))
+ const _power = createSeries()
+ const _linearVelocity = createSeries()
+ const _strokerate = createSeries()
+ const _strokedistance = createSeries()
+ const _caloriesPerHour = createSeries()
+ const _dragFactor = createSeries()
+ let _type = 'justrow'
+ let _startTimestamp
+ let _startMovingTime = 0
+ let _startLinearDistance = 0
+ let _startStrokeNumber = 0
+ let _startCalories = 0
+ let _targetTime = 0
+ let _targetDistance = 0
+ let _endMovingTime = 0
+ let _endLinearDistance = 0
+ let _totalNumberIntervals = 0
+ let _split = {
+ type: 'justrow',
+ targetDistance: 0,
+ targetTime: 0
+ }
+
+ function setStart (baseMetrics) {
+ resetSegmentMetrics()
+ _startMovingTime = (baseMetrics.totalMovingTime !== undefined && baseMetrics.totalMovingTime > 0 ? baseMetrics.totalMovingTime : 0)
+ _startLinearDistance = (baseMetrics.totalLinearDistance !== undefined && baseMetrics.totalLinearDistance > 0 ? baseMetrics.totalLinearDistance : 0)
+ _startTimestamp = baseMetrics.timestamp
+ _startCalories = baseMetrics.totalCalories
+ _startStrokeNumber = baseMetrics.totalNumberOfStrokes
+ }
+
+ function setStartTimestamp (timestamp) {
+ _startTimestamp = timestamp
+ }
+
+ function getStartTimestamp () {
+ return _startTimestamp
+ }
+
+ function summarize (intervals) {
+ let intervalNumber = 0
+ let totalDistance = 0
+ let totalTime = 0
+ let containsJustRow = false
+ _totalNumberIntervals = Math.max(intervals.length, 1)
+ switch (true) {
+ case (intervals.length === 0):
+ setEnd({ type: 'justrow' })
+ break
+ case (intervals.length === 1):
+ setEnd(intervals[0])
+ break
+ case (intervals.length > 1):
+ while (intervalNumber < intervals.length) {
+ switch (true) {
+ case (intervals[intervalNumber].type === 'rest' && intervals[intervalNumber].targetTime > 0):
+ // As a rest has no impact on the (target) total moving time and distance, there is nothing to do here
+ break
+ case (intervals[intervalNumber].type === 'distance' && intervals[intervalNumber].targetDistance > 0):
+ totalDistance = totalDistance + Number(intervals[intervalNumber].targetDistance)
+ break
+ case (intervals[intervalNumber].type === 'time' && intervals[intervalNumber].targetTime > 0):
+ totalTime = totalTime + Number(intervals[intervalNumber].targetTime)
+ break
+ case (intervals[intervalNumber].type === 'justrow'):
+ containsJustRow = true
+ break
+ default:
+ containsJustRow = true
+ }
+ intervalNumber++
+ }
+ switch (true) {
+ case (containsJustRow):
+ setEnd({ type: 'justrow' })
+ break
+ case (totalDistance > 0 && totalTime === 0):
+ setEnd({ type: 'distance', targetDistance: totalDistance })
+ break
+ case (totalTime > 0 && totalDistance === 0):
+ setEnd({ type: 'time', targetTime: totalTime })
+ break
+ case (totalTime > 0 && totalDistance > 0):
+ setEnd({ type: 'justrow' })
+ break
+ default:
+ setEnd({ type: 'justrow' })
+ }
+ break
+ default:
+ setEnd({ type: 'justrow' })
+ }
+ }
+
+ function setEnd (intervalSettings) {
+ // Set the primairy parameters
+ switch (true) {
+ case (intervalSettings.type === 'rest' && intervalSettings.targetTime > 0):
+ // A target time is set for a rest interval
+ _type = 'rest'
+ _targetTime = Number(intervalSettings.targetTime)
+ _targetDistance = 0
+ _endMovingTime = _startMovingTime + Number(intervalSettings.targetTime)
+ _endLinearDistance = 0
+ log.debug(` Workout parser, recognised ${_type} interval/split, ${_targetTime} seconds`)
+ break
+ case (intervalSettings.type === 'rest'):
+ // An undefined rest interval
+ _type = 'rest'
+ _targetTime = 0
+ _targetDistance = 0
+ _endMovingTime = _startMovingTime
+ _endLinearDistance = 0
+ log.debug(` Workout parser, recognised undetermined ${_type} interval`)
+ break
+ case (intervalSettings.type === 'distance' && intervalSettings.targetDistance > 0):
+ // A target distance is set
+ _type = 'distance'
+ _targetTime = 0
+ _targetDistance = Number(intervalSettings.targetDistance)
+ _endMovingTime = 0
+ _endLinearDistance = _startLinearDistance + Number(intervalSettings.targetDistance)
+ log.debug(` Workout parser, recognised ${_type} interval/split, ${_targetDistance} meters`)
+ break
+ case (intervalSettings.type === 'time' && intervalSettings.targetTime > 0):
+ // A target time is set
+ _type = 'time'
+ _targetTime = Number(intervalSettings.targetTime)
+ _targetDistance = 0
+ _endMovingTime = _startMovingTime + Number(intervalSettings.targetTime)
+ _endLinearDistance = 0
+ log.debug(` Workout parser, recognised ${_type} interval/split, ${_targetTime} seconds`)
+ break
+ case (intervalSettings.type === 'justrow'):
+ _type = 'justrow'
+ _targetTime = 0
+ _targetDistance = 0
+ _endMovingTime = 0
+ _endLinearDistance = 0
+ log.debug(` Workout parser, recognised ${_type} interval/split`)
+ break
+ default:
+ log.error(`Workout parser, unknown interval type '${intervalSettings.type}', defaulting to a 'justrow' interval`)
+ _type = 'justrow'
+ _targetTime = 0
+ _targetDistance = 0
+ _endMovingTime = 0
+ _endLinearDistance = 0
+ }
+
+ // Set the split parameters
+ switch (true) {
+ case (intervalSettings.type === 'rest'):
+ // A rest interval has no split defined
+ _split = {
+ type: 'rest',
+ targetDistance: 0,
+ targetTime: _targetTime
+ }
+ break
+ case (!!intervalSettings.split && intervalSettings.split !== undefined && intervalSettings.split.type === 'distance' && intervalSettings.split.targetDistance > 0):
+ // A target distance is set
+ _split = {
+ type: 'distance',
+ targetDistance: Number(intervalSettings.split.targetDistance),
+ targetTime: 0
+ }
+ break
+ case (!!intervalSettings.split && intervalSettings.split !== undefined && intervalSettings.split.type === 'time' && intervalSettings.split.targetTime > 0):
+ // A target time is set
+ _split = {
+ type: 'time',
+ targetDistance: 0,
+ targetTime: Number(intervalSettings.split.targetTime)
+ }
+ break
+ case (!!intervalSettings.split && intervalSettings.split !== undefined && intervalSettings.split.type === 'justrow'):
+ _split = {
+ type: _type,
+ targetDistance: _targetDistance,
+ targetTime: _targetTime
+ }
+ break
+ case (!intervalSettings.split):
+ // Split is left empty, we default to the entire interval
+ _split = {
+ type: _type,
+ targetDistance: _targetDistance,
+ targetTime: _targetTime
+ }
+ break
+ default:
+ log.error(`Workout parser, unknown split type '${intervalSettings.split.type}', defaulting to copying interval type`)
+ _split = {
+ type: _type,
+ targetDistance: _targetDistance,
+ targetTime: _targetTime
+ }
+ }
+ }
+
+ // Updates projectiondata and segment metrics
+ function push (baseMetrics) {
+ distanceOverTime.push(baseMetrics.totalMovingTime, baseMetrics.totalLinearDistance)
+ if (!!baseMetrics.cyclePower && !isNaN(baseMetrics.cyclePower) && baseMetrics.cyclePower > 0) { _power.push(baseMetrics.cyclePower) }
+ if (!!baseMetrics.cycleLinearVelocity && !isNaN(baseMetrics.cycleLinearVelocity) && baseMetrics.cycleLinearVelocity > 0) { _linearVelocity.push(baseMetrics.cycleLinearVelocity) }
+ if (!!baseMetrics.cycleStrokeRate && !isNaN(baseMetrics.cycleStrokeRate) && baseMetrics.cycleStrokeRate > 0) { _strokerate.push(baseMetrics.cycleStrokeRate) }
+ if (!!baseMetrics.cycleDistance && !isNaN(baseMetrics.cycleDistance) && baseMetrics.cycleDistance > 0) { _strokedistance.push(baseMetrics.cycleDistance) }
+ if (!!baseMetrics.totalCaloriesPerHour && !isNaN(baseMetrics.totalCaloriesPerHour) && baseMetrics.totalCaloriesPerHour > 0) { _caloriesPerHour.push(baseMetrics.totalCaloriesPerHour) }
+ if (!!baseMetrics.dragFactor && !isNaN(baseMetrics.dragFactor) && baseMetrics.dragFactor > 0) { _dragFactor.push(baseMetrics.dragFactor) }
+ }
+
+ // Returns the distance from te startpoint
+ function distanceFromStart (baseMetrics) {
+ if (!isNaN(_startLinearDistance) && _startLinearDistance >= 0 && !isNaN(baseMetrics.totalLinearDistance) && baseMetrics.totalLinearDistance > _startLinearDistance) {
+ return baseMetrics.totalLinearDistance - _startLinearDistance
+ } else {
+ return 0
+ }
+ }
+
+ // Returns the distance to the endpoint
+ function distanceToEnd (baseMetrics) {
+ if (_type === 'distance' && _endLinearDistance > 0) {
+ // We have set a distance boundary
+ return _endLinearDistance - baseMetrics.totalLinearDistance
+ } else {
+ return undefined
+ }
+ }
+
+ // Returns the moving time from the startpoint
+ function timeSinceStart (baseMetrics) {
+ if (!isNaN(_startMovingTime) && _startMovingTime >= 0 && !isNaN(baseMetrics.totalMovingTime) && baseMetrics.totalMovingTime > _startMovingTime) {
+ return baseMetrics.totalMovingTime - _startMovingTime
+ } else {
+ return 0
+ }
+ }
+
+ // Returns the projected time to the workoutsegment endpoint
+ function projectedEndTime () {
+ switch (true) {
+ case (_type === 'distance' && _endLinearDistance > 0 && distanceOverTime.reliable()):
+ // We are in a distance based interval, so we need to project
+ return distanceOverTime.projectY(_endLinearDistance)
+ case (_type === 'time' && _endMovingTime > 0):
+ return _endMovingTime
+ default:
+ return undefined
+ }
+ }
+
+ // Returns the projected time to the workoutsegment endpoint
+ function projectedEndDistance () {
+ switch (true) {
+ case (_type === 'distance' && _endLinearDistance > 0):
+ return _endLinearDistance
+ case (_type === 'time' && _endMovingTime > 0 && distanceOverTime.reliable()):
+ // We are in a time based interval, so we need to project
+ return distanceOverTime.projectX(_endMovingTime)
+ default:
+ return undefined
+ }
+ }
+
+ // Returns the time to the endpoint
+ function timeToEnd (baseMetrics) {
+ if ((_type === 'time' || _type === 'rest') && _endMovingTime > 0) {
+ // We are in a time based interval
+ return _endMovingTime - baseMetrics.totalMovingTime
+ } else {
+ return undefined
+ }
+ }
+
+ function totalTime (baseMetrics) {
+ if (!isNaN(_startTimestamp) && _startTimestamp >= 0 && !isNaN(baseMetrics.timestamp) && baseMetrics.timestamp > _startTimestamp) {
+ return Math.max((baseMetrics.timestamp.getTime() - _startTimestamp.getTime()) / 1000, (baseMetrics.totalMovingTime - _startMovingTime))
+ } else {
+ return 0
+ }
+ }
+
+ function restTime (baseMetrics) {
+ if (!isNaN(_startMovingTime) && !isNaN(_startTimestamp) && _startTimestamp >= 0 && !isNaN(baseMetrics.totalMovingTime) && !isNaN(baseMetrics.timestamp) && baseMetrics.timestamp > _startTimestamp) {
+ return (Math.max(baseMetrics.timestamp.getTime() - _startTimestamp.getTime(), 0) / 1000) - Math.max(baseMetrics.totalMovingTime - _startMovingTime, 0)
+ } else {
+ return 0
+ }
+ }
+
+ function averageLinearVelocity (baseMetrics) {
+ if (!isNaN(_startMovingTime) && _startMovingTime >= 0 && !isNaN(_startLinearDistance) && _startLinearDistance >= 0 && !isNaN(baseMetrics.totalMovingTime) && baseMetrics.totalMovingTime > _startMovingTime && !isNaN(baseMetrics.totalLinearDistance) && baseMetrics.totalLinearDistance > _startLinearDistance) {
+ return (baseMetrics.totalLinearDistance - _startLinearDistance) / (baseMetrics.totalMovingTime - _startMovingTime)
+ } else {
+ return _linearVelocity.average()
+ }
+ }
+
+ /**
+ * @param {number} linearVel
+ */
+ function linearVelocityToPace (linearVel) {
+ if (!isNaN(linearVel) && linearVel > 0) {
+ return (500.0 / linearVel)
+ } else {
+ return Infinity
+ }
+ }
+
+ function numberOfStrokes (baseMetrics) {
+ if (!isNaN(_startStrokeNumber) && _startStrokeNumber >= 0 && !isNaN(baseMetrics.totalNumberOfStrokes) && baseMetrics.totalNumberOfStrokes > _startStrokeNumber) {
+ return baseMetrics.totalNumberOfStrokes - _startStrokeNumber
+ } else {
+ return 0
+ }
+ }
+
+ function spentCalories (baseMetrics) {
+ if (!isNaN(_startCalories) && _startCalories >= 0 && !isNaN(baseMetrics.totalCalories) && baseMetrics.totalCalories > _startCalories) {
+ return baseMetrics.totalCalories - _startCalories
+ } else {
+ return 0
+ }
+ }
+
+ // Checks for reaching a boundary condition
+ function isEndReached (baseMetrics) {
+ if ((_type === 'distance' && _endLinearDistance > 0 && baseMetrics.totalLinearDistance >= _endLinearDistance) || (_type === 'time' && _endMovingTime > 0 && baseMetrics.totalMovingTime >= _endMovingTime)) {
+ // We have exceeded the boundary
+ return true
+ } else {
+ return false
+ }
+ }
+
+ function interpolateEnd (prevMetrics, currMetrics) {
+ const projectedMetrics = { ...prevMetrics }
+ projectedMetrics.modified = false
+ switch (true) {
+ case (_type === 'distance' && _endLinearDistance > 0 && currMetrics.totalLinearDistance > _endLinearDistance):
+ // We are in a distance based interval, and overshot the targetDistance
+ projectedMetrics.totalMovingTime = interpolatedTime(prevMetrics, currMetrics, _endLinearDistance)
+ projectedMetrics.timestamp = new Date(currMetrics.timestamp.getTime() - ((currMetrics.totalMovingTime - projectedMetrics.totalMovingTime) * 1000))
+ projectedMetrics.totalLinearDistance = _endLinearDistance
+ projectedMetrics.timestamp = currMetrics.timestamp - ((currMetrics.totalMovingTime - projectedMetrics.totalMovingTime) * 1000)
+ projectedMetrics.modified = true
+ break
+ case (_type === 'time' && _endMovingTime > 0 && currMetrics.totalMovingTime > _endMovingTime):
+ // We are in a time based interval, and overshot the targetTime
+ projectedMetrics.totalLinearDistance = interpolatedDistance(prevMetrics, currMetrics, _endMovingTime)
+ projectedMetrics.totalMovingTime = _endMovingTime
+ projectedMetrics.timestamp = new Date(_startTimestamp.getTime() + (_targetTime * 1000))
+ projectedMetrics.modified = true
+ break
+ default:
+ // Nothing to do
+ }
+ projectedMetrics.timestamp = new Date(currMetrics.timestamp.getTime() - ((currMetrics.totalMovingTime - projectedMetrics.totalMovingTime) * 1000))
+ // Prevent the edge case where we trigger two strokes at milliseconds apart when using the interpolation function
+ projectedMetrics.metricsContext.isDriveStart = false
+ projectedMetrics.metricsContext.isRecoveryStart = false
+ projectedMetrics.metricsContext.isSessionStart = false
+ projectedMetrics.metricsContext.isIntervalEnd = false
+ projectedMetrics.metricsContext.isSplitEnd = false
+ projectedMetrics.metricsContext.isPauseStart = false
+ projectedMetrics.metricsContext.isPauseEnd = false
+ projectedMetrics.metricsContext.isSessionStop = false
+ return projectedMetrics
+ }
+
+ function interpolatedTime (prevMetrics, currMetrics, targetDistance) {
+ if (prevMetrics.totalLinearDistance < targetDistance && targetDistance < currMetrics.totalLinearDistance) {
+ // See https://en.wikipedia.org/wiki/Linear_interpolation
+ return (prevMetrics.totalMovingTime + ((currMetrics.totalMovingTime - prevMetrics.totalMovingTime) * ((targetDistance - prevMetrics.totalLinearDistance) / (currMetrics.totalLinearDistance - prevMetrics.totalLinearDistance))))
+ } else {
+ return currMetrics.totalMovingTime
+ }
+ }
+
+ function interpolatedDistance (prevMetrics, currMetrics, targetTime) {
+ if (prevMetrics.totalMovingTime < targetTime && targetTime < currMetrics.totalMovingTime) {
+ // See https://en.wikipedia.org/wiki/Linear_interpolation
+ return (prevMetrics.totalLinearDistance + ((currMetrics.totalLinearDistance - prevMetrics.totalLinearDistance) * ((targetTime - prevMetrics.totalMovingTime) / (currMetrics.totalMovingTime - prevMetrics.totalMovingTime))))
+ } else {
+ return currMetrics.totalLinearDistance
+ }
+ }
+
+ function getSplit () {
+ return _split
+ }
+
+ function targetDistance () {
+ if (_type === 'distance' && _endLinearDistance > 0) {
+ return _targetDistance
+ } else {
+ return undefined
+ }
+ }
+
+ function targetTime () {
+ if (_type === 'time' && _endMovingTime > 0) {
+ // We have a distance boundary
+ return _targetTime
+ } else {
+ return undefined
+ }
+ }
+
+ function type () {
+ return _type
+ }
+
+ function metrics (baseMetrics) {
+ return {
+ type: _type,
+ ...(_totalNumberIntervals > 0 ? { numberOfIntervals: _totalNumberIntervals } : {}),
+ numberOfStrokes: numberOfStrokes(baseMetrics),
+ distance: {
+ absoluteStart: _startLinearDistance,
+ fromStart: distanceFromStart(baseMetrics),
+ target: targetDistance(),
+ toEnd: distanceToEnd(baseMetrics),
+ projectedEnd: projectedEndDistance()
+ },
+ movingTime: {
+ absoluteStart: _startMovingTime,
+ sinceStart: timeSinceStart(baseMetrics),
+ target: targetTime(),
+ toEnd: timeToEnd(baseMetrics),
+ projectedEnd: projectedEndTime()
+ },
+ timeSpent: {
+ total: totalTime(baseMetrics),
+ moving: timeSinceStart(baseMetrics),
+ rest: restTime(baseMetrics)
+ },
+ linearVelocity: {
+ average: averageLinearVelocity(baseMetrics),
+ minimum: _linearVelocity.minimum(),
+ maximum: _linearVelocity.maximum()
+ },
+ pace: {
+ average: linearVelocityToPace(averageLinearVelocity(baseMetrics)),
+ minimum: linearVelocityToPace(_linearVelocity.minimum()),
+ maximum: linearVelocityToPace(_linearVelocity.maximum())
+ },
+ power: {
+ average: _power.average(),
+ minimum: _power.minimum(),
+ maximum: _power.maximum()
+ },
+ strokeDistance: {
+ average: _strokedistance.average(),
+ minimum: _strokedistance.minimum(),
+ maximum: _strokedistance.maximum()
+ },
+ strokerate: {
+ average: _strokerate.average(),
+ minimum: _strokerate.minimum(),
+ maximum: _strokerate.maximum()
+ },
+ dragfactor: {
+ average: _dragFactor.average(),
+ minimum: _dragFactor.minimum(),
+ maximum: _dragFactor.maximum()
+ },
+ calories: {
+ totalSpent: spentCalories(baseMetrics),
+ averagePerHour: _caloriesPerHour.average()
+ }
+ }
+ }
+
+ function resetSegmentMetrics () {
+ _linearVelocity.reset()
+ _strokerate.reset()
+ _strokedistance.reset()
+ _caloriesPerHour.reset()
+ _power.reset()
+ _dragFactor.reset()
+ _type = 'justrow'
+ _startTimestamp = undefined
+ _startMovingTime = 0
+ _startLinearDistance = 0
+ _startStrokeNumber = 0
+ _startCalories = 0
+ _targetTime = 0
+ _targetDistance = 0
+ _endMovingTime = 0
+ _endLinearDistance = 0
+ _split = {
+ type: 'justrow',
+ targetDistance: 0,
+ targetTime: 0
+ }
+ }
+
+ function reset () {
+ resetSegmentMetrics()
+ distanceOverTime.reset()
+ }
+
+ return {
+ setStart,
+ setStartTimestamp,
+ getStartTimestamp,
+ summarize,
+ setEnd,
+ isEndReached,
+ interpolateEnd,
+ metrics,
+ timeSinceStart,
+ timeToEnd,
+ type,
+ push,
+ getSplit,
+ reset
+ }
+}
diff --git a/app/engine/utils/workoutSegment.test.js b/app/engine/utils/workoutSegment.test.js
new file mode 100644
index 0000000000..bdc193f89e
--- /dev/null
+++ b/app/engine/utils/workoutSegment.test.js
@@ -0,0 +1,478 @@
+'use strict'
+/*
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
+*/
+/**
+ * This Module tests the behaviour of the workout segments
+ */
+import { test } from 'uvu'
+import * as assert from 'uvu/assert'
+
+import { createWorkoutSegment } from './workoutSegment.js'
+
+const basicConfig = {
+ numOfPhasesForAveragingScreenData: 4
+}
+
+test('Test workoutSegment initialisation behaviour without setting an interval', () => {
+ const startingPoint = {
+ timestamp: new Date(),
+ totalMovingTime: 0,
+ totalLinearDistance: 0,
+ metricsContext: {}
+ }
+
+ const testSegment = createWorkoutSegment(basicConfig)
+ testDistanceFromStart(testSegment, startingPoint, 0)
+ testTimeSinceStart(testSegment, startingPoint, 0)
+ testdistanceToEnd(testSegment, startingPoint, undefined)
+ testTimeToEnd(testSegment, startingPoint, undefined)
+ testTargetTime(testSegment, startingPoint, undefined)
+ testTargetDistance(testSegment, startingPoint, undefined)
+ testIsEndReached(testSegment, startingPoint, false)
+})
+
+test('Test workoutSegment initialisation behaviour without setting an interval, after 2050 meters', () => {
+ const startingPoint = {
+ timestamp: new Date(),
+ totalMovingTime: 0,
+ totalLinearDistance: 0,
+ cyclePower: 0,
+ cycleLinearVelocity: 0,
+ cycleStrokeRate: 0,
+ cycleDistance: 0,
+ totalCaloriesPerHour: 0,
+ dragFactor: 0,
+ metricsContext: {}
+ }
+
+ const endPoint = {
+ timestamp: new Date(startingPoint.timestamp.getTime() + 490 * 1000),
+ totalMovingTime: 490,
+ totalLinearDistance: 2050,
+ cyclePower: 200,
+ cycleLinearVelocity: 4.16666,
+ cycleStrokeRate: 20,
+ cycleDistance: 10,
+ totalCaloriesPerHour: 800,
+ dragFactor: 100,
+ metricsContext: {}
+ }
+
+ const testSegment = createWorkoutSegment(basicConfig)
+ testDistanceFromStart(testSegment, startingPoint, 0)
+ testTimeSinceStart(testSegment, startingPoint, 0)
+ testdistanceToEnd(testSegment, startingPoint, undefined)
+ testTimeToEnd(testSegment, startingPoint, undefined)
+ testTargetTime(testSegment, startingPoint, undefined)
+ testTargetDistance(testSegment, startingPoint, undefined)
+ testIsEndReached(testSegment, startingPoint, false)
+ testDistanceFromStart(testSegment, endPoint, 2050)
+ testTimeSinceStart(testSegment, endPoint, 490)
+ testdistanceToEnd(testSegment, endPoint, undefined)
+ testTimeToEnd(testSegment, endPoint, undefined)
+ testIsEndReached(testSegment, endPoint, false)
+ testSegment.push(endPoint)
+ testAverageLinearVelocity (testSegment, endPoint, 4.183673469387755)
+ testMaximumLinearVelocity (testSegment, endPoint, 4.16666)
+ testMinimumLinearVelocity (testSegment, endPoint, 4.16666)
+ testAveragePace (testSegment, endPoint, 119.51219512195122)
+ testMaximumPace (testSegment, endPoint, 120.0001920003072)
+ testMinimumPace (testSegment, endPoint, 120.0001920003072)
+ testAveragePower (testSegment, endPoint, 200)
+ testMaximumPower (testSegment, endPoint, 200)
+ testMinimumPower (testSegment, endPoint, 200)
+})
+
+test('Test workoutSegment behaviour with setting a distance interval', () => {
+ const distanceInterval = {
+ type: 'distance',
+ targetDistance: 2025,
+ targetTime: 0,
+ split: {
+ type: 'distance',
+ targetDistance: 500,
+ targetTime: 0
+ }
+ }
+
+ const startingPoint = {
+ timestamp: new Date(),
+ totalMovingTime: 0,
+ totalLinearDistance: 0,
+ cyclePower: 0,
+ cycleLinearVelocity: 0,
+ cycleStrokeRate: 0,
+ cycleDistance: 0,
+ totalCaloriesPerHour: 0,
+ dragFactor: 100,
+ metricsContext: {}
+ }
+
+ const middlePoint = {
+ timestamp: new Date(startingPoint.timestamp.getTime() + 480 * 1000),
+ totalMovingTime: 480,
+ totalLinearDistance: 2000,
+ cyclePower: 200,
+ cycleLinearVelocity: 4.16666,
+ cycleStrokeRate: 20,
+ cycleDistance: 10,
+ totalCaloriesPerHour: 800,
+ dragFactor: 100,
+ metricsContext: {}
+ }
+
+ const endPoint = {
+ timestamp: new Date(startingPoint.timestamp.getTime() + 490 * 1000),
+ totalMovingTime: 490,
+ totalLinearDistance: 2050,
+ cyclePower: 200,
+ cycleLinearVelocity: 4.16666,
+ cycleStrokeRate: 20,
+ cycleDistance: 10,
+ totalCaloriesPerHour: 800,
+ dragFactor: 100,
+ metricsContext: {}
+ }
+
+ const testSegment = createWorkoutSegment(basicConfig)
+ testSegment.setStart(startingPoint)
+ testSegment.setEnd(distanceInterval)
+ testDistanceFromStart(testSegment, startingPoint, 0)
+ testTimeSinceStart(testSegment, startingPoint, 0)
+ testdistanceToEnd(testSegment, startingPoint, 2025)
+ testTimeToEnd(testSegment, startingPoint, undefined)
+ testIsEndReached(testSegment, startingPoint, false)
+ testDistanceFromStart(testSegment, middlePoint, 2000)
+ testTimeSinceStart(testSegment, middlePoint, 480)
+ testdistanceToEnd(testSegment, middlePoint, 25)
+ testTimeToEnd(testSegment, middlePoint, undefined)
+ testIsEndReached(testSegment, middlePoint, false)
+ testDistanceFromStart(testSegment, endPoint, 2050)
+ testTimeSinceStart(testSegment, endPoint, 490)
+ testdistanceToEnd(testSegment, endPoint, -25)
+ testTimeToEnd(testSegment, endPoint, undefined)
+ testIsEndReached(testSegment, endPoint, true)
+ testInterpolation(testSegment, middlePoint, endPoint, 485, 2025)
+ testSegment.push(middlePoint)
+ testSegment.push(endPoint)
+ testAverageLinearVelocity (testSegment, endPoint, 4.183673469387755)
+ testMaximumLinearVelocity (testSegment, endPoint, 4.16666)
+ testMinimumLinearVelocity (testSegment, endPoint, 4.16666)
+ testAveragePace (testSegment, endPoint, 119.51219512195122)
+ testMaximumPace (testSegment, endPoint, 120.0001920003072)
+ testMinimumPace (testSegment, endPoint, 120.0001920003072)
+ testAveragePower (testSegment, endPoint, 200)
+ testMaximumPower (testSegment, endPoint, 200)
+ testMinimumPower (testSegment, endPoint, 200)
+})
+
+test('Test workoutSegment behaviour with setting a time interval', () => {
+ const distanceInterval = {
+ type: 'time',
+ targetDistance: 0,
+ targetTime: 485,
+ split: {
+ type: 'time',
+ targetDistance: 0,
+ targetTime: 60
+ }
+ }
+
+ const startingPoint = {
+ timestamp: new Date(),
+ totalMovingTime: 0,
+ totalLinearDistance: 0,
+ metricsContext: {}
+ }
+
+ const middlePoint = {
+ timestamp: new Date(startingPoint.timestamp.getTime() + 480 * 1000),
+ totalMovingTime: 480,
+ totalLinearDistance: 2000,
+ cyclePower: 200,
+ cycleLinearVelocity: 4.16666,
+ cycleStrokeRate: 20,
+ cycleDistance: 10,
+ totalCaloriesPerHour: 800,
+ dragFactor: 100,
+ metricsContext: {}
+ }
+
+ const endPoint = {
+ timestamp: new Date(startingPoint.timestamp.getTime() + 490 * 1000),
+ totalMovingTime: 490,
+ totalLinearDistance: 2050,
+ cyclePower: 200,
+ cycleLinearVelocity: 4.16666,
+ cycleStrokeRate: 20,
+ cycleDistance: 10,
+ totalCaloriesPerHour: 800,
+ dragFactor: 100,
+ metricsContext: {}
+ }
+
+ const testSegment = createWorkoutSegment(basicConfig)
+ testSegment.setStart(startingPoint)
+ testSegment.setEnd(distanceInterval)
+ testDistanceFromStart(testSegment, startingPoint, 0)
+ testTimeSinceStart(testSegment, startingPoint, 0)
+ testdistanceToEnd(testSegment, startingPoint, undefined)
+ testTimeToEnd(testSegment, startingPoint, 485)
+ testIsEndReached(testSegment, startingPoint, false)
+ testDistanceFromStart(testSegment, middlePoint, 2000)
+ testTimeSinceStart(testSegment, middlePoint, 480)
+ testdistanceToEnd(testSegment, middlePoint, undefined)
+ testTimeToEnd(testSegment, middlePoint, 5)
+ testIsEndReached(testSegment, middlePoint, false)
+ testDistanceFromStart(testSegment, endPoint, 2050)
+ testTimeSinceStart(testSegment, endPoint, 490)
+ testdistanceToEnd(testSegment, endPoint, undefined)
+ testTimeToEnd(testSegment, endPoint, -5)
+ testIsEndReached(testSegment, endPoint, true)
+ testInterpolation(testSegment, middlePoint, endPoint, 485, 2025)
+ testSegment.push(middlePoint)
+ testSegment.push(endPoint)
+ testAverageLinearVelocity (testSegment, middlePoint, 4.166666666666667)
+ testMaximumLinearVelocity (testSegment, middlePoint, 4.16666)
+ testMinimumLinearVelocity (testSegment, middlePoint, 4.16666)
+ testAveragePace (testSegment, middlePoint, 119.99999999999999)
+ testMaximumPace (testSegment, middlePoint, 120.0001920003072)
+ testMinimumPace (testSegment, middlePoint, 120.0001920003072)
+ testAveragePower (testSegment, middlePoint, 200)
+ testMaximumPower (testSegment, middlePoint, 200)
+ testMinimumPower (testSegment, middlePoint, 200)
+})
+
+test('Test split behaviour when setting a distance interval', () => {
+ const distanceInterval = {
+ type: 'distance',
+ targetDistance: 2025,
+ targetTime: 0,
+ split: {
+ type: 'distance',
+ targetDistance: 500,
+ targetTime: 0
+ }
+ }
+
+ const startingPoint = {
+ timestamp: new Date(),
+ totalMovingTime: 0,
+ totalLinearDistance: 0,
+ metricsContext: {}
+ }
+
+ const middlePoint = {
+ timestamp: new Date(startingPoint.timestamp.getTime() + 118 * 1000),
+ totalMovingTime: 118,
+ totalLinearDistance: 490,
+ cyclePower: 180,
+ cycleLinearVelocity: 4.1,
+ cycleStrokeRate: 20,
+ cycleDistance: 10,
+ totalCaloriesPerHour: 800,
+ dragFactor: 100,
+ metricsContext: {}
+ }
+
+ const endPoint = {
+ timestamp: new Date(startingPoint.timestamp.getTime() + 122 * 1000),
+ totalMovingTime: 122,
+ totalLinearDistance: 510,
+ cyclePower: 220,
+ cycleLinearVelocity: 4.3,
+ cycleStrokeRate: 20,
+ cycleDistance: 10,
+ totalCaloriesPerHour: 800,
+ dragFactor: 100,
+ metricsContext: {}
+ }
+
+ const testSegment = createWorkoutSegment(basicConfig)
+ const testSplit = createWorkoutSegment(basicConfig)
+ testSegment.setStart(startingPoint)
+ testSegment.setEnd(distanceInterval)
+ testSplit.setStart(startingPoint)
+ testSplit.setEnd(testSegment.getSplit())
+ testDistanceFromStart(testSplit, startingPoint, 0)
+ testTimeSinceStart(testSplit, startingPoint, 0)
+ testdistanceToEnd(testSplit, startingPoint, 500)
+ testTimeToEnd(testSplit, startingPoint, undefined)
+ testIsEndReached(testSplit, startingPoint, false)
+ testDistanceFromStart(testSplit, middlePoint, 490)
+ testTimeSinceStart(testSplit, middlePoint, 118)
+ testdistanceToEnd(testSplit, middlePoint, 10)
+ testTimeToEnd(testSplit, middlePoint, undefined)
+ testIsEndReached(testSplit, middlePoint, false)
+ testDistanceFromStart(testSplit, endPoint, 510)
+ testTimeSinceStart(testSplit, endPoint, 122)
+ testdistanceToEnd(testSplit, endPoint, -10)
+ testTimeToEnd(testSplit, endPoint, undefined)
+ testIsEndReached(testSplit, endPoint, true)
+ testInterpolation(testSplit, middlePoint, endPoint, 120, 500)
+ testSegment.push(middlePoint)
+ testSegment.push(endPoint)
+ testAverageLinearVelocity (testSegment, middlePoint, 4.1525423728813555)
+ testMaximumLinearVelocity (testSegment, middlePoint, 4.3)
+ testMinimumLinearVelocity (testSegment, middlePoint, 4.1)
+ testAveragePace (testSegment, middlePoint, 120.40816326530613)
+ testMaximumPace (testSegment, middlePoint, 116.27906976744187)
+ testMinimumPace (testSegment, middlePoint, 121.95121951219514)
+ testAveragePower (testSegment, middlePoint, 200)
+ testMaximumPower (testSegment, middlePoint, 220)
+ testMinimumPower (testSegment, middlePoint, 180)
+})
+
+test('Test split behaviour with setting a time interval', () => {
+ const distanceInterval = {
+ type: 'time',
+ targetDistance: 0,
+ targetTime: 485,
+ split: {
+ type: 'time',
+ targetDistance: 0,
+ targetTime: 120
+ }
+ }
+
+ const startingPoint = {
+ timestamp: new Date(),
+ totalMovingTime: 0,
+ totalLinearDistance: 0,
+ metricsContext: {}
+ }
+
+ const middlePoint = {
+ timestamp: new Date(startingPoint.timestamp.getTime() + 118 * 1000),
+ totalMovingTime: 118,
+ totalLinearDistance: 490,
+ cyclePower: 200,
+ cycleLinearVelocity: 4.16666,
+ cycleStrokeRate: 20,
+ cycleDistance: 10,
+ totalCaloriesPerHour: 800,
+ dragFactor: 100,
+ metricsContext: {}
+ }
+
+ const endPoint = {
+ timestamp: new Date(startingPoint.timestamp.getTime() + 122 * 1000),
+ totalMovingTime: 122,
+ totalLinearDistance: 510,
+ cyclePower: 200,
+ cycleLinearVelocity: 4.16666,
+ cycleStrokeRate: 20,
+ cycleDistance: 10,
+ totalCaloriesPerHour: 800,
+ dragFactor: 100,
+ metricsContext: {}
+ }
+
+ const testSegment = createWorkoutSegment(basicConfig)
+ const testSplit = createWorkoutSegment(basicConfig)
+ testSegment.setStart(startingPoint)
+ testSegment.setEnd(distanceInterval)
+ testSplit.setStart(startingPoint)
+ testSplit.setEnd(testSegment.getSplit())
+ testDistanceFromStart(testSplit, startingPoint, 0)
+ testTimeSinceStart(testSplit, startingPoint, 0)
+ testdistanceToEnd(testSplit, startingPoint, undefined)
+ testTimeToEnd(testSplit, startingPoint, 120)
+ testIsEndReached(testSplit, startingPoint, false)
+ testDistanceFromStart(testSplit, middlePoint, 490)
+ testTimeSinceStart(testSplit, middlePoint, 118)
+ testdistanceToEnd(testSplit, middlePoint, undefined)
+ testTimeToEnd(testSplit, middlePoint, 2)
+ testIsEndReached(testSplit, middlePoint, false)
+ testDistanceFromStart(testSplit, endPoint, 510)
+ testTimeSinceStart(testSplit, endPoint, 122)
+ testdistanceToEnd(testSplit, endPoint, undefined)
+ testTimeToEnd(testSplit, endPoint, -2)
+ testIsEndReached(testSplit, endPoint, true)
+ testInterpolation(testSplit, middlePoint, endPoint, 120, 500)
+ testSegment.push(middlePoint)
+ testSegment.push(endPoint)
+ testAverageLinearVelocity (testSegment, middlePoint, 4.1525423728813555)
+ testMaximumLinearVelocity (testSegment, middlePoint, 4.16666)
+ testMinimumLinearVelocity (testSegment, middlePoint, 4.16666)
+ testAveragePace (testSegment, middlePoint, 120.40816326530613)
+ testMaximumPace (testSegment, middlePoint, 120.0001920003072)
+ testMinimumPace (testSegment, middlePoint, 120.0001920003072)
+ testAveragePower (testSegment, middlePoint, 200)
+ testMaximumPower (testSegment, middlePoint, 200)
+ testMinimumPower (testSegment, middlePoint, 200)
+})
+
+// ToDo: Test the project EndTime and project EndDistance functions
+
+function testDistanceFromStart (testedSegment, testedDatapoint, expectedValue) {
+ assert.ok(testedSegment.metrics(testedDatapoint).distance.fromStart === expectedValue, `Expected distance from the start should be ${expectedValue}, encountered ${testedSegment.metrics(testedDatapoint).distance.fromStart}`)
+}
+
+function testTimeSinceStart (testedSegment, testedDatapoint, expectedValue) {
+ assert.ok(testedSegment.metrics(testedDatapoint).movingTime.sinceStart === expectedValue, `Expected time since start should be ${expectedValue}, encountered ${testedSegment.metrics(testedDatapoint).movingTime.sinceStart}`)
+}
+
+function testdistanceToEnd (testedSegment, testedDatapoint, expectedValue) {
+ assert.ok(testedSegment.metrics(testedDatapoint).distance.toEnd === expectedValue, `Expected distance from the end to be ${expectedValue}, encountered ${testedSegment.metrics(testedDatapoint).distance.toEnd}`)
+}
+
+function testTimeToEnd (testedSegment, testedDatapoint, expectedValue) {
+ assert.ok(testedSegment.metrics(testedDatapoint).movingTime.toEnd === expectedValue, `Expected time to end to be ${expectedValue}, encountered ${testedSegment.metrics(testedDatapoint).movingTime.toEnd}`)
+}
+
+function testIsEndReached (testedSegment, testedDatapoint, expectedValue) {
+ assert.ok(testedSegment.isEndReached(testedDatapoint) === expectedValue, `Expected time to end to be ${expectedValue}, encountered ${testedSegment.isEndReached(testedDatapoint)}`)
+}
+
+function testTargetTime (testedSegment, testedDatapoint, expectedValue) {
+ assert.ok(testedSegment.metrics(testedDatapoint).movingTime.target === expectedValue, `Expected time to end to be ${expectedValue}, encountered ${testedSegment.metrics(testedDatapoint).movingTime.target}`)
+}
+
+function testTargetDistance (testedSegment, testedDatapoint, expectedValue) {
+ assert.ok(testedSegment.metrics(testedDatapoint).distance.target === expectedValue, `Expected time to end to be ${expectedValue}, encountered ${testedSegment.metrics(testedDatapoint).distance.target}`)
+}
+
+function testInterpolation (testedSegment, dataPointOne, dataPointTwo, ExpectedTime, ExpectedDistance) {
+ assert.ok(testedSegment.interpolateEnd(dataPointOne, dataPointTwo).totalMovingTime === ExpectedTime, `Expected extrapolated time be ${ExpectedTime}, encountered ${testedSegment.interpolateEnd(dataPointOne, dataPointTwo).totalMovingTime}`)
+ assert.ok(testedSegment.interpolateEnd(dataPointOne, dataPointTwo).totalLinearDistance === ExpectedDistance, `Expected time to end to be ${ExpectedDistance}, encountered ${testedSegment.interpolateEnd(dataPointOne, dataPointTwo).totalLinearDistance}`)
+}
+
+function testAverageLinearVelocity (testedSegment, testedDatapoint, expectedValue) {
+ assert.ok(testedSegment.metrics(testedDatapoint).linearVelocity.average === expectedValue, `Expected average linear velocity to be ${expectedValue}, encountered ${testedSegment.metrics(testedDatapoint).linearVelocity.average}`)
+}
+
+function testMaximumLinearVelocity (testedSegment, testedDatapoint, expectedValue) {
+ assert.ok(testedSegment.metrics(testedDatapoint).linearVelocity.maximum === expectedValue, `Expected maximum linear velocity to be ${expectedValue}, encountered ${testedSegment.metrics(testedDatapoint).linearVelocity.maximum}`)
+}
+
+function testMinimumLinearVelocity (testedSegment, testedDatapoint, expectedValue) {
+ assert.ok(testedSegment.metrics(testedDatapoint).linearVelocity.minimum === expectedValue, `Expected minimum linear velocity to be ${expectedValue}, encountered ${testedSegment.metrics(testedDatapoint).linearVelocity.minimum}`)
+}
+
+function testAveragePace (testedSegment, testedDatapoint, expectedValue) {
+ assert.ok(testedSegment.metrics(testedDatapoint).pace.average === expectedValue, `Expected average pace to end to be ${expectedValue}, encountered ${testedSegment.metrics(testedDatapoint).pace.average}`)
+}
+
+function testMaximumPace (testedSegment, testedDatapoint, expectedValue) {
+ assert.ok(testedSegment.metrics(testedDatapoint).pace.maximum === expectedValue, `Expected maximum pace to be ${expectedValue}, encountered ${testedSegment.metrics(testedDatapoint).pace.maximum}`)
+}
+
+function testMinimumPace (testedSegment, testedDatapoint, expectedValue) {
+ assert.ok(testedSegment.metrics(testedDatapoint).pace.minimum === expectedValue, `Expected minimum pace to be ${expectedValue}, encountered ${testedSegment.metrics(testedDatapoint).pace.minimum}`)
+}
+
+function testAveragePower (testedSegment, testedDatapoint, expectedValue) {
+ assert.ok(testedSegment.metrics(testedDatapoint).power.average === expectedValue, `Expected average power to be ${expectedValue}, encountered ${testedSegment.metrics(testedDatapoint).power.average}`)
+}
+
+function testMaximumPower (testedSegment, testedDatapoint, expectedValue) {
+ assert.ok(testedSegment.metrics(testedDatapoint).power.maximum === expectedValue, `Expected maximum power to be ${expectedValue}, encountered ${testedSegment.metrics(testedDatapoint).power.maximum}`)
+}
+
+function testMinimumPower (testedSegment, testedDatapoint, expectedValue) {
+ assert.ok(testedSegment.metrics(testedDatapoint).power.minimum === expectedValue, `Expected minimum power to be ${expectedValue}, encountered ${testedSegment.metrics(testedDatapoint).power.minimum}`)
+}
+
+test.run()
diff --git a/app/gpio/GpioTimerService.js b/app/gpio/GpioTimerService.js
index e73646c5ba..61dbd29527 100644
--- a/app/gpio/GpioTimerService.js
+++ b/app/gpio/GpioTimerService.js
@@ -1,13 +1,13 @@
'use strict'
/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
Measures the time between impulses on the GPIO pin. Started in a
separate thread, since we want the measured time to be as close as
possible to real time.
*/
import process from 'process'
-import { Gpio } from 'onoff'
+import pigpio from 'pigpio'
import os from 'os'
import config from '../tools/ConfigManager.js'
import log from 'loglevel'
@@ -15,37 +15,57 @@ import log from 'loglevel'
log.setLevel(config.loglevel.default)
export function createGpioTimerService () {
- if (Gpio.accessible) {
- if (config.gpioHighPriority) {
- // setting top (near-real-time) priority for the Gpio process, as we don't want to miss anything
- log.debug('setting priority for the Gpio-service to maximum (-20)')
- try {
- // setting priority of current process
- os.setPriority(-20)
- } catch (err) {
- log.debug('need root permission to set priority of Gpio-Thread')
- }
+ // Import the settings from the settings file
+ const triggeredFlank = config.gpioTriggeredFlank
+ const pollingInterval = config.gpioPollingInterval
+ const minimumPulseLength = config.gpioMinimumPulseLength
+
+ if (config.gpioPriority) {
+ // setting top (near-real-time) priority for the Gpio process, as we don't want to miss anything
+ log.debug(`Gpio-service: Setting priority to ${config.gpioPriority}`)
+ try {
+ // setting priority of current process
+ os.setPriority(config.gpioPriority)
+ } catch (err) {
+ log.debug(`Gpio-service: FAILED to set priority of Gpio-Thread, error ${err}, are root permissions granted?`)
}
+ }
- // read the sensor data from one of the Gpio pins of Raspberry Pi
- const sensor = new Gpio(config.gpioPin, 'in', 'rising')
- // use hrtime for time measurement to get a higher time precision
- let hrStartTime = process.hrtime()
+ const Gpio = pigpio.Gpio
- // assumes that GPIO-Port 17 is set to pullup and reed is connected to GND
- // therefore the value is 1 if the reed sensor is open
- sensor.watch((err, value) => {
- if (err) {
- throw err
- }
- const hrDelta = process.hrtime(hrStartTime)
- hrStartTime = process.hrtime()
- const delta = hrDelta[0] + hrDelta[1] / 1e9
- process.send(delta)
+ // Configure the gpio polling frequency
+ pigpio.configureClock(pollingInterval, pigpio.CLOCK_PCM)
+
+ // Configure the sensor readings for one of the Gpio pins of Raspberry Pi
+ const sensor = new Gpio(
+ config.gpioPin, {
+ mode: Gpio.INPUT,
+ pullUpDown: Gpio.PUD_UP,
+ alert: true
})
- } else {
- log.info('reading from Gpio is not (yet) supported on this platform')
- }
-}
+ // Set a minumum time a level must be stable before an alert event is emitted.
+ sensor.glitchFilter(minimumPulseLength)
+ log.debug(`Gpio-service: pin number ${config.gpioPin}, polling interval ${pollingInterval} us, triggered on ${triggeredFlank} flank, minimal pulse time ${minimumPulseLength} us`)
+
+ // set the default value
+ let previousTick = 0
+
+ // Define the alert handler
+ sensor.on('alert', (level, rawCurrentTick) => {
+ if ((triggeredFlank === 'Both') || (triggeredFlank === 'Down' && level === 0) || (triggeredFlank === 'Up' && level === 1)) {
+ const currentTick = (rawCurrentTick >> 0) / 1e6
+ let currentDt
+ if (currentTick > previousTick) {
+ currentDt = currentTick - previousTick
+ } else {
+ // We had a rollover of the tick, so the current tick misses 4,294,967,295 us
+ log.debug('Gpio-service: tick rollover detected and corrected')
+ currentDt = (currentTick + 4294.967295) - previousTick
+ }
+ previousTick = currentTick
+ process.send(currentDt)
+ }
+ })
+}
createGpioTimerService()
diff --git a/app/peripherals/PeripheralConstants.js b/app/peripherals/PeripheralConstants.js
new file mode 100644
index 0000000000..59c7f168b4
--- /dev/null
+++ b/app/peripherals/PeripheralConstants.js
@@ -0,0 +1,19 @@
+'use strict'
+/*
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
+
+ Some PM5 specific constants
+*/
+export const PeripheralConstants = {
+ serial: '431079192',
+ model: 'PM5',
+ name: 'PM5 431079192',
+ hardwareRevision: '634',
+ // See https://www.concept2.com/service/monitors/pm5/firmware for available versions
+ // please note: hardware versions exclude a software version, and thus might confuse the client
+ firmwareRevision: '8200-000372-176.000',
+ manufacturer: 'Concept2'
+}
+
+export const bleBroadcastInterval = 1000
+export const bleMinimumKnowDataUpdateInterval = 4000
diff --git a/app/peripherals/PeripheralManager.js b/app/peripherals/PeripheralManager.js
new file mode 100644
index 0000000000..97a97eed66
--- /dev/null
+++ b/app/peripherals/PeripheralManager.js
@@ -0,0 +1,473 @@
+'use strict'
+/*
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
+*/
+/**
+ * This manager creates the different Bluetooth Low Energy (BLE), ANT+ and MQTT Peripherals and allows
+ * switching between them
+ */
+/* eslint-disable max-lines -- This handles quite a lot of peripherals, can't do that with less code */
+import EventEmitter from 'node:events'
+
+import log from 'loglevel'
+
+import AntManager from './ant/AntManager.js'
+import { BleManager } from './ble/BleManager.js'
+
+import { createAntHrmPeripheral } from './ant/HrmPeripheral.js'
+import { createBleHrmPeripheral } from './ble/HrmPeripheral.js'
+import { createCpsPeripheral } from './ble/CpsPeripheral.js'
+import { createCscPeripheral } from './ble/CscPeripheral.js'
+import { createFEPeripheral } from './ant/FEPeripheral.js'
+import { createFtmsPeripheral } from './ble/FtmsPeripheral.js'
+import { createMQTTPeripheral } from './mqtt/mqtt.js'
+import { createPm5Peripheral } from './ble/Pm5Peripheral.js'
+
+/**
+ * @type {Array}
+ */
+const bleModes = ['FTMS', 'FTMSBIKE', 'PM5', 'CSC', 'CPS', 'OFF']
+/**
+ * @type {Array}
+ */
+const antModes = ['FE', 'OFF']
+/**
+ * @type {Array}
+ */
+const hrmModes = ['ANT', 'BLE', 'OFF']
+
+/**
+ * @param {Config} config
+ */
+export function createPeripheralManager (config) {
+ /**
+ * @type {EventEmitter<{heartRateMeasurement: Array>, control: Array}>}
+ */
+ const emitter = new EventEmitter()
+ const mqttEnabled = (config.mqtt.mqttBroker !== '') && (config.mqtt.username !== '') && (config.mqtt.password !== '') && (config.mqtt.machineName !== '')
+ /**
+ * @type {AntManager}
+ */
+ let _antManager
+ /**
+ * @type {BleManager}
+ */
+ let _bleManager
+
+ /**
+ * @type {ReturnType | undefined}
+ */
+ let blePeripheral
+ /**
+ * @type {BluetoothModes}
+ */
+ let bleMode
+
+ /**
+ * @type {ReturnType | undefined}
+ */
+ let antPeripheral
+ /**
+ * @type {AntPlusModes}
+ */
+ let antMode
+
+ /**
+ * @type {ReturnType | undefined}
+ */
+ let mqttPeripheral
+ if (mqttEnabled) {
+ mqttPeripheral = createMQTTPeripheral(config)
+
+ mqttPeripheral.on('control', (req) => {
+ emitter.emit('control', req)
+ })
+ }
+
+ /**
+ * @type {ReturnType | ReturnType | undefined}
+ */
+ let hrmPeripheral
+ /**
+ * @type {HeartRateModes}
+ */
+ let hrmMode
+ /**
+ * @type {NodeJS.Timeout}
+ */
+ let hrmWatchdogTimer
+ /**
+ * @type {Omit & {heartRateBatteryLevel?: number }}
+ */
+ let lastHrmData = {
+ heartrate: undefined,
+ heartRateBatteryLevel: undefined,
+ rrIntervals: []
+ }
+
+ let isPeripheralChangeInProgress = false
+
+ setupPeripherals()
+
+ async function setupPeripherals () {
+ // The order is important, starting with the BLEs causes EBUSY error on the HCI socket on switching. I was not able to find the cause - its probably the order within the async initialization of the BleManager, but cannot find a proper fix
+ await createAntPeripheral(config.antPlusMode)
+ await createHrmPeripheral(config.heartRateMode)
+ await createBlePeripheral(config.bluetoothMode)
+ }
+
+ /**
+ * This function handles all incomming commands. As all commands are broadasted to all managers, we need to filter here what is relevant
+ * for the peripherals and what is not
+ *
+ * @param {Command} Name of the command to be executed by the commandhandler
+ * @param {unknown} data for executing the command
+ *
+ * @see {@link https://github.com/JaapvanEkris/openrowingmonitor/blob/main/docs/Architecture.md#command-flow|The command flow documentation}
+ */
+ /* eslint-disable-next-line no-unused-vars -- data is irrelevant here, but it is a standardised interface */
+ async function handleCommand (commandName, data) {
+ switch (commandName) {
+ case ('updateIntervalSettings'):
+ break
+ case ('start'):
+ break
+ case ('startOrResume'):
+ notifyStatus({ name: 'startedOrResumedByUser' })
+ break
+ case ('pause'):
+ notifyStatus({ name: 'stoppedOrPausedByUser' })
+ break
+ case ('stop'):
+ notifyStatus({ name: 'stoppedOrPausedByUser' })
+ break
+ case ('reset'):
+ notifyStatus({ name: 'reset' })
+ break
+ case 'switchBlePeripheralMode':
+ switchBlePeripheralMode()
+ break
+ case 'switchAntPeripheralMode':
+ switchAntPeripheralMode()
+ break
+ case 'switchHrmMode':
+ switchHrmMode()
+ break
+ case 'refreshPeripheralConfig':
+ break
+ case 'upload':
+ break
+ case 'shutdown':
+ await shutdownAllPeripherals()
+ break
+ default:
+ log.error(`PeripheralManager: Received unknown command: ${commandName}`)
+ }
+ }
+
+ /**
+ * @param {BluetoothModes} [newMode]
+ */
+ async function switchBlePeripheralMode (newMode) {
+ if (isPeripheralChangeInProgress) { return }
+ isPeripheralChangeInProgress = true
+ // if no mode was passed, select the next one from the list
+ if (newMode === undefined) {
+ newMode = bleModes[(bleModes.indexOf(bleMode) + 1) % bleModes.length]
+ }
+ config.bluetoothMode = newMode
+ await createBlePeripheral(newMode)
+ isPeripheralChangeInProgress = false
+ }
+
+ /**
+ * @param {Metrics} metrics
+ */
+ function notifyMetrics (metrics) {
+ addHeartRateToMetrics(metrics)
+ if (bleMode !== 'OFF') { blePeripheral?.notifyData(metrics) }
+ if (antMode !== 'OFF') { antPeripheral?.notifyData(metrics) }
+ if (mqttEnabled) { mqttPeripheral?.notifyData(metrics) }
+ }
+
+ /**
+ * @param {{name: string}} status
+ */
+ function notifyStatus (status) {
+ if (bleMode !== 'OFF') { blePeripheral?.notifyStatus(status) }
+ if (antMode !== 'OFF') { antPeripheral?.notifyStatus(status) }
+ }
+
+ /**
+ * @param {BluetoothModes} newMode
+ */
+ async function createBlePeripheral (newMode) {
+ try {
+ if (_bleManager === undefined && newMode !== 'OFF') {
+ _bleManager = new BleManager()
+ }
+ } catch (error) {
+ log.error('BleManager creation error: ', error)
+ return
+ }
+
+ if (blePeripheral) {
+ await blePeripheral?.destroy()
+ blePeripheral = undefined
+ }
+
+ switch (newMode) {
+ case 'PM5':
+ log.info('bluetooth profile: Concept2 PM5')
+ blePeripheral = createPm5Peripheral(_bleManager, config, controlCallback)
+ bleMode = 'PM5'
+ break
+ case 'FTMSBIKE':
+ log.info('bluetooth profile: FTMS Indoor Bike')
+ blePeripheral = createFtmsPeripheral(_bleManager, controlCallback, config, true)
+ bleMode = 'FTMSBIKE'
+ break
+ case 'CSC':
+ log.info('bluetooth profile: Cycling Speed and Cadence')
+ blePeripheral = createCscPeripheral(_bleManager, config)
+ bleMode = 'CSC'
+ break
+ case 'CPS':
+ log.info('bluetooth profile: Cycling Power Meter')
+ blePeripheral = createCpsPeripheral(_bleManager, config)
+ bleMode = 'CPS'
+ break
+ case 'FTMS':
+ log.info('bluetooth profile: FTMS Rower')
+ blePeripheral = createFtmsPeripheral(_bleManager, controlCallback, config, false)
+ bleMode = 'FTMS'
+ break
+ default:
+ log.info('bluetooth profile: Off')
+ bleMode = 'OFF'
+ try {
+ if (_bleManager && hrmMode !== 'BLE') {
+ _bleManager.close()
+ }
+ } catch (error) {
+ log.error(error)
+ return
+ }
+ }
+
+ emitter.emit('control', {
+ req: {
+ name: 'refreshPeripheralConfig',
+ data: {}
+ }
+ })
+ }
+
+ /**
+ * @param {AntPlusModes} [newMode]
+ */
+ async function switchAntPeripheralMode (newMode) {
+ if (isPeripheralChangeInProgress) { return }
+ isPeripheralChangeInProgress = true
+ if (newMode === undefined) {
+ newMode = antModes[(antModes.indexOf(antMode) + 1) % antModes.length]
+ }
+ config.antPlusMode = newMode
+ await createAntPeripheral(newMode)
+ isPeripheralChangeInProgress = false
+ }
+
+ /**
+ * @param {AntPlusModes} newMode
+ */
+ async function createAntPeripheral (newMode) {
+ if (antPeripheral) {
+ await antPeripheral?.destroy()
+ antPeripheral = undefined
+ }
+
+ switch (newMode) {
+ case 'FE':
+ log.info('ant plus profile: FE')
+ if (_antManager === undefined) {
+ _antManager = new AntManager()
+ }
+
+ try {
+ antPeripheral = createFEPeripheral(_antManager)
+ antMode = 'FE'
+ await antPeripheral.attach()
+ } catch (error) {
+ log.error(error)
+ return
+ }
+ break
+
+ default:
+ log.info('ant plus profile: Off')
+ antMode = 'OFF'
+ try {
+ if (_antManager && hrmMode !== 'ANT') { await _antManager.closeAntStick() }
+ } catch (error) {
+ log.error(error)
+ return
+ }
+ }
+
+ emitter.emit('control', {
+ req: {
+ name: 'refreshPeripheralConfig',
+ data: {}
+ }
+ })
+ }
+
+ /**
+ * @param {HeartRateModes} [newMode]
+ */
+ async function switchHrmMode (newMode) {
+ if (isPeripheralChangeInProgress) { return }
+ isPeripheralChangeInProgress = true
+ if (newMode === undefined) {
+ newMode = hrmModes[(hrmModes.indexOf(hrmMode) + 1) % hrmModes.length]
+ }
+ config.heartRateMode = newMode
+ await createHrmPeripheral(newMode)
+ isPeripheralChangeInProgress = false
+ }
+
+ /**
+ * @param {HeartRateModes} newMode
+ */
+ async function createHrmPeripheral (newMode) {
+ if (hrmPeripheral) {
+ await hrmPeripheral?.destroy()
+ hrmPeripheral?.removeAllListeners()
+ hrmPeripheral = undefined
+ try {
+ if (_antManager && newMode !== 'ANT' && antMode === 'OFF') { await _antManager.closeAntStick() }
+ if (_bleManager && newMode !== 'BLE' && bleMode === 'OFF') { _bleManager.close() }
+ } catch (error) {
+ log.error(error)
+ return
+ }
+ }
+
+ switch (newMode) {
+ case 'ANT':
+ log.info('heart rate profile: ANT')
+ if (_antManager === undefined) {
+ _antManager = new AntManager()
+ }
+
+ try {
+ hrmPeripheral = createAntHrmPeripheral(_antManager)
+ hrmMode = 'ANT'
+ await hrmPeripheral.attach()
+ } catch (error) {
+ log.error(error)
+ return
+ }
+ break
+
+ case 'BLE':
+ log.info('heart rate profile: BLE')
+ try {
+ if (_bleManager === undefined) {
+ _bleManager = new BleManager()
+ }
+ } catch (error) {
+ log.error('BleManager creation error: ', error)
+ return
+ }
+ hrmPeripheral = createBleHrmPeripheral(_bleManager)
+ hrmMode = 'BLE'
+ await hrmPeripheral.attach()
+ break
+
+ default:
+ log.info('heart rate profile: Off')
+ hrmMode = 'OFF'
+ }
+
+ if (hrmPeripheral && hrmMode.toLocaleLowerCase() !== 'OFF'.toLocaleLowerCase()) {
+ hrmPeripheral.on('heartRateMeasurement', (heartRateMeasurement) => {
+ // Clear the HRM watchdog as new HRM data has been received
+ clearTimeout(hrmWatchdogTimer)
+ // Make sure we check the HRM validity here, so the rest of the app doesn't have to
+ if (heartRateMeasurement.heartrate !== undefined && config.userSettings.restingHR <= heartRateMeasurement.heartrate && heartRateMeasurement.heartrate <= config.userSettings.maxHR) {
+ lastHrmData = { ...heartRateMeasurement, heartRateBatteryLevel: heartRateMeasurement.batteryLevel }
+ emitter.emit('heartRateMeasurement', heartRateMeasurement)
+ } else {
+ log.info(`PeripheralManager: Heartrate value of ${heartRateMeasurement.heartrate} was outside valid range, setting it to undefined`)
+ heartRateMeasurement.heartrate = undefined
+ heartRateMeasurement.batteryLevel = undefined
+ emitter.emit('heartRateMeasurement', heartRateMeasurement)
+ }
+ // Re-arm the HRM watchdog to guarantee failsafe behaviour: after 6 seconds of no new HRM data, it will be invalidated
+ hrmWatchdogTimer = setTimeout(onHRMWatchdogTimeout, 6000)
+ })
+ }
+
+ emitter.emit('control', {
+ req: {
+ name: 'refreshPeripheralConfig',
+ data: {}
+ }
+ })
+ }
+
+ function onHRMWatchdogTimeout () {
+ lastHrmData.heartrate = undefined
+ lastHrmData.heartRateBatteryLevel = undefined
+ log.info('PeripheralManager: Heartrate data has not been updated in 6 seconds, setting it to undefined')
+ emitter.emit('heartRateMeasurement', lastHrmData)
+ }
+
+ /**
+ * @param {Metrics} metrics
+ */
+ function addHeartRateToMetrics (metrics) {
+ if (lastHrmData.heartrate !== undefined) {
+ metrics.heartrate = lastHrmData.heartrate
+ } else {
+ metrics.heartrate = undefined
+ }
+ // So far battery level is not used by any of the peripherals adding it for completeness sake
+ if (lastHrmData.heartRateBatteryLevel !== undefined) {
+ metrics.heartRateBatteryLevel = lastHrmData.heartRateBatteryLevel
+ } else {
+ metrics.heartRateBatteryLevel = undefined
+ }
+ }
+
+ /**
+ * @param {ControlPointEvent} event
+ */
+ function controlCallback (event) {
+ emitter.emit('control', event)
+
+ return true
+ }
+
+ async function shutdownAllPeripherals () {
+ log.debug('shutting down all peripherals')
+
+ try {
+ await blePeripheral?.destroy()
+ await antPeripheral?.destroy()
+ await hrmPeripheral?.destroy()
+ await _antManager?.closeAntStick()
+ _bleManager?.close()
+ if (mqttEnabled) { await mqttPeripheral?.destroy() }
+ } catch (error) {
+ log.error('peripheral shutdown was unsuccessful, restart of Pi may required', error)
+ }
+ }
+
+ return Object.assign(emitter, {
+ handleCommand,
+ notifyMetrics,
+ notifyStatus
+ })
+}
diff --git a/app/peripherals/ant/AntManager.js b/app/peripherals/ant/AntManager.js
new file mode 100644
index 0000000000..9bc479e00c
--- /dev/null
+++ b/app/peripherals/ant/AntManager.js
@@ -0,0 +1,45 @@
+'use strict'
+/*
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
+*/
+/**
+ * This manager creates a module to listen to ANT+ devices.
+ * This currently can be used to get the heart rate from ANT+ heart rate sensors.
+ *
+ * For this to work, you need an ANT+ USB stick, the following models might work:
+ * - Garmin USB or USB2 ANT+ or an off-brand clone of it (ID 0x1008)
+ * - Garmin mini ANT+ (ID 0x1009)
+ */
+import log from 'loglevel'
+
+import { AntDevice } from 'incyclist-ant-plus/lib/ant-device.js'
+
+export default class AntManager {
+ _isStickOpen = false
+ _stick = new AntDevice({ startupTimeout: 2000 })
+
+ async openAntStick () {
+ if (this._isStickOpen) { return }
+ if (!(await this._stick.open())) { throw (new Error('Error opening Ant Stick')) }
+
+ log.info('ANT+ stick found')
+ this._isStickOpen = true
+ }
+
+ async closeAntStick () {
+ if (!this._isStickOpen) { return }
+
+ if (!(await this._stick.close())) { throw (new Error('Error closing Ant Stick')) }
+
+ log.info('ANT+ stick is closed')
+ this._isStickOpen = false
+ }
+
+ isStickOpen () {
+ return this._isStickOpen
+ }
+
+ getAntStick () {
+ return this._stick
+ }
+}
diff --git a/app/peripherals/ant/FEPeripheral.js b/app/peripherals/ant/FEPeripheral.js
new file mode 100644
index 0000000000..68a865ed28
--- /dev/null
+++ b/app/peripherals/ant/FEPeripheral.js
@@ -0,0 +1,290 @@
+'use strict'
+/*
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
+*/
+/**
+ * Creates a ANT+ Peripheral with all the datapages that are required for an indoor rower
+ */
+import log from 'loglevel'
+
+import { PeripheralConstants } from '../PeripheralConstants.js'
+
+import { Messages } from 'incyclist-ant-plus'
+
+/**
+ * @param {import('./AntManager').default} antManager
+ */
+function createFEPeripheral (antManager) {
+ const antStick = antManager.getAntStick()
+ const deviceType = 0x11 // Ant FE-C device
+ const deviceNumber = 1
+ const deviceId = parseInt(PeripheralConstants.serial, 10) & 0xFFFF
+ const channel = 1
+ const broadcastPeriod = 8192 // 8192/32768 ~4hz
+ const broadcastInterval = broadcastPeriod / 32768 * 1000 // millisecond
+ const rfChannel = 57 // 2457 MHz
+ let dataPageCount = 0
+ let commonPageCount = 0
+ let accumulatedTime = 0
+ let accumulatedDistance = 0
+ let accumulatedStrokes = 0
+ /**
+ * @type {NodeJS.Timeout}
+ */
+ let timer
+
+ let sessionData = {
+ accumulatedStrokes: 0,
+ accumulatedDistance: 0,
+ accumulatedTime: 0,
+ accumulatedPower: 0,
+ cycleLinearVelocity: 0,
+ strokeRate: 0,
+ instantaneousPower: 0,
+ distancePerStroke: 0,
+ fitnessEquipmentState: fitnessEquipmentStates.ready,
+ sessionState: 'WaitingForStart'
+ }
+
+ async function attach () {
+ if (!antManager.isStickOpen()) { await antManager.openAntStick() }
+
+ const messages = [
+ Messages.assignChannel(channel, 'transmit'),
+ Messages.setDevice(channel, deviceId, deviceType, deviceNumber),
+ Messages.setFrequency(channel, rfChannel),
+ Messages.setPeriod(channel, broadcastPeriod),
+ Messages.openChannel(channel)
+ ]
+
+ log.info(`ANT+ FE server start [deviceId=${deviceId} channel=${channel}]`)
+ for (const message of messages) {
+ antStick.write(message)
+ }
+
+ timer = setTimeout(onBroadcastInterval, broadcastInterval)
+ }
+
+ function destroy () {
+ return new Promise((/** @type {(value: void) => void} */resolve) => {
+ clearInterval(timer)
+ log.info(`ANT+ FE server stopped [deviceId=${deviceId} channel=${channel}]`)
+
+ const messages = [
+ Messages.closeChannel(channel),
+ Messages.unassignChannel(channel)
+ ]
+ for (const message of messages) {
+ antStick.write(message)
+ }
+ resolve()
+ })
+ }
+
+ function onBroadcastInterval () {
+ dataPageCount++
+ let /** @type {Array} */data = []
+
+ switch (true) {
+ case dataPageCount === 65 || dataPageCount === 66:
+ if (commonPageCount % 2 === 0) { // 0x50 - Common Page for Manufacturers Identification (approx twice a minute)
+ data = [
+ channel,
+ 0x50, // Page 80
+ 0xFF, // Reserved
+ 0xFF, // Reserved
+ parseInt(PeripheralConstants.hardwareRevision, 10) & 0xFF, // Hardware Revision
+ ...Messages.intToLEHexArray(40, 2), // Manufacturer ID (value 255 = Development ID, value 40 = concept2)
+ 0x0001 // Model Number
+ ]
+ }
+ if (commonPageCount % 2 === 1) { // 0x51 - Common Page for Product Information (approx twice a minute)
+ data = [
+ channel,
+ 0x51, // Page 81
+ 0xFF, // Reserved
+ parseInt(PeripheralConstants.firmwareRevision.slice(-2), 10), // SW Revision (Supplemental)
+ parseInt(PeripheralConstants.firmwareRevision[0], 10), // SW Version
+ ...Messages.intToLEHexArray(parseInt(PeripheralConstants.serial, 10), 4) // Serial Number (None)
+ ]
+ }
+
+ if (dataPageCount === 66) {
+ commonPageCount++
+ dataPageCount = 0
+ }
+ break
+ case dataPageCount % 8 === 4: // 0x11 - General Settings Page (once a second)
+ case dataPageCount % 8 === 7:
+ data = [
+ channel,
+ 0x11, // Page 17
+ 0xFF, // Reserved
+ 0xFF, // Reserved
+ ...Messages.intToLEHexArray(sessionData.distancePerStroke, 1), // Stroke Length in 0.01 m
+ 0x7FFF, // Incline (Not Used)
+ 0x00, // Resistance (DF may be reported if conversion to the % is worked out (value in % with a resolution of 0.5%).
+ ...Messages.intToLEHexArray(feCapabilitiesBitField, 1)
+ ]
+ if (sessionData.sessionState === 'Rowing') {
+ log.trace(`Page 17 Data Sent. Event=${dataPageCount}. Stroke Length=${sessionData.distancePerStroke}.`)
+ log.trace(`Hex Stroke Length=0x${sessionData.distancePerStroke.toString(16)}.`)
+ }
+ break
+ case dataPageCount % 8 === 3: // 0x16 - Specific Rower Data (once a second)
+ case dataPageCount % 8 === 0:
+ data = [
+ channel,
+ 0x16, // Page 22
+ 0xFF, // Reserved
+ 0xFF, // Reserved
+ ...Messages.intToLEHexArray(sessionData.accumulatedStrokes, 1), // Stroke Count
+ ...Messages.intToLEHexArray(sessionData.strokeRate, 1), // Cadence / Stroke Rate
+ ...Messages.intToLEHexArray(sessionData.instantaneousPower, 2), // Instant Power (2 bytes)
+ ...Messages.intToLEHexArray((sessionData.fitnessEquipmentState + rowingCapabilitiesBitField), 1)
+ ]
+ if (sessionData.sessionState === 'Rowing') {
+ log.trace(`Page 22 Data Sent. Event=${dataPageCount}. Strokes=${sessionData.accumulatedStrokes}. Stroke Rate=${sessionData.strokeRate}. Power=${sessionData.instantaneousPower}`)
+ log.trace(`Hex Strokes=0x${sessionData.accumulatedStrokes.toString(16)}. Hex Stroke Rate=0x${sessionData.strokeRate.toString(16)}. Hex Power=0x${Messages.intToLEHexArray(sessionData.instantaneousPower, 2)}.`)
+ }
+ break
+ case dataPageCount % 4 === 2: // 0x10 - General FE Data (twice a second)
+ default:
+ data = [
+ channel,
+ 0x10, // Page 16
+ 0x16, // Rowing Machine (22)
+ ...Messages.intToLEHexArray(sessionData.accumulatedTime, 1), // elapsed time
+ ...Messages.intToLEHexArray(sessionData.accumulatedDistance, 1), // distance travelled
+ ...Messages.intToLEHexArray(sessionData.cycleLinearVelocity, 2), // speed in 0.001 m/s
+ 0xFF, // heart rate not being sent
+ ...Messages.intToLEHexArray((sessionData.fitnessEquipmentState + feCapabilitiesBitField), 1)
+ ]
+ if (sessionData.sessionState === 'Rowing') {
+ log.trace(`Page 16 Data Sent. Event=${dataPageCount}. Time=${sessionData.accumulatedTime}. Distance=${sessionData.accumulatedDistance}. Speed=${sessionData.cycleLinearVelocity}.`)
+ log.trace(`Hex Time=0x${sessionData.accumulatedTime.toString(16)}. Hex Distance=0x${sessionData.accumulatedDistance.toString(16)}. Hex Speed=0x${Messages.intToLEHexArray(sessionData.cycleLinearVelocity, 2)}.`)
+ }
+ break
+ }
+
+ const message = Messages.broadcastData(data)
+ antStick.write(message)
+ timer = setTimeout(onBroadcastInterval, broadcastInterval)
+ }
+
+ /**
+ * @remark Be aware: time, distance and strokes must always count upwards as small changes trigger a rollover at the watch side. So we must force this
+ * @see {@link https://github.com/JaapvanEkris/openrowingmonitor/discussions/100|this bugreport}
+ * @param {Metrics} data
+ */
+ function notifyData (data) {
+ accumulatedTime = Math.max(data.workout.timeSpent, sessionData.accumulatedTime)
+ accumulatedDistance = Math.max(data.workout.distance.fromStart, accumulatedDistance)
+ accumulatedStrokes = Math.max(data.workout.numberOfStrokes, accumulatedStrokes)
+ sessionData = {
+ ...sessionData,
+ accumulatedTime: (accumulatedTime > 0 ? Math.round(accumulatedTime * 4) : 0) & 0xFF,
+ accumulatedDistance: (accumulatedDistance > 0 ? Math.round(accumulatedDistance) : 0) & 0xFF,
+ accumulatedStrokes: (accumulatedStrokes > 0 ? Math.round(accumulatedStrokes) : 0) & 0xFF,
+ cycleLinearVelocity: (data.metricsContext.isMoving && data.cycleLinearVelocity > 0 ? Math.round(data.cycleLinearVelocity * 1000) : 0),
+ strokeRate: (data.metricsContext.isMoving && data.cycleStrokeRate > 0 ? Math.round(data.cycleStrokeRate) : 0) & 0xFF,
+ instantaneousPower: (data.metricsContext.isMoving && data.cyclePower > 0 ? Math.round(data.cyclePower) : 0) & 0xFFFF,
+ distancePerStroke: (data.metricsContext.isMoving && data.cycleDistance > 0 ? Math.round(data.cycleDistance * 100) : 0),
+ sessionState: data.sessionState
+ }
+
+ /**
+ * @See {@link https://c2usa.fogbugz.com/default.asp?W119| states description}
+ * - when machine is on and radio active, but have not yet begun a session -> status set to "ready", speed, etc. are all 0 (as forced by above requirement for data.metricsContext.isMoving)
+ * - first stroke -> status = 3 (in use)
+ * - end of wokrout -> status = 4 (finished)
+ * - Pause: go to 4 (finished, if data.metricsContext.isMoving = false); back to inUse if rowing starts coming back.
+ * every time move from "ready" to "inUse" it will create a new piece on the watch.
+ */
+ // ToDo: if cross split; raise LAP Toggle
+ switch (true) {
+ case (data.sessionState === 'Rowing'):
+ sessionData.fitnessEquipmentState = fitnessEquipmentStates.inUse
+ break
+ case (data.sessionState === 'Stopped'):
+ sessionData.fitnessEquipmentState = fitnessEquipmentStates.finished
+ break
+ case (data.sessionState === 'Paused'):
+ sessionData.fitnessEquipmentState = fitnessEquipmentStates.finished
+ break
+ case (data.sessionState === 'WaitingForStart'):
+ sessionData.fitnessEquipmentState = fitnessEquipmentStates.ready
+ break
+ default:
+ sessionData.fitnessEquipmentState = fitnessEquipmentStates.ready
+ }
+ }
+
+ /**
+ * FE does not have status characteristic, but is notified of a reset, which should be handled
+ * @param {{name: string}} status
+ */
+ function notifyStatus (status) {
+ switch (status?.name) {
+ case ('reset'):
+ reset()
+ break
+ default:
+ // Do nothing
+ }
+ }
+
+ function reset () {
+ dataPageCount = 0
+ commonPageCount = 0
+ accumulatedTime = 0
+ accumulatedDistance = 0
+ accumulatedStrokes = 0
+ sessionData = {
+ accumulatedStrokes: 0,
+ accumulatedDistance: 0,
+ accumulatedTime: 0,
+ accumulatedPower: 0,
+ cycleLinearVelocity: 0,
+ strokeRate: 0,
+ instantaneousPower: 0,
+ distancePerStroke: 0,
+ fitnessEquipmentState: fitnessEquipmentStates.ready,
+ sessionState: 'WaitingForStart'
+ }
+ }
+
+ return {
+ notifyData,
+ notifyStatus,
+ attach,
+ destroy
+ }
+}
+
+const fitnessEquipmentStates = {
+ asleep: (1 << 0x04),
+ ready: (2 << 0x04),
+ inUse: (3 << 0x04),
+ finished: (4 << 0x04),
+ lapToggleBit: (8 << 0x04)
+}
+
+const fitnessEquipmentCapabilities = {
+ hrDataSourceHandContactSensors: (0x03 << 0),
+ hrDataSourceEmSensors: (0x02 << 0),
+ hrDataSourceAntSensors: (0x01 << 0),
+ hrDataSourceInvalid: (0x00 << 0),
+ distanceTraveledEnabled: (0x01 << 2),
+ virtualSpeed: (0x01 << 3),
+ realSpeed: (0x00 << 3)
+}
+
+const rowingMachineCapabilities = {
+ accumulatedStrokesEnabled: (0x01 << 0)
+}
+
+const feCapabilitiesBitField = fitnessEquipmentCapabilities.hrDataSourceInvalid | fitnessEquipmentCapabilities.distanceTraveledEnabled | fitnessEquipmentCapabilities.realSpeed
+const rowingCapabilitiesBitField = rowingMachineCapabilities.accumulatedStrokesEnabled
+
+export { createFEPeripheral }
diff --git a/app/peripherals/ant/HrmPeripheral.js b/app/peripherals/ant/HrmPeripheral.js
new file mode 100644
index 0000000000..43e21a1a65
--- /dev/null
+++ b/app/peripherals/ant/HrmPeripheral.js
@@ -0,0 +1,121 @@
+'use strict'
+/*
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
+
+ Creates a ANT+ peripheral to recieve heartrate data from a HRM belt
+*/
+import EventEmitter from 'node:events'
+import log from 'loglevel'
+
+import { HeartRateSensor } from 'incyclist-ant-plus'
+
+/**
+ * @event createAntHrmPeripheral#heartRateMeasurement
+ * @type {HeartRateMeasurementEvent}
+ */
+/**
+ * @typedef {import('incyclist-ant-plus').IChannel} IChannel
+ */
+
+/**
+ * @param {import('./AntManager.js').default} antManager
+ * @fires createAntHrmPeripheral#heartRateMeasurement
+ */
+function createAntHrmPeripheral (antManager) {
+ /**
+ * @type {EventEmitter<{heartRateMeasurement: Array}>}
+ */
+ const emitter = new EventEmitter()
+ const antStick = antManager.getAntStick()
+ const heartRateSensor = new HeartRateSensor(0)
+ let lastBeatCount = 0
+ let lastBeatTime = 0
+
+ /**
+ * The RR interval in seconds
+ * @type {Array}
+ */
+ let rrIntervals = []
+ /**
+ * @type {number | undefined}
+ */
+ let batteryLevel
+ /** @type {IChannel & EventEmitter | undefined} */
+ let channel
+
+ async function attach () {
+ if (!antManager.isStickOpen()) { await antManager.openAntStick() }
+ channel = /** @type {IChannel & EventEmitter} */(antStick.getChannel())
+
+ channel.on('data', (profile, deviceID, /** @type {import('incyclist-ant-plus').HeartRateSensorState} */data) => {
+ switch (data.BatteryStatus) {
+ case 'New':
+ batteryLevel = 100
+ break
+ case 'Good':
+ batteryLevel = 80
+ break
+ case 'Ok':
+ batteryLevel = 60
+ break
+ case 'Low':
+ batteryLevel = 40
+ break
+ case 'Critical':
+ batteryLevel = 20
+ break
+ default:
+ batteryLevel = undefined
+ }
+
+ if (data.BatteryLevel && data.BatteryLevel > 0) {
+ batteryLevel = data.BatteryLevel
+ }
+
+ if (data.BeatCount !== lastBeatCount) {
+ /**
+ * @type {number | undefined}
+ */
+ let beatTimeDiff
+ if (data.PreviousBeat !== undefined) {
+ // Logic using previousBeatTime and also saving last beat time is seemingly redundant, but the specs prescribes that firstly the previousBeatTime should be used and only if that is not available should be the difference between two successive message be used when the beat count difference is one.
+ beatTimeDiff = data.PreviousBeat > data.BeatTime ? 65535 - (data.PreviousBeat - data.BeatTime) : data.BeatTime - data.PreviousBeat
+ } else if (data.BeatCount - lastBeatCount === 1) {
+ beatTimeDiff = lastBeatTime > data.BeatTime ? 65535 - (lastBeatTime - data.BeatTime) : data.BeatTime - lastBeatTime
+ }
+
+ rrIntervals = beatTimeDiff !== undefined ? [Math.round(beatTimeDiff / 1024 * 1000) / 1000] : []
+
+ lastBeatCount = data.BeatCount
+ lastBeatTime = data.BeatTime
+ }
+
+ emitter.emit('heartRateMeasurement', {
+ heartrate: data.ComputedHeartRate,
+ rrIntervals,
+ batteryLevel,
+ manufacturerId: data.ManId,
+ serialNumber: data.SerialNumber
+ })
+ })
+
+ if (!(await channel.startSensor(heartRateSensor))) {
+ log.error('Could not start ANT+ heart rate sensor')
+ }
+ }
+
+ async function destroy () {
+ if (!channel) {
+ log.debug('Ant Sensor does not seem to be running')
+ return
+ }
+ await channel.stopSensor(heartRateSensor)
+ }
+
+ return Object.assign(emitter, {
+ destroy,
+ attach
+ })
+}
+
+export { createAntHrmPeripheral }
diff --git a/app/peripherals/ble/BleManager.js b/app/peripherals/ble/BleManager.js
new file mode 100644
index 0000000000..9d255c5152
--- /dev/null
+++ b/app/peripherals/ble/BleManager.js
@@ -0,0 +1,148 @@
+import loglevel from 'loglevel'
+
+import HciSocket from 'hci-socket'
+import NodeBleHost from 'ble-host'
+
+/**
+ * @typedef {import('./ble-host.interface.js').BleManager} BleHostManager
+ */
+
+const log = loglevel.getLogger('Peripherals')
+
+export class BleManager {
+ /**
+ * @type {HciSocket | undefined}
+ */
+ #transport
+ /**
+ * @type {BleHostManager | undefined}
+ */
+ #manager
+ /**
+ * @type {Promise | undefined}
+ */
+ #managerOpeningTask
+
+ open () {
+ if (this.#manager !== undefined) {
+ return Promise.resolve(this.#manager)
+ }
+
+ if (this.#managerOpeningTask === undefined) {
+ this.#managerOpeningTask = new Promise((resolve, reject) => {
+ if (this.#manager) {
+ resolve(this.#manager)
+ }
+ log.debug('Opening BLE manager')
+
+ if (this.#transport === undefined) {
+ this.#transport = new HciSocket()
+ }
+
+ NodeBleHost.BleManager.create(this.#transport, {}, (/** @type {Error | null} */err, /** @type {BleHostManager} */manager) => {
+ if (err) { reject(err) }
+ this.#manager = manager
+ this.#managerOpeningTask = undefined
+ resolve(manager)
+ })
+ })
+ }
+
+ return this.#managerOpeningTask
+ }
+
+ close () {
+ try {
+ this.#transport?.close()
+ } catch (e) {
+ if (e.message !== 'Transport closed') {
+ log.error('Error while closing Ble socket')
+
+ throw e
+ }
+
+ log.debug('Ble socket is closed')
+ this.#transport = undefined
+ this.#manager = undefined
+ }
+ }
+
+ isOpen () {
+ return this.#manager !== undefined
+ }
+
+ getManager () {
+ return this.open()
+ }
+}
+
+/**
+ * Convert a 16-bit C2 PM5 UUID to a BLE standard 128-bit UUID.
+ * @param {string} uuid
+ * @returns
+ */
+export const toBLEStandard128BitUUID = (uuid) => {
+ return `0000${uuid}-0000-1000-8000-00805F9B34FB`
+}
+
+export class GattNotifyCharacteristic {
+ get characteristic () {
+ return this.#characteristic
+ }
+
+ get isSubscribed () {
+ return this.#isSubscribed
+ }
+
+ #characteristic
+ #isSubscribed = false
+
+ /**
+ * @type {import('./ble-host.interface.js').Connection | undefined}
+ */
+ #connection
+
+ /**
+ * @param {GattServerCharacteristicFactory} characteristic
+ */
+ constructor (characteristic) {
+ this.#characteristic = {
+ ...characteristic,
+ onSubscriptionChange: (/** @type {import('./ble-host.interface.js').Connection} */connection, /** @type {boolean} */ notification) => {
+ log.debug(`${this.#characteristic.name} subscription change: ${connection.peerAddress}, notification: ${notification}`)
+ this.#isSubscribed = notification
+ this.#connection = notification ? connection : undefined
+ }
+ }
+ }
+
+ /**
+ * @param {Buffer | string} buffer
+ */
+ notify (buffer) {
+ if (this.#characteristic.notify === undefined) {
+ throw new Error(`Characteristics ${this.#characteristic.name} has not been initialized`)
+ }
+
+ if (!this.#isSubscribed || this.#connection === undefined) {
+ return
+ }
+
+ this.#characteristic.notify(this.#connection, buffer)
+ }
+}
+
+export class GattService {
+ get gattService () {
+ return this.#gattService
+ }
+
+ #gattService
+
+ /**
+ * @param {GattServerServiceFactory} gattService
+ */
+ constructor (gattService) {
+ this.#gattService = gattService
+ }
+}
diff --git a/app/ble/BufferBuilder.js b/app/peripherals/ble/BufferBuilder.js
similarity index 60%
rename from app/ble/BufferBuilder.js
rename to app/peripherals/ble/BufferBuilder.js
index 5ddd624d03..bba12177bb 100644
--- a/app/ble/BufferBuilder.js
+++ b/app/peripherals/ble/BufferBuilder.js
@@ -1,16 +1,22 @@
'use strict'
/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
A buffer builder that simplifies the creation of payloads for BLE messages
*/
import log from 'loglevel'
-export default class BufferBuilder {
+export class BufferBuilder {
constructor () {
+ /**
+ * @type {Array}
+ */
this._dataArray = []
}
+ /**
+ * @param {number} value
+ */
writeUInt8 (value) {
const buffer = Buffer.alloc(1)
try {
@@ -21,6 +27,9 @@ export default class BufferBuilder {
this._dataArray.push(buffer)
}
+ /**
+ * @param {number} value
+ */
writeUInt16LE (value) {
const buffer = Buffer.alloc(2)
try {
@@ -31,6 +40,9 @@ export default class BufferBuilder {
this._dataArray.push(buffer)
}
+ /**
+ * @param {number} value
+ */
writeUInt24LE (value) {
const _value = value || 0
const buffer = Buffer.alloc(3)
@@ -47,6 +59,24 @@ export default class BufferBuilder {
this._dataArray.push(buffer)
}
+ /**
+ * @param {number} value
+ */
+ writeUInt32LE (value) {
+ const _value = value || 0
+ const buffer = Buffer.alloc(4)
+ if (value > 0xffffffff || value < 0) {
+ log.warn(new RangeError(`The value of "value" is out of range. It must be >= 0 and <= ${0xffffffff}. Received ${value}`))
+ } else {
+ try {
+ buffer.writeUint32LE(_value)
+ } catch (error) {
+ log.warn(error)
+ }
+ }
+ this._dataArray.push(buffer)
+ }
+
getBuffer () {
return Buffer.concat(this._dataArray)
}
diff --git a/app/ble/BufferBuilder.test.js b/app/peripherals/ble/BufferBuilder.test.js
similarity index 74%
rename from app/ble/BufferBuilder.test.js
rename to app/peripherals/ble/BufferBuilder.test.js
index 186be4281b..1f5d0a3eaa 100644
--- a/app/ble/BufferBuilder.test.js
+++ b/app/peripherals/ble/BufferBuilder.test.js
@@ -1,11 +1,13 @@
'use strict'
/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
*/
-import { test } from 'uvu'
import * as assert from 'uvu/assert'
-import BufferBuilder from './BufferBuilder.js'
import log from 'loglevel'
+import { test } from 'uvu'
+
+import { BufferBuilder } from './BufferBuilder.js'
+
log.setLevel(log.levels.SILENT)
test('valid max UInts should produce correct buffer', () => {
@@ -13,7 +15,8 @@ test('valid max UInts should produce correct buffer', () => {
buffer.writeUInt8(255)
buffer.writeUInt16LE(65535)
buffer.writeUInt24LE(16777215)
- assert.equal(buffer.getBuffer(), Buffer.from([0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]))
+ buffer.writeUInt32LE(4294967295)
+ assert.equal(buffer.getBuffer(), Buffer.from([0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]))
})
test('valid min UInts should produce correct buffer', () => {
@@ -21,7 +24,8 @@ test('valid min UInts should produce correct buffer', () => {
buffer.writeUInt8(0)
buffer.writeUInt16LE(0)
buffer.writeUInt24LE(0)
- assert.equal(buffer.getBuffer(), Buffer.from([0x0, 0x0, 0x0, 0x0, 0x0, 0x0]))
+ buffer.writeUInt32LE(0)
+ assert.equal(buffer.getBuffer(), Buffer.from([0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0]))
})
test('negative UInt8 should produce 1 bit buffer of 0x0', () => {
@@ -42,8 +46,15 @@ test('negative writeUInt24LE should produce 3 bit buffer of 0x0', () => {
assert.equal(buffer.getBuffer(), Buffer.from([0x0, 0x0, 0x0]))
})
+test('negative writeUInt32LE should produce 4 bit buffer of 0x0', () => {
+ const buffer = new BufferBuilder()
+ buffer.writeUInt32LE(-1)
+ assert.equal(buffer.getBuffer(), Buffer.from([0x0, 0x0, 0x0, 0x0]))
+})
+
test('invalid datatype value UInt16LE should produce 2 bit buffer of 0x0', () => {
const buffer = new BufferBuilder()
+ // @ts-ignore
buffer.writeUInt16LE(new Map())
assert.equal(buffer.getBuffer(), Buffer.from([0x0, 0x0]))
})
diff --git a/app/peripherals/ble/CpsPeripheral.js b/app/peripherals/ble/CpsPeripheral.js
new file mode 100644
index 0000000000..e5b1904b7c
--- /dev/null
+++ b/app/peripherals/ble/CpsPeripheral.js
@@ -0,0 +1,166 @@
+'use strict'
+/*
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
+*/
+/**
+ * Creates a Bluetooth Low Energy (BLE) Peripheral with all the Services that are required for
+ * a Cycling Power Profile
+ */
+import NodeBleHost from 'ble-host'
+import loglevel from 'loglevel'
+
+import { bleBroadcastInterval, bleMinimumKnowDataUpdateInterval } from '../PeripheralConstants.js'
+
+import { CyclingPowerService } from './cps/CyclingPowerMeterService.js'
+import { DeviceInformationService } from './common/DeviceInformationService.js'
+
+/**
+ * @typedef {import('./ble-host.interface.js').Connection} Connection
+ * @typedef {import('./ble-host.interface.js').BleManager} BleManager
+ */
+
+const log = loglevel.getLogger('Peripherals')
+
+/**
+ *
+ * @param {import('./BleManager.js').BleManager} bleManager
+ * @param {Config} config
+ * @returns
+ */
+export function createCpsPeripheral (bleManager, config) {
+ const cyclingPowerService = new CyclingPowerService((event) => {
+ log.debug('CPS Control Point', event)
+ return false
+ })
+ /**
+ * @type {Metrics}
+ */
+ let lastKnownMetrics = {
+ // This reference is to satisfy type checking while simplifying the initialization of lastKnownMetrics (i.e. allow partial initialization but have the type system consider it as a full Metrics type)
+ .../** @type {Metrics} */({}),
+ totalMovingTime: 0,
+ totalLinearDistance: 0,
+ dragFactor: config.rowerSettings.dragFactor
+ }
+ let timer = setTimeout(onBroadcastInterval, bleBroadcastInterval)
+
+ const deviceInformationService = new DeviceInformationService()
+ const cpsAppearance = 1156
+ const advDataBuffer = new NodeBleHost.AdvertisingDataBuilder()
+ .addFlags(['leGeneralDiscoverableMode', 'brEdrNotSupported'])
+ .addLocalName(/* isComplete */ false, `${config.ftmsRowerPeripheralName}`)
+ .addAppearance(cpsAppearance)
+ .add16BitServiceUUIDs(/* isComplete */ false, [cyclingPowerService.gattService.uuid])
+ .build()
+ const scanResponseBuffer = new NodeBleHost.AdvertisingDataBuilder()
+ .addLocalName(/* isComplete */ true, `${config.ftmsRowerPeripheralName} (CPS)`)
+ .build()
+
+ /**
+ * @type {BleManager | undefined}
+ */
+ let _manager
+ /**
+ * @type {Connection | undefined}
+ */
+ let _connection
+
+ setup()
+
+ async function setup () {
+ _manager = await bleManager.getManager()
+ _manager.gattDb.setDeviceName(`${config.ftmsRowerPeripheralName} (CPS)`)
+ _manager.gattDb.addServices([cyclingPowerService.gattService, deviceInformationService.gattService])
+ _manager.setAdvertisingData(advDataBuffer)
+ _manager.setScanResponseData(scanResponseBuffer)
+
+ await triggerAdvertising()
+ }
+
+ async function triggerAdvertising () {
+ _connection = await new Promise((/** @type {(value: Connection) => void} */resolve) => {
+ /** @type {BleManager} */(_manager).startAdvertising({/* options */}, (_status, connection) => {
+ resolve(connection)
+ })
+ })
+ log.debug(`CPS Connection established, address: ${_connection.peerAddress}`)
+
+ _connection.once('disconnect', async () => {
+ log.debug(`CPS client disconnected (address: ${_connection?.peerAddress}), restarting advertising`)
+ _connection = undefined
+ await triggerAdvertising()
+ }) // restart advertising after disconnect
+ }
+
+ // Broadcast the last known metrics
+ function onBroadcastInterval () {
+ cyclingPowerService.notifyData(lastKnownMetrics)
+ timer = setTimeout(onBroadcastInterval, bleBroadcastInterval)
+ }
+
+ /** Records the last known rowing metrics to CPS central
+ * As the client calculates its own speed based on time and distance,
+ * we an only update the last known metrics upon a stroke state change to prevent spiky behaviour
+ * @param {Metrics} metrics
+ */
+ function notifyData (metrics) {
+ if (metrics.metricsContext === undefined) { return }
+ switch (true) {
+ case (metrics.metricsContext.isSessionStop):
+ lastKnownMetrics = { ...metrics }
+ clearTimeout(timer)
+ onBroadcastInterval()
+ break
+ case (metrics.metricsContext.isPauseStart):
+ lastKnownMetrics = { ...metrics }
+ clearTimeout(timer)
+ onBroadcastInterval()
+ break
+ case (metrics.metricsContext.isRecoveryStart):
+ lastKnownMetrics = { ...metrics }
+ clearTimeout(timer)
+ onBroadcastInterval()
+ break
+ case (metrics.timestamp - lastKnownMetrics.timestamp >= bleMinimumKnowDataUpdateInterval):
+ lastKnownMetrics = { ...metrics }
+ clearTimeout(timer)
+ onBroadcastInterval()
+ break
+ default:
+ // Do nothing
+ }
+ }
+
+ /**
+ * CPS does not have status characteristic
+ * @param {{name: string}} status
+ */
+ /* eslint-disable-next-line no-unused-vars -- standardized characteristic interface where the status parameter isn't relevant */
+ function notifyStatus (status) {
+ }
+
+ function destroy () {
+ log.debug('Shutting down CPS peripheral')
+ clearTimeout(timer)
+ _manager?.gattDb.removeService(cyclingPowerService.gattService)
+ _manager?.gattDb.removeService(deviceInformationService.gattService)
+ return new Promise((resolve) => {
+ if (_connection !== undefined) {
+ log.debug('Terminating current CPS connection')
+ _connection.removeAllListeners()
+ _connection.once('disconnect', resolve)
+ _connection.disconnect()
+
+ return
+ }
+ _manager?.stopAdvertising(resolve)
+ })
+ }
+
+ return {
+ triggerAdvertising,
+ notifyData,
+ notifyStatus,
+ destroy
+ }
+}
diff --git a/app/peripherals/ble/CscPeripheral.js b/app/peripherals/ble/CscPeripheral.js
new file mode 100644
index 0000000000..97178a310e
--- /dev/null
+++ b/app/peripherals/ble/CscPeripheral.js
@@ -0,0 +1,164 @@
+'use strict'
+/*
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
+
+ Creates a Bluetooth Low Energy (BLE) Peripheral with all the Services that are required for
+ a Cycling Speed and Cadence Profile
+*/
+import NodeBleHost from 'ble-host'
+import loglevel from 'loglevel'
+
+import { bleBroadcastInterval, bleMinimumKnowDataUpdateInterval } from '../PeripheralConstants.js'
+
+import { CyclingSpeedCadenceService } from './csc/CyclingSpeedCadenceService.js'
+import { DeviceInformationService } from './common/DeviceInformationService.js'
+
+/**
+ * @typedef {import('./ble-host.interface.js').Connection} Connection
+ * @typedef {import('./ble-host.interface.js').BleManager} BleManager
+ */
+
+const log = loglevel.getLogger('Peripherals')
+
+/**
+ * @param {import('./BleManager.js').BleManager} bleManager
+ * @param {Config} config
+ */
+export function createCscPeripheral (bleManager, config) {
+ const cyclingSpeedCadenceService = new CyclingSpeedCadenceService((event) => {
+ log.debug('CSC Control Point', event)
+ return false
+ })
+
+ /**
+ * @type {Metrics}
+ */
+ let lastKnownMetrics = {
+ // This reference is to satisfy type checking while simplifying the initialization of lastKnownMetrics (i.e. allow partial initialization but have the type system consider it as a full Metrics type)
+ .../** @type {Metrics} */({}),
+ totalMovingTime: 0,
+ totalLinearDistance: 0,
+ dragFactor: config.rowerSettings.dragFactor
+ }
+ let timer = setTimeout(onBroadcastInterval, bleBroadcastInterval)
+
+ const deviceInformationService = new DeviceInformationService()
+ const cscAppearance = 1157 // Cycling Speed and Cadence Sensor
+ const advDataBuffer = new NodeBleHost.AdvertisingDataBuilder()
+ .addFlags(['leGeneralDiscoverableMode', 'brEdrNotSupported'])
+ .addLocalName(/* isComplete */ false, `${config.ftmsRowerPeripheralName}`)
+ .addAppearance(cscAppearance)
+ .add16BitServiceUUIDs(/* isComplete */ false, [cyclingSpeedCadenceService.gattService.uuid])
+ .build()
+ const scanResponseBuffer = new NodeBleHost.AdvertisingDataBuilder()
+ .addLocalName(/* isComplete */ true, `${config.ftmsRowerPeripheralName} (CSC)`)
+ .build()
+
+ /**
+ * @type {BleManager | undefined}
+ */
+ let _manager
+ /**
+ * @type {Connection | undefined}
+ */
+ let _connection
+
+ setup()
+
+ async function setup () {
+ _manager = await bleManager.getManager()
+ _manager.gattDb.setDeviceName(`${config.ftmsRowerPeripheralName} (CSC)`)
+ _manager.gattDb.addServices([cyclingSpeedCadenceService.gattService, deviceInformationService.gattService])
+ _manager.setAdvertisingData(advDataBuffer)
+ _manager.setScanResponseData(scanResponseBuffer)
+
+ await triggerAdvertising()
+ }
+
+ async function triggerAdvertising () {
+ _connection = await new Promise((/** @type {(value: Connection) => void} */resolve) => {
+ /** @type {BleManager} */(_manager).startAdvertising({/* options */}, (_status, connection) => {
+ resolve(connection)
+ })
+ })
+ log.debug(`CSC Connection established, address: ${_connection.peerAddress}`)
+
+ _connection.once('disconnect', async () => {
+ log.debug(`CSC client disconnected (address: ${_connection?.peerAddress}), restarting advertising`)
+ _connection = undefined
+ await triggerAdvertising()
+ }) // restart advertising after disconnect
+ }
+
+ // present current rowing metrics to CSC central
+ function onBroadcastInterval () {
+ cyclingSpeedCadenceService.notifyData(lastKnownMetrics)
+ timer = setTimeout(onBroadcastInterval, bleBroadcastInterval)
+ }
+
+ /** Records the last known rowing metrics to CSC central
+ * As the client calculates its own speed based on time and distance,
+ * we an only update the last known metrics upon a stroke state change to prevent spiky behaviour
+ * @param {Metrics} metrics
+ */
+ function notifyData (metrics) {
+ if (metrics.metricsContext === undefined) { return }
+ switch (true) {
+ case (metrics.metricsContext.isSessionStop):
+ lastKnownMetrics = { ...metrics }
+ clearTimeout(timer)
+ onBroadcastInterval()
+ break
+ case (metrics.metricsContext.isPauseStart):
+ lastKnownMetrics = { ...metrics }
+ clearTimeout(timer)
+ onBroadcastInterval()
+ break
+ case (metrics.metricsContext.isRecoveryStart):
+ lastKnownMetrics = { ...metrics }
+ clearTimeout(timer)
+ onBroadcastInterval()
+ break
+ case (metrics.timestamp - lastKnownMetrics.timestamp >= bleMinimumKnowDataUpdateInterval):
+ lastKnownMetrics = { ...metrics }
+ clearTimeout(timer)
+ onBroadcastInterval()
+ break
+ default:
+ // Do nothing
+ }
+ }
+
+ /**
+ * CSC does not have status characteristic
+ * @param {{name: string}} status
+ */
+ /* eslint-disable-next-line no-unused-vars -- standardized characteristic interface where the status parameter isn't relevant */
+ function notifyStatus (status) {
+ }
+
+ function destroy () {
+ log.debug('Shutting down CSC peripheral')
+ clearTimeout(timer)
+ _manager?.gattDb.removeService(cyclingSpeedCadenceService.gattService)
+ _manager?.gattDb.removeService(deviceInformationService.gattService)
+ return new Promise((resolve) => {
+ if (_connection !== undefined) {
+ log.debug('Terminating current CSC connection')
+ _connection.removeAllListeners()
+ _connection.once('disconnect', resolve)
+ _connection.disconnect()
+
+ return
+ }
+ _manager?.stopAdvertising(resolve)
+ })
+ }
+
+ return {
+ triggerAdvertising,
+ notifyData,
+ notifyStatus,
+ destroy
+ }
+}
diff --git a/app/peripherals/ble/FtmsPeripheral.js b/app/peripherals/ble/FtmsPeripheral.js
new file mode 100644
index 0000000000..9f761a7b5e
--- /dev/null
+++ b/app/peripherals/ble/FtmsPeripheral.js
@@ -0,0 +1,149 @@
+'use strict'
+/*
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
+
+ Creates a Bluetooth Low Energy (BLE) Peripheral with all the Services that are required for
+ a Fitness Machine Device
+
+ Relevant parts from https://www.bluetooth.com/specifications/specs/fitness-machine-profile-1-0/
+ The Fitness Machine shall instantiate one and only one Fitness Machine Service as Primary Service
+ The User Data Service, if supported, shall be instantiated as a Primary Service.
+ The Fitness Machine may instantiate the Device Information Service
+ (Manufacturer Name String, Model Number String)
+*/
+import NodeBleHost from 'ble-host'
+import loglevel from 'loglevel'
+
+import { DeviceInformationService } from './common/DeviceInformationService.js'
+import { FitnessMachineService } from './ftms/FitnessMachineService.js'
+
+/**
+ * @typedef {import('./ble-host.interface.js').BleManager} BleManager
+ * @typedef {import('./ble-host.interface.js').Connection} Connection
+ */
+
+const log = loglevel.getLogger('Peripherals')
+
+/**
+ * @param {import('./BleManager.js').BleManager} bleManager
+ * @param {ControlPointCallback} controlCallback
+ * @param {Config} config
+ * @param {boolean} simulateIndoorBike
+ */
+export function createFtmsPeripheral (bleManager, controlCallback, config, simulateIndoorBike) {
+ const peripheralName = simulateIndoorBike ? config.ftmsBikePeripheralName : config.ftmsRowerPeripheralName
+ const fitnessMachineService = new FitnessMachineService(controlCallback, simulateIndoorBike)
+ const deviceInformationService = new DeviceInformationService()
+
+ const rowerSupportedDataFlag = simulateIndoorBike ? 0x01 << 5 : 0x01 << 4
+ const fitnessMachineAvailable = 0x01
+
+ const advDataBuffer = new NodeBleHost.AdvertisingDataBuilder()
+ .addFlags(['leGeneralDiscoverableMode', 'brEdrNotSupported'])
+ .addLocalName(/* isComplete */ false, peripheralName.slice(0, 15))
+ .add16BitServiceUUIDs(/* isComplete */ true, [fitnessMachineService.gattService.uuid])
+ .add16BitServiceData(fitnessMachineService.gattService.uuid, Buffer.from([fitnessMachineAvailable, rowerSupportedDataFlag, rowerSupportedDataFlag >> 8]))
+ .build()
+
+ const scanResponseBuffer = new NodeBleHost.AdvertisingDataBuilder()
+ .addLocalName(/* isComplete */ true, peripheralName)
+ .build()
+
+ const broadcastInterval = config.ftmsUpdateInterval
+ /**
+ * @type {Metrics}
+ */
+ let lastKnownMetrics = {
+ // This reference is to satisfy type checking while simplifying the initialization of lastKnownMetrics (i.e. allow partial initialization but have the type system consider it as a full Metrics type)
+ .../** @type {Metrics} */({}),
+ totalMovingTime: 0,
+ totalLinearDistance: 0,
+ dragFactor: config.rowerSettings.dragFactor
+ }
+
+ let timer = setTimeout(onBroadcastInterval, broadcastInterval)
+
+ /**
+ * @type {BleManager | undefined}
+ */
+ let _manager
+ /**
+ * @type {Connection | undefined}
+ */
+ let _connection
+
+ setup()
+
+ async function setup () {
+ _manager = await bleManager.getManager()
+ _manager.gattDb.setDeviceName(peripheralName)
+ _manager.gattDb.addServices([fitnessMachineService.gattService, deviceInformationService.gattService])
+ _manager.setAdvertisingData(advDataBuffer)
+ _manager.setScanResponseData(scanResponseBuffer)
+
+ await triggerAdvertising()
+ }
+
+ async function triggerAdvertising () {
+ _connection = await new Promise((/** @type {(value: Connection) => void} */resolve) => {
+ /** @type {BleManager} */(_manager).startAdvertising({/* options */}, (_status, connection) => {
+ resolve(connection)
+ })
+ })
+ log.debug(`FTMS Connection established, address: ${_connection.peerAddress}`)
+
+ await new Promise((resolve) => { /** @type {Connection} */(_connection).gatt.exchangeMtu(resolve) })
+
+ _connection.once('disconnect', async () => {
+ log.debug(`FTMS client disconnected (address: ${_connection?.peerAddress}), restarting advertising`)
+ _connection = undefined
+ await triggerAdvertising()
+ }) // restart advertising after disconnect
+ }
+
+ /** Records the last known rowing metrics to FTMS central
+ * @param {Metrics} data
+ */
+ function notifyData (data) {
+ lastKnownMetrics = data
+ }
+
+ /**
+ * Present current rowing status to FTMS central
+ * @param {{name: string}} status
+ */
+ function notifyStatus (status) {
+ fitnessMachineService.notifyStatus(status)
+ }
+
+ function destroy () {
+ log.debug(`Shutting down FTMS ${simulateIndoorBike ? 'Bike' : 'Rower'} peripheral`)
+ clearTimeout(timer)
+ _manager?.gattDb.removeService(fitnessMachineService.gattService)
+ _manager?.gattDb.removeService(deviceInformationService.gattService)
+ return new Promise((resolve) => {
+ if (_connection !== undefined) {
+ log.debug(`Terminating current FTMS ${simulateIndoorBike ? 'Bike' : 'Rower'} connection`)
+ _connection.removeAllListeners()
+ _connection.once('disconnect', resolve)
+ _connection.disconnect()
+
+ return
+ }
+ _manager?.stopAdvertising(resolve)
+ })
+ }
+
+ // present current rowing metrics to FTMS central
+ function onBroadcastInterval () {
+ fitnessMachineService.notifyData(lastKnownMetrics)
+ timer = setTimeout(onBroadcastInterval, broadcastInterval)
+ }
+
+ return {
+ triggerAdvertising,
+ notifyData,
+ notifyStatus,
+ destroy
+ }
+}
diff --git a/app/peripherals/ble/HrmPeripheral.js b/app/peripherals/ble/HrmPeripheral.js
new file mode 100644
index 0000000000..508f9c11c0
--- /dev/null
+++ b/app/peripherals/ble/HrmPeripheral.js
@@ -0,0 +1,42 @@
+'use strict'
+/*
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
+*/
+import EventEmitter from 'node:events'
+
+import { HrmService } from './hrm/HrmService.js'
+
+/**
+ * @event createBleHrmPeripheral#heartRateMeasurement
+ * @param {import ('./BleManager.js').BleManager} bleManager
+ */
+export function createBleHrmPeripheral (bleManager) {
+ /**
+ * @type {EventEmitter<{heartRateMeasurement: Array}>}
+ */
+ const emitter = new EventEmitter()
+ /**
+ * @type {HrmService | undefined}
+ */
+ let _hrmService
+
+ async function attach () {
+ _hrmService = new HrmService(await bleManager.getManager())
+
+ _hrmService.on('heartRateMeasurement', (data) => {
+ emitter.emit('heartRateMeasurement', data)
+ })
+
+ _hrmService.start()
+ }
+
+ async function destroy () {
+ _hrmService?.removeAllListeners()
+ await _hrmService?.stop()
+ }
+
+ return Object.assign(emitter, {
+ destroy,
+ attach
+ })
+}
diff --git a/app/peripherals/ble/Pm5Peripheral.js b/app/peripherals/ble/Pm5Peripheral.js
new file mode 100644
index 0000000000..400170e7b4
--- /dev/null
+++ b/app/peripherals/ble/Pm5Peripheral.js
@@ -0,0 +1,128 @@
+'use strict'
+/*
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
+
+ Creates a Bluetooth Low Energy (BLE) Peripheral with all the Services that are used by the
+ Concept2 PM5 rowing machine.
+
+ see: https://www.concept2.co.uk/files/pdf/us/monitors/PM5_BluetoothSmartInterfaceDefinition.pdf
+ and https://www.concept2.co.uk/files/pdf/us/monitors/PM5_CSAFECommunicationDefinition.pdf
+*/
+import NodeBleHost from 'ble-host'
+import log from 'loglevel'
+
+import { pm5Constants, toC2128BitUUID } from './pm5/Pm5Constants.js'
+import { Pm5AppearanceService } from './pm5/Pm5AppearanceService.js'
+import { Pm5ControlService } from './pm5/control-service/Pm5ControlService.js'
+import { Pm5DeviceInformationService } from './pm5/Pm5DeviceInformationService.js'
+import { Pm5HeartRateControlService } from './pm5/heart-rate-service/Pm5HeartRateControlService.js'
+import { Pm5RowingService } from './pm5/rowing-service/Pm5RowingService.js'
+
+/**
+ * @typedef {import('./ble-host.interface.js').BleManager} BleManager
+ * @typedef {import('./ble-host.interface.js').Connection} Connection
+ */
+
+/**
+ * @param {import ('./BleManager.js').BleManager} bleManager
+ * @param {Config} config
+ * @param {ControlPointCallback} controlCallback
+ */
+export function createPm5Peripheral (bleManager, config, controlCallback) {
+ const deviceInformationService = new Pm5DeviceInformationService()
+ const appearanceService = new Pm5AppearanceService()
+ const controlService = new Pm5ControlService(controlCallback)
+ const rowingService = new Pm5RowingService(config)
+ const heartRateControlService = new Pm5HeartRateControlService()
+ const gattServices = [appearanceService.gattService, controlService.gattService, deviceInformationService.gattService, rowingService.gattService, heartRateControlService.gattService]
+
+ const advDataBuffer = new NodeBleHost.AdvertisingDataBuilder()
+ .addFlags(['leGeneralDiscoverableMode', 'brEdrNotSupported'])
+ .addLocalName(/* isComplete */ true, `${pm5Constants.name} Row`)
+ .build()
+ const scanResponseBuffer = new NodeBleHost.AdvertisingDataBuilder()
+ .add128BitServiceUUIDs(/* isComplete */ true, [toC2128BitUUID('0000')])
+ .build()
+
+ /**
+ * @type {BleManager | undefined}
+ */
+ let _manager
+ /**
+ * @type {Connection | undefined}
+ */
+ let _connection
+
+ setup()
+
+ async function setup () {
+ _manager = await bleManager.getManager()
+ _manager.gattDb.setDeviceName(pm5Constants.name)
+ _manager.gattDb.addServices(gattServices)
+ _manager.setAdvertisingData(advDataBuffer)
+ _manager.setScanResponseData(scanResponseBuffer)
+
+ await triggerAdvertising()
+ }
+
+ async function triggerAdvertising () {
+ _connection = await new Promise((/** @type {(value: Connection) => void} */resolve) => {
+ /** @type {BleManager} */(_manager).startAdvertising({/* options */}, (_status, connection) => {
+ resolve(connection)
+ })
+ })
+ log.debug(`PM5 Connection established, address: ${_connection.peerAddress}`)
+
+ await new Promise((resolve) => { /** @type {Connection} */(_connection).gatt.exchangeMtu(resolve) })
+
+ _connection.once('disconnect', async () => {
+ log.debug(`PM5 client disconnected (address: ${_connection?.peerAddress}), restarting advertising`)
+ _connection = undefined
+ await triggerAdvertising()
+ }) // restart advertising after disconnect
+ }
+
+ /**
+ * Records the last known rowing metrics to FTMS central
+ * @param {Metrics} data
+ */
+ function notifyData (data) {
+ rowingService.notifyData(data)
+ }
+
+ /**
+ * Present current rowing status to C2-PM5 central
+ * @param {{name: string}} status
+ */
+ /* eslint-disable-next-line no-unused-vars -- standardized characteristic interface where the data parameter isn't relevant */
+ function notifyStatus (status) {
+ }
+
+ function destroy () {
+ log.debug('Shutting down PM5 peripheral')
+
+ if (_manager !== undefined) {
+ gattServices.forEach((service) => {
+ /** @type {BleManager} */(_manager).gattDb.removeService(service)
+ })
+ }
+ return new Promise((resolve) => {
+ if (_connection !== undefined) {
+ log.debug('Terminating current PM5 connection')
+ _connection.removeAllListeners()
+ _connection.once('disconnect', resolve)
+ _connection.disconnect()
+
+ return
+ }
+ _manager?.stopAdvertising(resolve)
+ })
+ }
+
+ return {
+ triggerAdvertising,
+ notifyData,
+ notifyStatus,
+ destroy
+ }
+}
diff --git a/app/peripherals/ble/ble-host.interface.js b/app/peripherals/ble/ble-host.interface.js
new file mode 100644
index 0000000000..7292251062
--- /dev/null
+++ b/app/peripherals/ble/ble-host.interface.js
@@ -0,0 +1,658 @@
+/*
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
+*/
+/* eslint-disable no-unused-vars */
+import { EventEmitter } from 'node:stream'
+
+/**
+ * - not-permitted (Characteristic cannot be read)
+ * - open (Can always be read)
+ * - encrypted (Can only be read when the link is encrypted)
+ * - encrypted-mitm (Can only be read when the link is encrypted with a key that was generated with MITM protection)
+ * - encrypted-mitm-sc (Can only be read when the link is encrypted with a key that was generated with MITM protection and Secure Connections pairing)
+ * - custom (A user-provided method will called upon each read to determine if the read should be permitted)
+ * @typedef {'not-permitted'|'open'|'encrypted'|'encrypted-mitm'|'encrypted-mitm-sc'|'custom'}CharacteristicPermission
+ */
+
+/**
+ * BLE Manager for handling Bluetooth Low Energy operations.
+ */
+export class BleManager {
+ /**
+ * @type {GattServerDb}
+ */
+ // @ts-ignore
+ gattDb
+ /**
+ * Creates a BleManager instance.
+ * @param {import('node:events').EventEmitter} transport - The transport object for HCI packets.
+ * @param {object} options - Optional parameters.
+ * @param {string} options.staticRandomAddress - Optional static random address.
+ * @param {Function} callback - Callback function with error and manager instance.
+ */
+ static create (transport, options, callback) {
+ callback(null, new BleManager())
+ }
+
+ /**
+ * Starts a scan for BLE devices.
+ * @param {object} parameters - Scan parameters.
+ * @param {boolean} [parameters.activeScan=true] - Request scan response data.
+ * @param {number} [parameters.scanWindow=16] - Scan window in 0.625 ms units.
+ * @param {number} [parameters.scanInterval=16] - Scan interval in 0.625 ms units.
+ * @param {boolean} [parameters.filterDuplicates=false] - Filter duplicate advertisements.
+ * @param {Array} [parameters.scanFilters] - Array of scan filters.
+ * @returns {Scanner} The scanner instance.
+ */
+ startScan (parameters) {
+ return new Scanner()
+ }
+
+ /**
+ * Connects to a BLE device.
+ * @param {string} bdAddrType - Address type: "public" or "random".
+ * @param {string} bdAddr - Bluetooth Device Address.
+ * @param {object} parameters - Connection parameters.
+ * @param {number} [parameters.connIntervalMin=20] - Minimum connection interval.
+ * @param {number} [parameters.connIntervalMax=25] - Maximum connection interval.
+ * @param {number} [parameters.connLatency=0] - Slave latency.
+ * @param {number} [parameters.supervisionTimeout=500] - Supervision timeout.
+ * @param {(connection: Connection) => void} callback - Callback with the connection object.
+ * @returns {PendingConnection} A pending connection object.
+ */
+ connect (bdAddrType, bdAddr, parameters, callback) {
+ return new PendingConnection()
+ }
+
+ /**
+ * Removes a bonding between the local controller and a peer device.
+ * @param {string} identityAddressType - Identity address type ("public" or "random").
+ * @param {string} identityAddress - The identity address.
+ */
+ removeBond (identityAddressType, identityAddress) {}
+
+ /**
+ * Sets advertising data.
+ * @param {Buffer} data - Buffer containing max 31 bytes of advertising data.
+ * @param {Function} [callback] - Callback with HCI status code.
+ */
+ setAdvertisingData (data, callback) {}
+
+ /**
+ * Sets scan response data.
+ * @param {Buffer} data - Buffer containing max 31 bytes of scan response data.
+ * @param {Function} [callback] - Callback with HCI status code.
+ */
+ setScanResponseData (data, callback) {}
+
+ /**
+ * Starts advertising.
+ * @param {object} parameters - Advertising parameters.
+ * @param {number} [parameters.intervalMin=62.5] - Minimum advertising interval.
+ * @param {number} [parameters.intervalMax=62.5] - Maximum advertising interval.
+ * @param {string} [parameters.advertisingType="ADV_IND"] - Advertising type.
+ * @param {object} [parameters.directedAddress] - Directed address object.
+ * @param {(status: number, connection: Connection) => void} callback - Callback function.
+ */
+ startAdvertising (parameters, callback) {}
+
+ /**
+ * Stops advertising.
+ * @param {Function} [callback] - Callback with HCI status code.
+ */
+ stopAdvertising (callback) {}
+}
+
+/**
+ * Scanner for BLE device discovery.
+ * @fires Scanner#report
+ */
+export class Scanner extends EventEmitter {
+ /**
+ * Stops the scan.
+ */
+ stopScan () {}
+}
+
+/**
+ * Event emitted when a report is received during the scan.
+ * @type {object}
+ * @property {boolean} connectable - Whether the device is connectable (i.e. it did not send ADV_NONCONN_IND).
+ * @property {string} addressType - Address type, either 'public' or 'random'.
+ * @property {string} address - The Bluetooth address of the device.
+ * @property {number} rssi - The RSSI (Received Signal Strength Indicator) in dBm. (-127 to 20, 127 means not available).
+ * @property {Array