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/.markdownlint.json b/.markdownlint.json
index 75d13e4028..a978447aed 100644
--- a/.markdownlint.json
+++ b/.markdownlint.json
@@ -1,5 +1,6 @@
{
"default": true,
"MD013": false,
- "MD049": { "style": "asterisk" }
+ "MD049": { "style": "asterisk" },
+ "MD059": false
}
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/CpsPeripheral.js b/app/ble/CpsPeripheral.js
deleted file mode 100644
index 5d24e47784..0000000000
--- a/app/ble/CpsPeripheral.js
+++ /dev/null
@@ -1,108 +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 Cycling Power Profile
-*/
-import bleno from '@abandonware/bleno'
-import config from '../tools/ConfigManager.js'
-import log from 'loglevel'
-import CyclingPowerService from './cps/CyclingPowerMeterService.js'
-import DeviceInformationService from './common/DeviceInformationService.js'
-import AdvertisingDataBuilder from './common/AdvertisingDataBuilder.js'
-
-function createCpsPeripheral () {
- const peripheralName = `${config.ftmsRowerPeripheralName} (CPS)`
- const cyclingPowerService = new CyclingPowerService((event) => log.debug('CPS Control Point', event))
-
- bleno.on('stateChange', (state) => {
- triggerAdvertising(state)
- })
-
- bleno.on('advertisingStart', (error) => {
- if (!error) {
- bleno.setServices(
- [
- cyclingPowerService,
- new DeviceInformationService()
- ],
- (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') {
- const cpsAppearance = 1156
- const advertisingData = new AdvertisingDataBuilder([cyclingPowerService.uuid], cpsAppearance, peripheralName)
-
- bleno.startAdvertisingWithEIRData(
- advertisingData.buildAppearanceData(),
- advertisingData.buildScanData(),
- (error) => {
- if (error) log.error(error)
- }
- )
- } else {
- bleno.stopAdvertising()
- }
- }
-
- function notifyData (type, data) {
- if (type === 'strokeFinished' || type === 'metricsUpdate') {
- cyclingPowerService.notifyData(data)
- }
- }
-
- // CPS does not have status characteristic
- function notifyStatus (status) {
- }
-
- return {
- triggerAdvertising,
- notifyData,
- notifyStatus,
- destroy
- }
-}
-
-export { createCpsPeripheral }
diff --git a/app/ble/CscPeripheral.js b/app/ble/CscPeripheral.js
deleted file mode 100644
index 3c8e99cc79..0000000000
--- a/app/ble/CscPeripheral.js
+++ /dev/null
@@ -1,108 +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 Cycling Speed and Cadence Profile
-*/
-import bleno from '@abandonware/bleno'
-import config from '../tools/ConfigManager.js'
-import log from 'loglevel'
-import DeviceInformationService from './common/DeviceInformationService.js'
-import CyclingSpeedCadenceService from './csc/CyclingSpeedCadenceService.js'
-import AdvertisingDataBuilder from './common/AdvertisingDataBuilder.js'
-
-function createCscPeripheral () {
- const peripheralName = `${config.ftmsRowerPeripheralName} (CSC)`
- const cyclingSpeedCadenceService = new CyclingSpeedCadenceService((event) => log.debug('CSC Control Point', event))
-
- bleno.on('stateChange', (state) => {
- triggerAdvertising(state)
- })
-
- bleno.on('advertisingStart', (error) => {
- if (!error) {
- bleno.setServices(
- [
- cyclingSpeedCadenceService,
- new DeviceInformationService()
- ],
- (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') {
- const cscAppearance = 1157
- const advertisingData = new AdvertisingDataBuilder([cyclingSpeedCadenceService.uuid], cscAppearance, peripheralName)
-
- bleno.startAdvertisingWithEIRData(
- advertisingData.buildAppearanceData(),
- advertisingData.buildScanData(),
- (error) => {
- if (error) log.error(error)
- }
- )
- } else {
- bleno.stopAdvertising()
- }
- }
-
- function notifyData (type, data) {
- if (type === 'strokeFinished' || type === 'metricsUpdate') {
- cyclingSpeedCadenceService.notifyData(data)
- }
- }
-
- // CSC does not have status characteristic
- function notifyStatus (status) {
- }
-
- return {
- triggerAdvertising,
- notifyData,
- notifyStatus,
- destroy
- }
-}
-
-export { createCscPeripheral }
diff --git a/app/ble/FtmsPeripheral.js b/app/ble/FtmsPeripheral.js
deleted file mode 100644
index 7a54392f9f..0000000000
--- a/app/ble/FtmsPeripheral.js
+++ /dev/null
@@ -1,124 +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 config from '../tools/ConfigManager.js'
-import log from 'loglevel'
-import DeviceInformationService from './common/DeviceInformationService.js'
-import AdvertisingDataBuilder from './common/AdvertisingDataBuilder.js'
-
-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],
- (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') {
- const advertisingBuilder = new AdvertisingDataBuilder([fitnessMachineService.uuid])
- advertisingBuilder.setShortName(peripheralName)
- advertisingBuilder.setLongName(peripheralName)
-
- bleno.startAdvertisingWithEIRData(
- advertisingBuilder.buildAppearanceData(),
- advertisingBuilder.buildScanData(),
- (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 c75861fa24..0000000000
--- a/app/ble/PeripheralManager.js
+++ /dev/null
@@ -1,110 +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'
-import { createCpsPeripheral } from './CpsPeripheral.js'
-import { createCscPeripheral } from './CscPeripheral.js'
-
-const modes = ['FTMS', 'FTMSBIKE', 'PM5', 'CSC', 'CPS']
-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()
- }
-
- switch (newMode) {
- case 'PM5':
- log.info('bluetooth profile: Concept2 PM5')
- peripheral = createPm5Peripheral(controlCallback)
- mode = 'PM5'
- break
-
- case 'FTMSBIKE':
- log.info('bluetooth profile: FTMS Indoor Bike')
- peripheral = createFtmsPeripheral(controlCallback, {
- simulateIndoorBike: true
- })
- mode = 'FTMSBIKE'
- break
- case 'CSC':
- log.info('bluetooth profile: Cycling Speed and Cadence')
- peripheral = createCscPeripheral()
- mode = 'CSC'
- break
- case 'CPS':
- log.info('bluetooth profile: Cycling Power Meter')
- peripheral = createCpsPeripheral()
- mode = 'CPS'
- break
-
- case 'FTMS':
- default:
- log.info('bluetooth profile: FTMS Rower')
- peripheral = createFtmsPeripheral(controlCallback, {
- simulateIndoorBike: false
- })
- mode = 'FTMS'
- break
- }
- 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/common/AdvertisingDataBuilder.js b/app/ble/common/AdvertisingDataBuilder.js
deleted file mode 100644
index ba3dabf412..0000000000
--- a/app/ble/common/AdvertisingDataBuilder.js
+++ /dev/null
@@ -1,133 +0,0 @@
-'use strict'
-
-export default class AdvertisingDataBuilder {
- constructor (serviceUuids, appearance, longName, shortName) {
- this.shortName = shortName || longName || 'ORM'
- this.longName = longName || 'OpenRowingMonitor'
- this.serviceUuids = serviceUuids || []
- this.appearance = appearance
- }
-
- setLongName (name) {
- this.longName = name
- }
-
- setShortName (name) {
- this.shortName = name
- }
-
- addServiceUuid (serviceUuid) {
- this.serviceUuids.push(serviceUuid)
- }
-
- setAppearance (appearance) {
- this.appearance = appearance
- }
-
- buildScanData () {
- let scanDataLength = 0
- scanDataLength += 2 + this.longName.length
- const scanData = Buffer.alloc(scanDataLength)
-
- const nameBuffer = Buffer.from(this.longName)
-
- scanData.writeUInt8(1 + nameBuffer.length, 0)
- scanData.writeUInt8(0x08, 1)
- nameBuffer.copy(scanData, 2)
-
- return scanData
- }
-
- buildAppearanceData () {
- let advertisementDataLength = 3
-
- const serviceUuids16bit = []
- const serviceUuids128bit = []
- let i = 0
-
- if (this.serviceUuids.length) {
- for (i = 0; i < this.serviceUuids.length; i++) {
- const serviceUuid = Buffer.from(this.serviceUuids[i].match(/.{1,2}/g).reverse().join(''), 'hex')
-
- if (serviceUuid.length === 2) {
- serviceUuids16bit.push(serviceUuid)
- } else if (serviceUuid.length === 16) {
- serviceUuids128bit.push(serviceUuid)
- }
- }
- }
-
- if (serviceUuids16bit.length) {
- advertisementDataLength += 2 + 2 * serviceUuids16bit.length
- }
-
- if (serviceUuids128bit.length) {
- advertisementDataLength += 2 + 16 * serviceUuids128bit.length
- }
-
- if (this.appearance) {
- advertisementDataLength += 4
- }
-
- let name = this.shortName
-
- if (advertisementDataLength + 2 + name.length > 31) {
- const remainingDataLength = 31 - advertisementDataLength - 2
- name = name.substring(0, remainingDataLength)
- }
- advertisementDataLength += 2 + name.length
-
- const advertisementData = Buffer.alloc(advertisementDataLength)
-
- // flags
- advertisementData.writeUInt8(2, 0)
- advertisementData.writeUInt8(0x01, 1)
- advertisementData.writeUInt8(0x06, 2)
-
- let advertisementDataOffset = 3
-
- if (this.appearance) {
- advertisementData.writeUInt8(3, advertisementDataOffset)
- advertisementDataOffset++
- advertisementData.writeUInt8(0x19, advertisementDataOffset)
- advertisementDataOffset++
- advertisementData.writeUInt16LE(this.appearance, advertisementDataOffset)
- advertisementDataOffset += 2
- }
-
- advertisementData.writeUInt8(name.length + 1, advertisementDataOffset)
- advertisementDataOffset++
- advertisementData.writeUInt8(0x08, advertisementDataOffset)
- advertisementDataOffset++
- Buffer.from(name).copy(advertisementData, advertisementDataOffset)
- advertisementDataOffset += name.length
-
- if (serviceUuids16bit.length) {
- advertisementData.writeUInt8(1 + 2 * serviceUuids16bit.length, advertisementDataOffset)
- advertisementDataOffset++
-
- advertisementData.writeUInt8(0x03, advertisementDataOffset)
- advertisementDataOffset++
-
- for (i = 0; i < serviceUuids16bit.length; i++) {
- serviceUuids16bit[i].copy(advertisementData, advertisementDataOffset)
- advertisementDataOffset += serviceUuids16bit[i].length
- }
- }
-
- if (serviceUuids128bit.length) {
- advertisementData.writeUInt8(1 + 16 * serviceUuids128bit.length, advertisementDataOffset)
- advertisementDataOffset++
-
- advertisementData.writeUInt8(0x06, advertisementDataOffset)
- advertisementDataOffset++
-
- for (i = 0; i < serviceUuids128bit.length; i++) {
- serviceUuids128bit[i].copy(advertisementData, advertisementDataOffset)
- advertisementDataOffset += serviceUuids128bit[i].length
- }
- }
-
- return advertisementData
- }
-}
diff --git a/app/ble/common/AdvertisingDataBuilder.test.js b/app/ble/common/AdvertisingDataBuilder.test.js
deleted file mode 100644
index 8fbc991f75..0000000000
--- a/app/ble/common/AdvertisingDataBuilder.test.js
+++ /dev/null
@@ -1,117 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-*/
-import { test } from 'uvu'
-import * as assert from 'uvu/assert'
-import log from 'loglevel'
-import AdvertisingDataBuilder from './AdvertisingDataBuilder.js'
-log.setLevel(log.levels.SILENT)
-
-test('empty constructor should create default values', () => {
- // arrange
- const advertisementDataBuilder = new AdvertisingDataBuilder()
-
- // act
-
- // assert
- assert.type(advertisementDataBuilder.appearance, 'undefined')
- assert.equal(advertisementDataBuilder.longName, 'OpenRowingMonitor')
- assert.equal(advertisementDataBuilder.shortName, 'ORM', 'if longName is not defined short name should be ORM')
- assert.equal(advertisementDataBuilder.serviceUuids.length, 0)
-})
-
-test('should use long name as short name if latter is not set', () => {
- // arrange
- const advertisementDataBuilder = new AdvertisingDataBuilder([], undefined, 'testLongName')
-
- // act
-
- // assert
- assert.equal(advertisementDataBuilder.shortName, advertisementDataBuilder.longName)
-})
-
-test('should be able to set long name', () => {
- // arrange
- const advertisementDataBuilder = new AdvertisingDataBuilder()
- const name = 'longNameTest'
- // act
- advertisementDataBuilder.setLongName(name)
-
- // assert
- assert.equal(advertisementDataBuilder.longName, name)
-})
-
-test('should be able to set short name', () => {
- // arrange
- const advertisementDataBuilder = new AdvertisingDataBuilder()
-
- const name = 'shortNameTest'
- // act
- advertisementDataBuilder.setShortName(name)
-
- // assert
- assert.equal(advertisementDataBuilder.shortName, name)
-})
-
-test('should be able to set appearance field', () => {
- // arrange
- const advertisementDataBuilder = new AdvertisingDataBuilder()
-
- const appearance = 1157
- // act
- advertisementDataBuilder.setAppearance(appearance)
-
- // assert
- assert.equal(advertisementDataBuilder.appearance, appearance)
-})
-
-test('should be able to add service UUID', () => {
- // arrange
- const advertisementDataBuilder = new AdvertisingDataBuilder()
-
- // act
- advertisementDataBuilder.addServiceUuid('1800')
- advertisementDataBuilder.addServiceUuid('1801')
-
- // assert
- assert.equal(advertisementDataBuilder.serviceUuids.length, 2)
-})
-
-test('should add long name to scan data', () => {
- // arrange
- const name = 'testLongName'
- const advertisementDataBuilder = new AdvertisingDataBuilder(['1800'], undefined, name, 'short')
-
- // act
- const scanData = advertisementDataBuilder.buildScanData()
-
- // assert
- assert.equal(scanData.length, name.length + 2)
-})
-
-test('should produce correct byte array for advertising data', () => {
- // arrange
- const advertisementDataBuilder = new AdvertisingDataBuilder(['1816'], 1156, 'ORM')
-
- // act
- const advertisementData = advertisementDataBuilder.buildAppearanceData()
- // assert
- assert.equal([...advertisementData], [2, 1, 6, 3, 25, 132, 4, 4, 8, 79, 82, 77, 3, 3, 22, 24]
- )
-})
-
-test('should trim short name if advertising data is longer than 31 byte', () => {
- // arrange
- const advertisementDataBuilder = new AdvertisingDataBuilder(['1816'], 1156, 'OpenRowingMonitor CSC')
-
- // act
- const advertisementData = advertisementDataBuilder.buildAppearanceData()
-
- // assert
- assert.equal(advertisementData.length, 31)
- assert.equal([...advertisementData], [2, 1, 6, 3, 25, 132, 4, 19, 8, 79, 112, 101, 110, 82, 111, 119, 105, 110, 103, 77, 111, 110, 105, 116, 111, 114, 32, 3, 3, 22, 24])
- assert.match(advertisementData.toString(), /OpenRowingMonitor/)
-})
-
-test.run()
diff --git a/app/ble/common/DeviceInformationService.js b/app/ble/common/DeviceInformationService.js
deleted file mode 100644
index 100f5c4766..0000000000
--- a/app/ble/common/DeviceInformationService.js
+++ /dev/null
@@ -1,23 +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'
-import StaticReadCharacteristic from './StaticReadCharacteristic.js'
-
-export default class DeviceInformationService extends bleno.PrimaryService {
- constructor () {
- super({
- // uuid of 'Device Information Service'
- uuid: '180a',
- characteristics: [
- new StaticReadCharacteristic('2A24', 'Model Number', 'ORM2'),
- new StaticReadCharacteristic('2A25', 'Serial Number', '1234'),
- new StaticReadCharacteristic('2A28', 'Software Revision', '2'),
- new StaticReadCharacteristic('2A29', 'Manufacturer Name', 'OpenRowingMonitor')
- ]
- })
- }
-}
diff --git a/app/ble/common/SensorLocation.js b/app/ble/common/SensorLocation.js
deleted file mode 100644
index 9a86a15ed6..0000000000
--- a/app/ble/common/SensorLocation.js
+++ /dev/null
@@ -1,30 +0,0 @@
-'use strict'
-
-import BufferBuilder from '../BufferBuilder.js'
-
-export const sensorLocations =
-{
- other: 0,
- topOfShoe: 1,
- inShoe: 2,
- hip: 3,
- frontWheel: 4,
- leftCrank: 5,
- rightCrank: 6,
- leftPedal: 7,
- rightPedal: 8,
- frontHub: 9,
- rearDropout: 10,
- chainstay: 11,
- rearWheel: 12,
- rearHub: 13,
- chest: 14,
- spider: 15,
- chainRing: 16
-}
-
-export const SensorLocationAsBuffer = () => {
- const sensorLocationBuffer = new BufferBuilder()
- sensorLocationBuffer.writeUInt8(sensorLocations.other)
- return sensorLocationBuffer.getBuffer()
-}
diff --git a/app/ble/common/StaticReadCharacteristic.js b/app/ble/common/StaticReadCharacteristic.js
deleted file mode 100644
index ef2248fef6..0000000000
--- a/app/ble/common/StaticReadCharacteristic.js
+++ /dev/null
@@ -1,22 +0,0 @@
-'use strict'
-
-import bleno from '@abandonware/bleno'
-
-export default class StaticReadCharacteristic extends bleno.Characteristic {
- constructor (uuid, description, value) {
- super({
- uuid,
- properties: ['read'],
- value: Buffer.isBuffer(value) ? value : Buffer.from(value),
- descriptors: [
- new bleno.Descriptor({
- uuid: '2901',
- value: description
- })
- ]
- })
- this.uuid = uuid
- this.description = description
- this.value = Buffer.isBuffer(value) ? value : Buffer.from(value)
- }
-}
diff --git a/app/ble/cps/CpsControlPointCharacteristic.js b/app/ble/cps/CpsControlPointCharacteristic.js
deleted file mode 100644
index 6b1283d625..0000000000
--- a/app/ble/cps/CpsControlPointCharacteristic.js
+++ /dev/null
@@ -1,29 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- The connected Central can remotely control some parameters or our rowing monitor via this Control Point
-
- But for our use case proper implementation is not necessary (its mere existence with an empty handler suffice)
-*/
-import bleno from '@abandonware/bleno'
-
-export default class CyclingPowerControlPointCharacteristic extends bleno.Characteristic {
- constructor (controlPointCallback) {
- super({
- // Cycling Power Meter Control Point
- uuid: '2A66',
- value: null,
- properties: ['indicate', 'write']
- })
-
- this.controlled = false
- if (!controlPointCallback) { throw new Error('controlPointCallback required') }
- this.controlPointCallback = controlPointCallback
- }
-
- // Central sends a command to the Control Point
- // No need to handle any request to have this working
- onWriteRequest (data, offset, withoutResponse, callback) {
- }
-}
diff --git a/app/ble/cps/CpsMeasurementCharacteristic.js b/app/ble/cps/CpsMeasurementCharacteristic.js
deleted file mode 100644
index c87fcece50..0000000000
--- a/app/ble/cps/CpsMeasurementCharacteristic.js
+++ /dev/null
@@ -1,95 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-*/
-import bleno from '@abandonware/bleno'
-import log from 'loglevel'
-import BufferBuilder from '../BufferBuilder.js'
-
-export const cpsMeasurementFeaturesFlags = {
- pedalPowerBalancePresent: (0x01 << 0),
- pedalPowerBalanceReference: (0x01 << 1),
- accumulatedTorquePresent: (0x01 << 2),
- accumulatedTorqueSource: (0x01 << 3),
- accumulatedTorqueSourceWheel: (0x00 << 3),
- accumulatedTorqueSourceCrank: (0x01 << 3),
- wheelRevolutionDataPresent: (0x01 << 4),
- crankRevolutionDataPresent: (0x01 << 5),
- extremeForceMagnitudesPresent: (0x01 << 6),
- extremeTorqueMagnitudesPresent: (0x01 << 7),
- extremeAnglesPresent: (0x01 << 8),
- topDeadSpotAnglePresent: (0x01 << 9),
- bottomDeadSpotAnglePresent: (0x01 << 10),
- accumulatedEnergyPresent: (0x01 << 11),
- offsetCompensationIndicator: (0x01 << 12)
-}
-
-export default class CyclingPowerMeasurementCharacteristic extends bleno.Characteristic {
- constructor () {
- super({
- // Cycling Power Meter Measurement
- uuid: '2A63',
- value: null,
- properties: ['notify'],
- descriptors: [
- new bleno.Descriptor({
- uuid: '2901',
- value: 'Cycling Power Measurement'
- })
- ]
- })
- this._updateValueCallback = null
- this._subscriberMaxValueSize = null
- }
-
- onSubscribe (maxValueSize, updateValueCallback) {
- log.debug(`CyclingPowerMeasurementCharacteristic - central subscribed with maxSize: ${maxValueSize}`)
- this._updateValueCallback = updateValueCallback
- this._subscriberMaxValueSize = maxValueSize
- return this.RESULT_SUCCESS
- }
-
- onUnsubscribe () {
- log.debug('CyclingPowerMeasurementCharacteristic - central unsubscribed')
- this._updateValueCallback = null
- this._subscriberMaxValueSize = null
- return this.RESULT_UNLIKELY_ERROR
- }
-
- notify (data) {
- // ignore events without the mandatory fields
- if (!('cyclePower' in data)) {
- log.error('can not deliver bike data without mandatory fields')
- return this.RESULT_SUCCESS
- }
-
- if (this._updateValueCallback) {
- const bufferBuilder = new BufferBuilder()
-
- // Features flag
- bufferBuilder.writeUInt16LE(cpsMeasurementFeaturesFlags.wheelRevolutionDataPresent | cpsMeasurementFeaturesFlags.crankRevolutionDataPresent)
-
- // Instantaneous Power
- bufferBuilder.writeUInt16LE(Math.round(data.cyclePower))
-
- // Wheel revolution count (basically the distance in cm)
- bufferBuilder.writeUInt32LE(Math.round(Math.round(data.totalLinearDistance * 100)))
-
- // Wheel revolution time (ushort with 2048 resolution, resetting in every 32sec)
- bufferBuilder.writeUInt16LE(Math.round(data.totalMovingTime * 2048) % Math.pow(2, 16))
-
- // Total stroke count
- bufferBuilder.writeUInt16LE(Math.round(data.totalNumberOfStrokes))
-
- // last stroke time time (ushort with 1024 resolution, resetting in every 64sec)
- bufferBuilder.writeUInt16LE(Math.round(data.driveLastStartTime * 1024) % Math.pow(2, 16))
-
- const buffer = bufferBuilder.getBuffer()
- if (buffer.length > this._subscriberMaxValueSize) {
- log.warn(`CyclingPowerMeasurementCharacteristic - notification of ${buffer.length} bytes is too large for the subscriber`)
- }
- this._updateValueCallback(bufferBuilder.getBuffer())
- }
- return this.RESULT_SUCCESS
- }
-}
diff --git a/app/ble/cps/CyclingPowerMeterService.js b/app/ble/cps/CyclingPowerMeterService.js
deleted file mode 100644
index ac2c811f32..0000000000
--- a/app/ble/cps/CyclingPowerMeterService.js
+++ /dev/null
@@ -1,66 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-*/
-import bleno from '@abandonware/bleno'
-import BufferBuilder from '../BufferBuilder.js'
-import { SensorLocationAsBuffer } from '../common/SensorLocation.js'
-import StaticReadCharacteristic from '../common/StaticReadCharacteristic.js'
-import CyclingPowerControlPointCharacteristic from './CpsControlPointCharacteristic.js'
-import CyclingPowerMeasurementCharacteristic from './CpsMeasurementCharacteristic.js'
-
-export default class CyclingPowerService extends bleno.PrimaryService {
- constructor (controlPointCallback) {
- const cpsFeatureBuffer = new BufferBuilder()
- cpsFeatureBuffer.writeUInt32LE(featuresFlag)
-
- const measurementCharacteristic = new CyclingPowerMeasurementCharacteristic()
- super({
- // Cycling Power
- uuid: '1818',
- characteristics: [
- new StaticReadCharacteristic('2A65', 'Cycling Power Feature', cpsFeatureBuffer.getBuffer()),
- measurementCharacteristic,
- new StaticReadCharacteristic('2A5D', 'Sensor Location', SensorLocationAsBuffer()),
- new CyclingPowerControlPointCharacteristic(controlPointCallback)
- ]
- })
- this.measurementCharacteristic = measurementCharacteristic
- }
-
- notifyData (event) {
- this.measurementCharacteristic.notify(event)
- }
-}
-
-export const cpsFeaturesFlags =
-{
- pedalPowerBalanceSupported: (0x01 << 0),
- accumulatedTorqueSupported: (0x01 << 1),
- wheelRevolutionDataSupported: (0x01 << 2),
- crankRevolutionDataSupported: (0x01 << 3),
- extremeMagnitudesSupported: (0x01 << 4),
- extremeAnglesSupported: (0x01 << 5),
- topAndBottomDeadSpotAnglesSupported: (0x01 << 6),
- accumulatedEnergySupported: (0x01 << 7),
- offsetCompensationIndicatorSupported: (0x01 << 8),
- offsetCompensationSupported: (0x01 << 9),
- cyclingPowerMeasurementCharacteristicContentMaskingSupported: (0x01 << 10),
- multipleSensorLocationsSupported: (0x01 << 11),
- crankLengthAdjustmentSupported: (0x01 << 12),
- chainLengthAdjustmentSupported: (0x01 << 13),
- chainWeightAdjustmentSupported: (0x01 << 14),
- spanLengthAdjustmentSupported: (0x01 << 15),
- sensorMeasurementContext: (0x01 << 16),
- sensorMeasurementContextForce: (0x00 << 16),
- sensorMeasurementContextTorque: (0x01 << 16),
- instantaneousMeasurementDirectionSupported: (0x01 << 17),
- factoryCalibrationDateSupported: (0x01 << 18),
- enhancedOffsetCompensationSupported: (0x01 << 19),
- distributeSystemSupportUnspecified: (0x00 << 20),
- distributeSystemSupportNotInDistributed: (0x01 << 20),
- distributeSystemSupportInDistributed: (0x02 << 20),
- distributeSystemSupportRFU: (0x03 << 20)
-}
-
-const featuresFlag = cpsFeaturesFlags.sensorMeasurementContextForce | cpsFeaturesFlags.wheelRevolutionDataSupported | cpsFeaturesFlags.crankRevolutionDataSupported | cpsFeaturesFlags.distributeSystemSupportNotInDistributed
diff --git a/app/ble/csc/CscControlPointCharacteristic.js b/app/ble/csc/CscControlPointCharacteristic.js
deleted file mode 100644
index 1f9a110b95..0000000000
--- a/app/ble/csc/CscControlPointCharacteristic.js
+++ /dev/null
@@ -1,29 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- The connected Central can remotely control some parameters or our rowing monitor via this Control Point
-
- But for our use case proper implementation is not necessary (its mere existence with an empty handler suffice)
-*/
-import bleno from '@abandonware/bleno'
-
-export default class CyclingSpeedCadenceControlPointCharacteristic extends bleno.Characteristic {
- constructor (controlPointCallback) {
- super({
- // Cycling Speed and Cadence Control Point
- uuid: '2A55',
- value: null,
- properties: ['indicate', 'write']
- })
-
- this.controlled = false
- if (!controlPointCallback) { throw new Error('controlPointCallback required') }
- this.controlPointCallback = controlPointCallback
- }
-
- // Central sends a command to the Control Point
- // No need to handle any request to have this working
- onWriteRequest (data, offset, withoutResponse, callback) {
- }
-}
diff --git a/app/ble/csc/CscMeasurementCharacteristic.js b/app/ble/csc/CscMeasurementCharacteristic.js
deleted file mode 100644
index 60461588c2..0000000000
--- a/app/ble/csc/CscMeasurementCharacteristic.js
+++ /dev/null
@@ -1,81 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-*/
-import bleno from '@abandonware/bleno'
-import log from 'loglevel'
-import BufferBuilder from '../BufferBuilder.js'
-
-export default class CyclingSpeedCadenceMeasurementCharacteristic extends bleno.Characteristic {
- constructor () {
- super({
- // Cycling Speed and Cadence Measurement
- uuid: '2A5B',
- value: null,
- properties: ['notify'],
- descriptors: [
- new bleno.Descriptor({
- uuid: '2901',
- value: 'Cycling Speed and Cadence Measurement'
- })
- ]
- })
- this._updateValueCallback = null
- this._subscriberMaxValueSize = null
- }
-
- onSubscribe (maxValueSize, updateValueCallback) {
- log.debug(`CyclingSpeedCadenceMeasurementCharacteristic - central subscribed with maxSize: ${maxValueSize}`)
- this._updateValueCallback = updateValueCallback
- this._subscriberMaxValueSize = maxValueSize
- return this.RESULT_SUCCESS
- }
-
- onUnsubscribe () {
- log.debug('CyclingSpeedCadenceMeasurementCharacteristic - central unsubscribed')
- this._updateValueCallback = null
- this._subscriberMaxValueSize = null
- return this.RESULT_UNLIKELY_ERROR
- }
-
- notify (data) {
- // ignore events without the mandatory fields
- if (!('cyclePower' in data)) {
- log.error('can not deliver bike data without mandatory fields')
- return this.RESULT_SUCCESS
- }
-
- if (this._updateValueCallback) {
- const bufferBuilder = new BufferBuilder()
-
- // Features flag
- bufferBuilder.writeUInt8(cscFeaturesFlags.crankRevolutionDataSupported | cscFeaturesFlags.wheelRevolutionDataSupported)
-
- // Wheel revolution count (basically the distance in cm)
- bufferBuilder.writeUInt32LE(Math.round(Math.round(data.totalLinearDistance * 100)))
-
- // Wheel revolution time (ushort with 1024 resolution, resetting in every 64sec)
- bufferBuilder.writeUInt16LE(Math.round(data.totalMovingTime * 1024) % Math.pow(2, 16))
-
- // Total stroke count
- bufferBuilder.writeUInt16LE(Math.round(data.totalNumberOfStrokes))
-
- // last stroke time time (ushort with 1024 resolution, resetting in every 64sec)
- bufferBuilder.writeUInt16LE(Math.round(data.driveLastStartTime * 1024) % Math.pow(2, 16))
-
- const buffer = bufferBuilder.getBuffer()
- if (buffer.length > this._subscriberMaxValueSize) {
- log.warn(`CyclingSpeedCadenceMeasurementCharacteristic - notification of ${buffer.length} bytes is too large for the subscriber`)
- }
- this._updateValueCallback(bufferBuilder.getBuffer())
- }
- return this.RESULT_SUCCESS
- }
-}
-
-export const cscFeaturesFlags =
-{
- wheelRevolutionDataSupported: (0x01 << 0),
- crankRevolutionDataSupported: (0x01 << 1),
- multipleSensorLocationSupported: (0x01 << 2)
-}
diff --git a/app/ble/csc/CyclingSpeedCadenceService.js b/app/ble/csc/CyclingSpeedCadenceService.js
deleted file mode 100644
index 261b38505f..0000000000
--- a/app/ble/csc/CyclingSpeedCadenceService.js
+++ /dev/null
@@ -1,36 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-*/
-import bleno from '@abandonware/bleno'
-import BufferBuilder from '../BufferBuilder.js'
-import { SensorLocationAsBuffer } from '../common/SensorLocation.js'
-import StaticReadCharacteristic from '../common/StaticReadCharacteristic.js'
-import CyclingSpeedCadenceControlPointCharacteristic from './CscControlPointCharacteristic.js'
-import CyclingSpeedCadenceMeasurementCharacteristic, { cscFeaturesFlags } from './CscMeasurementCharacteristic.js'
-
-export default class CyclingSpeedCadenceService extends bleno.PrimaryService {
- constructor (controlPointCallback) {
- const cscFeatureBuffer = new BufferBuilder()
- cscFeatureBuffer.writeUInt16LE(featuresFlag)
-
- const measurementCharacteristic = new CyclingSpeedCadenceMeasurementCharacteristic()
- super({
- // Cycling Speed and Cadence
- uuid: '1816',
- characteristics: [
- new StaticReadCharacteristic('2A5C', 'Cycling Speed and Cadence Feature', cscFeatureBuffer.getBuffer()),
- measurementCharacteristic,
- new CyclingSpeedCadenceControlPointCharacteristic(controlPointCallback),
- new StaticReadCharacteristic('2A5D', 'Sensor Location', SensorLocationAsBuffer())
- ]
- })
- this.measurementCharacteristic = measurementCharacteristic
- }
-
- notifyData (event) {
- this.measurementCharacteristic.notify(event)
- }
-}
-
-const featuresFlag = cscFeaturesFlags.crankRevolutionDataSupported | cscFeaturesFlags.wheelRevolutionDataSupported
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 9a1b71c017..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 (!('cycleLinearVelocity' 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(data.cycleLinearVelocity * 3.6 * 100)
- // Instantaneous Cadence in rotations per minute (we use this to communicate the strokes per minute)
- bufferBuilder.writeUInt16LE(Math.round(data.cycleStrokeRate * 2))
- // Total Distance in meters
- bufferBuilder.writeUInt24LE(Math.round(data.totalLinearDistance))
- // Instantaneous Power in watts
- bufferBuilder.writeUInt16LE(Math.round(data.cyclePower))
- // Energy
- // Total energy in kcal
- bufferBuilder.writeUInt16LE(Math.round(data.totalCalories))
- // 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.totalCaloriesPerHour))
- // Energy per minute
- bufferBuilder.writeUInt8(Math.round(data.totalCaloriesPerMinute))
- // 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.totalMovingTime))
-
- 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 a3a376266e..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 (!('cycleStrokeRate' in data && 'totalNumberOfStrokes' 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.cycleStrokeRate * 2))
- // Stroke Count
- bufferBuilder.writeUInt16LE(Math.round(data.totalNumberOfStrokes))
- // Total Distance in meters
- bufferBuilder.writeUInt24LE(Math.round(data.totalLinearDistance))
- // 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.cyclePace !== Infinity && data.cyclePace < 65535 ? Math.round(data.cyclePace) : 0xFFFF)
- // Instantaneous Power in watts
- bufferBuilder.writeUInt16LE(Math.round(data.cyclePower))
- // Energy in kcal
- // Total energy in kcal
- bufferBuilder.writeUInt16LE(Math.round(data.totalCalories))
- // 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.totalCaloriesPerHour))
- // Energy per minute
- bufferBuilder.writeUInt8(Math.round(data.totalCaloriesPerMinute))
- // 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.totalMovingTime))
-
- 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 e4c352d4f5..0000000000
--- a/app/ble/pm5/Pm5Constants.js
+++ /dev/null
@@ -1,27 +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 Row',
- hardwareRevision: '907',
- // 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: '210',
- 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 2173275976..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.totalMovingTime * 100))
- // speed: UInt16LE in 0.001 m/sec
- bufferBuilder.writeUInt16LE(Math.round(data.cycleLinearVelocity * 1000))
- // strokeRate: UInt8 in strokes/min
- bufferBuilder.writeUInt8(Math.round(data.cycleStrokeRate))
- // 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.cyclePace !== Infinity && data.cyclePace > 0 && data.cyclePace < 655.34 ? data.cyclePace * 100 : 0xFFFF)
- // averagePace: UInt16LE in 0.01 sec/500m
- let averagePace = 0
- if (data.totalLinearDistance && data.totalLinearDistance !== 0) {
- averagePace = (data.totalMovingTime / data.totalLinearDistance) * 500
- }
- bufferBuilder.writeUInt16LE(Math.round(Math.min(averagePace * 100, 65535)))
- // 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.cyclePower))
- }
- // 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 66ccc66aec..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.totalMovingTime * 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.cyclePower))
- }
- // totalCalories: UInt16LE in kCal
- bufferBuilder.writeUInt16LE(Math.round(data.totalCalories))
- // 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 2a05515806..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.totalMovingTime * 100))
- // strokePower: UInt16LE in watts
- bufferBuilder.writeUInt16LE(Math.round(data.cyclePower))
- // strokeCalories: UInt16LE in cal
- bufferBuilder.writeUInt16LE(Math.round(data.strokeCalories * 1000))
- // strokeCount: UInt16LE
- bufferBuilder.writeUInt16LE(Math.round(data.totalNumberOfStrokes))
- // projectedWorkTime: UInt24LE in 1 sec
- bufferBuilder.writeUInt24LE(Math.round(data.cycleProjectedEndTime))
- // projectedWorkDistance: UInt24LE in 1 m
- bufferBuilder.writeUInt24LE(Math.round(data.cycleProjectedEndLinearDistance))
- if (!this._updateValueCallback) {
- // the multiplexer uses a slightly different format for the AdditionalStrokeData
- // it adds workPerStroke at the end
- // workPerStroke: UInt16LE in 0.1 Joules
- bufferBuilder.writeUInt16LE(Math.round(data.strokeWork * 10))
- }
-
- 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 c0116ee038..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.totalMovingTime * 100))
- // distance: UInt24LE in 0.1 m
- bufferBuilder.writeUInt24LE(Math.round(data.totalLinearDistance * 10))
- // workoutType: UInt8 0 WORKOUTTYPE_JUSTROW_NOSPLITS, 2 WORKOUTTYPE_FIXEDDIST_NOSPLITS, 4 WORKOUTTYPE_FIXEDTIME_NOSPLITS
- bufferBuilder.writeUInt8(data.sessiontype === 'Distance' ? 2 : (data.sessiontype === 'Time' ? 4 : 0))
- // intervalType: UInt8 will always use 255 (NONE)
- bufferBuilder.writeUInt8(255)
- // workoutState: UInt8 0 WAITTOBEGIN, 1 WORKOUTROW, 10 WORKOUTEND
- bufferBuilder.writeUInt8(data.sessionStatus === 'Rowing' ? 1 : (data.sessionStatus === 'WaitingForStart' ? 0 : 10))
- // rowingState: UInt8 0 INACTIVE, 1 ACTIVE
- bufferBuilder.writeUInt8(data.sessionStatus === 'Rowing' ? 1 : 0)
- // strokeState: UInt8 2 DRIVING, 4 RECOVERY
- bufferBuilder.writeUInt8(data.strokeState === 'WaitingForDrive' ? 0 : (data.strokeState === 'Drive' ? 2 : 4))
- // totalWorkDistance: UInt24LE in 1 m
- bufferBuilder.writeUInt24LE(Math.round(data.totalLinearDistance))
- // workoutDuration: UInt24LE in 0.01 sec (if type TIME)
- bufferBuilder.writeUInt24LE(Math.round(data.totalMovingTime * 100))
- // workoutDurationType: UInt8 0 TIME, 0x40 CALORIES, 0x80 DISTANCE, 0xC0 WATTS
- bufferBuilder.writeUInt8(data.sessiontype === 'Distance' ? 0x80 : 0)
- // dragFactor: UInt8
- bufferBuilder.writeUInt8(Math.round(Math.min(data.dragFactor, 255)))
-
- 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 4f69bda2e9..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.totalMovingTime * 100))
- // distance: UInt24LE in 0.1 m
- bufferBuilder.writeUInt24LE(Math.round(data.totalLinearDistance * 10))
- // driveLength: UInt8 in 0.01 m
- bufferBuilder.writeUInt8(Math.round(data.driveLength * 100))
- // driveTime: UInt8 in 0.01 s
- bufferBuilder.writeUInt8(Math.round(data.driveDuration * 100))
- // strokeRecoveryTime: UInt16LE in 0.01 s
- bufferBuilder.writeUInt16LE(Math.round(data.recoveryDuration * 100))
- // strokeDistance: UInt16LE in 0.01 s
- bufferBuilder.writeUInt16LE(Math.round(data.cycleDistance * 100))
- // peakDriveForce: UInt16LE in 0.1 lbs
- bufferBuilder.writeUInt16LE(Math.round(data.drivePeakHandleForce * 0.224809 * 10))
- // averageDriveForce: UInt16LE in 0.1 lbs
- bufferBuilder.writeUInt16LE(Math.round(data.driveAverageHandleForce * 0.224809 * 10))
- if (this._updateValueCallback) {
- // workPerStroke is only added if data is not send via multiplexer
- // workPerStroke: UInt16LE in 0.1 Joules
- bufferBuilder.writeUInt16LE(Math.round(data.strokeWork * 10))
- }
- // strokeCount: UInt16LE
- bufferBuilder.writeUInt16LE(data.totalNumberOfStrokes)
- 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..8c817c77a8 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,20 +71,22 @@ export class AppDialog extends AppElement {
padding: 0;
}
`
+ @property({ type: Boolean })
+ accessor isValid = true
@property({ type: Boolean, reflect: true })
- dialogOpen
+ accessor dialogOpen
render () {
return html`
@@ -95,6 +101,12 @@ export class AppDialog extends AppElement {
}
}
+ confirm () {
+ if (this.isValid) {
+ this.dialog.value.close('confirm')
+ }
+ }
+
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..e6701b1428 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
*/
@@ -20,8 +20,8 @@ export class DashboardMetric extends AppElement {
}
`
- @property({ type: String })
- batteryLevel = ''
+ @property({ type: Number })
+ accessor batteryLevel = 0
render () {
// 416 is the max width value of the battery bar in the SVG graphic
diff --git a/app/client/components/DashboardActions.js b/app/client/components/DashboardActions.js
deleted file mode 100644
index 109cdfa4f4..0000000000
--- a/app/client/components/DashboardActions.js
+++ /dev/null
@@ -1,171 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/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 './AppDialog.js'
-
-@customElement('dashboard-actions')
-export class DashboardActions extends AppElement {
- static styles = css`
- button {
- 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;
- font-size: 60%;
- text-decoration: none;
- display: inline-flex;
- width: 3.5em;
- height: 2.5em;
- justify-content: center;
- align-items: center;
- }
- button:hover {
- filter: brightness(150%);
- }
-
- #fullscreen-icon {
- display: inline-flex;
- }
-
- #windowed-icon {
- display: none;
- }
-
- .icon {
- height: 1.7em;
- }
-
- .peripheral-mode {
- font-size: 80%;
- }
-
- @media (display-mode: fullscreen) {
- #fullscreen-icon {
- display: none;
- }
- #windowed-icon {
- display: inline-flex;
- }
- }
- `
-
- @state({ type: Object })
- dialog
-
- render () {
- return html`
-
- ${this.renderOptionalButtons()}
-
-
${this.peripheralMode()}
- ${this.dialog ? this.dialog : ''}
- `
- }
-
- 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) {
- 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) {
- buttons.push(html`
-
- `)
- }
-
- if (this.appState?.config?.stravaUploadEnabled) {
- buttons.push(html`
-
- `)
- }
- return buttons
- }
-
- peripheralMode () {
- const value = this.appState?.config?.peripheralMode
-
- switch (value) {
- case 'PM5':
- return 'C2 PM5'
- case 'FTMSBIKE':
- return 'FTMS Bike'
- case 'CSC':
- return 'BLE Bike Speed + Cadence'
- case 'CPS':
- return 'BLE Bike Power'
- case 'FTMS':
- return 'FTMS Rower'
- default:
- return ''
- }
- }
-
- toggleFullscreen () {
- const fullscreenElement = document.getElementsByTagName('web-app')[0]
- if (!document.fullscreenElement) {
- fullscreenElement.requestFullscreen({ navigationUI: 'hide' })
- } else {
- if (document.exitFullscreen) {
- document.exitFullscreen()
- }
- }
- }
-
- reset () {
- this.sendEvent('triggerAction', { command: 'reset' })
- }
-
- switchPeripheralMode () {
- this.sendEvent('triggerAction', { command: 'switchPeripheralMode' })
- }
-
- uploadTraining () {
- this.dialog = html`
-
-
- Do you want to finish your workout and upload it to Strava?
-
- `
- function dialogClosed (event) {
- this.dialog = undefined
- if (event.detail === 'confirm') {
- this.sendEvent('triggerAction', { command: 'uploadTraining' })
- }
- }
- }
-
- shutdown () {
- this.dialog = html`
-
-
- Do you want to shutdown the device?
-
- `
- function dialogClosed (event) {
- 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..c0a79715a7
--- /dev/null
+++ b/app/client/components/DashboardForceCurve.js
@@ -0,0 +1,189 @@
+'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'
+
+/** @type {import('chart.js').Plugin<'line', {positions: number[]}>} */
+const divisionLinesPlugin = {
+ id: 'divisionLines',
+ afterDatasetsDraw (chart, args, options) {
+ if (!options.positions?.length) { return }
+ const { ctx, chartArea: { top, bottom } } = chart
+ ctx.save()
+ ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)'
+ ctx.lineWidth = 5
+ ctx.setLineDash([5, 5])
+ options.positions.forEach((xPos) => {
+ const xPixel = chart.scales.x.getPixelForValue(xPos)
+ ctx.beginPath()
+ ctx.moveTo(xPixel, top)
+ ctx.lineTo(xPixel, bottom)
+ ctx.stroke()
+ })
+ ctx.restore()
+ }
+}
+
+Chart.register(ChartDataLabels, Legend, Filler, LinearScale, LineController, PointElement, LineElement, divisionLinesPlugin)
+
+@customElement('dashboard-force-curve')
+export class DashboardForceCurve extends AppElement {
+ static styles = css`
+ :host {
+ display: block;
+ position: relative;
+ }
+
+ .title {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ font-size: 80%;
+ text-align: center;
+ padding: 0.2em 0;
+ z-index: 1; /* ensures title stays above canvas */
+ }
+
+ canvas {
+ width: 100%;
+ height: 100%;
+ cursor: pointer;
+ }
+ `
+
+ @property({
+ type: Boolean
+ })
+ accessor updateForceCurve = false
+
+ @property({
+ type: Array
+ })
+ accessor value = []
+
+ /** @type {0 | 2 | 3} */
+ @property({
+ type: Number
+ })
+ accessor divisionMode = 0
+
+ @state()
+ accessor _chart
+
+ /** @type {0 | 2 | 3} */
+ @state()
+ accessor _divisionMode = 0
+
+ shouldUpdate (changedProperties) {
+ return this.updateForceCurve || changedProperties.has('divisionMode') || this._chart === undefined
+ }
+
+ _handleClick () {
+ const modes = /** @type {(0 | 2 | 3)[]} */ ([0, 2, 3])
+ const nextMode = modes[(modes.indexOf(this.divisionMode) + 1) % modes.length]
+ this.sendEvent('changeGuiSetting', { forceCurveDivisionMode: nextMode })
+ }
+
+ _updateDivisionLines () {
+ if (!this._chart?.options?.plugins) { return }
+ const dataLength = this.value?.length || 0
+ const positions = this.divisionMode > 0 && dataLength > 0 ?
+ Array.from({ length: this.divisionMode - 1 }, (_, i) => ((i + 1) * dataLength) / this.divisionMode) :
+ []
+ // @ts-ignore - divisionLines is a custom plugin not in Chart.js types
+ this._chart.options.plugins.divisionLines.positions = positions
+ }
+
+ willUpdate () {
+ if (this._chart?.data) {
+ this._chart.data.datasets[0].data = this.value?.map((data, index) => ({ y: data, x: index }))
+ this._updateDivisionLines()
+ }
+ }
+
+ // Updated runs _after_ DOM elements exist, which is what chart.js expects.
+ updated () {
+ this._chart.update()
+ }
+
+ 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: {
+ display: false
+ },
+ legend: {
+ display: false
+ },
+ // @ts-ignore - divisionLines is a custom plugin not in Chart.js types
+ divisionLines: {
+ positions: []
+ }
+ },
+ 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 () {
+ return html`
+
+ ${this._chart?.data.datasets[0].data.length ?
+ '' :
+ html` Force Curve
`
+ }
+
+ `
+ }
+}
diff --git a/app/client/components/DashboardMetric.js b/app/client/components/DashboardMetric.js
index 185c89f470..5844fb6899 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
+ accessor icon = ''
@property({ type: String })
- unit = ''
+ accessor unit = ''
@property({ type: String })
- value = ''
+ accessor value
render () {
return html`
- ${this.icon}
+
- ${this.value !== undefined ? this.value : '--'}
+ ${this.value !== undefined ? this.value : '--'}
${this.unit}
diff --git a/app/client/components/DashboardToolbar.js b/app/client/components/DashboardToolbar.js
new file mode 100644
index 0000000000..ae4085e0af
--- /dev/null
+++ b/app/client/components/DashboardToolbar.js
@@ -0,0 +1,241 @@
+'use strict'
+/*
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
+
+ Toolbar component combining settings and action buttons
+*/
+
+import { AppElement, html, css } from './AppElement.js'
+import { customElement, property, state } from 'lit/decorators.js'
+import { iconSettings, iconUndo, iconExpand, iconCompress, iconPoweroff, iconBluetooth, iconUpload, iconHeartbeat, iconAntplus } from '../lib/icons.js'
+import './SettingsDialog.js'
+import './AppDialog.js'
+
+@customElement('dashboard-toolbar')
+export class DashboardToolbar extends AppElement {
+ static styles = css`
+ :host {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 0.5em;
+ padding: 0.2em 0.3em;
+ background: var(--theme-widget-color);
+ border-radius: var(--theme-border-radius);
+ }
+
+ .button-group {
+ display: flex;
+ align-items: center;
+ gap: 0.3em;
+ flex-wrap: wrap;
+ }
+
+ button {
+ position: relative;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ width: 2.5em;
+ height: 2.5em;
+ padding: 0;
+ border: 0;
+ border-radius: var(--theme-border-radius);
+ background: var(--theme-button-color);
+ color: var(--theme-font-color);
+ font-size: 0.4em;
+ cursor: pointer;
+ }
+
+ button:hover {
+ filter: brightness(150%);
+ }
+
+ button .text {
+ position: absolute;
+ left: 2px;
+ bottom: 2px;
+ font-size: 40%;
+ }
+
+ .icon {
+ height: 1.2em;
+ }
+
+ .peripheral-mode-container {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: 0.1em;
+ }
+
+ .peripheral-mode {
+ font-size: 0.3em;
+ }
+
+ .fullscreen-icon { display: flex; }
+ .windowed-icon { display: none; }
+
+ @media (display-mode: fullscreen) {
+ .fullscreen-icon { display: none; }
+ .windowed-icon { display: flex; }
+ }
+ `
+
+ @property({ type: Object })
+ accessor config = {}
+
+ @state()
+ accessor _appMode = 'BROWSER'
+
+ @state()
+ accessor _dialog
+
+ render () {
+ return html`
+
+
+
+ ${this.renderOptionalButtons()}
+
+
+
+
+ ${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 = []
+ if (this._appMode === 'BROWSER' && document.documentElement.requestFullscreen) {
+ buttons.push(html`
+
+ `)
+ }
+ if (this._appMode === 'KIOSK' && this.config?.shutdownEnabled) {
+ buttons.push(html`
+
+ `)
+ }
+ if (this.config?.uploadEnabled) {
+ buttons.push(html`
+
+ `)
+ }
+ return buttons
+ }
+
+ 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'
+ }
+ }
+
+ openSettings () {
+ this._dialog = html` {
+ this._dialog = undefined
+ }}>`
+ }
+
+ toggleFullscreen () {
+ const fullscreenElement = document.getElementsByTagName('web-app')[0]
+ if (!document.fullscreenElement) {
+ fullscreenElement.requestFullscreen({ navigationUI: 'hide' })
+ } else {
+ if (document.exitFullscreen) {
+ document.exitFullscreen()
+ }
+ }
+ }
+
+ reset () {
+ this.sendEvent('triggerAction', { command: 'reset' })
+ }
+
+ switchBlePeripheralMode () {
+ this.sendEvent('triggerAction', { command: 'switchBlePeripheralMode' })
+ }
+
+ switchAntPeripheralMode () {
+ this.sendEvent('triggerAction', { command: 'switchAntPeripheralMode' })
+ }
+
+ switchHrmPeripheralMode () {
+ this.sendEvent('triggerAction', { command: 'switchHrmMode' })
+ }
+
+ uploadTraining () {
+ this._dialog = html`
+ {
+ // this._dialog = undefined
+ if (event.detail === 'confirm') {
+ this.sendEvent('triggerAction', { command: 'upload' })
+ }
+ }}>
+
+ Do you want to finish your workout and upload it to webservices (Strava, Intervals.icu and RowsAndAll)?
+
+ `
+ }
+
+ shutdown () {
+ this._dialog = html`
+ {
+ this._dialog = undefined
+ if (event.detail === 'confirm') {
+ this.sendEvent('triggerAction', { command: 'shutdown' })
+ }
+ }}>
+
+ Do you want to shutdown the device?
+
+ `
+ }
+}
diff --git a/app/client/components/PerformanceDashboard.js b/app/client/components/PerformanceDashboard.js
index a37fcb41fc..9021a02ccf 100644
--- a/app/client/components/PerformanceDashboard.js
+++ b/app/client/components/PerformanceDashboard.js
@@ -1,106 +1,110 @@
'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 './DashboardToolbar.js'
+import './WorkoutDialog.js'
+import { DASHBOARD_METRICS } from '../store/dashboardMetrics.js'
@customElement('performance-dashboard')
export class PerformanceDashboard extends AppElement {
static styles = css`
:host {
display: grid;
- height: calc(100vh - 2vw);
- padding: 1vw;
- grid-gap: 1vw;
- grid-template-columns: repeat(4, minmax(0, 1fr));
- grid-template-rows: repeat(2, minmax(0, 1fr));
+ grid-template:
+ "toolbar" auto
+ "metrics" 1fr
+ / 1fr;
+ height: 100vh;
+ gap: 1vw;
+ box-sizing: border-box;
+ }
+
+ dashboard-toolbar {
+ grid-area: toolbar;
+ }
+
+ .metrics-grid {
+ grid-area: metrics;
+ display: grid;
+ gap: 1vw;
+ grid-template-columns: repeat(4, 1fr);
+ grid-template-rows: repeat(2, 1fr);
+ min-height: 0; /* prevent grid blowout */
+ }
+
+ .metrics-grid.rows-3 {
+ grid-template-rows: repeat(3, 1fr);
}
@media (orientation: portrait) {
- :host {
- grid-template-columns: repeat(2, minmax(0, 1fr));
- grid-template-rows: repeat(4, minmax(0, 1fr));
+ .metrics-grid {
+ grid-template-columns: repeat(2, 1fr);
+ grid-template-rows: repeat(4, 1fr);
+ }
+
+ .metrics-grid.rows-3 {
+ grid-template-rows: repeat(6, 1fr);
}
}
- dashboard-metric, dashboard-actions {
+ /* This should be defined within the component */
+ dashboard-metric,
+ dashboard-force-curve {
background: var(--theme-widget-color);
text-align: center;
- position: relative;
- padding: 0.5em 0.2em 0 0.2em;
+ padding: 0.2em;
border-radius: var(--theme-border-radius);
- }
-
- dashboard-actions {
- padding: 0.5em 0 0 0;
+ position: relative;
+ min-height: 0; /* prevent grid blowout */
}
`
+ @property()
+ accessor appState = {}
- @property({ type: Object })
- metrics
-
- @property({ type: Object })
- appState = APP_STATE
+ @state()
+ accessor _dialog = null
- render () {
- const metrics = this.calculateFormattedMetrics(this.appState.metrics)
- return html`
-
-
-
-
- ${metrics?.heartrate?.value
- ? html`
-
- ${metrics?.heartrateBatteryLevel?.value
- ? html`
-
- `
- : ''
- }
- `
- : html``}
-
-
-
+ _handleWorkoutOpen = (type) => {
+ this.sendEvent('workout-open', type)
+ this._dialog = html`
+ { this._dialog = null }}
+ >
`
}
- // 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 = {
- totalLinearDistanceFormatted: (value) => value >= 10000
- ? { value: (value / 1000).toFixed(2), unit: 'km' }
- : { value: Math.round(value), unit: 'm' },
- totalCalories: (value) => Math.round(value),
- cyclePower: (value) => Math.round(value),
- cycleStrokeRate: (value) => Math.round(value)
- }
+ dashboardMetricComponentsFactory = (appState) => {
+ const metrics = appState.metrics
+ const configs = appState.config
- 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
- }
- }
- }
- return formattedMetrics
+ const dashboardMetricComponents = Object.keys(DASHBOARD_METRICS).reduce((dashboardMetrics, key) => {
+ dashboardMetrics[key] = DASHBOARD_METRICS[key].template(metrics, configs, this._handleWorkoutOpen)
+
+ return dashboardMetrics
+ }, {})
+
+ return dashboardMetricComponents
+ }
+
+ render () {
+ const metricConfig = [...new Set(this.appState.config.guiConfigs.dashboardMetrics)].reduce((prev, metricName) => {
+ prev.push(this.dashboardMetricComponentsFactory(this.appState)[metricName])
+ return prev
+ }, [])
+
+ const gridClass = this.appState.config.guiConfigs.maxNumberOfTiles === 12 ? 'rows-3' : ''
+
+ return html`
+
+
+ ${this._dialog ? this._dialog : ''}
+ `
}
}
diff --git a/app/client/components/SettingsDialog.js b/app/client/components/SettingsDialog.js
new file mode 100644
index 0000000000..64623cb9fd
--- /dev/null
+++ b/app/client/components/SettingsDialog.js
@@ -0,0 +1,270 @@
+'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 })
+ accessor config = {}
+
+ @queryAll('.metric-selector input')
+ accessor _inputs
+
+ @query('input[name="showIcons"]')
+ accessor _showIconInput
+
+ @query('input[name="maxNumberOfTiles"]')
+ accessor _maxNumberOfTilesInput
+
+ @query('input[name="trueBlackTheme"]')
+ accessor _trueBlackThemeInput
+
+ @state()
+ accessor _selectedMetrics = []
+
+ @state()
+ accessor _sumSelectedSlots = 0
+
+ @state()
+ accessor _isValid = false
+
+ @state()
+ accessor _showIcons = true
+
+ @state()
+ accessor _maxNumberOfTiles = 8
+
+ @state()
+ accessor _trueBlackTheme = false
+
+ 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
+ this._trueBlackTheme = this.config.trueBlackTheme ?? false
+ 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
+ this._trueBlackThemeInput.checked = this._trueBlackTheme
+ }
+
+ 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()
+ }
+
+ toggleTrueBlackTheme (e) {
+ this._trueBlackTheme = e.target.checked
+ }
+
+ 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,
+ trueBlackTheme: this._trueBlackTheme
+ })
+ }
+ }
+}
diff --git a/app/client/components/WorkoutDialog.js b/app/client/components/WorkoutDialog.js
new file mode 100644
index 0000000000..89c1a35b45
--- /dev/null
+++ b/app/client/components/WorkoutDialog.js
@@ -0,0 +1,165 @@
+'use strict'
+/*
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
+
+ Component that renders a workout goal picker dialog
+*/
+import { AppElement, html, css } from './AppElement.js'
+import { customElement, property, state } from 'lit/decorators.js'
+import './AppDialog.js'
+
+const WORKOUT_CONFIG = {
+ distance: {
+ title: 'Set Distance',
+ unit: 'meters',
+ increments: [
+ { label: '+100m', value: 100 },
+ { label: '+500m', value: 500 },
+ { label: '+1K', value: 1000 },
+ { label: '+2K', value: 2000 }
+ ],
+ format (v) {
+ return v >= 99999.5 ? (v / 1000).toFixed(v % 1000 === 0 ? 0 : 1) + 'K' : v
+ },
+ buildPlan: (val) => [{ type: 'distance', targetDistance: String(val), targetTime: '0' }]
+ },
+ time: {
+ title: 'Set Time',
+ unit: 'minutes',
+ increments: [
+ { label: '+1 min', value: 60 },
+ { label: '+5 min', value: 300 },
+ { label: '+10 min', value: 600 },
+ { label: '+20 min', value: 1200 }
+ ],
+ format: (v) => {
+ const minutes = v / 60
+ return minutes % 1 === 0 ? `${minutes}` : `${minutes.toFixed(2)}`
+ },
+ buildPlan: (val) => [{ type: 'time', targetDistance: '0', targetTime: String(val) }]
+ },
+ calories: {
+ title: 'Set Calories',
+ unit: 'kcal',
+ increments: [
+ { label: '+10 kcal', value: 10 },
+ { label: '+50 kcal', value: 50 },
+ { label: '+100 kcal', value: 100 },
+ { label: '+500 kcal', value: 500 }
+ ],
+ format: (v) => v,
+ buildPlan: (val) => [{ type: 'calories', targetCalories: String(val) }]
+ }
+}
+
+@customElement('workout-dialog')
+export class WorkoutDialog extends AppElement {
+ static styles = css`
+ :host {
+ position: absolute;
+ }
+
+ .total-display {
+ text-align: center;
+ font-size: 200%;
+ font-weight: bold;
+ color: var(--theme-font-color);
+ line-height: 1;
+ padding: 0.2em 0 0.1em;
+ }
+
+ .total-unit {
+ text-align: center;
+ font-size: 45%;
+ color: var(--theme-font-color);
+ opacity: 0.7;
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+ }
+
+ .increment-row {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 0.3em;
+ width: 100%;
+
+ @media (max-width: 425px) {
+ grid-template-columns: repeat(2, 1fr);
+ }
+ }
+
+ button {
+ padding: 0.6em 0.4em;
+ background: var(--theme-widget-color);
+ color: var(--theme-font-color);
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ border-radius: var(--theme-border-radius);
+ font-family: var(--theme-font-family);
+ font-size: 50%;
+ font-weight: bold;
+ cursor: pointer;
+ text-align: center;
+ white-space: nowrap;
+ user-select: none;
+
+ &:hover {
+ filter: brightness(130%);
+ }
+
+ &:active {
+ filter: brightness(150%);
+ }
+ }
+
+ .reset-btn {
+ grid-column: 1 / -1;
+ }
+
+ legend {
+ text-align: center;
+ }
+ `
+
+ @property({ type: String })
+ accessor type = 'distance'
+
+ @state()
+ accessor _total = 0
+
+ get _config () {
+ return WORKOUT_CONFIG[this.type] ?? WORKOUT_CONFIG.distance
+ }
+
+ render () {
+ const cfg = this._config
+ return html`
+ 0} @close=${this._onClose}>
+
+ ${this._total > 0 ? cfg.format(this._total) : '0'}
+ ${cfg.unit}
+
+ ${cfg.increments.map((inc) => html`
+
+ `)}
+
+
+
+ `
+ }
+
+ _increment (value) {
+ this._total += value
+ }
+
+ _reset () {
+ this._total = 0
+ }
+
+ _onClose (event) {
+ if (event.detail === 'confirm' && this._total > 0) {
+ const plan = this._config.buildPlan(this._total)
+ this.sendEvent('triggerAction', { command: 'updateIntervalSettings', data: plan })
+ }
+ this.dispatchEvent(new CustomEvent('close'))
+ }
+}
diff --git a/app/client/index.html b/app/client/index.html
index 22ef8e813f..25277bd09f 100644
--- a/app/client/index.html
+++ b/app/client/index.html
@@ -5,7 +5,7 @@
-
+
@@ -26,6 +26,13 @@
--theme-border-radius: 3px;
}
+ /* True Black theme for OLED/AMOLED displays */
+ html[data-theme="true-black"] {
+ --theme-background-color: #000000;
+ --theme-widget-color: #000000;
+ --theme-button-color: #000000;
+ }
+
body {
background-color: var(--theme-background-color);
color: var(--theme-font-color);
diff --git a/app/client/index.js b/app/client/index.js
index b26dfcd4e6..7376036edc 100644
--- a/app/client/index.js
+++ b/app/client/index.js
@@ -1,23 +1,27 @@
'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- Main Initialization Component of the Web Component App
-*/
+/**
+ * @copyright {@link https://github.com/JaapvanEkris/openrowingmonitor|OpenRowingMonitor}
+ *
+ * @file Main Initialization Component of the Web Component App
+ */
import { LitElement, html } from 'lit'
import { customElement, state } from 'lit/decorators.js'
import { APP_STATE } from './store/appState.js'
+import { DASHBOARD_METRICS } from './store/dashboardMetrics.js'
import { createApp } from './lib/app.js'
import './components/PerformanceDashboard.js'
+// Catch async update errors from Lit 3.x (they are re-fired asynchronously)
+window.addEventListener('unhandledrejection', (event) => {
+ // eslint-disable-next-line no-console -- Needed for debugging
+ console.error('Unhandled promise rejection (may be Lit update error):', event.reason)
+})
+
@customElement('web-app')
export class App extends LitElement {
@state()
- appState = APP_STATE
-
- @state()
- metrics
+ accessor _appState = APP_STATE
constructor () {
super()
@@ -28,6 +32,21 @@ 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) => {
+ let savedValue = JSON.parse(localStorage.getItem(key))
+
+ // Validate dashboardMetrics against known valid keys
+ if (key === 'dashboardMetrics' && Array.isArray(savedValue)) {
+ savedValue = savedValue.filter((metric) => DASHBOARD_METRICS[metric] !== undefined)
+ }
+
+ config[key] = savedValue ?? config[key]
+ })
+
+ // apply theme based on saved preference
+ this.applyTheme(config.trueBlackTheme)
+
// 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,29 +58,60 @@ 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) => {
+ const detail = { ...event.detail }
+
+ // Validate dashboardMetrics against known valid keys before saving
+ if (Array.isArray(detail.dashboardMetrics)) {
+ detail.dashboardMetrics = detail.dashboardMetrics.filter((metric) => DASHBOARD_METRICS[metric] !== undefined)
+ }
+
+ Object.keys(detail).forEach((key) => {
+ localStorage.setItem(key, JSON.stringify(detail[key]))
+ })
+ const newGuiConfigs = {
+ ...this._appState.config.guiConfigs,
+ ...detail
+ }
+ this.updateState({
+ config: {
+ ...this._appState.config,
+ guiConfigs: newGuiConfigs
+ }
+ })
+ this.applyTheme(newGuiConfigs.trueBlackTheme)
+ })
+ }
+
+ applyTheme (trueBlackTheme) {
+ if (trueBlackTheme) {
+ document.documentElement.setAttribute('data-theme', 'true-black')
+ } else {
+ document.documentElement.removeAttribute('data-theme')
+ }
}
// 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 = () => {
+ 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))
- }
+ JSON.parse(JSON.stringify(this._appState))
// once we have multiple views, then we would rather reference some kind of router here
// instead of embedding the performance-dashboard directly
render () {
return html`
`
}
diff --git a/app/client/lib/app.js b/app/client/lib/app.js
index 86da9f67b7..ebc13a2536 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 = ['totalNumberOfStrokes', 'totalLinearDistanceFormatted', 'totalCalories', 'cyclePower', 'heartrate',
- 'heartrateBatteryLevel', 'cyclePaceFormatted', 'cycleStrokeRate', 'totalMovingTimeFormatted']
-
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,33 +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 and peripheral mode
- if (data.totalNumberOfStrokes < 1) {
- if (data.totalLinearDistanceFormatted > 0) {
- activeFields = ['totalLinearDistanceFormatted', 'heartrate', 'heartrateBatteryLevel']
- } else if (data.totalMovingTimeFormatted !== '00:00') {
- activeFields = ['totalMovingTimeFormatted', 'heartrate', 'heartrateBatteryLevel']
- } else {
- 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) {
@@ -124,27 +87,42 @@ 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 'updateIntervalSettings': {
+ socket.send(JSON.stringify({ command: 'updateIntervalSettings', data: action.data }))
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/manifest.json b/app/client/manifest.json
index 9c3c01843c..9ecf5d8cb1 100644
--- a/app/client/manifest.json
+++ b/app/client/manifest.json
@@ -1,7 +1,7 @@
{
- "short_name": "Rowing Monitor",
- "name": "Open Rowing Monitor",
- "description": "A rowing monitor for rowing exercise machines",
+ "short_name": "ORM",
+ "name": "OpenRowingMonitor",
+ "description": "A rowing monitor for indoor rowing machines",
"icons": [
{
"src": "icon.png",
diff --git a/app/client/store/appState.js b/app/client/store/appState.js
index 12666d7de6..330d043a82 100644
--- a/app/client/store/appState.js
+++ b/app/client/store/appState.js
@@ -1,21 +1,58 @@
'use strict'
/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- Defines the global state of the app
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
*/
-
+/**
+ * @file Defines the initial global state of the webclient, before the webserver pushes actual data
+ */
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: {},
+ metrics: {
+ strokeState: 'WaitingForDrive',
+ sessionState: 'WaitingForStart',
+ totalMovingTime: 0,
+ pauseCountdownTime: 0,
+ totalNumberOfStrokes: 0,
+ totalLinearDistance: 0,
+ cyclePace: Infinity,
+ cyclePower: 0,
+ cycleStrokeRate: 0,
+ driveLength: 0,
+ driveDuration: 0,
+ driveHandleForceCurve: [],
+ drivePeakHandleForce: 0,
+ driveDistance: 0,
+ recoveryDuration: 0,
+ dragFactor: undefined,
+ interval: {
+ type: 'justrow',
+ movingTime: {
+ sinceStart: 0,
+ toEnd: 0
+ },
+ distance: {
+ fromStart: 0,
+ toEnd: 0
+ }
+ }
+ },
config: {
- // currently can be FTMS, FTMSBIKE, PM5, CSC, CPS
- 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'],
+ showIcons: true,
+ maxNumberOfTiles: 8,
+ trueBlackTheme: false,
+ forceCurveDivisionMode: 0
+ }
}
}
diff --git a/app/client/store/dashboardMetrics.js b/app/client/store/dashboardMetrics.js
new file mode 100644
index 0000000000..92a756f6f0
--- /dev/null
+++ b/app/client/store/dashboardMetrics.js
@@ -0,0 +1,153 @@
+'use strict'
+/**
+ * @copyright {@link https://github.com/JaapvanEkris/openrowingmonitor|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/DashboardMetric.js'
+import '../components/BatteryIcon.js'
+
+export const DASHBOARD_METRICS = {
+ distance: {
+ displayName: 'Distance',
+ size: 1,
+ template: (metrics, config, onWorkoutOpen) => {
+ 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 html` onWorkoutOpen?.('distance')}
+ .icon=${config?.guiConfigs?.showIcons ? iconRoute : ''}
+ .unit=${linearDistance.unit}
+ .value=${linearDistance.distance}
+ >`
+ }
+ },
+
+ 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?.interval?.numberOfStrokes, 'stk', config?.guiConfigs?.showIcons ? iconPaddle : '') },
+
+ calories: {
+ displayName: 'Calories',
+ size: 1,
+ template: (metrics, config, onWorkoutOpen) => {
+ const calories = metrics?.interval?.type === 'calories' ? Math.max(metrics?.interval?.calories?.toEnd, 0) : Math.max(metrics?.interval?.calories?.sinceStart, 0)
+
+ return html` onWorkoutOpen?.('calories')}
+ .icon=${config?.guiConfigs?.showIcons ? iconFire : ''}
+ .unit=${'kcal'}
+ .value=${formatNumber(calories ?? 0)}
+ >`
+ }
+ },
+
+ timer: {
+ displayName: 'Timer',
+ size: 1,
+ template: (metrics, config, onWorkoutOpen) => {
+ 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 html` onWorkoutOpen?.('time')}
+ .icon=${config?.guiConfigs?.showIcons ? icon : ''}
+ .unit=${''}
+ .value=${secondsToTimeString(time ?? 0)}
+ >`
+ }
+ },
+
+ 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, config) => html`
+
+ ` },
+
+ peakForce: { displayName: 'Peak Force', size: 1, template: (metrics) => simpleMetricFactory(formatNumber(metrics?.drivePeakHandleForce), 'N', 'Peak Force') },
+
+ strokeRatio: {
+ displayName: 'Stroke Ratio',
+ size: 1,
+ template: (metrics) => {
+ // Check to make sure both values are truthy
+ // no 0, null, or undefined
+ const validRatio = metrics?.driveDuration && metrics?.recoveryDuration
+ let ratio
+
+ if (validRatio) {
+ ratio = `1:${(metrics.recoveryDuration / metrics.driveDuration).toFixed(1)}`
+ } else {
+ ratio = undefined
+ }
+
+ return simpleMetricFactory(ratio, '', 'Ratio')
+ }
+ }
+}
+
+/**
+ * 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
index b20b09c1e3..ed7093bbfe 100644
--- a/app/engine/Flywheel.js
+++ b/app/engine/Flywheel.js
@@ -1,47 +1,73 @@
'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/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.
-*/
-
+/**
+ * @copyright [OpenRowingMonitor]{@link https://github.com/JaapvanEkris/openrowingmonitor}
+ *
+ * @file 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).
+ *
+ * The calculation of angular velocity and acceleration is based on regression analysis, as the second derivative tends to be
+ * quite fragile when small errors are thrown in the mix. The physics 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.
+ * @see {@link See also https://github.com/JaapvanEkris/openrowingmonitor/blob/main/docs/physics_openrowingmonitor.md#determining-the-angular-velocity-and-angular-acceleration-of-the-flywheel|this approach}
+ *
+ * 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
+ */
import loglevel from 'loglevel'
-import { createStreamFilter } from './utils/StreamFilter.js'
-import { createSeries } from './utils/Series.js'
-import { createOLSLinearSeries } from './utils/OLSLinearSeries.js'
-import { createTSQuadraticSeries } from './utils/FullTSQuadraticSeries.js'
+import { createCyclicErrorFilter } from './utils/CyclicErrorFilter.js'
+import { createTSLinearSeries } from './utils/TSLinearSeries.js'
+import { createWeighedSeries } from './utils/WeighedSeries.js'
+import { createMovingRegressor } from './utils/MovingWindowRegressor.js'
+
const log = loglevel.getLogger('RowingEngine')
-function createFlywheel (rowerSettings) {
+/**
+ * @param {object} rowerSettings - The rower settings configuration object
+ * @param {integer} rowerSettings.numOfImpulsesPerRevolution - Number of impulses per flywheel revolution
+ * @param {integer} rowerSettings.flankLength - Length of the flank used
+ * @param {float} rowerSettings.minimumRecoveryTime - Minimum time a recovery should last (seconds)
+ * @param {float} rowerSettings.maximumStrokeTimeBeforePause - Minimum time that has to pass after the last drive for a pause to kick in (seconds)
+ * @param {float} rowerSettings.flywheelInertia - Inertia of the flywheel
+ * @param {float} rowerSettings.dragFactor - (initial) Dragfactor
+ * @param {boolean} rowerSettings.autoAdjustDragFactor - Indicates if the Flywheel.js is allowed to automatically adjust dragfactor (false turns the filter off)
+ * @param {float} rowerSettings.minimumDragQuality - Minimum Goodness Of Fit before the calculated dragfactor is accepted
+ * @param {integer} rowerSettings.dragFactorSmoothing - Number of recoveries to be weighed in the current dragfactor
+ * @param {boolean} rowerSettings.autoAdjustRecoverySlope - Allow OpenRowingMonitor to adjust the recoverySlope based on the previous recoveries (and thus dragfactor)
+ * @param {float} rowerSettings.minimumRecoverySlope - (initial) recpvery slope
+ * @param {float} rowerSettings.autoAdjustRecoverySlopeMargin - Margin to be maintained for the automatically adjusted recovery slope
+ * @param {float} rowerSettings.minimumStrokeQuality - Minimum Goodness Of Fit for a slope to be considered reliable for stroke detection
+ * @param {float} rowerSettings.sprocketRadius - Radius of the driving sprocket (centimeters)
+ * @param {float} rowerSettings.minimumForceBeforeStroke - Minimum force for the flywheel to be considered powered (Newton)
+ * @param {float} rowerSettings.systematicErrorAgressiveness - Agressiveness of the systematic error correction algorithm (0 turns the filter off)
+ * @param {float} rowerSettings.minimumTimeBetweenImpulses - minimum expected time between impulses (in seconds)
+ * @param {float} rowerSettings.maximumTimeBetweenImpulses - maximum expected time between impulses (in seconds)
+ */
+export function createFlywheel (rowerSettings) {
const angularDisplacementPerImpulse = (2.0 * Math.PI) / rowerSettings.numOfImpulsesPerRevolution
- const flankLength = Math.max(3, rowerSettings.flankLength)
+ const flankLength = rowerSettings.flankLength
const minimumDragFactorSamples = Math.floor(rowerSettings.minimumRecoveryTime / rowerSettings.maximumTimeBetweenImpulses)
- const minumumTorqueBeforeStroke = rowerSettings.minumumForceBeforeStroke * (rowerSettings.sprocketRadius / 100)
- const currentDt = createStreamFilter(rowerSettings.smoothing, rowerSettings.maximumTimeBetweenImpulses)
- const _deltaTime = createOLSLinearSeries(flankLength)
- const _angularDistance = createTSQuadraticSeries(flankLength)
- const _angularVelocityMatrix = []
- const _angularAccelerationMatrix = []
- const drag = createStreamFilter(rowerSettings.dragFactorSmoothing, (rowerSettings.dragFactor / 1000000))
- const recoveryDeltaTime = createOLSLinearSeries()
+ const minimumAngularVelocity = angularDisplacementPerImpulse / rowerSettings.maximumTimeBetweenImpulses
+ const minimumTorqueBeforeStroke = rowerSettings.minimumForceBeforeStroke * (rowerSettings.sprocketRadius / 100)
+ const _angularDistance = createMovingRegressor(flankLength)
+ const _deltaTime = createTSLinearSeries(flankLength)
+ const drag = createWeighedSeries(rowerSettings.dragFactorSmoothing, (rowerSettings.dragFactor / 1000000))
+ const recoveryDeltaTime = createTSLinearSeries()
+ const cyclicErrorFilter = createCyclicErrorFilter(rowerSettings, recoveryDeltaTime)
const strokedetectionMinimalGoodnessOfFit = rowerSettings.minimumStrokeQuality
- const minumumRecoverySlope = createStreamFilter(rowerSettings.dragFactorSmoothing, rowerSettings.minumumRecoverySlope)
- let _deltaTimeBeforeFlank
+ const minimumRecoverySlope = createWeighedSeries(rowerSettings.dragFactorSmoothing, rowerSettings.minimumRecoverySlope)
+ let rawTime = 0
+ let rawNumberOfImpulses = 0
+ let totalTimeSpinning = 0
+ let totalNumberOfImpulses = 0
+ let _totalWork = 0
+ let _deltaTimeBeforeFlank = {}
let _angularVelocityAtBeginFlank
let _angularVelocityBeforeFlank
let _angularAccelerationAtBeginFlank
@@ -50,18 +76,19 @@ function createFlywheel (rowerSettings) {
let _torqueBeforeFlank
let inRecoveryPhase
let maintainMetrics
- let totalNumberOfImpulses
- let totalTimeSpinning
- let currentCleanTime
- let currentRawTime
- let currentAngularDistance
reset()
+ /**
+ * @param {float} dataPoint - The lenght of the impuls (currentDt) in seconds
+ * @description This function is called from Rower.js each time the sensor detected an impulse. It transforms this (via the buffers) into a robust flywheel position, speed and acceleration.
+ * It also calculates dragfactor and provides the indicators for stroke detection.
+ */
+ /* 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 (dataPoint > rowerSettings.maximumStrokeTimeBeforePause || dataPoint < 0) {
+ 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)`)
- dataPoint = currentDt.clean()
+ log.debug(`*** WARNING: currentDt of ${dataPoint} sec isn't between 0 and maximumStrokeTimeBeforePause (${rowerSettings.maximumStrokeTimeBeforePause} sec), value skipped`)
+ return
}
if (dataPoint > rowerSettings.maximumTimeBetweenImpulses && maintainMetrics) {
@@ -69,125 +96,152 @@ function createFlywheel (rowerSettings) {
log.debug(`*** WARNING: currentDt of ${dataPoint} sec is above maximumTimeBetweenImpulses (${rowerSettings.maximumTimeBetweenImpulses} sec)`)
}
- if (dataPoint < rowerSettings.minimumTimeBetweenImpulses && 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 minimumTimeBetweenImpulses (${rowerSettings.minimumTimeBetweenImpulses} 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.yAtSeriesBegin()
- totalTimeSpinning += _deltaTimeBeforeFlank
+
+ _deltaTimeBeforeFlank = cyclicErrorFilter.atSeriesBegin()
+ totalTimeSpinning += _deltaTimeBeforeFlank.clean
_angularVelocityBeforeFlank = _angularVelocityAtBeginFlank
_angularAccelerationBeforeFlank = _angularAccelerationAtBeginFlank
- _torqueBeforeFlank = _torqueAtBeginFlank
+ // As drag is recalculated at the begin of the drive, we need to recalculate the torque
+ _torqueBeforeFlank = (rowerSettings.flywheelInertia * _angularAccelerationBeforeFlank + drag.weighedAverage() * Math.pow(_angularVelocityBeforeFlank, 2))
- // 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)
+ // Feed the drag calculation, as we didn't reset the Semaphore in the previous cycle based on the current flank
+ recoveryDeltaTime.push(totalTimeSpinning, _deltaTimeBeforeFlank.clean, _deltaTimeBeforeFlank.goodnessOfFit)
+ // Feed the systematic error filter buffer
+ cyclicErrorFilter.recordRawDatapoint(totalNumberOfImpulses, totalTimeSpinning, _deltaTimeBeforeFlank.raw)
+ } else {
+ // Accumulate the energy total as we are in the drive phase
+ _totalWork += Math.max(_torqueBeforeFlank * angularDisplacementPerImpulse, 0)
+ // Process a value in the systematic error filter buffer. We need to do this slowly to prevent radical changes which might disturbe the force curve etc.
+ cyclicErrorFilter.processNextRawDatapoint()
}
} else {
- _deltaTimeBeforeFlank = 0
+ _deltaTimeBeforeFlank.clean = 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 OLS 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] = createSeries(flankLength)
- _angularAccelerationMatrix[_angularAccelerationMatrix.length] = createSeries(flankLength)
-
- let i = 0
- while (i < _angularVelocityMatrix.length) {
- _angularVelocityMatrix[i].push(_angularDistance.firstDerivativeAtPosition(i))
- _angularAccelerationMatrix[i].push(_angularDistance.secondDerivativeAtPosition(i))
- i++
- }
+ const cleanCurrentDt = cyclicErrorFilter.applyFilter(dataPoint, totalNumberOfImpulses + flankLength)
+ rawTime += cleanCurrentDt.clean
+ rawNumberOfImpulses++
+ const currentAngularDistance = rawNumberOfImpulses * angularDisplacementPerImpulse
- _angularVelocityAtBeginFlank = _angularVelocityMatrix[0].median()
- _angularAccelerationAtBeginFlank = _angularAccelerationMatrix[0].median()
+ // Let's feed the stroke detection algorithm
+ _deltaTime.push(rawTime, cleanCurrentDt.clean, cleanCurrentDt.goodnessOfFit)
- // And finally calculate the torque
- _torqueAtBeginFlank = (rowerSettings.flywheelInertia * _angularAccelerationAtBeginFlank + drag.clean() * Math.pow(_angularVelocityAtBeginFlank, 2))
+ // Calculate the metrics that are needed for more advanced metrics, like the foce curve
+ _angularDistance.push(rawTime, currentAngularDistance, cleanCurrentDt.goodnessOfFit)
+ _angularVelocityAtBeginFlank = _angularDistance.firstDerivative(0)
+ _angularAccelerationAtBeginFlank = _angularDistance.secondDerivative(0)
+ _torqueAtBeginFlank = (rowerSettings.flywheelInertia * _angularAccelerationAtBeginFlank + drag.weighedAverage() * Math.pow(_angularVelocityAtBeginFlank, 2))
}
+ /* eslint-enable max-statements */
+ /**
+ * @description Function to handle the start of a pause/stop based on a trigger from Rower.js
+ */
function maintainStateOnly () {
maintainMetrics = false
}
+ /**
+ * @description Function to handle the end of a pause/stop based on a trigger from Rower.js
+ */
function maintainStateAndMetrics () {
maintainMetrics = true
+ cyclicErrorFilter.resetFilterConfiguration()
}
+ /**
+ * @description Function to handle the start of the recovery phase based on a trigger from Rower.js
+ */
function markRecoveryPhaseStart () {
inRecoveryPhase = true
recoveryDeltaTime.reset()
+ cyclicErrorFilter.clearDatapointBuffer()
}
+ /**
+ * @description Function to handle the completion of the recovery phase based on a trigger from Rower.js
+ */
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()))
+ 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
- minumumRecoverySlope.push((1 - rowerSettings.autoAdjustRecoverySlopeMargin) * recoveryDeltaTime.slope())
- log.debug(`*** Calculated recovery slope: ${recoveryDeltaTime.slope().toFixed(6)}, Goodness of Fit: ${recoveryDeltaTime.goodnessOfFit().toFixed(4)}`)
+ minimumRecoverySlope.push((1 - rowerSettings.autoAdjustRecoverySlopeMargin) * recoveryDeltaTime.slope(), recoveryDeltaTime.goodnessOfFit())
+ log.info(`*** 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`)
+ log.info(`*** Calculated recovery slope: ${recoveryDeltaTime.slope().toFixed(6)}, Goodness of Fit: ${recoveryDeltaTime.goodnessOfFit().toFixed(4)}, not used as autoAdjustRecoverySlope isn't set to true`)
}
} else {
+ // As the drag calculation is considered unreliable, we must skip updating the systematic error filter that depends on it
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)}`)
+ cyclicErrorFilter.forceFlushDatapointBuffer()
}
}
}
+ /**
+ * @returns {float} the time the flywheel is spinning in seconds BEFORE the beginning of the flank
+ */
function spinningTime () {
- // This function returns the time the flywheel is spinning in seconds BEFORE the beginning of the flank
return totalTimeSpinning
}
+ /**
+ * @returns {float} the total energy produced onto the flywheel in Joules BEFORE the beginning of the flank
+ */
+ function totalWork () {
+ return Math.max(_totalWork, 0)
+ }
+
+ /**
+ * @returns {float} the current DeltaTime BEFORE the flank
+ */
function deltaTime () {
- return _deltaTimeBeforeFlank
+ return _deltaTimeBeforeFlank.clean
}
+ /**
+ * @returns {float} the absolute angular position of the flywheel in Radians BEFORE the beginning of the flank
+ * Please observe that the first datapoint shouldstart at 0
+ */
function angularPosition () {
- // This function returns the absolute angular position of the flywheel in Radians BEFORE the beginning of the flank
- return totalNumberOfImpulses * angularDisplacementPerImpulse
+ return (Math.max(totalNumberOfImpulses, 0) * angularDisplacementPerImpulse)
}
+ /**
+ * @returns {float} the angular velocity of the flywheel in Radians/sec BEFORE the flank
+ */
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 {
@@ -195,8 +249,10 @@ function createFlywheel (rowerSettings) {
}
}
+ /**
+ * @returns {float} the angular acceleration of the flywheel in Radians/sec^2 BEFORE the flank
+ */
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 {
@@ -204,6 +260,9 @@ function createFlywheel (rowerSettings) {
}
}
+ /**
+ * @returns {float} the torque on the flywheel in N/m BEFORE the flank
+ */
function torque () {
if (maintainMetrics && (_deltaTime.length() >= flankLength)) {
return _torqueBeforeFlank
@@ -212,70 +271,84 @@ function createFlywheel (rowerSettings) {
}
}
+ /**
+ * @returns {float} the current dragfactor of the flywheel
+ */
function dragFactor () {
- // Ths function returns the current dragfactor of the flywheel
- return drag.clean()
+ return drag.weighedAverage()
}
- 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
- // * A decelerating flywheel as the slope of the CurrentDt's goes up
- // * All CurrentDt's in the flank are above the maximum
- if (_deltaTime.slope() > 0 && deltaTimesAbove(rowerSettings.maximumTimeBetweenImpulses)) {
- return true
+ /**
+ * @returns {boolean} 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
+ */
+ function dragFactorIsReliable () {
+ if (rowerSettings.autoAdjustDragFactor) {
+ return drag.reliable()
} else {
- return false
- }
- }
-
- function isAboveMinimumSpeed () {
- // Check if the flywheel has reached its minimum speed. We conclude this based on all CurrentDt's in the flank are below
- // the maximum, indicating a sufficiently fast flywheel
- if (deltaTimesEqualorBelow(rowerSettings.maximumTimeBetweenImpulses)) {
return true
- } else {
- return false
}
}
- function isUnpowered () {
- if ((deltaTimeSlopeAbove(minumumRecoverySlope.clean()) || torqueAbsent()) && _deltaTime.length() >= flankLength) {
- // We reached the minimum number of increasing currentDt values
+ /**
+ * @returns {boolean} indicator of 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
+ */
+ function isDwelling () {
+ if (_angularVelocityAtBeginFlank < minimumAngularVelocity && deltaTimeSlopeAbove(minimumRecoverySlope.weighedAverage())) {
return true
} else {
return false
}
}
- function isPowered () {
- if ((deltaTimeSlopeBelow(minumumRecoverySlope.clean()) && torquePresent()) || _deltaTime.length() < flankLength) {
+ /**
+ * @returns {boolean} indicator 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
+ */
+ function isAboveMinimumSpeed () {
+ if ((_angularVelocityAtBeginFlank >= minimumAngularVelocity) && (_deltaTime.Y.atSeriesBegin() <= rowerSettings.maximumTimeBetweenImpulses) && (_deltaTime.Y.atSeriesBegin() > rowerSettings.minimumTimeBetweenImpulses)) {
return true
} else {
return false
}
}
- function deltaTimesAbove (threshold) {
- if (_deltaTime.numberOfYValuesAbove(threshold) === flankLength) {
+ /**
+ * @returns {boolean} indicator if the flywheel is unpowered
+ * 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
+ */
+ function isUnpowered () {
+ if (deltaTimeSlopeAbove(minimumRecoverySlope.weighedAverage()) && (torqueAbsent() || (rowerSettings.autoAdjustDragFactor && !drag.reliable()))) {
return true
} else {
return false
}
}
- function deltaTimesEqualorBelow (threshold) {
- if (_deltaTime.numberOfYValuesEqualOrBelow(threshold) === flankLength) {
+ /**
+ * @returns {boolean} indicator if the flywheel is powered
+ */
+ function isPowered () {
+ if (deltaTimeSlopeBelow(minimumRecoverySlope.weighedAverage()) && torquePresent()) {
return true
} else {
return false
}
}
+ /**
+ * @param {float} threshold - Maximum slope
+ * @returns {boolean} indicator if the currentDt slope is below the specified slope
+ * 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
+ */
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
if (_deltaTime.slope() < threshold && _deltaTime.length() >= flankLength) {
return true
} else {
@@ -283,11 +356,15 @@ function createFlywheel (rowerSettings) {
}
}
+ /**
+ * @param {float} threshold - Maximum slope
+ * @returns {boolean} indicator if the currentDt slope is above the specified slope
+ * 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
+ */
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 {
@@ -295,43 +372,62 @@ function createFlywheel (rowerSettings) {
}
}
+ /**
+ * @returns {boolean} indicator if there is a torque present at the beginning of the flank above the minimum torque
+ * 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)
+ */
function torquePresent () {
- // This is a typical indication that the flywheel is decelerating which might work on some machines: successive currentDt's are increasing
- if (_torqueAtBeginFlank > minumumTorqueBeforeStroke) {
+ if (_torqueAtBeginFlank >= minimumTorqueBeforeStroke) {
return true
} else {
return false
}
}
+ /**
+ * @returns {boolean} indicator if there is a torque present at the beginning of the flank below the minimum torque
+ * 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)
+ */
function torqueAbsent () {
- // This is a typical indication that the flywheel is Accelerating which might work on some machines: successive currentDt's are decreasing
- if (_torqueAtBeginFlank < minumumTorqueBeforeStroke) {
+ if (_torqueAtBeginFlank < minimumTorqueBeforeStroke) {
return true
} else {
return false
}
}
+ /**
+ * @param {float} slope - Recovery slope to be converted
+ * @returns {float} Dragfactor to be used in all calculations
+ * @description Helper function to convert a recovery slope into a dragfactor
+ */
function slopeToDrag (slope) {
return ((slope * rowerSettings.flywheelInertia) / angularDisplacementPerImpulse)
}
+ /**
+ * @description This function is used for clearing all data, returning the flywheel.js to its initial state
+ */
function reset () {
maintainMetrics = false
inRecoveryPhase = false
+ rawTime = 0
+ rawNumberOfImpulses = 0
+ totalTimeSpinning = 0
+ totalNumberOfImpulses = -1
+ _totalWork = 0
drag.reset()
+ cyclicErrorFilter.reset()
+ cyclicErrorFilter.applyFilter(0, flankLength - 1)
recoveryDeltaTime.reset()
_deltaTime.reset()
_angularDistance.reset()
- totalNumberOfImpulses = 0
- totalTimeSpinning = 0
- currentCleanTime = 0
- currentRawTime = 0
- currentAngularDistance = 0
_deltaTime.push(0, 0)
_angularDistance.push(0, 0)
- _deltaTimeBeforeFlank = 0
+ _deltaTimeBeforeFlank.clean = 0
_angularVelocityBeforeFlank = 0
_angularAccelerationBeforeFlank = 0
_torqueAtBeginFlank = 0
@@ -345,17 +441,18 @@ function createFlywheel (rowerSettings) {
markRecoveryPhaseStart,
markRecoveryPhaseCompleted,
spinningTime,
+ totalWork,
deltaTime,
angularPosition,
angularVelocity,
angularAcceleration,
torque,
dragFactor,
+ dragFactorIsReliable,
isDwelling,
isAboveMinimumSpeed,
isUnpowered,
- isPowered
+ isPowered,
+ reset
}
}
-
-export { createFlywheel }
diff --git a/app/engine/Flywheel.test.js b/app/engine/Flywheel.test.js
index 9e2276ca28..091535214f 100644
--- a/app/engine/Flywheel.test.js
+++ b/app/engine/Flywheel.test.js
@@ -1,37 +1,45 @@
'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-*/
+/**
+ * @copyright {@link https://github.com/JaapvanEkris/openrowingmonitor|OpenRowingMonitor}
+ *
+ * @file Tests of the Flywheel object
+ */
import { test } from 'uvu'
import * as assert from 'uvu/assert'
import { deepMerge } from '../tools/Helper.js'
-import { replayRowingSession } from '../tools/RowingRecorder.js'
+import { replayRowingSession } from '../recorders/RowingReplayer.js'
import rowerProfiles from '../../config/rowerProfiles.js'
import { createFlywheel } from './Flywheel.js'
-const baseConfig = {
+const baseConfig = { // Based on Concept 2 settings, as this is the validation system
numOfImpulsesPerRevolution: 6,
- smoothing: 1,
- flankLength: 11,
- minimumStrokeQuality: 0.30,
- minumumRecoverySlope: 0,
- autoAdjustRecoverySlope: true,
- autoAdjustRecoverySlopeMargin: 0.10,
- minumumForceBeforeStroke: 50,
- minimumRecoveryTime: 2,
- minimumTimeBetweenImpulses: 0.005,
- maximumTimeBetweenImpulses: 0.02,
+ sprocketRadius: 1.4,
+ maximumStrokeTimeBeforePause: 6.0,
+ dragFactor: 110,
autoAdjustDragFactor: true,
+ minimumDragQuality: 0.95,
dragFactorSmoothing: 3,
- dragFactor: 100,
- minimumDragQuality: 0.83,
- flywheelInertia: 0.1,
- sprocketRadius: 2
+ minimumTimeBetweenImpulses: 0.005,
+ maximumTimeBetweenImpulses: 0.020,
+ flankLength: 12,
+ systematicErrorAgressiveness: 0,
+ systematicErrorNumberOfDatapoints: 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', () => {
+/**
+ * @description Test behaviour for no datapoints
+ */
+test('Init_01: Correct Flywheel behaviour at initialisation', () => {
const flywheel = createFlywheel(baseConfig)
testDeltaTime(flywheel, 0)
testSpinningTime(flywheel, 0)
@@ -39,23 +47,701 @@ test('Correct Flywheel behaviour at initialisation', () => {
testAngularVelocity(flywheel, 0)
testAngularAcceleration(flywheel, 0)
testTorque(flywheel, 0)
- testDragFactor(flywheel, 0.0001)
+ testDragFactor(flywheel, 0.00011)
testIsDwelling(flywheel, false)
testIsUnpowered(flywheel, false)
- testIsPowered(flywheel, true)
+ testIsPowered(flywheel, false)
})
-// Test behaviour for one datapoint
-// ToDo: Add additional test for testing the behaviour after a single datapoint
+/**
+ * @todo Test behaviour for one datapoint
+ */
-// Test behaviour for perfect upgoing flank
-// ToDo: Add additional test to test isDwelling, isUnpowered and isPowered with an upgoing flank
+/**
+ * @todo Test behaviour for perfect upgoing flank
+ */
-// Test behaviour for perfect downgoing flank
-// ToDo: Add additional test to test isDwelling, isUnpowered and isPowered with an downgoing flank
+/**
+ * @todo Test behaviour for perfect downgoing flank
+ */
+
+/**
+ * @description Test of the integration of the underlying FullTSQuadraticEstimator object
+ * This uses the same data as the function y = 2 x^2 + 4 * x
+ */
+test('Theoretical_01: Test of correct algorithmic integration of FullTSQuadraticEstimator and Flywheel object for quadratic function f(x) = 2 * x^2 + 4 * x', () => {
+ const testConfig = {
+ numOfImpulsesPerRevolution: 6,
+ sprocketRadius: 1,
+ maximumStrokeTimeBeforePause: 6.0,
+ dragFactor: 10,
+ autoAdjustDragFactor: false,
+ minimumDragQuality: 0.95,
+ dragFactorSmoothing: 3,
+ minimumTimeBetweenImpulses: 0,
+ maximumTimeBetweenImpulses: 1,
+ flankLength: 12,
+ systematicErrorAgressiveness: 0,
+ systematicErrorNumberOfDatapoints: 1,
+ minimumStrokeQuality: 0.36,
+ minimumForceBeforeStroke: 0,
+ minimumRecoverySlope: 0.00070,
+ autoAdjustRecoverySlope: false,
+ autoAdjustRecoverySlopeMargin: 0.15,
+ minimumDriveTime: 0.40,
+ minimumRecoveryTime: 0.90,
+ flywheelInertia: 0.1031,
+ magicConstant: 2.8
+ }
+ const flywheel = createFlywheel(testConfig) // Please note, Datapoint 0 is automatically added by this initialisation
+ flywheel.maintainStateAndMetrics()
+ testSpinningTime(flywheel, 0)
+ testAngularPosition(flywheel, 0)
+ testAngularVelocity(flywheel, 0)
+ testAngularAcceleration(flywheel, 0)
+ flywheel.pushValue(0.234341433963188) // Datapoint 1
+ testDeltaTime(flywheel, 0)
+ testSpinningTime(flywheel, 0)
+ testAngularPosition(flywheel, 0)
+ testAngularVelocity(flywheel, 0)
+ testAngularAcceleration(flywheel, 0)
+ flywheel.pushValue(0.196461680094298) // Datapoint 2
+ testDeltaTime(flywheel, 0)
+ testSpinningTime(flywheel, 0)
+ testAngularPosition(flywheel, 0)
+ testAngularVelocity(flywheel, 0)
+ testAngularAcceleration(flywheel, 0)
+ flywheel.pushValue(0.172567188397595) // Datapoint 3
+ testDeltaTime(flywheel, 0)
+ testSpinningTime(flywheel, 0)
+ testAngularPosition(flywheel, 0)
+ testAngularVelocity(flywheel, 0)
+ testAngularAcceleration(flywheel, 0)
+ flywheel.pushValue(0.155718979643243) // Datapoint 4
+ testDeltaTime(flywheel, 0)
+ testSpinningTime(flywheel, 0)
+ testAngularPosition(flywheel, 0)
+ testAngularVelocity(flywheel, 0)
+ testAngularAcceleration(flywheel, 0)
+ flywheel.pushValue(0.143013206725950) // Datapoint 5
+ testDeltaTime(flywheel, 0)
+ testSpinningTime(flywheel, 0)
+ testAngularPosition(flywheel, 0)
+ testAngularVelocity(flywheel, 0)
+ testAngularAcceleration(flywheel, 0)
+ flywheel.pushValue(0.132987841748253) // Datapoint 6
+ testDeltaTime(flywheel, 0)
+ testSpinningTime(flywheel, 0)
+ testAngularPosition(flywheel, 0)
+ testAngularVelocity(flywheel, 0)
+ testAngularAcceleration(flywheel, 0)
+ flywheel.pushValue(0.124815090780014) // Datapoint 7
+ testDeltaTime(flywheel, 0)
+ testSpinningTime(flywheel, 0)
+ testAngularPosition(flywheel, 0)
+ testAngularVelocity(flywheel, 0)
+ testAngularAcceleration(flywheel, 0)
+ flywheel.pushValue(0.117986192571703) // Datapoint 8
+ testDeltaTime(flywheel, 0)
+ testSpinningTime(flywheel, 0)
+ testAngularPosition(flywheel, 0)
+ testAngularVelocity(flywheel, 0)
+ testAngularAcceleration(flywheel, 0)
+ flywheel.pushValue(0.112168841458569) // Datapoint 9
+ testDeltaTime(flywheel, 0)
+ testSpinningTime(flywheel, 0)
+ testAngularPosition(flywheel, 0)
+ testAngularVelocity(flywheel, 0)
+ testAngularAcceleration(flywheel, 0)
+ flywheel.pushValue(0.107135523306685) // Datapoint 10
+ testDeltaTime(flywheel, 0)
+ testSpinningTime(flywheel, 0)
+ testAngularPosition(flywheel, 0)
+ testAngularVelocity(flywheel, 0)
+ testAngularAcceleration(flywheel, 0)
+ flywheel.pushValue(0.102724506937187) // Datapoint 11
+ testDeltaTime(flywheel, 0)
+ testSpinningTime(flywheel, 0)
+ testAngularPosition(flywheel, 0)
+ testAngularVelocity(flywheel, 0)
+ testAngularAcceleration(flywheel, 0)
+ flywheel.pushValue(0.098817239158663) // Datapoint 12
+ testDeltaTime(flywheel, 0) // Values from Datapoint 0 are now passsing through
+ testSpinningTime(flywheel, 0)
+ testAngularPosition(flywheel, 0)
+ testAngularVelocity(flywheel, 4.000000000000004)
+ testAngularAcceleration(flywheel, 3.99999999999998)
+ flywheel.pushValue(0.095324565640171) // Datapoint 13
+ testDeltaTime(flywheel, 0.234341433963188) // Values from Datapoint 1 are now passsing through
+ testSpinningTime(flywheel, 0.234341433963188)
+ testAngularPosition(flywheel, 1.0471975511965976)
+ testAngularVelocity(flywheel, 4.937365735852752)
+ testAngularAcceleration(flywheel, 3.999999999999979)
+ flywheel.pushValue(0.092177973027300) // Datapoint 14
+ testDeltaTime(flywheel, 0.196461680094298) // Values from Datapoint 2 are now passsing through
+ testSpinningTime(flywheel, 0.430803114057486)
+ testAngularPosition(flywheel, 2.0943951023931953)
+ testAngularVelocity(flywheel, 5.723212456229939)
+ testAngularAcceleration(flywheel, 3.999999999999979)
+ flywheel.pushValue(0.089323823233014) // Datapoint 15
+ testDeltaTime(flywheel, 0.172567188397595) // Values from Datapoint 3 are now passsing through
+ testSpinningTime(flywheel, 0.6033703024550809)
+ testAngularPosition(flywheel, 3.141592653589793)
+ testAngularVelocity(flywheel, 6.413481209820315)
+ testAngularAcceleration(flywheel, 3.9999999999999787)
+ flywheel.pushValue(0.086719441920360) // Datapoint 16
+ testDeltaTime(flywheel, 0.155718979643243) // Values from Datapoint 4 are now passsing through
+ testSpinningTime(flywheel, 0.7590892820983239)
+ testAngularPosition(flywheel, 4.1887902047863905)
+ testAngularVelocity(flywheel, 7.036357128393282)
+ testAngularAcceleration(flywheel, 3.999999999999977)
+ flywheel.pushValue(0.084330395149166) // Datapoint 17
+ testDeltaTime(flywheel, 0.143013206725950) // Values from Datapoint 5 are now passsing through
+ testSpinningTime(flywheel, 0.9021024888242739)
+ testAngularPosition(flywheel, 5.235987755982988)
+ testAngularVelocity(flywheel, 7.608409955297075)
+ testAngularAcceleration(flywheel, 3.999999999999975)
+ flywheel.pushValue(0.082128549835466) // Datapoint 18
+ testDeltaTime(flywheel, 0.132987841748253) // Values from Datapoint 6 are now passsing through
+ testSpinningTime(flywheel, 1.035090330572527)
+ testAngularPosition(flywheel, 6.283185307179586)
+ testAngularVelocity(flywheel, 8.140361322290087)
+ testAngularAcceleration(flywheel, 3.9999999999999782)
+ flywheel.pushValue(0.080090664596669) // Datapoint 19
+ testDeltaTime(flywheel, 0.124815090780014) // Values from Datapoint 7 are now passsing through
+ testSpinningTime(flywheel, 1.159905421352541)
+ testAngularPosition(flywheel, 7.330382858376184)
+ testAngularVelocity(flywheel, 8.639621685410138)
+ testAngularAcceleration(flywheel, 3.99999999999998)
+ flywheel.pushValue(0.078197347646078) // Datapoint 20
+ testDeltaTime(flywheel, 0.117986192571703) // Values from Datapoint 8 are now passsing through
+ testSpinningTime(flywheel, 1.277891613924244)
+ testAngularPosition(flywheel, 8.377580409572781)
+ testAngularVelocity(flywheel, 9.111566455696952)
+ testAngularAcceleration(flywheel, 3.999999999999985)
+ flywheel.pushValue(0.076432273828253) // Datapoint 21
+ testDeltaTime(flywheel, 0.112168841458569) // Values from Datapoint 9 are now passsing through
+ testSpinningTime(flywheel, 1.390060455382813)
+ testAngularPosition(flywheel, 9.42477796076938)
+ testAngularVelocity(flywheel, 9.560241821531228)
+ testAngularAcceleration(flywheel, 3.9999999999999845)
+ flywheel.pushValue(0.074781587915460) // Datapoint 22
+ testDeltaTime(flywheel, 0.107135523306685) // Values from Datapoint 10 are now passsing through
+ testSpinningTime(flywheel, 1.4971959786894982)
+ testAngularPosition(flywheel, 10.471975511965976)
+ testAngularVelocity(flywheel, 9.98878391475797)
+ testAngularAcceleration(flywheel, 3.99999999999998)
+ flywheel.pushValue(0.073233443959153) // Datapoint 23
+ testDeltaTime(flywheel, 0.102724506937187) // Values from Datapoint 11 are now passsing through
+ testSpinningTime(flywheel, 1.599920485626685)
+ testAngularPosition(flywheel, 11.519173063162574)
+ testAngularVelocity(flywheel, 10.399681942506724)
+ testAngularAcceleration(flywheel, 3.999999999999972)
+ flywheel.pushValue(0.071777645486524) // Datapoint 24
+ testDeltaTime(flywheel, 0.098817239158663) // Values from Datapoint 12 are now passsing through
+ testSpinningTime(flywheel, 1.6987377247853481)
+ testAngularPosition(flywheel, 12.566370614359172)
+ testAngularVelocity(flywheel, 10.794950899141375)
+ testAngularAcceleration(flywheel, 3.99999999999996)
+ flywheel.pushValue(0.070405361445316) // Datapoint 25
+ testDeltaTime(flywheel, 0.095324565640171) // Values from Datapoint 13 are now passsing through
+ testSpinningTime(flywheel, 1.794062290425519)
+ testAngularPosition(flywheel, 13.613568165555769)
+ testAngularVelocity(flywheel, 11.17624916170206)
+ testAngularAcceleration(flywheel, 3.9999999999999463)
+ flywheel.pushValue(0.069108899742145) // Datapoint 26
+ testDeltaTime(flywheel, 0.092177973027300) // Values from Datapoint 14 are now passsing through
+ testSpinningTime(flywheel, 1.886240263452819)
+ testAngularPosition(flywheel, 14.660765716752367)
+ testAngularVelocity(flywheel, 11.544961053811264)
+ testAngularAcceleration(flywheel, 3.999999999999933)
+ flywheel.pushValue(0.067881525062373) // Datapoint 27
+ testDeltaTime(flywheel, 0.089323823233014) // Values from Datapoint 15 are now passsing through
+ testSpinningTime(flywheel, 1.975564086685833)
+ testAngularPosition(flywheel, 15.707963267948964)
+ testAngularVelocity(flywheel, 11.902256346743307)
+ testAngularAcceleration(flywheel, 3.9999999999999245)
+ flywheel.pushValue(0.066717311088441) // Datapoint 28
+ testDeltaTime(flywheel, 0.086719441920360) // Values from Datapoint 16 are now passsing through
+ testSpinningTime(flywheel, 2.062283528606193)
+ testAngularPosition(flywheel, 16.755160819145562)
+ testAngularVelocity(flywheel, 12.249134114424734)
+ testAngularAcceleration(flywheel, 3.9999999999999245)
+ flywheel.pushValue(0.065611019694526) // Datapoint 29
+ testDeltaTime(flywheel, 0.084330395149166) // Values from Datapoint 17 are now passsing through
+ testSpinningTime(flywheel, 2.1466139237553588)
+ testAngularPosition(flywheel, 17.80235837034216)
+ testAngularVelocity(flywheel, 12.586455695021384)
+ testAngularAcceleration(flywheel, 3.9999999999999396)
+ flywheel.pushValue(0.064558001484125) // Datapoint 30
+ testDeltaTime(flywheel, 0.082128549835466) // Values from Datapoint 18 are now passsing through
+ testSpinningTime(flywheel, 2.228742473590825)
+ testAngularPosition(flywheel, 18.84955592153876)
+ testAngularVelocity(flywheel, 12.914969894363232)
+ testAngularAcceleration(flywheel, 3.9999999999999574)
+ flywheel.pushValue(0.063554113352442) // Datapoint 31
+ testDeltaTime(flywheel, 0.080090664596669) // Values from Datapoint 19 are now passsing through
+ testSpinningTime(flywheel, 2.308833138187494)
+ testAngularPosition(flywheel, 19.896753472735355)
+ testAngularVelocity(flywheel, 13.235332552749886)
+ testAngularAcceleration(flywheel, 3.9999999999999867)
+})
-// Test behaviour for perfect stroke
-test('Correct Flywheel behaviour for a noisefree stroke', () => {
+/**
+ * @description Test of the integration of the underlying FullTSQuadraticEstimator object
+ * The data follows the function y = X^3 + 2 * x^2 + 4 * x
+ * To test if multiple quadratic regressions can decently approximate a cubic function
+ */
+test('Theoretical_02: Test of correct algorithmic integration of FullTSQuadraticEstimator and Flywheel object for cubic function f(x) = X^3 + 2 * x^2 + 4 * x', () => {
+ const testConfig = {
+ numOfImpulsesPerRevolution: 6,
+ sprocketRadius: 1,
+ maximumStrokeTimeBeforePause: 6.0,
+ dragFactor: 10,
+ autoAdjustDragFactor: false,
+ minimumDragQuality: 0.95,
+ dragFactorSmoothing: 3,
+ minimumTimeBetweenImpulses: 0,
+ maximumTimeBetweenImpulses: 1,
+ flankLength: 12,
+ systematicErrorAgressiveness: 0,
+ systematicErrorNumberOfDatapoints: 1,
+ minimumStrokeQuality: 0.36,
+ minimumForceBeforeStroke: 0,
+ minimumRecoverySlope: 0.00070,
+ autoAdjustRecoverySlope: false,
+ autoAdjustRecoverySlopeMargin: 0.15,
+ minimumDriveTime: 0.40,
+ minimumRecoveryTime: 0.90,
+ flywheelInertia: 0.1031,
+ magicConstant: 2.8
+ }
+ const flywheel = createFlywheel(testConfig) // Please note, Datapoint 0 is automatically added by this initialisation
+ flywheel.maintainStateAndMetrics()
+ testSpinningTime(flywheel, 0)
+ testAngularPosition(flywheel, 0)
+ testAngularVelocity(flywheel, 0)
+ testAngularAcceleration(flywheel, 0)
+ flywheel.pushValue(0.231815755285445) // Datapoint 1
+ testDeltaTime(flywheel, 0)
+ testSpinningTime(flywheel, 0)
+ testAngularPosition(flywheel, 0)
+ testAngularVelocity(flywheel, 0)
+ testAngularAcceleration(flywheel, 0)
+ flywheel.pushValue(0.186170118209325) // Datapoint 2
+ testDeltaTime(flywheel, 0)
+ testSpinningTime(flywheel, 0)
+ testAngularPosition(flywheel, 0)
+ testAngularVelocity(flywheel, 0)
+ testAngularAcceleration(flywheel, 0)
+ flywheel.pushValue(0.155673811324399) // Datapoint 3
+ testDeltaTime(flywheel, 0)
+ testSpinningTime(flywheel, 0)
+ testAngularPosition(flywheel, 0)
+ testAngularVelocity(flywheel, 0)
+ testAngularAcceleration(flywheel, 0)
+ flywheel.pushValue(0.134264409859047) // Datapoint 4
+ testDeltaTime(flywheel, 0)
+ testSpinningTime(flywheel, 0)
+ testAngularPosition(flywheel, 0)
+ testAngularVelocity(flywheel, 0)
+ testAngularAcceleration(flywheel, 0)
+ flywheel.pushValue(0.118490308292909) // Datapoint 5
+ testDeltaTime(flywheel, 0)
+ testSpinningTime(flywheel, 0)
+ testAngularPosition(flywheel, 0)
+ testAngularVelocity(flywheel, 0)
+ testAngularAcceleration(flywheel, 0)
+ flywheel.pushValue(0.106396192260267) // Datapoint 6
+ testDeltaTime(flywheel, 0)
+ testSpinningTime(flywheel, 0)
+ testAngularPosition(flywheel, 0)
+ testAngularVelocity(flywheel, 0)
+ testAngularAcceleration(flywheel, 0)
+ flywheel.pushValue(0.096822693623239) // Datapoint 7
+ testDeltaTime(flywheel, 0)
+ testSpinningTime(flywheel, 0)
+ testAngularPosition(flywheel, 0)
+ testAngularVelocity(flywheel, 0)
+ testAngularAcceleration(flywheel, 0)
+ flywheel.pushValue(0.08904704613513) // Datapoint 8
+ testDeltaTime(flywheel, 0)
+ testSpinningTime(flywheel, 0)
+ testAngularPosition(flywheel, 0)
+ testAngularVelocity(flywheel, 0)
+ testAngularAcceleration(flywheel, 0)
+ flywheel.pushValue(0.08259777558252) // Datapoint 9
+ testDeltaTime(flywheel, 0)
+ testSpinningTime(flywheel, 0)
+ testAngularPosition(flywheel, 0)
+ testAngularVelocity(flywheel, 0)
+ testAngularAcceleration(flywheel, 0)
+ flywheel.pushValue(0.077155055952201) // Datapoint 10
+ testDeltaTime(flywheel, 0)
+ testSpinningTime(flywheel, 0)
+ testAngularPosition(flywheel, 0)
+ testAngularVelocity(flywheel, 0)
+ testAngularAcceleration(flywheel, 0)
+ flywheel.pushValue(0.072494552013330) // Datapoint 11
+ testDeltaTime(flywheel, 0)
+ testSpinningTime(flywheel, 0)
+ testAngularPosition(flywheel, 0)
+ testAngularVelocity(flywheel, 0)
+ testAngularAcceleration(flywheel, 0)
+ flywheel.pushValue(0.068454336759262) // Datapoint 12
+ testDeltaTime(flywheel, 0) // Values from Datapoint 0 are now passsing through
+ testSpinningTime(flywheel, 0)
+ testAngularPosition(flywheel, 0)
+ testAngularVelocity(flywheel, 3.1619218560691382) // Theoretical value: 4
+ testAngularAcceleration(flywheel, 7.251023549310239) // Theoretical value: 4
+ flywheel.pushValue(0.064914611722656) // Datapoint 13
+ testDeltaTime(flywheel, 0.231815755285445) // Values from Datapoint 1 are now passsing through
+ testSpinningTime(flywheel, 0.231815755285445)
+ testAngularPosition(flywheel, 1.0471975511965976)
+ testAngularVelocity(flywheel, 4.7950174071704375) // Theoretical value: 5.088478654, error: -6,64%
+ testAngularAcceleration(flywheel, 7.324931550092107) // Theoretical value: 5.390894532, error: 38,46%
+ flywheel.pushValue(0.061784830519864) // Datapoint 14
+ testDeltaTime(flywheel, 0.186170118209325) // Values from Datapoint 2 are now passsing through
+ testSpinningTime(flywheel, 0.41798587349477)
+ testAngularPosition(flywheel, 2.0943951023931953)
+ testAngularVelocity(flywheel, 6.098616558470422) // Theoretical value: 6.196080065, error: -2,14%
+ testAngularAcceleration(flywheel, 7.6561044943826655) // Theoretical value: 6.507915241, error: 18,21%
+ flywheel.pushValue(0.058995265576639) // Datapoint 15
+ testDeltaTime(flywheel, 0.155673811324399) // Values from Datapoint 3 are now passsing through
+ testSpinningTime(flywheel, 0.5736596848191691)
+ testAngularPosition(flywheel, 3.141592653589793)
+ testAngularVelocity(flywheel, 7.261045146876231) // Theoretical value: 7.281895041, error: -0,79%
+ testAngularAcceleration(flywheel, 8.125127482273879) // Theoretical value: 7.441958109, error: 9,49%
+ flywheel.pushValue(0.056491331538715) // Datapoint 16
+ testDeltaTime(flywheel, 0.134264409859047) // Values from Datapoint 4 are now passsing through
+ testSpinningTime(flywheel, 0.707924094678216)
+ testAngularPosition(flywheel, 4.1887902047863905)
+ testAngularVelocity(flywheel, 8.335452316712825) // Theoretical value: 8.33516595, error: -0,42%
+ testAngularAcceleration(flywheel, 8.591085532405152) // Theoretical value: 8.247544568, error: 4,32%
+ flywheel.pushValue(0.054229670373632) // Datapoint 17
+ testDeltaTime(flywheel, 0.118490308292909) // Values from Datapoint 5 are now passsing through
+ testSpinningTime(flywheel, 0.826414402971125)
+ testAngularPosition(flywheel, 5.235987755982988)
+ testAngularVelocity(flywheel, 9.346198019520214) // Theoretical value: 9.354539908, error: -0,44%
+ testAngularAcceleration(flywheel, 9.058162877855903) // Theoretical value: 8.958486418, error: 1,06%
+ flywheel.pushValue(0.052175392433679) // Datapoint 18
+ testDeltaTime(flywheel, 0.106396192260267) // Values from Datapoint 6 are now passsing through
+ testSpinningTime(flywheel, 0.932810595231392)
+ testAngularPosition(flywheel, 6.283185307179586)
+ testAngularVelocity(flywheel, 10.314972131734738) // Theoretical value: 10.3416492, error: -0,56%
+ testAngularAcceleration(flywheel, 9.531782371110172) // Theoretical value: 9.596863571, error: -0,95%
+ flywheel.pushValue(0.05030009417797) // Datapoint 19
+ testDeltaTime(flywheel, 0.096822693623239) // Values from Datapoint 7 are now passsing through
+ testSpinningTime(flywheel, 1.029633288854631)
+ testAngularPosition(flywheel, 7.330382858376184)
+ testAngularVelocity(flywheel, 11.253026452431792) // Theoretical value: 11.29896728, error: -0,68%
+ testAngularAcceleration(flywheel, 10.006689891934712) // Theoretical value: 10.17779973, error: -2,15%
+ flywheel.pushValue(0.04858040892819) // Datapoint 20
+ testDeltaTime(flywheel, 0.08904704613513) // Values from Datapoint 8 are now passsing through
+ testSpinningTime(flywheel, 1.118680334989761)
+ testAngularPosition(flywheel, 8.377580409572781)
+ testAngularVelocity(flywheel, 12.167114512288897) // Theoretical value: 12.22905842, error: -0,76%
+ testAngularAcceleration(flywheel, 10.479926499860289) // Theoretical value: 10.71208201, error: -2,78%
+ flywheel.pushValue(0.046996930546829) // Datapoint 21
+ testDeltaTime(flywheel, 0.08259777558252) // Values from Datapoint 9 are now passsing through
+ testSpinningTime(flywheel, 1.201278110572281)
+ testAngularPosition(flywheel, 9.42477796076938)
+ testAngularVelocity(flywheel, 13.062289353874645) // Theoretical value: 13.13431974, error: -0,79%
+ testAngularAcceleration(flywheel, 10.945741904208647) // Theoretical value: 11.20766866, error: -3,03%
+ flywheel.pushValue(0.045533402601137) // Datapoint 22
+ testDeltaTime(flywheel, 0.077155055952201) // Values from Datapoint 10 are now passsing through
+ testSpinningTime(flywheel, 1.278433166524482)
+ testAngularPosition(flywheel, 10.471975511965976)
+ testAngularVelocity(flywheel, 13.94075092506632) // Theoretical value: 14.01690675, error: -0,78%
+ testAngularAcceleration(flywheel, 11.403650671998298) // Theoretical value: 11.670599, error: -2,98%
+ flywheel.pushValue(0.044176099545603) // Datapoint 23
+ testDeltaTime(flywheel, 0.072494552013330) // Values from Datapoint 11 are now passsing through
+ testSpinningTime(flywheel, 1.350927718537812)
+ testAngularPosition(flywheel, 11.519173063162574)
+ testAngularVelocity(flywheel, 14.80669498176648) // Theoretical value: 14.87872798, error: -0,69%
+ testAngularAcceleration(flywheel, 11.856689681955814) // Theoretical value: 12.10556631, error: -2,69%
+ flywheel.pushValue(0.042913348809906) // Datapoint 24
+ testDeltaTime(flywheel, 0.068454336759262) // Values from Datapoint 12 are now passsing through
+ testSpinningTime(flywheel, 1.419382055297074)
+ testAngularPosition(flywheel, 12.566370614359172)
+ testAngularVelocity(flywheel, 15.659331443649155) // Theoretical value: 15.72146448, error: -0,57%
+ testAngularAcceleration(flywheel, 12.303309060000915) // Theoretical value: 12.51629233, error: -2,22%
+ flywheel.pushValue(0.041735157665124) // Datapoint 25
+ testDeltaTime(flywheel, 0.064914611722656) // Values from Datapoint 13 are now passsing through, so we cleared all startup noise
+ testSpinningTime(flywheel, 1.484296667019730)
+ testAngularPosition(flywheel, 13.613568165555769)
+ testAngularVelocity(flywheel, 16.492736768968747) // Theoretical value: 16.54659646, error: -0,47%
+ testAngularAcceleration(flywheel, 12.721354618621062) // Theoretical value: 12.90578, error: -1,86%
+ flywheel.pushValue(0.040632918960300) // Datapoint 26
+ testDeltaTime(flywheel, 0.061784830519864) // Values from Datapoint 14 are now passsing through
+ testSpinningTime(flywheel, 1.546081497539594)
+ testAngularPosition(flywheel, 14.660765716752367)
+ testAngularVelocity(flywheel, 17.307691210719753) // Theoretical value: 17.35542998, error: -0,40%
+ testAngularAcceleration(flywheel, 13.11397255097641) // Theoretical value: 13.27648899, error: -1,59%
+ flywheel.pushValue(0.039599176898486) // Datapoint 27
+ testDeltaTime(flywheel, 0.058995265576639) // Values from Datapoint 15 are now passsing through
+ testSpinningTime(flywheel, 1.605076763116233)
+ testAngularPosition(flywheel, 15.707963267948964)
+ testAngularVelocity(flywheel, 18.10649398672465) // Theoretical value: 18.1491213, error: -0,34%
+ testAngularAcceleration(flywheel, 13.486098587071863) // Theoretical value: 13.63046058, error: -1,38%
+ flywheel.pushValue(0.038627438996519) // Datapoint 28
+ testDeltaTime(flywheel, 0.056491331538715) // Values from Datapoint 16 are now passsing through
+ testSpinningTime(flywheel, 1.661568094654948)
+ testAngularPosition(flywheel, 16.755160819145562)
+ testAngularVelocity(flywheel, 18.890426542395847) // Theoretical value: 18.92869798, error: -0,29%
+ testAngularAcceleration(flywheel, 13.840428977171639) // Theoretical value: 13.96940857, error: -1,20%
+ flywheel.pushValue(0.037712023914259) // Datapoint 29
+ testDeltaTime(flywheel, 0.054229670373632) // Values from Datapoint 17 are now passsing through
+ testSpinningTime(flywheel, 1.715797765028580)
+ testAngularPosition(flywheel, 17.80235837034216)
+ testAngularVelocity(flywheel, 19.660398675998614) // Theoretical value: 19.69507697, error: -0,26%
+ testAngularAcceleration(flywheel, 14.178743620219855) // Theoretical value: 14.29478659, error: -1,06%
+ flywheel.pushValue(0.036847937394809) // Datapoint 30
+ testDeltaTime(flywheel, 0.052175392433679) // Values from Datapoint 18 are now passsing through
+ testSpinningTime(flywheel, 1.767973157462259)
+ testAngularPosition(flywheel, 18.84955592153876)
+ testAngularVelocity(flywheel, 20.41744737019342) // Theoretical value: 20.44907989, error: -0,23%
+ testAngularAcceleration(flywheel, 14.502790132816358) // Theoretical value: 14.60783894, error: -0,94%
+ flywheel.pushValue(0.036030770419579) // Datapoint 31
+ testDeltaTime(flywheel, 0.05030009417797) // Values from Datapoint 19 are now passsing through
+ testSpinningTime(flywheel, 1.8182732516402291)
+ testAngularPosition(flywheel, 19.896753472735355)
+ testAngularVelocity(flywheel, 21.162376267362376) // Theoretical value: 21.19144586, error: -0,20%
+ testAngularAcceleration(flywheel, 14.813903373334538) // Theoretical value: 14.90963951, error: -0,83%
+})
+
+/**
+ * @description Test of the integration of the underlying FullTSQuadraticEstimator object
+ * The data follows the function y = X^3 + 2 * x^2 + 4 * x with a +/-0.0001 sec injected noise in currentDt
+ * To test if multiple quadratic regressions can decently approximate a cubic function with noise
+ * Please note: theoretical values are based on the perfect function (i.e. without noise)
+ */
+test('Theoretical_03: Test of correct algorithmic integration of FullTSQuadraticEstimator and Flywheel object for cubic function f(x) = X^3 + 2 * x^2 + 4 * x with +/- 0.0001 error', () => {
+ const testConfig = {
+ numOfImpulsesPerRevolution: 6,
+ sprocketRadius: 1,
+ maximumStrokeTimeBeforePause: 6.0,
+ dragFactor: 10,
+ autoAdjustDragFactor: false,
+ minimumDragQuality: 0.95,
+ dragFactorSmoothing: 3,
+ minimumTimeBetweenImpulses: 0,
+ maximumTimeBetweenImpulses: 1,
+ flankLength: 12,
+ systematicErrorAgressiveness: 0,
+ systematicErrorNumberOfDatapoints: 1,
+ minimumStrokeQuality: 0.36,
+ minimumForceBeforeStroke: 0,
+ minimumRecoverySlope: 0.00070,
+ autoAdjustRecoverySlope: false,
+ autoAdjustRecoverySlopeMargin: 0.15,
+ minimumDriveTime: 0.40,
+ minimumRecoveryTime: 0.90,
+ flywheelInertia: 0.1031,
+ magicConstant: 2.8
+ }
+ const flywheel = createFlywheel(testConfig) // Please note, Datapoint 0 is automatically added by this initialisation
+ flywheel.maintainStateAndMetrics()
+ testSpinningTime(flywheel, 0)
+ testAngularPosition(flywheel, 0)
+ testAngularVelocity(flywheel, 0)
+ testAngularAcceleration(flywheel, 0)
+ flywheel.pushValue(0.231915755285445) // Datapoint 1
+ testDeltaTime(flywheel, 0)
+ testSpinningTime(flywheel, 0)
+ testAngularPosition(flywheel, 0)
+ testAngularVelocity(flywheel, 0)
+ testAngularAcceleration(flywheel, 0)
+ flywheel.pushValue(0.186070118209325) // Datapoint 2
+ testDeltaTime(flywheel, 0)
+ testSpinningTime(flywheel, 0)
+ testAngularPosition(flywheel, 0)
+ testAngularVelocity(flywheel, 0)
+ testAngularAcceleration(flywheel, 0)
+ flywheel.pushValue(0.155773811324398) // Datapoint 3
+ testDeltaTime(flywheel, 0)
+ testSpinningTime(flywheel, 0)
+ testAngularPosition(flywheel, 0)
+ testAngularVelocity(flywheel, 0)
+ testAngularAcceleration(flywheel, 0)
+ flywheel.pushValue(0.134164409859047) // Datapoint 4
+ testDeltaTime(flywheel, 0)
+ testSpinningTime(flywheel, 0)
+ testAngularPosition(flywheel, 0)
+ testAngularVelocity(flywheel, 0)
+ testAngularAcceleration(flywheel, 0)
+ flywheel.pushValue(0.118590308292909) // Datapoint 5
+ testDeltaTime(flywheel, 0)
+ testSpinningTime(flywheel, 0)
+ testAngularPosition(flywheel, 0)
+ testAngularVelocity(flywheel, 0)
+ testAngularAcceleration(flywheel, 0)
+ flywheel.pushValue(0.106296192260267) // Datapoint 6
+ testDeltaTime(flywheel, 0)
+ testSpinningTime(flywheel, 0)
+ testAngularPosition(flywheel, 0)
+ testAngularVelocity(flywheel, 0)
+ testAngularAcceleration(flywheel, 0)
+ flywheel.pushValue(0.096922693623239) // Datapoint 7
+ testDeltaTime(flywheel, 0)
+ testSpinningTime(flywheel, 0)
+ testAngularPosition(flywheel, 0)
+ testAngularVelocity(flywheel, 0)
+ testAngularAcceleration(flywheel, 0)
+ flywheel.pushValue(0.08894704613513) // Datapoint 8
+ testDeltaTime(flywheel, 0)
+ testSpinningTime(flywheel, 0)
+ testAngularPosition(flywheel, 0)
+ testAngularVelocity(flywheel, 0)
+ testAngularAcceleration(flywheel, 0)
+ flywheel.pushValue(0.08269777558252) // Datapoint 9
+ testDeltaTime(flywheel, 0)
+ testSpinningTime(flywheel, 0)
+ testAngularPosition(flywheel, 0)
+ testAngularVelocity(flywheel, 0)
+ testAngularAcceleration(flywheel, 0)
+ flywheel.pushValue(0.077055055952201) // Datapoint 10
+ testDeltaTime(flywheel, 0)
+ testSpinningTime(flywheel, 0)
+ testAngularPosition(flywheel, 0)
+ testAngularVelocity(flywheel, 0)
+ testAngularAcceleration(flywheel, 0)
+ flywheel.pushValue(0.07259455201333) // Datapoint 11
+ testDeltaTime(flywheel, 0)
+ testSpinningTime(flywheel, 0)
+ testAngularPosition(flywheel, 0)
+ testAngularVelocity(flywheel, 0)
+ testAngularAcceleration(flywheel, 0)
+ flywheel.pushValue(0.068354336759262) // Datapoint 12
+ testDeltaTime(flywheel, 0) // Values from Datapoint 0 are now passsing through
+ testSpinningTime(flywheel, 0)
+ testAngularPosition(flywheel, 0)
+ testAngularVelocity(flywheel, 3.1651252708296993) // Theoretical value: 4
+ testAngularAcceleration(flywheel, 7.2468812808500696) // Theoretical value: 4
+ flywheel.pushValue(0.065014611722656) // Datapoint 13
+ testDeltaTime(flywheel, 0.231915755285445) // Values from Datapoint 1 are now passsing through
+ testSpinningTime(flywheel, 0.231915755285445)
+ testAngularPosition(flywheel, 1.0471975511965976)
+ testAngularVelocity(flywheel, 4.7981896825575205) // Theoretical value: 5.088478654, error: -6.58%
+ testAngularAcceleration(flywheel, 7.320784012928006) // Theoretical value: 5.390894532, error: 38.38%
+ flywheel.pushValue(0.061684830519864) // Datapoint 14
+ testDeltaTime(flywheel, 0.186070118209325) // Values from Datapoint 2 are now passsing through
+ testSpinningTime(flywheel, 0.41798587349477)
+ testAngularPosition(flywheel, 2.0943951023931953)
+ testAngularVelocity(flywheel, 6.100352571838149) // Theoretical value: 6.196080065, error: -2.11%
+ testAngularAcceleration(flywheel, 7.650380140052492) // Theoretical value: 6.507915241, error: 18.14%
+ flywheel.pushValue(0.059095265576639) // Datapoint 15
+ testDeltaTime(flywheel, 0.155773811324398) // Values from Datapoint 3 are now passsing through
+ testSpinningTime(flywheel, 0.573759684819168)
+ testAngularPosition(flywheel, 3.141592653589793)
+ testAngularVelocity(flywheel, 7.262664999379819) // Theoretical value: 7.281895041, error: -0.77%
+ testAngularAcceleration(flywheel, 8.117964017032822) // Theoretical value: 7.441958109, error: 9.40%
+ flywheel.pushValue(0.056391331538715) // Datapoint 16
+ testDeltaTime(flywheel, 0.134164409859047) // Values from Datapoint 4 are now passsing through
+ testSpinningTime(flywheel, 0.7079240946782149)
+ testAngularPosition(flywheel, 4.1887902047863905)
+ testAngularVelocity(flywheel, 8.335671487914347) // Theoretical value: 8.33516595, error: -0.42%
+ testAngularAcceleration(flywheel, 8.58427221387106) // Theoretical value: 8.247544568, error: 4.24%
+ flywheel.pushValue(0.054329670373632) // Datapoint 17
+ testDeltaTime(flywheel, 0.118590308292909) // Values from Datapoint 5 are now passsing through
+ testSpinningTime(flywheel, 0.8265144029711239)
+ testAngularPosition(flywheel, 5.235987755982988)
+ testAngularVelocity(flywheel, 9.347109926263196) // Theoretical value: 9.354539908, error: -0.44%
+ testAngularAcceleration(flywheel, 9.052626876076234) // Theoretical value: 8.958486418, error: 1.00%
+ flywheel.pushValue(0.052075392433679) // Datapoint 18
+ testDeltaTime(flywheel, 0.106296192260267) // Values from Datapoint 6 are now passsing through
+ testSpinningTime(flywheel, 0.9328105952313909)
+ testAngularPosition(flywheel, 6.283185307179586)
+ testAngularVelocity(flywheel, 10.314706935144432) // Theoretical value: 10.3416492, error: -0.56%
+ testAngularAcceleration(flywheel, 9.52653469078407) // Theoretical value: 9.596863571, error: -1.00%
+ flywheel.pushValue(0.05040009417797) // Datapoint 19
+ testDeltaTime(flywheel, 0.096922693623239) // Values from Datapoint 7 are now passsing through
+ testSpinningTime(flywheel, 1.0297332888546298)
+ testAngularPosition(flywheel, 7.330382858376184)
+ testAngularVelocity(flywheel, 11.253653421461035) // Theoretical value: 11.29896728, error: -0.67%
+ testAngularAcceleration(flywheel, 10.001358612662711) // Theoretical value: 10.17779973, error: -2.21%
+ flywheel.pushValue(0.04848040892819) // Datapoint 20
+ testDeltaTime(flywheel, 0.08894704613513) // Values from Datapoint 8 are now passsing through
+ testSpinningTime(flywheel, 1.1186803349897598)
+ testAngularPosition(flywheel, 8.377580409572781)
+ testAngularVelocity(flywheel, 12.166767447463288) // Theoretical value: 12.22905842, error: -0.76%
+ testAngularAcceleration(flywheel, 10.47394441606818) // Theoretical value: 10.71208201, error: -2.84%
+ flywheel.pushValue(0.047096930546829) // Datapoint 21
+ testDeltaTime(flywheel, 0.08269777558252) // Values from Datapoint 9 are now passsing through
+ testSpinningTime(flywheel, 1.2013781105722798)
+ testAngularPosition(flywheel, 9.42477796076938)
+ testAngularVelocity(flywheel, 13.062997567333893) // Theoretical value: 13.13431974, error: -0.79%
+ testAngularAcceleration(flywheel, 10.940063240068076) // Theoretical value: 11.20766866, error: -3.08%
+ flywheel.pushValue(0.045433402601137) // Datapoint 22
+ testDeltaTime(flywheel, 0.077055055952201) // Values from Datapoint 10 are now passsing through
+ testSpinningTime(flywheel, 1.2784331665244808)
+ testAngularPosition(flywheel, 10.471975511965976)
+ testAngularVelocity(flywheel, 13.940480188006552) // Theoretical value: 14.01690675, error: -0.78%
+ testAngularAcceleration(flywheel, 11.397389413208364) // Theoretical value: 11.670599, error: -3.04%
+ flywheel.pushValue(0.044276099545603) // Datapoint 23
+ testDeltaTime(flywheel, 0.07259455201333) // Values from Datapoint 11 are now passsing through
+ testSpinningTime(flywheel, 1.3510277185378108)
+ testAngularPosition(flywheel, 11.519173063162574)
+ testAngularVelocity(flywheel, 14.807840698982423) // Theoretical value: 14.87872798, error: -0.68%
+ testAngularAcceleration(flywheel, 11.848780564150369) // Theoretical value: 12.10556631, error: -2.76%
+ flywheel.pushValue(0.042813348809906) // Datapoint 24
+ testDeltaTime(flywheel, 0.068354336759262) // Values from Datapoint 12 are now passsing through
+ testSpinningTime(flywheel, 1.4193820552970728)
+ testAngularPosition(flywheel, 12.566370614359172)
+ testAngularVelocity(flywheel, 15.65917726721796) // Theoretical value: 15.72146448, error: -0.57%
+ testAngularAcceleration(flywheel, 12.293943915780252) // Theoretical value: 12.51629233, error: -2.30%
+ flywheel.pushValue(0.041835157665124) // Datapoint 25
+ testDeltaTime(flywheel, 0.065014611722656) // Values from Datapoint 13 are now passsing through, so we cleared all startup noise
+ testSpinningTime(flywheel, 1.4843966670197288)
+ testAngularPosition(flywheel, 13.613568165555769)
+ testAngularVelocity(flywheel, 16.49447250537608) // Theoretical value: 16.54659646, error: -0.46%
+ testAngularAcceleration(flywheel, 12.710407075508567) // Theoretical value: 12.90578, error: -1.95%
+ flywheel.pushValue(0.040532918960300) // Datapoint 26
+ testDeltaTime(flywheel, 0.061684830519864) // Values from Datapoint 14 are now passsing through
+ testSpinningTime(flywheel, 1.546081497539593)
+ testAngularPosition(flywheel, 14.660765716752367)
+ testAngularVelocity(flywheel, 17.308891329044464) // Theoretical value: 17.35542998, error: -0.39%
+ testAngularAcceleration(flywheel, 13.100466914875906) // Theoretical value: 13.27648899, error: -1.70%
+ flywheel.pushValue(0.039699176898486) // Datapoint 27
+ testDeltaTime(flywheel, 0.059095265576639) // Values from Datapoint 15 are now passsing through
+ testSpinningTime(flywheel, 1.605176763116232)
+ testAngularPosition(flywheel, 15.707963267948964)
+ testAngularVelocity(flywheel, 18.109702829774772) // Theoretical value: 18.1491213, error: -0.32%
+ testAngularAcceleration(flywheel, 13.469377816872242) // Theoretical value: 13.63046058, error: -1.51%
+ flywheel.pushValue(0.038527438996519) // Datapoint 28
+ testDeltaTime(flywheel, 0.056391331538715) // Values from Datapoint 16 are now passsing through
+ testSpinningTime(flywheel, 1.661568094654947)
+ testAngularPosition(flywheel, 16.755160819145562)
+ testAngularVelocity(flywheel, 18.892749084779705) // Theoretical value: 18.92869798, error: -0.28%
+ testAngularAcceleration(flywheel, 13.819955339924142) // Theoretical value: 13.96940857, error: -1.35%
+ flywheel.pushValue(0.037812023914259) // Datapoint 29
+ testDeltaTime(flywheel, 0.054329670373632) // Values from Datapoint 17 are now passsing through
+ testSpinningTime(flywheel, 1.715897765028579)
+ testAngularPosition(flywheel, 17.80235837034216)
+ testAngularVelocity(flywheel, 19.664430174199474) // Theoretical value: 19.69507697, error: -0.24%
+ testAngularAcceleration(flywheel, 14.154531841302834) // Theoretical value: 14.29478659, error: -1.23%
+ flywheel.pushValue(0.036747937394809) // Datapoint 30
+ testDeltaTime(flywheel, 0.052075392433679) // Values from Datapoint 18 are now passsing through
+ testSpinningTime(flywheel, 1.767973157462258)
+ testAngularPosition(flywheel, 18.84955592153876)
+ testAngularVelocity(flywheel, 20.419916102229333) // Theoretical value: 20.44907989, error: -0.21%
+ testAngularAcceleration(flywheel, 14.474639639378996) // Theoretical value: 14.60783894, error: -1.13%
+ flywheel.pushValue(0.036130770419579) // Datapoint 31
+ testDeltaTime(flywheel, 0.05040009417797) // Values from Datapoint 19 are now passsing through
+ testSpinningTime(flywheel, 1.818373251640228)
+ testAngularPosition(flywheel, 19.896753472735355)
+ testAngularVelocity(flywheel, 21.16654168342182) // Theoretical value: 21.19144586, error: -0.18%
+ testAngularAcceleration(flywheel, 14.782028789603949) // Theoretical value: 14.90963951, error: -1.05%
+})
+
+/**
+ * @todo Test behaviour with noise CEC filter active
+ */
+
+/**
+ * @description Test behaviour for perfect stroke
+ */
+test('Theoretical_04: Correct Flywheel behaviour for a noisefree stroke', () => {
const flywheel = createFlywheel(baseConfig)
flywheel.maintainStateAndMetrics()
testDeltaTime(flywheel, 0)
@@ -64,10 +750,10 @@ test('Correct Flywheel behaviour for a noisefree stroke', () => {
testAngularVelocity(flywheel, 0)
testAngularAcceleration(flywheel, 0)
testTorque(flywheel, 0)
- testDragFactor(flywheel, 0.0001)
+ testDragFactor(flywheel, 0.00011)
testIsDwelling(flywheel, false)
testIsUnpowered(flywheel, false)
- testIsPowered(flywheel, true)
+ testIsPowered(flywheel, false)
flywheel.pushValue(0.011221636)
flywheel.pushValue(0.011175504)
flywheel.pushValue(0.01116456)
@@ -87,13 +773,13 @@ test('Correct Flywheel behaviour for a noisefree stroke', () => {
flywheel.pushValue(0.010526151)
flywheel.pushValue(0.010511225)
flywheel.pushValue(0.010386684)
- testDeltaTime(flywheel, 0.011051853)
- testSpinningTime(flywheel, 0.088970487)
- testAngularPosition(flywheel, 9.42477796076938)
- testAngularVelocity(flywheel, 95.27559080008358)
- testAngularAcceleration(flywheel, 23.690349229418256)
- testTorque(flywheel, 3.276778743172323)
- testDragFactor(flywheel, 0.0001)
+ testDeltaTime(flywheel, 0.011062297)
+ testSpinningTime(flywheel, 0.077918634)
+ testAngularPosition(flywheel, 7.330382858376184)
+ testAngularVelocity(flywheel, 94.88636656676766)
+ testAngularAcceleration(flywheel, 28.483961147946758)
+ testTorque(flywheel, 3.9270728759800413)
+ testDragFactor(flywheel, 0.00011)
testIsDwelling(flywheel, false)
testIsUnpowered(flywheel, false)
testIsPowered(flywheel, true)
@@ -112,13 +798,13 @@ test('Correct Flywheel behaviour for a noisefree stroke', () => {
flywheel.pushValue(0.011099509)
flywheel.pushValue(0.011131862)
flywheel.pushValue(0.011209919)
- testDeltaTime(flywheel, 0.01089567)
- testSpinningTime(flywheel, 0.24984299900000007)
- testAngularPosition(flywheel, 25.132741228718345)
- testAngularVelocity(flywheel, 96.63189639573201)
- testAngularAcceleration(flywheel, -28.68758647905641)
- testTorque(flywheel, -1.9349863078020926)
- testDragFactor(flywheel, 0.0001)
+ testDeltaTime(flywheel, 0.010722165)
+ testSpinningTime(flywheel, 0.23894732900000007)
+ testAngularPosition(flywheel, 23.03834612632515)
+ testAngularVelocity(flywheel, 97.06865123831865)
+ testAngularAcceleration(flywheel, -32.75873752642214)
+ testTorque(flywheel, -2.340970303119225)
+ testDragFactor(flywheel, 0.00011)
testIsDwelling(flywheel, false)
testIsUnpowered(flywheel, true)
testIsPowered(flywheel, false)
@@ -137,35 +823,42 @@ test('Correct Flywheel behaviour for a noisefree stroke', () => {
flywheel.pushValue(0.021099509)
flywheel.pushValue(0.021131862)
flywheel.pushValue(0.021209919)
- testDeltaTime(flywheel, 0.02089567)
- testSpinningTime(flywheel, 0.45433115300000004)
- testAngularPosition(flywheel, 40.84070449666731)
- testAngularVelocity(flywheel, 50.44417826920988)
- testAngularAcceleration(flywheel, -25.426721357529768)
- testTorque(flywheel, -2.2882106236273945)
- testDragFactor(flywheel, 0.0001)
+ testDeltaTime(flywheel, 0.020722165)
+ testSpinningTime(flywheel, 0.43343548300000007)
+ testAngularPosition(flywheel, 38.746309394274114)
+ testAngularVelocity(flywheel, 52.0369112052433)
+ testAngularAcceleration(flywheel, -172.04934534879823)
+ testTorque(flywheel, -17.440425091405032)
+ testDragFactor(flywheel, 0.00011)
testIsDwelling(flywheel, true)
testIsUnpowered(flywheel, true)
testIsPowered(flywheel, false)
})
-// Test behaviour for noisy upgoing flank
-// ToDo: Add additional test to test isDwelling, isUnpowered and isPowered with an upgoing flank
+/**
+ * @todo Test behaviour for noisy upgoing flank
+ */
-// Test behaviour for noisy downgoing flank
-// ToDo: Add additional test to test isDwelling, isUnpowered and isPowered with an downgoing flank
+/**
+ * @todo Test behaviour for noisy downgoing flank
+ */
-// Test behaviour for noisy stroke
-// ToDo: Add additional test to test isDwelling, isUnpowered and isPowered with an upgoing and downgoing flank
+/**
+ * @todo Test behaviour for noisy stroke
+ */
-// Test drag factor calculation
-// ToDo: Add additional test to test dragfactor calculation
+/**
+ * @todo Test drag factor calculation
+ */
-// Test Dynamic stroke detection
-// ToDo: Add additional test to test isDwelling, isUnpowered and isPowered with an upgoing and downgoing flank with dynamic stroke detection
+/**
+ * @todo Test Dynamic stroke detection
+ */
-// Test behaviour for not maintaining metrics
-test('Correct Flywheel behaviour at maintainStateOnly', () => {
+/**
+ * @description Test behaviour for not maintaining metrics
+ */
+test('Theoretical_05: Correct Flywheel behaviour at maintainStateOnly', () => {
const flywheel = createFlywheel(baseConfig)
flywheel.maintainStateAndMetrics()
testDeltaTime(flywheel, 0)
@@ -174,10 +867,10 @@ test('Correct Flywheel behaviour at maintainStateOnly', () => {
testAngularVelocity(flywheel, 0)
testAngularAcceleration(flywheel, 0)
testTorque(flywheel, 0)
- testDragFactor(flywheel, 0.0001)
+ testDragFactor(flywheel, 0.00011)
testIsDwelling(flywheel, false)
testIsUnpowered(flywheel, false)
- testIsPowered(flywheel, true)
+ testIsPowered(flywheel, false)
flywheel.maintainStateOnly()
flywheel.pushValue(0.011221636)
flywheel.pushValue(0.011175504)
@@ -204,7 +897,7 @@ test('Correct Flywheel behaviour at maintainStateOnly', () => {
testAngularVelocity(flywheel, 0)
testAngularAcceleration(flywheel, 0)
testTorque(flywheel, 0)
- testDragFactor(flywheel, 0.0001)
+ testDragFactor(flywheel, 0.00011)
testIsDwelling(flywheel, false)
testIsUnpowered(flywheel, false)
testIsPowered(flywheel, true)
@@ -229,89 +922,342 @@ test('Correct Flywheel behaviour at maintainStateOnly', () => {
testAngularVelocity(flywheel, 0)
testAngularAcceleration(flywheel, 0)
testTorque(flywheel, 0)
- testDragFactor(flywheel, 0.0001)
+ 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))
+/**
+ * @description Test against a theoretical model, based on perfect clean data, as described in
+ * @see {@link https://github.com/JaapvanEkris/openrowingmonitor/discussions/215|this discussion}
+ */
+test('Theoretical_06: Correct Flywheel behaviour with perfect clean data', async () => {
+ const flywheel = createFlywheel(deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Theoretical_Model))
testSpinningTime(flywheel, 0)
testAngularPosition(flywheel, 0)
- testDragFactor(flywheel, (rowerProfiles.Sportstech_WRX700.dragFactor / 1000000))
+ testDragFactor(flywheel, (rowerProfiles.Theoretical_Model.dragFactor / 1000000))
+ flywheel.maintainStateAndMetrics()
- // Inject 16 strokes
+ // Inject the data
+ await replayRowingSession(flywheel.pushValue, { filename: 'recordings/Theoretical_Simulation_Clean.csv', realtime: false, loop: false })
+
+ testSpinningTime(flywheel, 98.9709819388311)
+ testAngularPosition(flywheel, 7972.314957259698)
+ testDeltaTime(flywheel, 0.05977130668982511)
+ testAngularVelocity(flywheel, 17.531701264128273)
+ testAngularAcceleration(flywheel, -0.3470957714275053)
+ testTorque(flywheel, -0.013843769373297968)
+ testIsDwelling(flywheel, true)
+ testIsUnpowered(flywheel, true)
+ testIsPowered(flywheel, false)
+ testDragFactor(flywheel, (rowerProfiles.Theoretical_Model.dragFactor / 1000000)) // As Flywheel.js doesn't detect strokes (this is a function of Rower.js), the dragcalculation shouldn't be triggered
+})
+
+/**
+ * @description Test against a theoretical model, based on noise-injected data, as described in
+ * @see {@link https://github.com/JaapvanEkris/openrowingmonitor/discussions/215|this discussion}
+ */
+test('Theoretical_07: Correct Flywheel behaviour with noise-injected data', async () => {
+ const flywheel = createFlywheel(deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Theoretical_Model))
+ testSpinningTime(flywheel, 0)
+ testAngularPosition(flywheel, 0)
+ testDragFactor(flywheel, (rowerProfiles.Theoretical_Model.dragFactor / 1000000))
flywheel.maintainStateAndMetrics()
- 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))
+
+ // Inject the data
+ await replayRowingSession(flywheel.pushValue, { filename: 'recordings/Theoretical_Simulation_Random_Noise.csv', realtime: false, loop: false })
+
+ testSpinningTime(flywheel, 248.53909245603822)
+ testAngularPosition(flywheel, 21826.738559590685)
+ testDeltaTime(flywheel, 0.09678841914560446)
+ testAngularVelocity(flywheel, 10.812632937564636)
+ testAngularAcceleration(flywheel, -0.1402363382869548)
+ testTorque(flywheel, -0.0060875713516336544)
+ testIsDwelling(flywheel, true)
+ testIsUnpowered(flywheel, true)
+ testIsPowered(flywheel, false)
+ testDragFactor(flywheel, (rowerProfiles.Theoretical_Model.dragFactor / 1000000)) // As Flywheel.js doesn't detect strokes (this is a function of Rower.js), the dragcalculation shouldn't be triggered
})
-test('Correct Flywheel behaviour with a DKN R-320', async () => {
- const flywheel = createFlywheel(deepMerge(rowerProfiles.DEFAULT, rowerProfiles.DKN_R320))
+/**
+ * @description Test against a theoretical model, based on a simulation of magnet positioning errors, as described in
+ * @see {@link https://github.com/JaapvanEkris/openrowingmonitor/discussions/215|this discussion}
+ * Magnet errors -0.01, +0.1, +0.2, -0.2, -0.1, +0.01 degrees
+ */
+test('Theoretical_08: Correct Flywheel behaviour with structural magnet errors', async () => {
+ const flywheel = createFlywheel(deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Theoretical_Model))
+ testSpinningTime(flywheel, 0)
+ testAngularPosition(flywheel, 0)
+ testDragFactor(flywheel, (rowerProfiles.Theoretical_Model.dragFactor / 1000000))
+ flywheel.maintainStateAndMetrics()
+
+ // Inject the data
+ await replayRowingSession(flywheel.pushValue, { filename: 'recordings/Theoretical_Simulation_Systematic_Noise.csv', realtime: false, loop: false })
+
+ testSpinningTime(flywheel, 248.5384526565442)
+ testAngularPosition(flywheel, 21826.738559590685)
+ testDeltaTime(flywheel, 0.09696462739685785)
+ testAngularVelocity(flywheel, 10.811915344241925)
+ testAngularAcceleration(flywheel, -0.1416257800887151)
+ testTorque(flywheel, -0.0062277096749287116)
+ testIsDwelling(flywheel, true)
+ testIsUnpowered(flywheel, true)
+ testIsPowered(flywheel, false)
+ testDragFactor(flywheel, (rowerProfiles.Theoretical_Model.dragFactor / 1000000)) // As Flywheel.js doesn't detect strokes (this is a function of Rower.js), the dragcalculation shouldn't be triggered
+})
+
+/**
+ * @description Test behaviour for the C2 Model C
+ */
+test('C2_ModelC_01: 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, 15634.659439365203)
+ testDeltaTime(flywheel, 0.1269760000000133)
+ testAngularVelocity(flywheel, 17.15696419975984)
+ testAngularAcceleration(flywheel, 19.214668279855754)
+ testTorque(flywheel, 1.9822842933004643)
+ testIsDwelling(flywheel, false)
+ testIsUnpowered(flywheel, false)
+ testIsPowered(flywheel, false)
+ testDragFactor(flywheel, (rowerProfiles.Concept2_Model_C.dragFactor / 1000000)) // As Flywheel.js doesn't detect strokes (this is a function of Rower.js), the dragcalculation shouldn't be triggered
+})
+
+/**
+ * @description Test behaviour for the C2 RowErg
+ */
+test('C2_RowErg_01: 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, 65960.87935477128)
+ testDeltaTime(flywheel, 0.009975)
+ testAngularVelocity(flywheel, 103.61722616806219)
+ testAngularAcceleration(flywheel, -10.88942996208896)
+ testTorque(flywheel, -0.37568315550441544)
+ testIsDwelling(flywheel, false)
+ testIsUnpowered(flywheel, false)
+ testIsPowered(flywheel, false)
+ testDragFactor(flywheel, (rowerProfiles.Concept2_RowErg.dragFactor / 1000000)) // As Flywheel.js doesn't detect strokes (this is a function of Rower.js), the dragcalculation shouldn't be triggered
+})
+
+/**
+ * @description Test behaviour for the DKN R-320
+ */
+test('DKN_R320_01: Correct Flywheel behaviour with a DKN R-320', async () => {
+ const flywheel = createFlywheel(deepMerge(rowerProfiles.DEFAULT, rowerProfiles.DKN_R320))
+
testAngularPosition(flywheel, 0)
testDragFactor(flywheel, (rowerProfiles.DKN_R320.dragFactor / 1000000))
+ flywheel.maintainStateAndMetrics()
// Inject 10 strokes
- flywheel.maintainStateAndMetrics()
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))
+ testAngularPosition(flywheel, 490.0884539600077)
+ testDeltaTime(flywheel, 0.301283162)
+ testAngularVelocity(flywheel, 18.449095403544845)
+ testAngularAcceleration(flywheel, -13.540617584449196)
+ testTorque(flywheel, -9.827554878438296)
+ testIsDwelling(flywheel, false)
+ testIsUnpowered(flywheel, true)
+ testIsPowered(flywheel, false)
+ testDragFactor(flywheel, (rowerProfiles.DKN_R320.dragFactor / 1000000)) // As Flywheel.js doesn't detect strokes (this is a function of Rower.js), the dragcalculation shouldn't be triggered
})
-test('Correct Flywheel behaviour with a NordicTrack RX800', async () => {
+/**
+ * @description Test behaviour for the NordicTrack RX800
+ */
+test('NordicT_RX800_01: 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))
-
- // Inject 10 strokes
flywheel.maintainStateAndMetrics()
+
+ // Inject data
await replayRowingSession(flywheel.pushValue, { filename: 'recordings/RX800.csv', realtime: false, loop: false })
- testSpinningTime(flywheel, 22.65622640199999)
- testAngularPosition(flywheel, 1446.7034169780998)
- // 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))
+ testSpinningTime(flywheel, 22.721492397999985)
+ testAngularPosition(flywheel, 1448.2742133048946)
+ testDeltaTime(flywheel, 0.027508437)
+ testAngularVelocity(flywheel, 56.91429403149175)
+ testAngularAcceleration(flywheel, -9.697829866471647)
+ testTorque(flywheel, -1.0167810813166995)
+ testIsDwelling(flywheel, false)
+ testIsUnpowered(flywheel, true)
+ testIsPowered(flywheel, false)
+ testDragFactor(flywheel, (rowerProfiles.NordicTrack_RX800.dragFactor / 1000000)) // As Flywheel.js doesn't detect strokes (this is a function of Rower.js), the dragcalculation shouldn't be triggered
})
-test('Correct Flywheel behaviour with a full session on a SportsTech WRX700', async () => {
+/**
+ * @description Test behaviour for the Merarch R50
+ */
+test('Merarch_R50_01: Correct Flywheel behaviour with a Merarch R50', async () => {
+ const flywheel = createFlywheel(deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Merach_R50))
+ testSpinningTime(flywheel, 0)
+ testAngularPosition(flywheel, 0)
+ testDragFactor(flywheel, (rowerProfiles.Merach_R50.dragFactor / 1000000))
+ flywheel.maintainStateAndMetrics()
+
+ // Inject data
+ await replayRowingSession(flywheel.pushValue, { filename: 'recordings/Merach_R50_510m.csv', realtime: false, loop: false })
+
+ testSpinningTime(flywheel, 167.08544299999994)
+ testAngularPosition(flywheel, 15104.777478459726)
+ testDeltaTime(flywheel, 0.08684500000003936)
+ testAngularVelocity(flywheel, 72.08365670111948)
+ testAngularAcceleration(flywheel, -6.128049101129095)
+ testTorque(flywheel, -0.08746742433423627)
+ testIsDwelling(flywheel, true)
+ testIsUnpowered(flywheel, true)
+ testIsPowered(flywheel, false)
+ testDragFactor(flywheel, (rowerProfiles.Merach_R50.dragFactor / 1000000)) // As Flywheel.js doesn't detect strokes (this is a function of Rower.js), the dragcalculation shouldn't be triggered
+})
+
+/**
+ * @description Test behaviour for the Oartec Slider
+ */
+test('Oartec_Slider_01: Correct Flywheel behaviour with a Oartec Slider', async () => {
+ const flywheel = createFlywheel(deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Oartec_Slider))
+ testSpinningTime(flywheel, 0)
+ testAngularPosition(flywheel, 0)
+ testDragFactor(flywheel, (rowerProfiles.Oartec_Slider.dragFactor / 1000000))
+ flywheel.maintainStateAndMetrics()
+
+ // Inject data
+ await replayRowingSession(flywheel.pushValue, { filename: 'recordings/Oartec_Slider.csv', realtime: false, loop: false })
+
+ testSpinningTime(flywheel, 67.32300999999995)
+ testAngularPosition(flywheel, 5168.967112706406)
+ testDeltaTime(flywheel, 0.08461499999998523)
+ testAngularVelocity(flywheel, 24.795470297327896)
+ testAngularAcceleration(flywheel, -0.9584556279706938)
+ testTorque(flywheel, -0.009086457786651847)
+ testIsDwelling(flywheel, true)
+ testIsUnpowered(flywheel, true)
+ testIsPowered(flywheel, false)
+ testDragFactor(flywheel, (rowerProfiles.Oartec_Slider.dragFactor / 1000000)) // As Flywheel.js doesn't detect strokes (this is a function of Rower.js), the dragcalculation shouldn't be triggered
+})
+
+/**
+ * @description Test behaviour for the Schwinn Windrigger
+ */
+test('Schwinn_Wndrggr_01: Correct Flywheel behaviour with a Schwinn Windrigger', async () => {
+ const flywheel = createFlywheel(deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Schwinn_Windrigger))
+ testSpinningTime(flywheel, 0)
+ testAngularPosition(flywheel, 0)
+ testDragFactor(flywheel, (rowerProfiles.Schwinn_Windrigger.dragFactor / 1000000))
+ flywheel.maintainStateAndMetrics()
+
+ // Inject data
+ await replayRowingSession(flywheel.pushValue, { filename: 'recordings/Schwinn_Windrigger.csv', realtime: false, loop: false })
+
+ testSpinningTime(flywheel, 124.52743099999996)
+ testAngularPosition(flywheel, 10197.609753552468)
+ testDeltaTime(flywheel, 0.53818)
+ testAngularVelocity(flywheel, 11.09436351003069)
+ testAngularAcceleration(flywheel, -2.1935917561168776)
+ testTorque(flywheel, -0.16317885032008017)
+ testIsDwelling(flywheel, true)
+ testIsUnpowered(flywheel, true)
+ testIsPowered(flywheel, false)
+ testDragFactor(flywheel, (rowerProfiles.Schwinn_Windrigger.dragFactor / 1000000)) // As Flywheel.js doesn't detect strokes (this is a function of Rower.js), the dragcalculation shouldn't be triggered
+})
+
+/**
+ * @description Test behaviour for the WRX700
+ */
+test('Sportstech_WRX700_01: 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 846 strokes
+ // Inject 16 strokes
+ await replayRowingSession(flywheel.pushValue, { filename: 'recordings/WRX700_2magnets.csv', realtime: false, loop: false })
+
+ testSpinningTime(flywheel, 46.302522627)
+ testAngularPosition(flywheel, 738.2742735936014)
+ testDeltaTime(flywheel, 0.374942146)
+ testAngularVelocity(flywheel, 7.73724815197113)
+ testAngularAcceleration(flywheel, -3.4832490689573206)
+ testTorque(flywheel, -0.5922590427634897)
+ testIsDwelling(flywheel, true)
+ testIsUnpowered(flywheel, true)
+ testIsPowered(flywheel, false)
+ testDragFactor(flywheel, (rowerProfiles.Sportstech_WRX700.dragFactor / 1000000)) // As Flywheel.js doesn't detect strokes (this is a function of Rower.js), the dragcalculation shouldn't be triggered
+})
+
+/**
+ * @description Test behaviour for the SportsTech WRX700
+ */
+test('Sportstech_WRX700_02: 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, 2342.741183077012)
- testAngularPosition(flywheel, 37337.82868791469)
- // The dragfactor should remain static
- testDragFactor(flywheel, (rowerProfiles.Sportstech_WRX700.dragFactor / 1000000))
+
+ testSpinningTime(flywheel, 2340.0100514160117)
+ testAngularPosition(flywheel, 37322.120724646746)
+ testDeltaTime(flywheel, 0.381367661)
+ testAngularVelocity(flywheel, 7.510250158842609)
+ testAngularAcceleration(flywheel, -3.3685328592655774)
+ testTorque(flywheel, -0.6204202203225615)
+ testIsDwelling(flywheel, true)
+ testIsUnpowered(flywheel, true)
+ testIsPowered(flywheel, false)
+ testDragFactor(flywheel, (rowerProfiles.Sportstech_WRX700.dragFactor / 1000000)) // As Flywheel.js doesn't detect strokes (this is a function of Rower.js), the dragcalculation shouldn't be triggered
})
-test('A full session for a Concept2 RowErg should produce plausible results', async () => {
- const flywheel = createFlywheel(deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Concept2_RowErg))
+/**
+ * @description Test behaviour for the Topiom V2
+ */
+test('TopiomV2_01: Correct Flywheel behaviour with a Topiom V2', async () => {
+ const flywheel = createFlywheel(deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Topiom_V2))
testSpinningTime(flywheel, 0)
testAngularPosition(flywheel, 0)
- testDragFactor(flywheel, (rowerProfiles.Concept2_RowErg.dragFactor / 1000000))
-
+ testDragFactor(flywheel, (rowerProfiles.Topiom_V2.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))
+
+ // Inject data
+ await replayRowingSession(flywheel.pushValue, { filename: 'recordings/Topiom_V2_1magnet.csv', realtime: false, loop: false })
+
+ testSpinningTime(flywheel, 2452.8183219999933)
+ testAngularPosition(flywheel, 48562.739239191025)
+ testDeltaTime(flywheel, 1.6244899999999802)
+ testAngularVelocity(flywheel, 3.570665383502728)
+ testAngularAcceleration(flywheel, -0.3302458188366401)
+ testTorque(flywheel, 0.02904532439241564)
+ testIsDwelling(flywheel, true)
+ testIsUnpowered(flywheel, true)
+ testIsPowered(flywheel, false)
+ testDragFactor(flywheel, (rowerProfiles.Topiom_V2.dragFactor / 1000000)) // As Flywheel.js doesn't detect strokes (this is a function of Rower.js), the dragcalculation shouldn't be triggered
})
-// Test behaviour after reset
-// ToDo: Add additional test to test isDwelling, isUnpowered and isPowered after a reset
+/**
+ * @todo 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()}`)
@@ -353,4 +1299,8 @@ 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/Rower.js b/app/engine/Rower.js
index 82f73e2523..ce744456d2 100644
--- a/app/engine/Rower.js
+++ b/app/engine/Rower.js
@@ -1,34 +1,36 @@
'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
-*/
-
+/**
+ * @copyright {@link https://github.com/JaapvanEkris/openrowingmonitor|OpenRowingMonitor}
+ *
+ * @file The Rowing Engine models the physics of a real rowing boat. It takes impulses from the flywheel of a rowing machine
+ * and calculates parameters such as work, stroke rates and linear movement.
+ *
+ * This implementation uses concepts that are described here:
+ * - @see {@link https://github.com/JaapvanEkris/openrowingmonitor/blob/main/docs/physics_openrowingmonitor.md#relevant-linear-metrics|the description of our underlying physics model}
+ * - @see {@link https://eodg.atm.ox.ac.uk/user/dudhia/rowing/physics/ergometer.html|Physics of Ergometers by Anu Dudhia}
+ * - @see {@link https://dvernooy.github.io/projects/ergware|Dave Vernooy's good explanation of the physics involved}
+ */
+/* 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')
-function createRower (rowerSettings) {
+export function createRower (rowerSettings) {
const flywheel = createFlywheel(rowerSettings)
const sprocketRadius = rowerSettings.sprocketRadius / 100
- const driveHandleForce = createCurveMetrics(2)
- const driveHandleVelocity = createCurveMetrics(3)
- const driveHandlePower = createCurveMetrics(1)
+ const driveHandleForce = createCurveMetrics()
+ const driveHandleVelocity = createCurveMetrics()
+ const driveHandlePower = createCurveMetrics()
let _strokeState = 'WaitingForDrive'
let _totalNumberOfStrokes = -1.0
let recoveryPhaseStartTime = 0.0
- let _recoveryDuration = 0.0
+ let _recoveryDuration
let drivePhaseStartTime = 0.0
- let _driveDuration = 0.0
+ let _driveDuration
+ let drivePhaseStartFlywheelWork = 0.0
+ let _driveFlywheelWork = 0.0
let drivePhaseStartAngularPosition = 0.0
let drivePhaseAngularDisplacement = 0.0
let _driveLinearDistance = 0.0
@@ -36,13 +38,15 @@ function createRower (rowerSettings) {
let recoveryPhaseAngularDisplacement = 0.0
let _recoveryLinearDistance = 0.0
const minimumCycleDuration = rowerSettings.minimumDriveTime + rowerSettings.minimumRecoveryTime
- let _cycleDuration = minimumCycleDuration
- let _cycleLinearVelocity = 0.0
- let _cyclePower = 0.0
+ 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
@@ -53,19 +57,32 @@ function createRower (rowerSettings) {
case (_strokeState === 'Stopped'):
// We are in a stopped state, so don't do anything
break
- case (_strokeState === 'WaitingForDrive' && flywheel.isPowered() && flywheel.isAboveMinimumSpeed()):
- // We change into the "Drive" phase since were waiting for a drive phase, and we see a clear force exerted on the flywheel
+ 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
- log.debug(`*** Rowing (re)started with a DRIVE phase at time: ${flywheel.spinningTime().toFixed(4)} sec`)
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) && flywheel.isUnpowered()):
+ 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()
@@ -73,7 +90,7 @@ function createRower (rowerSettings) {
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 for drive phase length (${(flywheel.spinningTime() - drivePhaseStartTime).toFixed(4)} sec) to exceed minimumDriveTime (${rowerSettings.minimumDriveTime} sec)`)
+ 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'):
@@ -140,16 +157,24 @@ function createRower (rowerSettings) {
function endDrivePhase () {
// Here, we conclude the Drive Phase
- // The FSM guarantees that we have a credible driveDuration and cycletime
+ // The FSM guarantees that we have a credible driveDuration and cycletime in normal operation, but NOT at the start
_driveDuration = flywheel.spinningTime() - drivePhaseStartTime
- _cycleDuration = _recoveryDuration + _driveDuration
+ _driveFlywheelWork = flywheel.totalWork() - drivePhaseStartFlywheelWork
+ drivePhaseStartFlywheelWork = flywheel.totalWork()
drivePhaseAngularDisplacement = flywheel.angularPosition() - drivePhaseStartAngularPosition
_driveLength = drivePhaseAngularDisplacement * sprocketRadius
_driveLinearDistance = calculateLinearDistance(drivePhaseAngularDisplacement, _driveDuration)
totalLinearDistance += _driveLinearDistance
- _cyclePower = calculateCyclePower()
- _cycleLinearVelocity = calculateLinearVelocity(drivePhaseAngularDisplacement + recoveryPhaseAngularDisplacement, _cycleDuration)
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 () {
@@ -168,23 +193,29 @@ function createRower (rowerSettings) {
function endRecoveryPhase () {
// First, we conclude the recovery phase
- // The FSM guarantees that we have a credible recoveryDuration and cycletime
+ // 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
- _cycleDuration = _recoveryDuration + _driveDuration
recoveryPhaseAngularDisplacement = flywheel.angularPosition() - recoveryPhaseStartAngularPosition
_recoveryLinearDistance = calculateLinearDistance(recoveryPhaseAngularDisplacement, _recoveryDuration)
totalLinearDistance += _recoveryLinearDistance
preliminaryTotalLinearDistance = totalLinearDistance
- _cycleLinearVelocity = calculateLinearVelocity(drivePhaseAngularDisplacement + recoveryPhaseAngularDisplacement, _cycleDuration)
- _cyclePower = calculateCyclePower()
- flywheel.markRecoveryPhaseCompleted()
+ 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: baseAngularDisplacement was not credible, baseTime: ${baseAngularDisplacement}`)
+ log.error(`Time: ${flywheel.spinningTime().toFixed(4)} sec: calculateLinearDistance error: Angular Displacement of ${baseAngularDisplacement} was not credible, baseTime = ${baseTime}`)
return 0
}
}
@@ -226,6 +257,10 @@ function createRower (rowerSettings) {
return flywheel.spinningTime()
}
+ function totalFlywheelWorkSinceStart () {
+ return flywheel.totalWork()
+ }
+
function driveLastStartTime () {
return drivePhaseStartTime
}
@@ -235,59 +270,130 @@ function createRower (rowerSettings) {
}
function cycleDuration () {
- return _cycleDuration
+ if (_driveDuration >= rowerSettings.minimumDriveTime && _recoveryDuration >= rowerSettings.minimumRecoveryTime) {
+ return _cycleDuration
+ } else {
+ return undefined
+ }
}
function cycleLinearDistance () {
- return _driveLinearDistance + _recoveryLinearDistance
+ if (_driveDuration >= rowerSettings.minimumDriveTime && _recoveryDuration >= rowerSettings.minimumRecoveryTime) {
+ return _driveLinearDistance + _recoveryLinearDistance
+ } else {
+ return undefined
+ }
}
function cycleLinearVelocity () {
- return _cycleLinearVelocity
+ if (_driveDuration >= rowerSettings.minimumDriveTime && _recoveryDuration >= rowerSettings.minimumRecoveryTime) {
+ return _cycleLinearVelocity
+ } else {
+ return undefined
+ }
}
function cyclePower () {
- return _cyclePower
+ if (_driveDuration >= rowerSettings.minimumDriveTime && _recoveryDuration >= rowerSettings.minimumRecoveryTime) {
+ return _cyclePower
+ } else {
+ return undefined
+ }
}
-
function driveDuration () {
- return _driveDuration
+ if (_driveDuration >= rowerSettings.minimumDriveTime) {
+ return _driveDuration
+ } else {
+ return undefined
+ }
}
function driveLinearDistance () {
- return _driveLinearDistance
+ if (_driveDuration >= rowerSettings.minimumDriveTime) {
+ return _driveLinearDistance
+ } else {
+ return undefined
+ }
}
function driveLength () {
- return _driveLength
+ if (_driveDuration >= rowerSettings.minimumDriveTime) {
+ return _driveLength
+ } else {
+ return undefined
+ }
+ }
+
+ function driveFlywheelWork () {
+ if (_driveDuration >= rowerSettings.minimumDriveTime) {
+ return _driveFlywheelWork
+ } else {
+ return undefined
+ }
}
function driveAverageHandleForce () {
- return driveHandleForce.average()
+ if (_driveDuration >= rowerSettings.minimumDriveTime) {
+ return driveHandleForce.average()
+ } else {
+ return undefined
+ }
}
function drivePeakHandleForce () {
- return driveHandleForce.peak()
+ if (_driveDuration >= rowerSettings.minimumDriveTime) {
+ return driveHandleForce.peak()
+ } else {
+ return undefined
+ }
+ }
+
+ function drivePeakHandleForceNormalizedPosition () {
+ if (_driveDuration >= rowerSettings.minimumDriveTime) {
+ return driveHandleForce.peakNormalizedPosition()
+ } else {
+ return undefined
+ }
}
function driveHandleForceCurve () {
- return driveHandleForce.curve()
+ if (_driveDuration >= rowerSettings.minimumDriveTime) {
+ return driveHandleForce.curve()
+ } else {
+ return undefined
+ }
}
function driveHandleVelocityCurve () {
- return driveHandleVelocity.curve()
+ if (_driveDuration >= rowerSettings.minimumDriveTime) {
+ return driveHandleVelocity.curve()
+ } else {
+ return undefined
+ }
}
function driveHandlePowerCurve () {
- return driveHandlePower.curve()
+ if (_driveDuration >= rowerSettings.minimumDriveTime) {
+ return driveHandlePower.curve()
+ } else {
+ return undefined
+ }
}
function recoveryDuration () {
- return _recoveryDuration
+ if (_recoveryDuration >= rowerSettings.minimumRecoveryTime) {
+ return _recoveryDuration
+ } else {
+ return undefined
+ }
}
function recoveryDragFactor () {
- return flywheel.dragFactor() * 1000000
+ if (flywheel.dragFactorIsReliable()) {
+ return flywheel.dragFactor() * 1000000
+ } else {
+ return undefined
+ }
}
function instantHandlePower () {
@@ -299,8 +405,11 @@ function createRower (rowerSettings) {
}
function allowMovement () {
- log.debug(`*** ALLOW MOVEMENT command by RowingEngine recieved at time: ${flywheel.spinningTime().toFixed(4)} sec`)
- _strokeState = 'WaitingForDrive'
+ 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 () {
@@ -317,11 +426,16 @@ function createRower (rowerSettings) {
function reset () {
_strokeState = 'WaitingForDrive'
- flywheel.maintainStateOnly()
+ flywheel.reset()
+ driveHandleForce.reset()
+ driveHandleVelocity.reset()
+ driveHandlePower.reset()
_totalNumberOfStrokes = -1.0
drivePhaseStartTime = 0.0
drivePhaseStartAngularPosition = 0.0
_driveDuration = 0.0
+ drivePhaseStartFlywheelWork = 0.0
+ _driveFlywheelWork = 0.0
drivePhaseAngularDisplacement = 0.0
_driveLinearDistance = 0.0
recoveryPhaseStartTime = 0.0
@@ -347,6 +461,7 @@ function createRower (rowerSettings) {
driveLastStartTime,
totalMovingTimeSinceStart,
totalLinearDistanceSinceStart,
+ totalFlywheelWorkSinceStart,
cycleDuration,
cycleLinearDistance,
cycleLinearVelocity,
@@ -354,8 +469,10 @@ function createRower (rowerSettings) {
driveDuration,
driveLinearDistance,
driveLength,
+ driveFlywheelWork,
driveAverageHandleForce,
drivePeakHandleForce,
+ drivePeakHandleForceNormalizedPosition,
driveHandleForceCurve,
driveHandleVelocityCurve,
driveHandlePowerCurve,
@@ -365,5 +482,3 @@ function createRower (rowerSettings) {
reset
}
}
-
-export { createRower }
diff --git a/app/engine/Rower.test.js b/app/engine/Rower.test.js
index 9e32ab5be7..3fcba98e0c 100644
--- a/app/engine/Rower.test.js
+++ b/app/engine/Rower.test.js
@@ -1,111 +1,94 @@
'use strict'
-/*
-
- 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.
-*/
+/**
+ * @copyright {@link https://github.com/JaapvanEkris/openrowingmonitor|OpenRowingMonitor}
+ *
+ * @file 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 '../tools/RowingRecorder.js'
+import { replayRowingSession } from '../recorders/RowingReplayer.js'
import { deepMerge } from '../tools/Helper.js'
import { createRower } from './Rower.js'
-const baseConfig = {
+const baseConfig = { // Based on Concept 2 settings, as this is the validation system
numOfImpulsesPerRevolution: 6,
- smoothing: 1,
- flankLength: 11,
- minimumStrokeQuality: 0.30,
- minumumRecoverySlope: 0,
- autoAdjustRecoverySlope: true,
- autoAdjustRecoverySlopeMargin: 0.10,
- minumumForceBeforeStroke: 50,
- minimumRecoveryTime: 0.9,
- minimumDriveTime: 0.4,
- maximumStrokeTimeBeforePause: 6.0,
- minimumTimeBetweenImpulses: 0.005,
- maximumTimeBetweenImpulses: 0.02,
+ sprocketRadius: 1.4,
+ maximumStrokeTimeBeforePause: 0.3, // Modification to standard settings to shorten test cases
+ dragFactor: 110,
autoAdjustDragFactor: true,
+ minimumDragQuality: 0.95,
dragFactorSmoothing: 3,
- dragFactor: 100,
- minimumDragQuality: 0.83,
- flywheelInertia: 0.1,
- magicConstant: 2.8,
- sprocketRadius: 2
+ minimumTimeBetweenImpulses: 0.005,
+ maximumTimeBetweenImpulses: 0.017,
+ flankLength: 12,
+ systematicErrorAgressiveness: 0,
+ systematicErrorNumberOfDatapoints: 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', () => {
+/**
+ * @description Test behaviour for no datapoints
+ */
+test('Init_01: Correct rower behaviour at initialisation', () => {
const rower = createRower(baseConfig)
testStrokeState(rower, 'WaitingForDrive')
testTotalMovingTimeSinceStart(rower, 0)
testTotalNumberOfStrokes(rower, 0)
testTotalLinearDistanceSinceStart(rower, 0)
- testCycleDuration(rower, 1.3)
- testCycleLinearDistance(rower, 0)
- testCycleLinearVelocity(rower, 0)
- testCyclePower(rower, 0)
- testDriveDuration(rower, 0)
- testDriveLinearDistance(rower, 0)
- testDriveLength(rower, 0)
- testDriveAverageHandleForce(rower, 0)
- testDrivePeakHandleForce(rower, 0)
- testRecoveryDuration(rower, 0)
- testRecoveryDragFactor(rower, 100)
+ 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 series of datapoint
-// ToDo: add detailed test with a series of datapoints describng a complete stroke
-
-// Test behaviour for three perfect identical strokes, including settingling behaviour of metrics
-test('Correct Rower behaviour for three noisefree strokes with dynamic dragfactor and stroke detection', () => {
- const specificConfig = {
- numOfImpulsesPerRevolution: 6,
- smoothing: 1,
- flankLength: 11,
- minimumStrokeQuality: 0.30,
- minumumRecoverySlope: 0,
- autoAdjustRecoverySlope: true,
- autoAdjustRecoverySlopeMargin: 0.10,
- minumumForceBeforeStroke: 50,
- minimumDriveTime: 0.1,
- minimumRecoveryTime: 0.2,
- maximumStrokeTimeBeforePause: 0.2,
- minimumTimeBetweenImpulses: 0.005,
- maximumTimeBetweenImpulses: 0.02,
- autoAdjustDragFactor: true,
- dragFactorSmoothing: 3,
- dragFactor: 100,
- minimumDragQuality: 0.83,
- flywheelInertia: 0.1,
- magicConstant: 2.8,
- sprocketRadius: 2
- }
-
- const rower = createRower(specificConfig)
+/**
+ * @todo Test behaviour for one datapoint
+ */
+
+/**
+ * @description Test behaviour for three perfect identical strokes, including settingling behaviour of metrics
+ */
+test('Theoretical_01: 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, 0.30000000000000004)
- testCycleLinearDistance(rower, 0)
- testCycleLinearVelocity(rower, 0)
- testCyclePower(rower, 0)
- testDriveDuration(rower, 0)
- testDriveLinearDistance(rower, 0)
- testDriveLength(rower, 0)
- testDriveAverageHandleForce(rower, 0)
- testDrivePeakHandleForce(rower, 0)
- testRecoveryDuration(rower, 0)
- testRecoveryDragFactor(rower, 100)
+ 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)
- testStrokeState(rower, 'WaitingForDrive')
rower.handleRotationImpulse(0.011175504)
rower.handleRotationImpulse(0.01116456)
rower.handleRotationImpulse(0.011130263)
@@ -115,11 +98,8 @@ test('Correct Rower behaviour for three noisefree strokes with dynamic dragfacto
rower.handleRotationImpulse(0.011051853)
rower.handleRotationImpulse(0.010973313)
rower.handleRotationImpulse(0.010919756)
- testStrokeState(rower, 'WaitingForDrive')
rower.handleRotationImpulse(0.01086431)
- testStrokeState(rower, 'Drive')
rower.handleRotationImpulse(0.010800864)
- testStrokeState(rower, 'Drive')
rower.handleRotationImpulse(0.010956987)
rower.handleRotationImpulse(0.010653396)
rower.handleRotationImpulse(0.010648619)
@@ -128,21 +108,21 @@ test('Correct Rower behaviour for three noisefree strokes with dynamic dragfacto
rower.handleRotationImpulse(0.010511225)
rower.handleRotationImpulse(0.010386684)
testStrokeState(rower, 'Drive')
- testTotalMovingTimeSinceStart(rower, 0.088970487)
- testTotalLinearDistanceSinceStart(rower, 0.27588786257094444)
+ testTotalMovingTimeSinceStart(rower, 0.077918634)
+ testTotalLinearDistanceSinceStart(rower, 0.2135951659708087)
testTotalNumberOfStrokes(rower, 1)
- testCycleDuration(rower, 0.30000000000000004)
- testCycleLinearDistance(rower, 0.27588786257094444)
- testCycleLinearVelocity(rower, 0) // Shouldn't this one be filled after the first drive?
- testCyclePower(rower, 0) // Shouldn't this one be filled after the first drive?
- testDriveDuration(rower, 0) // Shouldn't this one be filled after the first drive?
- testDriveLinearDistance(rower, 0.27588786257094444)
- testDriveLength(rower, 0) // Shouldn't this one be filled after the first drive?
- testDriveAverageHandleForce(rower, 156.05318736972495)
- testDrivePeakHandleForce(rower, 163.83893715861615)
- testRecoveryDuration(rower, 0)
- testRecoveryDragFactor(rower, 100)
- testInstantHandlePower(rower, 312.1970306768984)
+ 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, 367.97696436918955)
// Recovery initial stroke starts here
rower.handleRotationImpulse(0.010769)
rower.handleRotationImpulse(0.010707554)
@@ -160,22 +140,22 @@ test('Correct Rower behaviour for three noisefree strokes with dynamic dragfacto
rower.handleRotationImpulse(0.011131862)
rower.handleRotationImpulse(0.011209919)
testStrokeState(rower, 'Recovery')
- testTotalMovingTimeSinceStart(rower, 0.24984299900000007)
- testTotalLinearDistanceSinceStart(rower, 0.7931776048914653)
+ testTotalMovingTimeSinceStart(rower, 0.23894732900000007)
+ testTotalLinearDistanceSinceStart(rower, 0.7475830808978304)
testTotalNumberOfStrokes(rower, 1)
- testCycleDuration(rower, 0.143485717)
- testCycleLinearDistance(rower, 0.7931776048914653)
- testCycleLinearVelocity(rower, 3.1244766799874912)
- testCyclePower(rower, 0)
+ testCycleDuration(rower, undefined)
+ testCycleLinearDistance(rower, undefined)
+ testCycleLinearVelocity(rower, undefined)
+ testCyclePower(rower, undefined)
testDriveDuration(rower, 0.143485717)
- testDriveLinearDistance(rower, 0.4483177766777847)
- testDriveLength(rower, 0.2722713633111154)
- testDriveAverageHandleForce(rower, 168.33379255795953)
- testDrivePeakHandleForce(rower, 220.19702843648562)
- testRecoveryDuration(rower, 0)
- testRecoveryDragFactor(rower, 100)
+ testDriveLinearDistance(rower, 0.4271903319416174)
+ testDriveLength(rower, 0.1759291886010284)
+ testDriveAverageHandleForce(rower, 276.6342676838739)
+ testDrivePeakHandleForce(rower, 332.99182222129025)
+ testRecoveryDuration(rower, undefined)
+ testRecoveryDragFactor(rower, undefined)
testInstantHandlePower(rower, 0)
- // Drive seconds stroke starts here
+ // Drive second stroke starts here
rower.handleRotationImpulse(0.011221636)
rower.handleRotationImpulse(0.011175504)
rower.handleRotationImpulse(0.01116456)
@@ -196,21 +176,21 @@ test('Correct Rower behaviour for three noisefree strokes with dynamic dragfacto
rower.handleRotationImpulse(0.010511225)
rower.handleRotationImpulse(0.010386684)
testStrokeState(rower, 'Drive')
- testTotalMovingTimeSinceStart(rower, 0.46020725100000004)
- testTotalLinearDistanceSinceStart(rower, 1.519974294345203)
+ testTotalMovingTimeSinceStart(rower, 0.44915539800000004)
+ testTotalLinearDistanceSinceStart(rower, 1.790756456114694)
testTotalNumberOfStrokes(rower, 2)
- testCycleDuration(rower, 0.404798464)
- testCycleLinearDistance(rower, 1.0716565176674184)
- testCycleLinearVelocity(rower, 3.1521398371477467)
- testCyclePower(rower, 87.69492447163606)
+ testCycleDuration(rower, 0.36002684500000004)
+ testCycleLinearDistance(rower, 1.3635661241730768)
+ testCycleLinearVelocity(rower, 4.328458575829895)
+ testCyclePower(rower, 227.0689899451657)
testDriveDuration(rower, 0.143485717)
- testDriveLinearDistance(rower, 0.24399292995458496)
- testDriveLength(rower, 0.2722713633111154)
- testDriveAverageHandleForce(rower, 156.87845718774872)
- testDrivePeakHandleForce(rower, 227.37033987102245)
- testRecoveryDuration(rower, 0.261312747)
- testRecoveryDragFactor(rower, 283.33086731525583)
- testInstantHandlePower(rower, 432.851053772137)
+ testDriveLinearDistance(rower, 0.3895903211923076)
+ testDriveLength(rower, 0.1759291886010284)
+ testDriveAverageHandleForce(rower, 236.92279327988305)
+ testDrivePeakHandleForce(rower, 378.60223820258005)
+ testRecoveryDuration(rower, 0.21654112800000003)
+ testRecoveryDragFactor(rower, 281.5961372923874)
+ testInstantHandlePower(rower, 502.7377823299629)
// Recovery second stroke starts here
rower.handleRotationImpulse(0.010769)
rower.handleRotationImpulse(0.010707554)
@@ -228,20 +208,20 @@ test('Correct Rower behaviour for three noisefree strokes with dynamic dragfacto
rower.handleRotationImpulse(0.011131862)
rower.handleRotationImpulse(0.011209919)
testStrokeState(rower, 'Recovery')
- testTotalMovingTimeSinceStart(rower, 0.6210797630000001)
- testTotalLinearDistanceSinceStart(rower, 2.2519530842089575)
+ testTotalMovingTimeSinceStart(rower, 0.6101840930000001)
+ testTotalLinearDistanceSinceStart(rower, 2.521238308350271)
testTotalNumberOfStrokes(rower, 2)
- testCycleDuration(rower, 0.37123676400000005)
- testCycleLinearDistance(rower, 0.9759717198183395)
- testCycleLinearVelocity(rower, 4.469255430992759)
- testCyclePower(rower, 249.95599708025222)
- testDriveDuration(rower, 0.10992401700000004)
- testDriveLinearDistance(rower, 0.48798585990916965)
- testDriveLength(rower, 0.20943951023931945)
- testDriveAverageHandleForce(rower, 198.7144253754593)
- testDrivePeakHandleForce(rower, 294.92974697493514)
- testRecoveryDuration(rower, 0.261312747)
- testRecoveryDragFactor(rower, 283.33086731525583)
+ testCycleDuration(rower, 0.44526865700000007)
+ testCycleLinearDistance(rower, 1.1200721734278847)
+ testCycleLinearVelocity(rower, 4.484147636986217)
+ testCyclePower(rower, 252.46300093932936)
+ testDriveDuration(rower, 0.22872752900000004)
+ testDriveLinearDistance(rower, 1.0226745931298076)
+ testDriveLength(rower, 0.3078760800517996)
+ testDriveAverageHandleForce(rower, 288.45140756259053)
+ testDrivePeakHandleForce(rower, 447.108514349131)
+ testRecoveryDuration(rower, 0.21654112800000003)
+ testRecoveryDragFactor(rower, 281.5961372923874)
testInstantHandlePower(rower, 0)
// Drive third stroke starts here
rower.handleRotationImpulse(0.011221636)
@@ -264,21 +244,21 @@ test('Correct Rower behaviour for three noisefree strokes with dynamic dragfacto
rower.handleRotationImpulse(0.010511225)
rower.handleRotationImpulse(0.010386684)
testStrokeState(rower, 'Drive')
- testTotalMovingTimeSinceStart(rower, 0.8314440150000004)
- testTotalLinearDistanceSinceStart(rower, 3.17912621803638)
+ testTotalMovingTimeSinceStart(rower, 0.8203921620000004)
+ testTotalLinearDistanceSinceStart(rower, 3.4465153211820017)
testTotalNumberOfStrokes(rower, 3)
- testCycleDuration(rower, 0.3376750640000003)
- testCycleLinearDistance(rower, 1.4151589937365927)
- testCycleLinearVelocity(rower, 4.479916721710978)
- testCyclePower(rower, 251.74905786098182)
- testDriveDuration(rower, 0.10992401700000004)
- testDriveLinearDistance(rower, 0.3903886879273361)
- testDriveLength(rower, 0.20943951023931945)
- testDriveAverageHandleForce(rower, 140.7974193430079)
- testDrivePeakHandleForce(rower, 227.3703398700472)
- testRecoveryDuration(rower, 0.22775104700000026)
- testRecoveryDragFactor(rower, 283.33086731525583)
- testInstantHandlePower(rower, 432.8510537702822)
+ testCycleDuration(rower, 0.3268520060000002)
+ testCycleLinearDistance(rower, 1.0226745931298078)
+ testCycleLinearVelocity(rower, 4.469801860329268)
+ testCyclePower(rower, 250.04769019893303)
+ testDriveDuration(rower, 0.22872752900000004)
+ testDriveLinearDistance(rower, 0.5843854817884615)
+ testDriveLength(rower, 0.3078760800517996)
+ testDriveAverageHandleForce(rower, 192.2653879294337)
+ testDrivePeakHandleForce(rower, 378.6022382039591)
+ testRecoveryDuration(rower, 0.09812447700000015)
+ testRecoveryDragFactor(rower, 281.5961372923874)
+ testInstantHandlePower(rower, 502.73778233173203)
// Recovery third stroke starts here
rower.handleRotationImpulse(0.010769)
rower.handleRotationImpulse(0.010707554)
@@ -296,20 +276,20 @@ test('Correct Rower behaviour for three noisefree strokes with dynamic dragfacto
rower.handleRotationImpulse(0.011131862)
rower.handleRotationImpulse(0.011209919)
testStrokeState(rower, 'Recovery')
- testTotalMovingTimeSinceStart(rower, 0.9923165270000005)
- testTotalLinearDistanceSinceStart(rower, 3.911105007900135)
+ testTotalMovingTimeSinceStart(rower, 0.9814208570000005)
+ testTotalLinearDistanceSinceStart(rower, 4.176997173417578)
testTotalNumberOfStrokes(rower, 3)
testCycleDuration(rower, 0.3712367640000004)
- testCycleLinearDistance(rower, 1.122367477791091)
- testCycleLinearVelocity(rower, 4.469255430992756)
- testCyclePower(rower, 249.95599708025168)
- testDriveDuration(rower, 0.14348571700000012)
- testDriveLinearDistance(rower, 0.634381617881921)
- testDriveLength(rower, 0.2722713633111155)
- testDriveAverageHandleForce(rower, 177.72502014311627)
- testDrivePeakHandleForce(rower, 294.9297469748562)
- testRecoveryDuration(rower, 0.22775104700000026)
- testRecoveryDragFactor(rower, 283.33086731525583)
+ testCycleLinearDistance(rower, 1.314867334024038)
+ testCycleLinearVelocity(rower, 4.4601155532842265)
+ testCyclePower(rower, 248.42560903379598)
+ testDriveDuration(rower, 0.27311228700000023)
+ testDriveLinearDistance(rower, 1.2174697537259611)
+ testDriveLength(rower, 0.36651914291880905)
+ testDriveAverageHandleForce(rower, 256.5447026931294)
+ testDrivePeakHandleForce(rower, 447.1085143512751)
+ testRecoveryDuration(rower, 0.09812447700000015)
+ testRecoveryDragFactor(rower, 281.5961372923874)
testInstantHandlePower(rower, 0)
// Dwelling state starts here
rower.handleRotationImpulse(0.020769)
@@ -328,121 +308,419 @@ test('Correct Rower behaviour for three noisefree strokes with dynamic dragfacto
rower.handleRotationImpulse(0.021131862)
rower.handleRotationImpulse(0.021209919)
testStrokeState(rower, 'WaitingForDrive')
- testTotalMovingTimeSinceStart(rower, 1.1137102920000004)
+ testTotalMovingTimeSinceStart(rower, 1.1551868460000003)
testTotalNumberOfStrokes(rower, 3)
- testTotalLinearDistanceSinceStart(rower, 4.447889453800221)
- testCycleDuration(rower, 0.37123676400000005)
- testCycleLinearDistance(rower, 1.6591519236911776)
- testCycleLinearVelocity(rower, 4.469255430992759)
- testCyclePower(rower, 249.95599708025233)
- testDriveDuration(rower, 0.14348571700000012)
- testDriveLinearDistance(rower, 0.634381617881921)
- testDriveLength(rower, 0.2722713633111155)
- testDriveAverageHandleForce(rower, 177.72502014311627)
- testDrivePeakHandleForce(rower, 294.9297469748562)
- testRecoveryDuration(rower, 0.22775104699999993)
- testRecoveryDragFactor(rower, 283.33086731525583)
+ testTotalLinearDistanceSinceStart(rower, 4.858780235504117)
+ testCycleDuration(rower, 0.4683079950000001)
+ testCycleLinearDistance(rower, 1.996650396110577)
+ testCycleLinearVelocity(rower, 4.263541125558996)
+ testCyclePower(rower, 217.00483025394573)
+ testDriveDuration(rower, 0.27311228700000023)
+ testDriveLinearDistance(rower, 1.2174697537259611)
+ testDriveLength(rower, 0.36651914291880905)
+ testDriveAverageHandleForce(rower, 256.5447026931294)
+ testDrivePeakHandleForce(rower, 447.1085143512751)
+ testRecoveryDuration(rower, 0.19519570799999986)
+ testRecoveryDragFactor(rower, 281.5961372923874)
testInstantHandlePower(rower, 0)
})
-// Test behaviour for noisy upgoing flank
-// ToDo: add detailed test with a series of datapoints describng a complete upgoing flank
+/**
+ * @todo Test behaviour for noisy stroke
+ */
-// Test behaviour for noisy downgoing flank
-// ToDo: add detailed test with a series of datapoints describng a complete downgoing flank
+/**
+ * @todo Test behaviour after reset
+ */
-// Test behaviour for noisy stroke
-// ToDo: add detailed test with a series of datapoints describng a complete upgoing and downgoing flank
+/**
+ * @todo Test drag factor calculation
+ */
-// Test drag factor calculation
-// ToDo: add a test to test the dragfactor calculation (can be reused from Flywheel.test.js)
+/**
+ * @description Test against a theoretical model, based on perfect clean data, as described in
+ * @see {@link https://github.com/JaapvanEkris/openrowingmonitor/discussions/215|this discussion}
+ */
+test('Theoretical_02: Correct Rower behaviour with perfect clean data', async () => {
+ const rower = createRower(deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Theoretical_Model))
+ testTotalMovingTimeSinceStart(rower, 0)
+ testTotalLinearDistanceSinceStart(rower, 0)
+ testTotalNumberOfStrokes(rower, 0)
+ // As dragFactor isn't static, it isn't available yet
+ testRecoveryDragFactor(rower, undefined)
+
+ await replayRowingSession(rower.handleRotationImpulse, { filename: 'recordings/Theoretical_Simulation_Clean.csv', realtime: false, loop: false })
+
+ testTotalMovingTimeSinceStart(rower, 61.3575905099299)
+ testTotalLinearDistanceSinceStart(rower, 235.71100184559577)
+ testTotalNumberOfStrokes(rower, 20)
+ // As the session has stopped, all data is supposed to be undefined again
+ testCycleDuration(rower, 6.002600148120585)
+ testCycleLinearDistance(rower, 20.51792911861166)
+ testCycleLinearVelocity(rower, 3.418173560175646)
+ testCyclePower(rower, 111.82537494177338)
+ testDriveDuration(rower, 0.8536150547956822)
+ testDriveLinearDistance(rower, 3.3708026409143326)
+ testDriveLength(rower, 1.363241772147737)
+ testDriveAverageHandleForce(rower, 376.227848755665)
+ testDrivePeakHandleForce(rower, 595.5968022268906) // Theoretical value 600N
+ testRecoveryDuration(rower, 5.148985093324903)
+ testInstantHandlePower(rower, 0)
+ testRecoveryDragFactor(rower, 119.92470860879553) // Theoretical value 120 * 10^−6 [ k g m 2 ]
+})
-// Test Dynamic stroke detection
-// ToDo: add a test to test the dynamic stroke detection (can be reused from Flywheel.test.js)
+/**
+ * @description Test against a theoretical model, based on noise-injected data, as described in
+ * @see {@link https://github.com/JaapvanEkris/openrowingmonitor/discussions/215|this discussion}
+ */
+test('Theoretical_03: Correct Rower behaviour with noise-injected data', async () => {
+ const rower = createRower(deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Theoretical_Model))
+ testTotalMovingTimeSinceStart(rower, 0)
+ testTotalLinearDistanceSinceStart(rower, 0)
+ testTotalNumberOfStrokes(rower, 0)
+ // As dragFactor isn't static, it isn't available yet
+ testRecoveryDragFactor(rower, undefined)
+
+ await replayRowingSession(rower.handleRotationImpulse, { filename: 'recordings/Theoretical_Simulation_Random_Noise.csv', realtime: false, loop: false })
+
+ testTotalMovingTimeSinceStart(rower, 181.37410307752515)
+ testTotalLinearDistanceSinceStart(rower, 706.3941674377243)
+ testTotalNumberOfStrokes(rower, 60)
+ testCycleDuration(rower, 6.017174805743167)
+ testCycleLinearDistance(rower, 20.556148767785583)
+ testCycleLinearVelocity(rower, 3.4162343421008674)
+ testCyclePower(rower, 111.63515863384868)
+ testDriveDuration(rower, 0.9022807545438525)
+ testDriveLinearDistance(rower, 3.5909710922219444)
+ testDriveLength(rower, 1.4521488442443375)
+ testDriveAverageHandleForce(rower, 357.06261389429113)
+ testDrivePeakHandleForce(rower, 597.4110902759764) // Theoretical value 600N
+ testRecoveryDuration(rower, 5.114894051199315)
+ testInstantHandlePower(rower, 0)
+ testRecoveryDragFactor(rower, 119.95115701917243) // Theoretical value 120 * 10^−6 [ k g m 2 ]
+})
-// Test behaviour after reset
-// ToDo: add detailed test with a series of datapoints followed by a reset
+/**
+ * @description Test against a theoretical model, based on a simulation of magnet positioning errors, as described in
+ * @see {@link https://github.com/JaapvanEkris/openrowingmonitor/discussions/215|this discussion}
+ * Magnet errors -0.01, +0.1, +0.2, -0.2, -0.1, +0.01 degrees
+ */
+test('Theoretical_04: Correct Rower behaviour with structural magnet errors', async () => {
+ const rower = createRower(deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Theoretical_Model))
+ testTotalMovingTimeSinceStart(rower, 0)
+ testTotalLinearDistanceSinceStart(rower, 0)
+ testTotalNumberOfStrokes(rower, 0)
+ // As dragFactor isn't static, it isn't available yet
+ testRecoveryDragFactor(rower, undefined)
+
+ await replayRowingSession(rower.handleRotationImpulse, { filename: 'recordings/Theoretical_Simulation_Systematic_Noise.csv', realtime: false, loop: false })
+
+ testTotalMovingTimeSinceStart(rower, 181.35701304844142)
+ testTotalLinearDistanceSinceStart(rower, 706.3286131515105)
+ testTotalNumberOfStrokes(rower, 60)
+ testCycleDuration(rower, 6.002627869048808)
+ testCycleLinearDistance(rower, 20.51792911861257)
+ testCycleLinearVelocity(rower, 3.4181577745989578)
+ testCyclePower(rower, 111.823825675902)
+ testDriveDuration(rower, 0.8536290880211368)
+ testDriveLinearDistance(rower, 3.370802640914105)
+ testDriveLength(rower, 1.363241772147737)
+ testDriveAverageHandleForce(rower, 376.5982501172787)
+ testDrivePeakHandleForce(rower, 595.8795141321255) // Theoretical value 600N
+ testRecoveryDuration(rower, 5.148998781027672)
+ testInstantHandlePower(rower, 0)
+ testRecoveryDragFactor(rower, 119.92470860881883) // Theoretical value 120 * 10^−6 [ k g m 2 ]
+})
-// Test behaviour with real-life data
+/**
+ * @description Test behaviour for the C2 Model C
+ */
+test('C2_ModelC_01: 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)
+ // As dragFactor isn't static, it isn't available yet
+ testRecoveryDragFactor(rower, undefined)
+
+ await replayRowingSession(rower.handleRotationImpulse, { filename: 'recordings/Concept2_Model_C.csv', realtime: false, loop: false })
+
+ testTotalMovingTimeSinceStart(rower, 181.4588596531004)
+ testTotalLinearDistanceSinceStart(rower, 552.4833037536148)
+ testTotalNumberOfStrokes(rower, 81)
+ testCycleDuration(rower, 2.7431250688428577)
+ testCycleLinearDistance(rower, 3.4692073560232397)
+ testCycleLinearVelocity(rower, 1.1301499807254836)
+ testCyclePower(rower, 4.041720500786868)
+ testDriveDuration(rower, 0.7592322967347798)
+ testDriveLinearDistance(rower, 1.3286326044344432)
+ testDriveLength(rower, 0.38117990863556767)
+ testDriveAverageHandleForce(rower, 89.5649019624021)
+ testDrivePeakHandleForce(rower, 176.95825697187902)
+ testRecoveryDuration(rower, 1.983892772108078)
+ testInstantHandlePower(rower, 31.747253027739596)
+ testRecoveryDragFactor(rower, 122.56864708679767)
+})
-test('sample data for Sportstech WRX700 should produce plausible results', async () => {
- const rower = createRower(deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Sportstech_WRX700))
+/**
+ * @description Test behaviour for the C2 RowErg
+ */
+test('C2_RowErg_01: 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, rowerProfiles.Sportstech_WRX700.dragFactor)
+ testRecoveryDragFactor(rower, undefined)
- await replayRowingSession(rower.handleRotationImpulse, { filename: 'recordings/WRX700_2magnets.csv', realtime: false, loop: false })
+ await replayRowingSession(rower.handleRotationImpulse, { filename: 'recordings/Concept2_RowErg_Session_2000meters.csv', realtime: false, loop: false })
- testTotalMovingTimeSinceStart(rower, 46.302522627)
- testTotalLinearDistanceSinceStart(rower, 166.2959671641673)
- testTotalNumberOfStrokes(rower, 16)
- // As dragFactor is static, it should remain in place
- testRecoveryDragFactor(rower, rowerProfiles.Sportstech_WRX700.dragFactor)
+ testTotalMovingTimeSinceStart(rower, 589.9664157362346)
+ testTotalLinearDistanceSinceStart(rower, 2028.3093228150667)
+ testTotalNumberOfStrokes(rower, 206)
+ testCycleDuration(rower, 2.991845945763316)
+ testCycleLinearDistance(rower, 9.249577681017865)
+ testCycleLinearVelocity(rower, 3.467310312773511)
+ testCyclePower(rower, 116.7175506729407)
+ testDriveDuration(rower, 0.7332495742683705)
+ testDriveLinearDistance(rower, 2.5693271336160244)
+ testDriveLength(rower, 1.1728612573401769)
+ testDriveAverageHandleForce(rower, 284.3396357312365)
+ testDrivePeakHandleForce(rower, 444.35598591963236)
+ testRecoveryDuration(rower, 2.2585963714949457)
+ testInstantHandlePower(rower, 0)
+ testRecoveryDragFactor(rower, 80.77192371126783)
})
-test('sample data for DKN R-320 should produce plausible results', async () => {
+/**
+ * @description Test behaviour for the DKN R-320
+ */
+test('DKN_R320_01: 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)
- testRecoveryDragFactor(rower, rowerProfiles.DKN_R320.dragFactor)
+ testRecoveryDragFactor(rower, rowerProfiles.DKN_R320.dragFactor) // As dragFactor is static, it should be known at initialisation
await replayRowingSession(rower.handleRotationImpulse, { filename: 'recordings/DKNR320.csv', realtime: false, loop: false })
testTotalMovingTimeSinceStart(rower, 21.701535821)
- testTotalLinearDistanceSinceStart(rower, 70.11298001986664)
+ testTotalLinearDistanceSinceStart(rower, 69.20242183779045)
testTotalNumberOfStrokes(rower, 10)
- // As dragFactor is static, it should remain in place
- testRecoveryDragFactor(rower, rowerProfiles.DKN_R320.dragFactor)
+ testCycleDuration(rower, 2.2897938159999995)
+ testCycleLinearDistance(rower, 7.284465456609514)
+ testCycleLinearVelocity(rower, 3.181275713869568)
+ testCyclePower(rower, 90.14921752119831)
+ testDriveDuration(rower, 1.2778628719999965)
+ testDriveLinearDistance(rower, 3.642232728304757)
+ testDriveLength(rower, 1.7592918860102824)
+ testDriveAverageHandleForce(rower, 385.62555350976965)
+ testDrivePeakHandleForce(rower, 604.6392224523523)
+ testRecoveryDuration(rower, 1.011930944000003)
+ testInstantHandlePower(rower, 0)
+ testRecoveryDragFactor(rower, rowerProfiles.DKN_R320.dragFactor) // As dragFactor is static, it should remain in place
})
-test('sample data for NordicTrack RX800 should produce plausible results', async () => {
+/**
+ * @description Test behaviour for the NordicTrack RX800
+ */
+test('NordicT_RX800_01: 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, rowerProfiles.NordicTrack_RX800.dragFactor)
+ testRecoveryDragFactor(rower, undefined)
await replayRowingSession(rower.handleRotationImpulse, { filename: 'recordings/RX800.csv', realtime: false, loop: false })
- testTotalMovingTimeSinceStart(rower, 17.389910236000024)
- testTotalLinearDistanceSinceStart(rower, 62.052936751782944)
- testTotalNumberOfStrokes(rower, 8)
- // As dragFactor is dynamic, it should have changed
- testRecoveryDragFactor(rower, 486.702741763346)
+ testTotalMovingTimeSinceStart(rower, 22.368358745999995)
+ testTotalLinearDistanceSinceStart(rower, 80.8365747440095)
+ testTotalNumberOfStrokes(rower, 10)
+ testCycleDuration(rower, 2.237534478999997)
+ testCycleLinearDistance(rower, 7.751801481871587)
+ testCycleLinearVelocity(rower, 3.621913392059043)
+ testCyclePower(rower, 133.0369301265531)
+ testDriveDuration(rower, 0.6251555590000031)
+ testDriveLinearDistance(rower, 2.290304983280253)
+ testDriveLength(rower, 1.2252211349000253)
+ testDriveAverageHandleForce(rower, 242.21443734968256)
+ testDrivePeakHandleForce(rower, 389.6437952657745)
+ testRecoveryDuration(rower, 1.612378919999994)
+ testInstantHandlePower(rower, 0)
+ testRecoveryDragFactor(rower, 493.8082148322739)
+})
+
+/**
+ * @description Test behaviour for the Merarch R50
+ */
+test('Merarch_R50_01: sample data for Merarch R50 should produce plausible results', async () => {
+ const rower = createRower(deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Merach_R50))
+ testTotalMovingTimeSinceStart(rower, 0)
+ testTotalLinearDistanceSinceStart(rower, 0)
+ testTotalNumberOfStrokes(rower, 0)
+ testRecoveryDragFactor(rower, undefined)
+
+ await replayRowingSession(rower.handleRotationImpulse, { filename: 'recordings/Merach_R50_510m.csv', realtime: false, loop: false })
+
+ testTotalMovingTimeSinceStart(rower, 129.30676700000004)
+ testTotalLinearDistanceSinceStart(rower, 511.02296249261553)
+ testTotalNumberOfStrokes(rower, 50)
+ testCycleDuration(rower, 6.080085000000054)
+ testCycleLinearDistance(rower, 21.069134297647764)
+ testCycleLinearVelocity(rower, 3.465046801046821)
+ testCyclePower(rower, 116.48911500649851)
+ testDriveDuration(rower, 0.8127870000000712)
+ testDriveLinearDistance(rower, 3.327846651885306)
+ testDriveLength(rower, 1.347743248390033)
+ testDriveAverageHandleForce(rower, 367.91178083645434)
+ testDrivePeakHandleForce(rower, 672.9842122409556)
+ testRecoveryDuration(rower, 5.2672979999999825)
+ testInstantHandlePower(rower, 0)
+ testRecoveryDragFactor(rower, 123.11298950726905)
+})
+
+/**
+ * @description Test behaviour for the Oartec Slider
+ */
+test('Oartec_Slider_01: sample data for Oartec Slider should produce plausible results', async () => {
+ const rower = createRower(deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Oartec_Slider))
+ testTotalMovingTimeSinceStart(rower, 0)
+ testTotalLinearDistanceSinceStart(rower, 0)
+ testTotalNumberOfStrokes(rower, 0)
+ testRecoveryDragFactor(rower, undefined)
+
+ await replayRowingSession(rower.handleRotationImpulse, { filename: 'recordings/Oartec_Slider.csv', realtime: false, loop: false })
+
+ testTotalMovingTimeSinceStart(rower, 58.32198919882932)
+ testTotalLinearDistanceSinceStart(rower, 195.17761419351442)
+ testTotalNumberOfStrokes(rower, 14)
+ testCycleDuration(rower, 13.371270849084922)
+ testCycleLinearDistance(rower, 33.36960390678204)
+ testCycleLinearVelocity(rower, 2.4956847109450653)
+ testCyclePower(rower, 43.52383815572541)
+ testDriveDuration(rower, 1.1030650912501372)
+ testDriveLinearDistance(rower, 4.294337493820962)
+ testDriveLength(rower, 1.5408380992502635)
+ testDriveAverageHandleForce(rower, 381.3323961655147)
+ testDrivePeakHandleForce(rower, 541.5104462470034)
+ testRecoveryDuration(rower, 12.268205757834785)
+ testInstantHandlePower(rower, 0)
+ testRecoveryDragFactor(rower, 171.76129602250094)
+})
+
+/**
+ * @description Test behaviour for the Schwinn Windrigger
+ */
+test('Schwinn_Wndrggr_01: sample data for Schwinn Windrigger should produce plausible results', async () => {
+ const rower = createRower(deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Schwinn_Windrigger))
+ testTotalMovingTimeSinceStart(rower, 0)
+ testTotalLinearDistanceSinceStart(rower, 0)
+ testTotalNumberOfStrokes(rower, 0)
+ testRecoveryDragFactor(rower, undefined)
+
+ await replayRowingSession(rower.handleRotationImpulse, { filename: 'recordings/Schwinn_Windrigger.csv', realtime: false, loop: false })
+
+ testTotalMovingTimeSinceStart(rower, 120.58333099999997)
+ testTotalLinearDistanceSinceStart(rower, 432.11617011823284)
+ testTotalNumberOfStrokes(rower, 47)
+ testCycleDuration(rower, 9.136834999999948)
+ testCycleLinearDistance(rower, 18.08516640554577)
+ testCycleLinearVelocity(rower, 1.9838607607875394)
+ testCyclePower(rower, 21.862085772339665)
+ testDriveDuration(rower, 0.6514299999999906)
+ testDriveLinearDistance(rower, 1.9110111872560942)
+ testDriveLength(rower, 0.6905220652590236)
+ testDriveAverageHandleForce(rower, 245.6040207461455)
+ testDrivePeakHandleForce(rower, 356.88032010420187)
+ testRecoveryDuration(rower, 8.485404999999957)
+ testInstantHandlePower(rower, 0)
+ testRecoveryDragFactor(rower, 244.79315251519714)
+})
+
+/**
+ * @description Test behaviour for the Sportstech WRX700
+ */
+test('Sportstech_WRX700_01: 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) // As dragFactor is static, it should be known at initialisation
+
+ await replayRowingSession(rower.handleRotationImpulse, { filename: 'recordings/WRX700_2magnets.csv', realtime: false, loop: false })
+
+ testTotalMovingTimeSinceStart(rower, 46.302522627)
+ testTotalLinearDistanceSinceStart(rower, 165.58832475070278)
+ testTotalNumberOfStrokes(rower, 16)
+ testCycleDuration(rower, 2.629863708000002)
+ testCycleLinearDistance(rower, 12.02992102889722)
+ testCycleLinearVelocity(rower, 4.03619251053907)
+ testCyclePower(rower, 184.10841872694715)
+ testDriveDuration(rower, 1.297822779999997)
+ testDriveLinearDistance(rower, 5.661139307716341)
+ testDriveLength(rower, 1.7592918860102864)
+ testDriveAverageHandleForce(rower, 347.2288656270118)
+ testDrivePeakHandleForce(rower, 634.0186435697934)
+ testRecoveryDuration(rower, 1.332040928000005)
+ testInstantHandlePower(rower, 0)
+ testRecoveryDragFactor(rower, rowerProfiles.Sportstech_WRX700.dragFactor) // As dragFactor is static, it should remain in place
})
-test('A full session for SportsTech WRX700 should produce plausible results', async () => {
+/**
+ * @description Test behaviour for the SportsTech WRX700 in a full session
+ */
+test('Sportstech_WRX700_02: 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)
+ testRecoveryDragFactor(rower, rowerProfiles.Sportstech_WRX700.dragFactor) // As dragFactor is static, it should be known at initialisation
await replayRowingSession(rower.handleRotationImpulse, { filename: 'recordings/WRX700_2magnets_session.csv', realtime: false, loop: false })
- testTotalMovingTimeSinceStart(rower, 2342.241183077012)
- testTotalLinearDistanceSinceStart(rower, 8408.914799199298)
+ testTotalMovingTimeSinceStart(rower, 2340.0100514160117)
+ testTotalLinearDistanceSinceStart(rower, 8406.084229545408)
testTotalNumberOfStrokes(rower, 846)
- // As dragFactor is static, it should remain in place
- testRecoveryDragFactor(rower, rowerProfiles.Sportstech_WRX700.dragFactor)
+ testCycleDuration(rower, 2.64479661400037)
+ testCycleLinearDistance(rower, 9.906993788503808)
+ testCycleLinearVelocity(rower, 3.47828310363887)
+ testCyclePower(rower, 117.82916841975236)
+ testDriveDuration(rower, 1.3936550620001071)
+ testDriveLinearDistance(rower, 4.953496894251904)
+ testDriveLength(rower, 1.5393804002590334)
+ testDriveAverageHandleForce(rower, 210.6300325410566)
+ testDrivePeakHandleForce(rower, 352.68327935116827)
+ testRecoveryDuration(rower, 1.251141552000263)
+ testInstantHandlePower(rower, 0)
+ testRecoveryDragFactor(rower, rowerProfiles.Sportstech_WRX700.dragFactor) // As dragFactor is static, it should remain in place
})
-test('A full session for a Concept2 RowErg should produce plausible results', async () => {
- const rower = createRower(deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Concept2_RowErg))
+/**
+ * @description Test behaviour for the Topiom V2
+ */
+test('TopiomV2_01: sample data for Topiom V2 should produce plausible results', async () => {
+ const rower = createRower(deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Topiom_V2))
testTotalMovingTimeSinceStart(rower, 0)
testTotalLinearDistanceSinceStart(rower, 0)
testTotalNumberOfStrokes(rower, 0)
- testRecoveryDragFactor(rower, rowerProfiles.Concept2_RowErg.dragFactor)
-
- await replayRowingSession(rower.handleRotationImpulse, { filename: 'recordings/Concept2_RowErg_Session_2000meters.csv', realtime: false, loop: false })
-
- testTotalMovingTimeSinceStart(rower, 590.4201840000001)
- testTotalLinearDistanceSinceStart(rower, 2030.6574002852396)
- testTotalNumberOfStrokes(rower, 206)
- // As dragFactor isn't static, it should be updated
- testRecoveryDragFactor(rower, 80.81243631988698)
+ testRecoveryDragFactor(rower, rowerProfiles.Topiom_V2.dragFactor)
+
+ await replayRowingSession(rower.handleRotationImpulse, { filename: 'recordings/Topiom_V2_1magnet.csv', realtime: false, loop: false })
+
+ testTotalMovingTimeSinceStart(rower, 2443.938449999993)
+ testTotalLinearDistanceSinceStart(rower, 9785.826483676927)
+ testTotalNumberOfStrokes(rower, 1201)
+ testCycleDuration(rower, 6.065000999999484)
+ testCycleLinearDistance(rower, 16.480858179530163)
+ testCycleLinearVelocity(rower, 2.717371057239985)
+ testCyclePower(rower, 56.182992510450624)
+ testDriveDuration(rower, 1.0403479999999945)
+ testDriveLinearDistance(rower, 3.8032749645067345)
+ testDriveLength(rower, 1.3194689145075973)
+ testDriveAverageHandleForce(rower, 140.7915517540433)
+ testDrivePeakHandleForce(rower, 207.94182032146372)
+ testRecoveryDuration(rower, 5.024652999999489)
+ testInstantHandlePower(rower, 0)
+ testRecoveryDragFactor(rower, rowerProfiles.Topiom_V2.dragFactor)
})
function testStrokeState (rower, expectedValue) {
@@ -510,10 +788,8 @@ function testInstantHandlePower (rower, expectedValue) {
assert.ok(rower.instantHandlePower() === expectedValue, `instantHandlePower should be ${expectedValue} Watt at ${rower.totalMovingTimeSinceStart()} sec, is ${rower.instantHandlePower()}`)
}
-/*
-function reportAll (rower) {
- assert.ok(0, `time: ${rower.totalMovingTimeSinceStart()}, state ${rower.strokeState()}, No Strokes: ${rower.totalNumberOfStrokes()}, 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()}`)
+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/RowingStatistics.js b/app/engine/RowingStatistics.js
index 8249b3d56f..4158d08c1f 100644
--- a/app/engine/RowingStatistics.js
+++ b/app/engine/RowingStatistics.js
@@ -1,177 +1,72 @@
'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- This Module calculates the training specific metrics.
-*/
-import { EventEmitter } from 'events'
+/**
+ * @copyright {@link https://github.com/JaapvanEkris/openrowingmonitor|OpenRowingMonitor}
+ *
+ * @file This Module creates a persistent, consistent and user presentable set of metrics.
+ * @see {@link https://github.com/JaapvanEkris/openrowingmonitor/blob/main/docs/Architecture.md#rowingstatisticsjs|the architecture description}
+ */
import { createRower } from './Rower.js'
-import { createOLSLinearSeries } from './utils/OLSLinearSeries.js'
+import { createWLSLinearSeries } from './utils/WLSLinearSeries.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, session) {
- const numOfDataPointsForAveraging = config.numOfPhasesForAveragingScreenData
- const webUpdateInterval = Math.min(config.webUpdateInterval, 2000)
- const peripheralUpdateInterval = Math.min(config.peripheralUpdateInterval, 1000)
- const emitter = new EventEmitter()
+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.rowerSettings.maximumStrokeTimeBeforePause
- const cycleDuration = createStreamFilter(numOfDataPointsForAveraging, (minimumStrokeTime + maximumStrokeTime) / 2)
- const cycleDistance = createStreamFilter(numOfDataPointsForAveraging, 0)
- const cyclePower = createStreamFilter(numOfDataPointsForAveraging, 0)
- const cycleLinearVelocity = createStreamFilter(numOfDataPointsForAveraging, 0)
- let sessionStatus = 'WaitingForStart'
- let heartrateResetTimer
+ 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 = 0
+ let totalNumberOfStrokes = -1
let driveLastStartTime = 0
let strokeCalories = 0
+ let totalCalories = 0
let strokeWork = 0
- const calories = createOLSLinearSeries()
- const distanceOverTime = createOLSLinearSeries(Math.min(4, numOfDataPointsForAveraging))
- const driveDuration = createStreamFilter(numOfDataPointsForAveraging, config.rowerSettings.minimumDriveTime)
- const driveLength = createStreamFilter(numOfDataPointsForAveraging, 1.1)
- const driveDistance = createStreamFilter(numOfDataPointsForAveraging, 3)
- const recoveryDuration = createStreamFilter(numOfDataPointsForAveraging, config.rowerSettings.minimumRecoveryTime)
- const driveAverageHandleForce = createStreamFilter(numOfDataPointsForAveraging, 0.0)
- const drivePeakHandleForce = createStreamFilter(numOfDataPointsForAveraging, 0.0)
- const driveHandleForceCurve = createCurveAligner(config.rowerSettings.minumumForceBeforeStroke)
+ let totalWork = 0
+ const calories = createWLSLinearSeries()
+ 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 drivePeakHandleForceNormalizedPosition = createStreamFilter(halfNumOfDataPointsForAveraging, undefined)
+ const driveHandleForceCurve = createCurveAligner(config.rowerSettings.minimumForceBeforeStroke)
const driveHandleVelocityCurve = createCurveAligner(1.0)
const driveHandlePowerCurve = createCurveAligner(50)
- let dragFactor = config.rowerSettings.dragFactor
- let heartrate = 0
- let heartrateBatteryLevel = 0
- const postExerciseHR = []
+ let dragFactor
let instantPower = 0.0
let lastStrokeState = 'WaitingForDrive'
- // send metrics to the web clients periodically
- setInterval(emitWebMetrics, webUpdateInterval)
-
- // 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, peripheralUpdateInterval)
-
- function handleRotationImpulse (currentDt) {
- // Provide the rower with new data
- rower.handleRotationImpulse(currentDt)
-
- // This is the core of the finite state machine that defines all state transitions
- switch (true) {
- case (sessionStatus === 'WaitingForStart' && rower.strokeState() === 'Drive'):
- sessionStatus = 'Rowing'
- startTraining()
- updateContinousMetrics()
- emitMetrics('recoveryFinished')
- break
- case (sessionStatus === 'Paused' && rower.strokeState() === 'Drive'):
- sessionStatus = 'Rowing'
- resumeTraining()
- updateContinousMetrics()
- emitMetrics('recoveryFinished')
- break
- case (sessionStatus !== 'Stopped' && rower.strokeState() === 'Stopped'):
- sessionStatus = 'Stopped'
- // We need to emit the metrics AFTER the sessionstatus changes to anything other than "Rowing", which forces most merics to zero
- // This is intended behaviour, as the rower/flywheel indicate the rower has stopped somehow
- stopTraining()
- break
- case (sessionStatus === 'Rowing' && rower.strokeState() === 'WaitingForDrive'):
- sessionStatus = 'Paused'
- pauseTraining()
- break
- case (sessionStatus === 'Rowing' && lastStrokeState === 'Recovery' && rower.strokeState() === 'Drive' && intervalTargetReached()):
- updateContinousMetrics()
- updateCycleMetrics()
- handleRecoveryEnd()
- emitMetrics('intervalTargetReached')
- break
- case (sessionStatus === 'Rowing' && lastStrokeState === 'Recovery' && rower.strokeState() === 'Drive'):
- updateContinousMetrics()
- updateCycleMetrics()
- handleRecoveryEnd()
- emitMetrics('recoveryFinished')
- break
- case (sessionStatus === 'Rowing' && lastStrokeState === 'Drive' && rower.strokeState() === 'Recovery' && intervalTargetReached()):
- updateContinousMetrics()
- updateCycleMetrics()
- handleDriveEnd()
- emitMetrics('intervalTargetReached')
- break
- case (sessionStatus === 'Rowing' && lastStrokeState === 'Drive' && rower.strokeState() === 'Recovery'):
- updateContinousMetrics()
- updateCycleMetrics()
- handleDriveEnd()
- emitMetrics('driveFinished')
- break
- case (sessionStatus === 'Rowing' && intervalTargetReached()):
- updateContinousMetrics()
- emitMetrics('intervalTargetReached')
- break
- case (sessionStatus === 'Rowing'):
- updateContinousMetrics()
- break
- case (sessionStatus === 'Paused'):
- // We are in a paused state, we won't update any metrics
- break
- case (sessionStatus === 'WaitingForStart'):
- // We can't change into the "Rowing" state since we are waiting for a drive phase that didn't come
- break
- case (sessionStatus === 'Stopped'):
- // We are in a stopped state, so we won't update any metrics
- break
- default:
- log.error(`Time: ${rower.totalMovingTimeSinceStart()}, state ${rower.strokeState()} found in the Rowing Statistics, which is not captured by Finite State Machine`)
- }
- lastStrokeState = rower.strokeState()
- }
-
- function startTraining () {
- rower.allowMovement()
- }
-
- function allowResumeTraining () {
- rower.allowMovement()
- sessionStatus = 'WaitingForStart'
- }
+ resetMetricsContext()
- function resumeTraining () {
+ function allowStartOrResumeTraining () {
rower.allowMovement()
}
function stopTraining () {
rower.stopMoving()
lastStrokeState = 'Stopped'
- // Emitting the metrics BEFORE the sessionstatus changes to anything other than "Rowing" forces most merics to zero
- // As there are more than one way to this method, we FIRST emit the metrics and then set them to zero
- // If they need to be forced to zero (as the flywheel seems to have stopped), this status has to be set before the call
- emitMetrics('rowingStopped')
- sessionStatus = 'Stopped'
- postExerciseHR.splice(0, postExerciseHR.length)
- measureRecoveryHR()
}
// clear the metrics in case the user pauses rowing
function pauseTraining () {
- log.debug('*** Paused rowing ***')
rower.pauseMoving()
+ metricsContext.isMoving = false
cycleDuration.reset()
cycleDistance.reset()
cyclePower.reset()
cycleLinearVelocity.reset()
lastStrokeState = 'WaitingForDrive'
- // We need to emit the metrics BEFORE the sessionstatus changes to anything other than "Rowing", as it forces most merics to zero
- emitMetrics('rowingPaused')
- sessionStatus = 'Paused'
- postExerciseHR.splice(0, postExerciseHR.length)
- measureRecoveryHR()
}
function resetTraining () {
@@ -183,30 +78,121 @@ function createRowingStatistics (config, session) {
totalLinearDistance = 0.0
totalNumberOfStrokes = -1
driveLastStartTime = 0
- distanceOverTime.reset()
driveDuration.reset()
+ recoveryDuration.reset()
+ driveLength.reset()
+ driveDistance.reset()
+ driveAverageHandleForce.reset()
+ drivePeakHandleForce.reset()
+ drivePeakHandleForceNormalizedPosition.reset()
+ driveHandleForceCurve.reset()
+ driveHandleVelocityCurve.reset()
+ driveHandlePowerCurve.reset()
cycleDuration.reset()
cycleDistance.reset()
cyclePower.reset()
+ totalCalories = 0
strokeCalories = 0
+ totalWork = 0
strokeWork = 0
- postExerciseHR.splice(0, postExerciseHR.length)
+ dragFactor = undefined
cycleLinearVelocity.reset()
lastStrokeState = 'WaitingForDrive'
- emitMetrics('rowingPaused')
- sessionStatus = 'WaitingForStart'
+ resetMetricsContext()
+ }
+
+ /**
+ * 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()
+ }
+
+ // Basic metricContext structure
+ function resetMetricsContext () {
+ metricsContext = {
+ isMoving: false,
+ isDriveStart: false,
+ isRecoveryStart: false
+ }
}
// initiated when updating key statistics
function updateContinousMetrics () {
- totalMovingTime = rower.totalMovingTimeSinceStart()
- totalLinearDistance = rower.totalLinearDistanceSinceStart()
+ totalMovingTime = Math.max(totalMovingTime, rower.totalMovingTimeSinceStart())
+ totalLinearDistance = Math.max(totalLinearDistance, rower.totalLinearDistanceSinceStart())
instantPower = rower.instantHandlePower()
+ totalWork = Math.max(totalWork, rower.totalFlywheelWorkSinceStart())
+ totalCalories = ((4 * totalWork) + (350 * totalMovingTime)) / 4200
}
function updateCycleMetrics () {
- distanceOverTime.push(rower.totalMovingTimeSinceStart(), rower.totalLinearDistanceSinceStart())
- if (rower.cycleDuration() < maximumStrokeTime && rower.cycleDuration() > minimumStrokeTime) {
+ 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())
@@ -218,133 +204,80 @@ function createRowingStatistics (config, session) {
}
function handleDriveEnd () {
- 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())
+ if (rower.driveDuration() !== undefined) {
+ driveDuration.push(rower.driveDuration())
+ driveLength.push(rower.driveLength())
+ driveDistance.push(rower.driveLinearDistance())
+ driveAverageHandleForce.push(rower.driveAverageHandleForce())
+ drivePeakHandleForce.push(rower.drivePeakHandleForce())
+ drivePeakHandleForceNormalizedPosition.push(rower.drivePeakHandleForceNormalizedPosition())
+ driveHandleForceCurve.push(rower.driveHandleForceCurve())
+ driveHandleVelocityCurve.push(rower.driveHandleVelocityCurve())
+ driveHandlePowerCurve.push(rower.driveHandlePowerCurve())
+ // based on: http://eodg.atm.ox.ac.uk/user/dudhia/rowing/physics/ergometer.html#section11
+ strokeWork = rower.driveFlywheelWork()
+ strokeCalories = ((4 * rower.driveFlywheelWork()) + (350 * cycleDuration.clean())) / 4200
+ if (cyclePower.reliable() && cycleDuration.reliable()) {
+ calories.push(totalMovingTime, totalCalories, 1)
+ }
+ }
}
// initiated when the stroke state changes
function handleRecoveryEnd () {
totalNumberOfStrokes = rower.totalNumberOfStrokes()
driveLastStartTime = rower.driveLastStartTime()
- recoveryDuration.push(rower.recoveryDuration())
- dragFactor = rower.recoveryDragFactor()
-
- // 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.yAtSeriesEnd() + strokeCalories
- calories.push(totalMovingTime, totalCalories)
- }
-
- // 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 intervalTargetReached () {
- if ((session.targetDistance > 0 && rower.totalLinearDistanceSinceStart() >= session.targetDistance) || (session.targetTime > 0 && rower.totalMovingTimeSinceStart() >= session.targetTime)) {
- return true
+ if (rower.recoveryDuration() !== undefined) {
+ recoveryDuration.push(rower.recoveryDuration())
+ }
+ if (rower.recoveryDuration() !== undefined && rower.recoveryDragFactor() !== undefined) {
+ dragFactor = rower.recoveryDragFactor()
} else {
- return false
+ dragFactor = undefined
}
- }
- function measureRecoveryHR () {
- // This function is called when the rowing session is stopped. postExerciseHR[0] is the last measured excercise HR
- // Thus postExerciseHR[1] is Recovery HR after 1 min, etc..
- if (heartrate !== undefined && heartrate > config.userSettings.restingHR && sessionStatus !== 'Rowing') {
- log.debug(`*** HRR-${postExerciseHR.length}: ${heartrate}`)
- postExerciseHR.push(heartrate)
- if ((postExerciseHR.length > 1) && (postExerciseHR.length <= 4)) {
- // We skip reporting postExerciseHR[0] and only report measuring postExerciseHR[1], postExerciseHR[2], postExerciseHR[3]
- emitter.emit('HRRecoveryUpdate', postExerciseHR)
- }
- if (postExerciseHR.length < 4) {
- // We haven't got three post-exercise HR measurements yet, let's schedule the next measurement
- setTimeout(measureRecoveryHR, 60000)
- }
+ if (cyclePower.reliable() && cycleDuration.reliable()) {
+ calories.push(totalMovingTime, totalCalories, 1)
}
}
- function emitWebMetrics () {
- emitMetrics('webMetricsUpdate')
- }
-
- function emitPeripheralMetrics () {
- emitMetrics('peripheralMetricsUpdate')
- }
-
- function emitMetrics (emitType = 'webMetricsUpdate') {
- emitter.emit(emitType, getMetrics())
- }
-
- function getMetrics () {
- const cyclePace = cycleLinearVelocity.clean() !== 0 && cycleLinearVelocity.raw() > 0 && sessionStatus === 'Rowing' ? (500.0 / cycleLinearVelocity.clean()) : Infinity
+ /* 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 {
- sessiontype: session.targetDistance > 0 ? 'Distance' : (session.targetTime > 0 ? 'Time' : 'JustRow'),
- sessionStatus,
+ metricsContext,
strokeState: rower.strokeState(),
totalMovingTime: totalMovingTime > 0 ? totalMovingTime : 0,
- driveLastStartTime: driveLastStartTime > 0 ? driveLastStartTime : 0,
- totalMovingTimeFormatted: session.targetTime > 0 ? secondsToTimeString(Math.round(Math.max(session.targetTime - totalMovingTime, 0))) : secondsToTimeString(Math.round(totalMovingTime)),
totalNumberOfStrokes: totalNumberOfStrokes > 0 ? totalNumberOfStrokes : 0,
totalLinearDistance: totalLinearDistance > 0 ? totalLinearDistance : 0, // meters
- totalLinearDistanceFormatted: session.targetDistance > 0 ? Math.max(session.targetDistance - totalLinearDistance, 0) : totalLinearDistance,
- strokeCalories: strokeCalories > 0 ? strokeCalories : 0, // kCal
- strokeWork: strokeWork > 0 ? strokeWork : 0, // Joules
- totalCalories: calories.yAtSeriesEnd() > 0 ? calories.yAtSeriesEnd() : 0, // kcal
+ totalWork: totalWork > 0 ? totalWork : 0, // Joules
+ strokeCalories: strokeCalories > 0 && metricsContext.isMoving === true ? strokeCalories : undefined, // kCal
+ strokeWork: strokeWork > 0 && metricsContext.isMoving === true ? strokeWork : undefined, // Joules
+ totalCalories: totalCalories > 0 ? totalCalories : 0, // kcal
totalCaloriesPerMinute: totalMovingTime > 60 ? caloriesPerPeriod(totalMovingTime - 60, totalMovingTime) : caloriesPerPeriod(0, 60),
totalCaloriesPerHour: totalMovingTime > 3600 ? caloriesPerPeriod(totalMovingTime - 3600, totalMovingTime) : caloriesPerPeriod(0, 3600),
- cycleDuration: cycleDuration.clean() > minimumStrokeTime && cycleDuration.clean() < maximumStrokeTime && cycleLinearVelocity.raw() > 0 && sessionStatus === 'Rowing' ? cycleDuration.clean() : NaN, // seconds
- cycleStrokeRate: cycleDuration.clean() > minimumStrokeTime && cycleLinearVelocity.raw() > 0 && sessionStatus === 'Rowing' ? (60.0 / cycleDuration.clean()) : 0, // strokeRate in SPM
- cycleDistance: cycleDistance.raw() > 0 && cycleLinearVelocity.raw() > 0 && sessionStatus === 'Rowing' ? cycleDistance.clean() : 0, // meters
- cycleLinearVelocity: cycleLinearVelocity.clean() > 0 && cycleLinearVelocity.raw() > 0 && sessionStatus === 'Rowing' ? cycleLinearVelocity.clean() : 0, // m/s
- cyclePace: cycleLinearVelocity.raw() > 0 ? cyclePace : Infinity, // seconds/500m
- cyclePaceFormatted: cycleLinearVelocity.raw() > 0 ? secondsToTimeString(Math.round(cyclePace)) : Infinity,
- cyclePower: cyclePower.clean() > 0 && cycleLinearVelocity.raw() > 0 && sessionStatus === 'Rowing' ? cyclePower.clean() : 0, // watts
- cycleProjectedEndTime: session.targetDistance > 0 ? distanceOverTime.projectY(session.targetDistance) : session.targetTime,
- cycleProjectedEndLinearDistance: session.targetTime > 0 ? distanceOverTime.projectX(session.targetTime) : session.targetDistance,
- driveDuration: driveDuration.clean() >= config.rowerSettings.minimumDriveTime && totalNumberOfStrokes > 0 && sessionStatus === 'Rowing' ? driveDuration.clean() : NaN, // seconds
- driveLength: driveLength.clean() > 0 && sessionStatus === 'Rowing' ? driveLength.clean() : NaN, // meters of chain movement
- driveDistance: driveDistance.clean() >= 0 && sessionStatus === 'Rowing' ? driveDistance.clean() : NaN, // meters
- driveAverageHandleForce: driveAverageHandleForce.clean() > 0 && sessionStatus === 'Rowing' ? driveAverageHandleForce.clean() : NaN,
- drivePeakHandleForce: drivePeakHandleForce.clean() > 0 && sessionStatus === 'Rowing' ? drivePeakHandleForce.clean() : NaN,
- driveHandleForceCurve: drivePeakHandleForce.clean() > 0 && sessionStatus === 'Rowing' ? driveHandleForceCurve.lastCompleteCurve() : [NaN],
- driveHandleVelocityCurve: drivePeakHandleForce.clean() > 0 && sessionStatus === 'Rowing' ? driveHandleVelocityCurve.lastCompleteCurve() : [NaN],
- driveHandlePowerCurve: drivePeakHandleForce.clean() > 0 && sessionStatus === 'Rowing' ? driveHandlePowerCurve.lastCompleteCurve() : [NaN],
- recoveryDuration: recoveryDuration.clean() >= config.rowerSettings.minimumRecoveryTime && totalNumberOfStrokes > 0 && sessionStatus === 'Rowing' ? recoveryDuration.clean() : NaN, // seconds
- dragFactor: dragFactor > 0 ? dragFactor : config.rowerSettings.dragFactor, // Dragfactor
- instantPower: instantPower > 0 && rower.strokeState() === 'Drive' ? instantPower : 0,
- heartrate: heartrate > 30 ? heartrate : undefined,
- heartrateBatteryLevel: heartrateBatteryLevel > 0 ? heartrateBatteryLevel : undefined // BE AWARE, changing undefined to NaN kills the GUI!!!
- }
- }
-
- // 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)
- if (hours > 0) {
- return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
- } else {
- return `${minutes}:${seconds.toString().padStart(2, '0')}`
+ 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,
+ drivePeakHandleForceNormalizedPosition: drivePeakHandleForceNormalizedPosition.clean() > 0 && metricsContext.isMoving === true ? drivePeakHandleForceNormalizedPosition.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 */
function caloriesPerPeriod (periodBegin, periodEnd) {
const beginCalories = calories.projectX(periodBegin)
@@ -352,14 +285,12 @@ function createRowingStatistics (config, session) {
return (endCalories - beginCalories)
}
- return Object.assign(emitter, {
- handleHeartrateMeasurement,
+ return {
handleRotationImpulse,
- pause: pauseTraining,
- stop: stopTraining,
- resume: allowResumeTraining,
- reset: resetTraining
- })
+ allowStartOrResumeTraining,
+ stopTraining,
+ pauseTraining,
+ resetTraining,
+ getMetrics: allMetrics
+ }
}
-
-export { createRowingStatistics }
diff --git a/app/engine/RowingStatistics.test.js b/app/engine/RowingStatistics.test.js
new file mode 100644
index 0000000000..0ef7d4e800
--- /dev/null
+++ b/app/engine/RowingStatistics.test.js
@@ -0,0 +1,1125 @@
+'use strict'
+/**
+ * @copyright {@link https://github.com/JaapvanEkris/openrowingmonitor|OpenRowingMonitor}
+ *
+ * @file This test is a test of the Rowingstatistics object, that tests wether this object fills all fields correctly, given one validated rower, (the
+ * Concept2 RowErg) using a validated cycle of strokes. The combination with all possible known rowers is tested.
+ */
+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,
+ systematicErrorAgressiveness: 0,
+ systematicErrorNumberOfDatapoints: 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
+ }
+}
+
+/**
+ * @description Test behaviour for no datapoints
+ */
+test('Init_01: 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)
+})
+
+/**
+ * @todo Test behaviour for one datapoint
+ */
+
+/**
+ * @description Test behaviour for three perfect identical strokes, including settingling behaviour of metrics
+ */
+test('Theoretical_01: 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.2135951659708087)
+ 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.7475830808978304)
+ testTotalNumberOfStrokes(rowingStatistics, 0)
+ testCycleDuration(rowingStatistics, undefined)
+ testCycleDistance(rowingStatistics, undefined)
+ testCycleLinearVelocity(rowingStatistics, undefined)
+ testCyclePower(rowingStatistics, undefined)
+ testDriveDuration(rowingStatistics, undefined)
+ testDriveDistance(rowingStatistics, 0.4271903319416174)
+ testDriveLength(rowingStatistics, 0.1759291886010284)
+ testDriveAverageHandleForce(rowingStatistics, 276.6342676838739)
+ testDrivePeakHandleForce(rowingStatistics, 332.99182222129025)
+ 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.790756456114694)
+ testTotalNumberOfStrokes(rowingStatistics, 1)
+ testCycleDuration(rowingStatistics, undefined)
+ testCycleDistance(rowingStatistics, undefined)
+ testCycleLinearVelocity(rowingStatistics, undefined)
+ testCyclePower(rowingStatistics, undefined)
+ testDriveDuration(rowingStatistics, 0.143485717)
+ testDriveDistance(rowingStatistics, 0.4271903319416174)
+ testDriveLength(rowingStatistics, 0.1759291886010284)
+ testDriveAverageHandleForce(rowingStatistics, 276.6342676838739)
+ testDrivePeakHandleForce(rowingStatistics, 332.99182222129025)
+ testRecoveryDuration(rowingStatistics, 0.21654112800000003)
+ testDragFactor(rowingStatistics, 281.5961372923874)
+ 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.521238308350271)
+ testTotalNumberOfStrokes(rowingStatistics, 1)
+ testCycleDuration(rowingStatistics, undefined)
+ testCycleDistance(rowingStatistics, undefined)
+ testCycleLinearVelocity(rowingStatistics, undefined)
+ testCyclePower(rowingStatistics, undefined)
+ testDriveDuration(rowingStatistics, 0.22872752900000004)
+ testDriveDistance(rowingStatistics, 1.0226745931298076)
+ testDriveLength(rowingStatistics, 0.3078760800517996)
+ testDriveAverageHandleForce(rowingStatistics, 288.45140756259053)
+ testDrivePeakHandleForce(rowingStatistics, 447.108514349131)
+ testRecoveryDuration(rowingStatistics, 0.21654112800000003)
+ testDragFactor(rowingStatistics, 281.5961372923874)
+ 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.4465153211820017)
+ testTotalNumberOfStrokes(rowingStatistics, 2)
+ testCycleDuration(rowingStatistics, undefined)
+ testCycleDistance(rowingStatistics, undefined)
+ testCycleLinearVelocity(rowingStatistics, undefined)
+ testCyclePower(rowingStatistics, undefined)
+ testDriveDuration(rowingStatistics, 0.22872752900000004)
+ testDriveDistance(rowingStatistics, 1.0226745931298076)
+ testDriveLength(rowingStatistics, 0.3078760800517996)
+ testDriveAverageHandleForce(rowingStatistics, 288.45140756259053)
+ testDrivePeakHandleForce(rowingStatistics, 447.108514349131)
+ testRecoveryDuration(rowingStatistics, 0.09812447700000015)
+ testDragFactor(rowingStatistics, 281.5961372923874)
+ 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.176997173417578)
+ testTotalNumberOfStrokes(rowingStatistics, 2)
+ testCycleDuration(rowingStatistics, undefined)
+ testCycleDistance(rowingStatistics, undefined)
+ testCycleLinearVelocity(rowingStatistics, undefined)
+ testCyclePower(rowingStatistics, undefined)
+ testDriveDuration(rowingStatistics, 0.27311228700000023)
+ testDriveDistance(rowingStatistics, 1.2174697537259611)
+ testDriveLength(rowingStatistics, 0.36651914291880905)
+ testDriveAverageHandleForce(rowingStatistics, 256.5447026931294)
+ testDrivePeakHandleForce(rowingStatistics, 447.1085143512751)
+ testRecoveryDuration(rowingStatistics, 0.09812447700000015)
+ testDragFactor(rowingStatistics, 281.5961372923874)
+ 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.1344792920000004)
+ testTotalNumberOfStrokes(rowingStatistics, 2)
+ testTotalLinearDistance(rowingStatistics, 4.810081445355078)
+ 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, 281.5961372923874)
+ testInstantHandlePower(rowingStatistics, undefined)
+})
+
+/**
+ * @todo Test the effects of smoothing parameters
+ */
+
+/**
+ * @todo Test force curve behaviour
+ */
+
+/**
+ * @todo Test behaviour after reset
+ */
+
+/**
+ * @description Test against a theoretical model, based on perfect clean data, as described in
+ * @see {@link https://github.com/JaapvanEkris/openrowingmonitor/discussions/215|this discussion}
+ */
+test('Theoretical_02: Correct Rowingstatistics behaviour with perfect clean data', async () => {
+ const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Theoretical_Model)
+ const testConfig = {
+ loglevel: {
+ default: 'silent',
+ RowingEngine: 'silent'
+ },
+ numOfPhasesForAveragingScreenData: 2,
+ rowerSettings: rowerProfile
+ }
+ const rowingStatistics = createRowingStatistics(testConfig)
+ testTotalMovingTime(rowingStatistics, 0)
+ testTotalLinearDistance(rowingStatistics, 0)
+ testTotalNumberOfStrokes(rowingStatistics, 0)
+ 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, undefined)
+ testInstantHandlePower(rowingStatistics, undefined)
+ testDragFactor(rowingStatistics, undefined)
+
+ await replayRowingSession(rowingStatistics.handleRotationImpulse, { filename: 'recordings/Theoretical_Simulation_Clean.csv', realtime: false, loop: false })
+
+ testTotalMovingTime(rowingStatistics, 61.343050894547225)
+ testTotalLinearDistance(rowingStatistics, 235.67436268645264)
+ testTotalNumberOfStrokes(rowingStatistics, 19)
+ // As the session has stopped, all data is supposed to be undefined again
+ 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)
+ testInstantHandlePower(rowingStatistics, undefined)
+ testDragFactor(rowingStatistics, 119.92470860873752) // Theoretical value 120 * 10^−6 [ k g m 2 ]
+})
+
+/**
+ * @description Test against a theoretical model, based on noise-injected data, as described in
+ * @see {@link https://github.com/JaapvanEkris/openrowingmonitor/discussions/215|this discussion}
+ */
+test('Theoretical_03: Correct Rowingstatistics behaviour with noise-injected data', async () => {
+ const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Theoretical_Model)
+ const testConfig = {
+ loglevel: {
+ default: 'silent',
+ RowingEngine: 'silent'
+ },
+ numOfPhasesForAveragingScreenData: 2,
+ rowerSettings: rowerProfile
+ }
+ const rowingStatistics = createRowingStatistics(testConfig)
+ testTotalMovingTime(rowingStatistics, 0)
+ testTotalLinearDistance(rowingStatistics, 0)
+ testTotalNumberOfStrokes(rowingStatistics, 0)
+ 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)
+ testInstantHandlePower(rowingStatistics, undefined)
+ testDragFactor(rowingStatistics, undefined)
+
+ await replayRowingSession(rowingStatistics.handleRotationImpulse, { filename: 'recordings/Theoretical_Simulation_Random_Noise.csv', realtime: false, loop: false })
+
+ testTotalMovingTime(rowingStatistics, 181.35954479988044)
+ testTotalLinearDistance(rowingStatistics, 706.3578534826354)
+ testTotalNumberOfStrokes(rowingStatistics, 59)
+ 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)
+ testInstantHandlePower(rowingStatistics, undefined)
+ testDragFactor(rowingStatistics, 119.95812733742444) // Theoretical value 120 * 10^−6 [ k g m 2 ]
+})
+
+/**
+ * @description Test against a theoretical model, based on a simulation of magnet positioning errors, as described in
+ * @see {@link https://github.com/JaapvanEkris/openrowingmonitor/discussions/215|this discussion}
+ * Magnet errors -0.01, +0.1, +0.2, -0.2, -0.1, +0.01 degrees
+ */
+test('Theoretical_04: Correct Rowingstatistics behaviour with structural magnet errors', async () => {
+ const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Theoretical_Model)
+ const testConfig = {
+ loglevel: {
+ default: 'silent',
+ RowingEngine: 'silent'
+ },
+ numOfPhasesForAveragingScreenData: 2,
+ rowerSettings: rowerProfile
+ }
+ const rowingStatistics = createRowingStatistics(testConfig)
+ testTotalMovingTime(rowingStatistics, 0)
+ testTotalLinearDistance(rowingStatistics, 0)
+ testTotalNumberOfStrokes(rowingStatistics, 0)
+ 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)
+ testInstantHandlePower(rowingStatistics, undefined)
+ testDragFactor(rowingStatistics, undefined)
+
+ await replayRowingSession(rowingStatistics.handleRotationImpulse, { filename: 'recordings/Theoretical_Simulation_Systematic_Noise.csv', realtime: false, loop: false })
+
+ testTotalMovingTime(rowingStatistics, 181.3424733944077)
+ testTotalLinearDistance(rowingStatistics, 706.291973992365)
+ testTotalNumberOfStrokes(rowingStatistics, 59)
+ 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)
+ testInstantHandlePower(rowingStatistics, undefined)
+ testDragFactor(rowingStatistics, 119.92470860871332) // Theoretical value 120 * 10^−6 [ k g m 2 ]
+})
+
+/**
+ * @description Test behaviour for the C2 Model C
+ */
+test('C2_ModelC_01: 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 })
+
+ testIsMoving(rowingStatistics, true) // As data has stopped abrubtly and the last drive is 4 sec ago, rowingStatistics's watchdog hasn't been triggered yet
+ testIsDriveStart(rowingStatistics, false)
+ testIsRecoveryStart(rowingStatistics, false)
+ testTotalMovingTime(rowingStatistics, 181.4588596531004)
+ testTotalLinearDistance(rowingStatistics, 552.4833037536148)
+ testTotalWork(rowingStatistics, 20593.15388304616)
+ testTotalCalories(rowingStatistics, 34.73409914542138)
+ testTotalCaloriesPerMinute(rowingStatistics, 12.371981079533317)
+ testTotalCaloriesPerHour (rowingStatistics, 742.318864771999)
+ testTotalNumberOfStrokes(rowingStatistics, 80)
+ testStrokeState(rowingStatistics, 'Drive')
+ testStrokeWork(rowingStatistics, 25.512113604279875)
+ testStrokeCalories(rowingStatistics, 0.21500383832546952)
+ testCycleDistance(rowingStatistics, 2.9525168987431822)
+ testCycleStrokeRate(rowingStatistics, 24.336340661672818)
+ testCycleDuration(rowingStatistics, 2.465448722719998)
+ testCycleLinearVelocity(rowingStatistics, 1.2061131266447134)
+ testCyclePace(rowingStatistics, 414.55481161286275)
+ testCyclePower(rowingStatistics, 4.971183651233226)
+ testDriveLastStartTime (rowingStatistics, 177.31209030312513)
+ testDriveDuration(rowingStatistics, 0.7592322967347798)
+ testDriveLength(rowingStatistics, 0.38117990863556767)
+ testDriveDistance(rowingStatistics, 0.9595679920915494)
+ testDriveAverageHandleForce(rowingStatistics, 102.2713643967354)
+ testDrivePeakHandleForce(rowingStatistics, 212.5533895691384)
+ testRecoveryDuration(rowingStatistics, 1.983892772108078)
+ testDragFactor(rowingStatistics, 122.56864708679767)
+})
+
+/**
+ * @description Test behaviour for the C2 RowErg
+ */
+test('C2_RowErg_01: 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 })
+
+ testIsMoving(rowingStatistics, true) // As data stops abrubtly, the flywheel will not have timed out
+ testIsDriveStart(rowingStatistics, false)
+ testIsRecoveryStart(rowingStatistics, false)
+ testTotalMovingTime(rowingStatistics, 589.9664157362346)
+ testTotalLinearDistance(rowingStatistics, 2028.3093228150667)
+ testTotalWork(rowingStatistics, 67751.31484279277)
+ testTotalCalories(rowingStatistics, 113.68892973306028)
+ testTotalCaloriesPerMinute(rowingStatistics, 11.542360132437295)
+ testTotalCaloriesPerHour (rowingStatistics, 692.5416079462377)
+ testTotalNumberOfStrokes(rowingStatistics, 205)
+ testStrokeState(rowingStatistics, 'Recovery')
+ testStrokeWork(rowingStatistics, 330.7759067800944)
+ testStrokeCalories(rowingStatistics, 0.5617995397938088)
+ testCycleDistance(rowingStatistics, 10.261713455631917)
+ testCycleStrokeRate(rowingStatistics, 20.26138264188829)
+ testCycleDuration(rowingStatistics, 2.9612984000389133)
+ testCycleLinearVelocity(rowingStatistics, 3.4650957806968687)
+ testCyclePace(rowingStatistics, 144.29615561721775)
+ testCyclePower(rowingStatistics, 116.49419766383924)
+ testDriveLastStartTime (rowingStatistics, 587.302081036686) // Since the last drive is witin 6 seconds, the Flywheel.js has not been able to trigger a pause yet
+ testDriveDuration(rowingStatistics, 0.7332495742683705)
+ testDriveLength(rowingStatistics, 1.1728612573401769)
+ testDriveDistance(rowingStatistics, 2.5693271336160244)
+ testDriveAverageHandleForce(rowingStatistics, 284.3396357312365)
+ testDrivePeakHandleForce(rowingStatistics, 444.35598591963236)
+ testRecoveryDuration(rowingStatistics, 2.2585963714949457)
+ testDragFactor(rowingStatistics, 80.77192371126783)
+})
+
+/**
+ * @description Test behaviour for the DKN R-320
+ */
+test('DKN_R320_01: 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 })
+
+ testIsMoving(rowingStatistics, true) // The recording stops abrubtly, and rowingStatistics's watchdog hasn't kicked in yet
+ testIsDriveStart(rowingStatistics, false)
+ testIsRecoveryStart(rowingStatistics, false)
+ testTotalMovingTime(rowingStatistics, 21.701535821)
+ testTotalLinearDistance(rowingStatistics, 69.20242183779045)
+ testTotalWork(rowingStatistics, 5200.736822863752)
+ testTotalCalories(rowingStatistics, 6.761544006858335)
+ testTotalCaloriesPerMinute(rowingStatistics, 19.726851654537608)
+ testTotalCaloriesPerHour (rowingStatistics, 1183.6110992722565)
+ testTotalNumberOfStrokes(rowingStatistics, 9)
+ testStrokeState(rowingStatistics, 'Recovery')
+ testStrokeWork(rowingStatistics, 646.220649414442)
+ testStrokeCalories(rowingStatistics, 0.8038903655792305)
+ testCycleDistance(rowingStatistics, 7.284465456609526)
+ testCycleStrokeRate(rowingStatistics, 26.533345021949888)
+ testCycleDuration(rowingStatistics, 2.2613055365)
+ testCycleLinearVelocity(rowingStatistics, 3.2218652746225382)
+ testCyclePace(rowingStatistics, 155.189605207368)
+ testCyclePower(rowingStatistics, 93.68863152770354)
+ testDriveLastStartTime (rowingStatistics, 19.511980053000002)
+ testDriveDuration(rowingStatistics, 1.2778628719999965)
+ testDriveLength(rowingStatistics, 1.7592918860102824)
+ testDriveDistance(rowingStatistics, 3.642232728304757)
+ testDriveAverageHandleForce(rowingStatistics, 385.62555350976965)
+ testDrivePeakHandleForce(rowingStatistics, 604.6392224523523)
+ testRecoveryDuration(rowingStatistics, 1.011930944000003)
+ testDragFactor(rowingStatistics, rowerProfiles.DKN_R320.dragFactor) // This is a fixed-drag machine
+})
+
+/**
+ * @description Test behaviour for the NordicTrack RX800
+ */
+test('NordicT_RX800_01: 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 })
+
+ testIsMoving(rowingStatistics, true) // The recording stops abrubtly, and rowingStatistics's watchdog hasn't kicked in yet
+ testIsDriveStart(rowingStatistics, false)
+ testIsRecoveryStart(rowingStatistics, false)
+ testTotalMovingTime(rowingStatistics, 22.368358745999995)
+ testTotalLinearDistance(rowingStatistics, 80.8365747440095)
+ testTotalWork(rowingStatistics, 3132.9891152627774)
+ testTotalCalories(rowingStatistics, 4.847829052893121)
+ testTotalCaloriesPerMinute(rowingStatistics, 12.915884067211993)
+ testTotalCaloriesPerHour (rowingStatistics, 774.9530440327195)
+ testTotalNumberOfStrokes(rowingStatistics, 9)
+ testStrokeState(rowingStatistics, 'Recovery')
+ testStrokeWork(rowingStatistics, 298.42006474992013)
+ testStrokeCalories(rowingStatistics, 0.47141003264278075)
+ testCycleDistance(rowingStatistics, 8.14484194775459)
+ testCycleStrokeRate(rowingStatistics, 26.709337908517135)
+ testCycleDuration(rowingStatistics, 2.246405365999996)
+ testCycleLinearVelocity(rowingStatistics, 3.627196444262048)
+ testCyclePace(rowingStatistics, 137.84751051765127)
+ testCyclePower(rowingStatistics, 133.6207873388181)
+ testDriveLastStartTime (rowingStatistics, 20.24632183399998)
+ testDriveDuration(rowingStatistics, 0.6251555590000031)
+ testDriveLength(rowingStatistics, 1.2252211349000253)
+ testDriveDistance(rowingStatistics, 2.290304983280253)
+ testDriveAverageHandleForce(rowingStatistics, 242.21443734968256)
+ testDrivePeakHandleForce(rowingStatistics, 389.6437952657745)
+ testRecoveryDuration(rowingStatistics, 1.612378919999994)
+ testDragFactor(rowingStatistics, 493.8082148322739)
+})
+
+/**
+ * @description Test against the Merarch R50
+ * @see {@link https://github.com/JaapvanEkris/openrowingmonitor/discussions/140|this discussion}
+ */
+test('Merarch_R50_01: Data with Merarch R50 should produce plausible results', async () => {
+ const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Merach_R50)
+ 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/Merach_R50_510m.csv', realtime: false, loop: false })
+
+ testIsMoving(rowingStatistics, false)
+ testIsDriveStart(rowingStatistics, false)
+ testIsRecoveryStart(rowingStatistics, false)
+ testTotalMovingTime(rowingStatistics, 129.219922)
+ testTotalLinearDistance(rowingStatistics, 510.8083338801155)
+ testTotalWork(rowingStatistics, 22829.81352857388)
+ testTotalCalories(rowingStatistics, 32.51100638435607)
+ testTotalCaloriesPerMinute(rowingStatistics, 15.52334646861831)
+ testTotalCaloriesPerHour (rowingStatistics, 931.4007881170988)
+ testTotalNumberOfStrokes(rowingStatistics, 49)
+ testStrokeState(rowingStatistics, 'WaitingForDrive')
+ testStrokeWork(rowingStatistics, undefined)
+ testStrokeCalories(rowingStatistics, undefined)
+ testCycleDistance(rowingStatistics, undefined)
+ testCycleStrokeRate(rowingStatistics, undefined)
+ testCycleDuration(rowingStatistics, undefined)
+ testCycleLinearVelocity(rowingStatistics, undefined)
+ testCyclePace(rowingStatistics, Infinity)
+ testCyclePower(rowingStatistics, undefined)
+ testDriveLastStartTime (rowingStatistics, 123.22668199999998)
+ testDriveDuration(rowingStatistics, undefined)
+ testDriveLength(rowingStatistics, undefined)
+ testDriveDistance(rowingStatistics, undefined)
+ testDriveAverageHandleForce(rowingStatistics, undefined)
+ testDrivePeakHandleForce(rowingStatistics, undefined)
+ testRecoveryDuration(rowingStatistics, undefined)
+ testDragFactor(rowingStatistics, 123.2635201451201)
+})
+
+/**
+ * @description Test against the Oartec Slider
+ * @see {@link https://github.com/JaapvanEkris/openrowingmonitor/discussions/218|this discussion}
+ */
+test('Oartec_Slider_01: Test for the Oartec Slider on a 190 meters run', async () => {
+ const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Oartec_Slider)
+ 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/Oartec_Slider.csv', realtime: false, loop: false })
+
+ testIsMoving(rowingStatistics, false)
+ testIsDriveStart(rowingStatistics, false)
+ testIsRecoveryStart(rowingStatistics, false)
+ testTotalMovingTime(rowingStatistics, 58.26220982176262)
+ testTotalLinearDistance(rowingStatistics, 195.08912586384486)
+ testTotalWork(rowingStatistics, 7140.670099435771)
+ testTotalCalories(rowingStatistics, 11.655822341752382)
+ testTotalCaloriesPerMinute(rowingStatistics, 13.934098177873182)
+ testTotalCaloriesPerHour (rowingStatistics, 836.0458906723909)
+ testTotalNumberOfStrokes(rowingStatistics, 13)
+ testStrokeState(rowingStatistics, 'WaitingForDrive')
+ testStrokeWork(rowingStatistics, undefined)
+ testStrokeCalories(rowingStatistics, undefined)
+ testCycleDistance(rowingStatistics, undefined)
+ testCycleStrokeRate(rowingStatistics, undefined)
+ testCycleDuration(rowingStatistics, undefined)
+ testCycleLinearVelocity(rowingStatistics, undefined)
+ testCyclePace(rowingStatistics, Infinity)
+ testCyclePower(rowingStatistics, undefined)
+ testDriveLastStartTime (rowingStatistics, 44.9507183497444)
+ testDriveDuration(rowingStatistics, undefined)
+ testDriveLength(rowingStatistics, undefined)
+ testDriveDistance(rowingStatistics, undefined)
+ testDriveAverageHandleForce(rowingStatistics, undefined)
+ testDrivePeakHandleForce(rowingStatistics, undefined)
+ testRecoveryDuration(rowingStatistics, undefined)
+ testDragFactor(rowingStatistics, 171.65666790675232)
+})
+
+/**
+ * @description Test against the Schwinn Windrigger
+ * @see {@link https://github.com/JaapvanEkris/openrowingmonitor/discussions/159|this discussion}
+ */
+test('Schwinn_Wndrggr_01: Test of the Schwinn Windrigger', async () => {
+ const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Schwinn_Windrigger)
+ 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/Schwinn_Windrigger.csv', realtime: false, loop: false })
+
+ testIsMoving(rowingStatistics, false)
+ testIsDriveStart(rowingStatistics, false)
+ testIsRecoveryStart(rowingStatistics, false)
+ testTotalMovingTime(rowingStatistics, 120.29544599999997)
+ testTotalLinearDistance(rowingStatistics, 431.5031059961717)
+ testTotalWork(rowingStatistics, 18455.110628084884)
+ testTotalCalories(rowingStatistics, 27.600916336271315)
+ testTotalCaloriesPerMinute(rowingStatistics, 14.955024168523664)
+ testTotalCaloriesPerHour (rowingStatistics, 897.30145011142)
+ testTotalNumberOfStrokes(rowingStatistics, 46)
+ testStrokeState(rowingStatistics, 'WaitingForDrive')
+ testStrokeWork(rowingStatistics, undefined)
+ testStrokeCalories(rowingStatistics, undefined)
+ testCycleDistance(rowingStatistics, undefined)
+ testCycleStrokeRate(rowingStatistics, undefined)
+ testCycleDuration(rowingStatistics, undefined)
+ testCycleLinearVelocity(rowingStatistics, undefined)
+ testCyclePace(rowingStatistics, Infinity)
+ testCyclePower(rowingStatistics, undefined)
+ testDriveLastStartTime (rowingStatistics, 111.44649600000002)
+ testDriveDuration(rowingStatistics, undefined)
+ testDriveLength(rowingStatistics, undefined)
+ testDriveDistance(rowingStatistics, undefined)
+ testDriveAverageHandleForce(rowingStatistics, undefined)
+ testDrivePeakHandleForce(rowingStatistics, undefined)
+ testRecoveryDuration(rowingStatistics, undefined)
+ testDragFactor(rowingStatistics, 229.67514256584752)
+})
+
+/**
+ * @description Test behaviour for the Sportstech WRX700
+ */
+test('Sportstech_WRX700_01: 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 })
+
+ testIsMoving(rowingStatistics, true) // The recording stops abrubtly, and rowingStatistics's watchdog hasn't kicked in yet
+ testIsDriveStart(rowingStatistics, false)
+ testIsRecoveryStart(rowingStatistics, false)
+ testTotalMovingTime(rowingStatistics, 46.302522627)
+ testTotalLinearDistance(rowingStatistics, 165.58832475070278)
+ testTotalWork(rowingStatistics, 9748.548017262458)
+ testTotalCalories(rowingStatistics, 13.142874997261865)
+ testTotalCaloriesPerMinute(rowingStatistics, 16.06317491735509)
+ testTotalCaloriesPerHour (rowingStatistics, 963.7904950413053)
+ testTotalNumberOfStrokes(rowingStatistics, 15)
+ testStrokeState(rowingStatistics, 'Recovery')
+ testStrokeWork(rowingStatistics, 763.0876311342454)
+ testStrokeCalories(rowingStatistics, 0.9544143736814243)
+ testCycleDistance(rowingStatistics, 10.968457408700388)
+ testCycleStrokeRate(rowingStatistics, 21.96216589358063)
+ testCycleDuration(rowingStatistics, 2.731970985500002)
+ testCycleLinearVelocity(rowingStatistics, 4.015620240977591)
+ testCyclePace(rowingStatistics, 124.51376624156981)
+ testCyclePower(rowingStatistics, 181.32184485208828)
+ testDriveLastStartTime (rowingStatistics, 42.953401899000006)
+ testDriveDuration(rowingStatistics, 1.297822779999997)
+ testDriveLength(rowingStatistics, 1.7592918860102864)
+ testDriveDistance(rowingStatistics, 5.661139307716341)
+ testDriveAverageHandleForce(rowingStatistics, 347.2288656270118)
+ testDrivePeakHandleForce(rowingStatistics, 634.0186435697934)
+ testRecoveryDuration(rowingStatistics, 1.332040928000005)
+ testDragFactor(rowingStatistics, rowerProfiles.Sportstech_WRX700.dragFactor)
+})
+
+/**
+ * @description Test behaviour for the SportsTech WRX700 in a full session
+ */
+test('Sportstech_WRX700_02: 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 })
+
+ testIsMoving(rowingStatistics, true) // There were no timeouts yet
+ testIsDriveStart(rowingStatistics, false)
+ testIsRecoveryStart(rowingStatistics, false)
+ testTotalMovingTime(rowingStatistics, 2340.0100514160117)
+ testTotalLinearDistance(rowingStatistics, 8406.084229545408)
+ testTotalWork(rowingStatistics, 487699.09367508633)
+ testTotalCalories(rowingStatistics, 659.476164927607)
+ testTotalCaloriesPerMinute(rowingStatistics, 17.199317176667705)
+ testTotalCaloriesPerHour (rowingStatistics, 1031.959030600064)
+ testTotalNumberOfStrokes(rowingStatistics, 845)
+ testStrokeState(rowingStatistics, 'Recovery')
+ testStrokeWork(rowingStatistics, 395.80950222397223)
+ testStrokeCalories(rowingStatistics, 0.5966066672311777)
+ testCycleDistance(rowingStatistics, 9.553172581771296)
+ testCycleStrokeRate(rowingStatistics, 22.763981039266426)
+ testCycleDuration(rowingStatistics, 2.635742838500164)
+ testCycleLinearVelocity(rowingStatistics, 3.624974543637083)
+ testCyclePace(rowingStatistics, 137.93200310265624)
+ testCyclePower(rowingStatistics, 134.02976389064236)
+ testDriveLastStartTime (rowingStatistics, 2336.8188294280117)
+ testDriveDuration(rowingStatistics, 1.3936550620001071)
+ testDriveLength(rowingStatistics, 1.5393804002590334)
+ testDriveDistance(rowingStatistics, 4.953496894251904)
+ testDriveAverageHandleForce(rowingStatistics, 210.6300325410566)
+ testDrivePeakHandleForce(rowingStatistics, 352.68327935116827)
+ testRecoveryDuration(rowingStatistics, 1.251141552000263)
+ testDragFactor(rowingStatistics, rowerProfiles.Sportstech_WRX700.dragFactor)
+})
+
+/**
+ * @description Test against the Topiom V2
+ * @see {@link https://github.com/JaapvanEkris/openrowingmonitor/discussions/144|this discussion}
+ */
+test('TopiomV2_01: Data for the Topiom V2 should produce plausible results for 9750 meter run', async () => {
+ const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Topiom_V2)
+ const testConfig = {
+ loglevel: {
+ default: 'silent',
+ RowingEngine: 'silent'
+ },
+ numOfPhasesForAveragingScreenData: 2,
+ userSettings: {
+ sex: 'male'
+ },
+ rowerSettings: rowerProfile
+ }
+ const rowingStatistics = createRowingStatistics(testConfig)
+ testTotalMovingTime(rowingStatistics, 0)
+ testTotalLinearDistance(rowingStatistics, 0)
+ testTotalNumberOfStrokes(rowingStatistics, 0)
+ testDragFactor(rowingStatistics, undefined)
+
+ await replayRowingSession(rowingStatistics.handleRotationImpulse, { filename: 'recordings/Topiom_V2_1magnet.csv', realtime: false, loop: false })
+
+ testIsMoving(rowingStatistics, false)
+ testIsDriveStart(rowingStatistics, false)
+ testIsRecoveryStart(rowingStatistics, false)
+ testTotalMovingTime(rowingStatistics, 2443.167392999993)
+ testTotalLinearDistance(rowingStatistics, 9784.558725355426)
+ testTotalWork(rowingStatistics, 390420.6310543407)
+ testTotalCalories(rowingStatistics, 575.4264551827048)
+ testTotalCaloriesPerMinute(rowingStatistics, 14.085069675489422)
+ testTotalCaloriesPerHour (rowingStatistics, 845.1041805293637)
+ testTotalNumberOfStrokes(rowingStatistics, 1200)
+ testStrokeState(rowingStatistics, 'WaitingForDrive')
+ testStrokeWork(rowingStatistics, undefined)
+ testStrokeCalories(rowingStatistics, undefined)
+ testCycleDistance(rowingStatistics, undefined)
+ testCycleStrokeRate(rowingStatistics, undefined)
+ testCycleDuration(rowingStatistics, undefined)
+ testCycleLinearVelocity(rowingStatistics, undefined)
+ testCyclePace(rowingStatistics, Infinity)
+ testCyclePower(rowingStatistics, undefined)
+ testDriveLastStartTime (rowingStatistics, 2437.8734489999933)
+ testDriveDuration(rowingStatistics, undefined)
+ testDriveLength(rowingStatistics, undefined)
+ testDriveDistance(rowingStatistics, undefined)
+ testDriveAverageHandleForce(rowingStatistics, undefined)
+ testDrivePeakHandleForce(rowingStatistics, undefined)
+ testRecoveryDuration(rowingStatistics, undefined)
+ testDragFactor(rowingStatistics, rowerProfiles.Topiom_V2.dragFactor)
+})
+
+function testIsMoving (rowingStatistics, expectedValue) {
+ assert.ok(rowingStatistics.getMetrics().metricsContext.isMoving === expectedValue, `isMoving should be ${expectedValue} at ${rowingStatistics.getMetrics().totalMovingTime} sec, is ${rowingStatistics.getMetrics().metricsContext.isMoving}`)
+}
+
+function testIsDriveStart (rowingStatistics, expectedValue) {
+ assert.ok(rowingStatistics.getMetrics().metricsContext.isDriveStart === expectedValue, `isDriveStart should be ${expectedValue} at ${rowingStatistics.getMetrics().totalMovingTime} sec, is ${rowingStatistics.getMetrics().metricsContext.isDriveStart}`)
+}
+
+function testIsRecoveryStart (rowingStatistics, expectedValue) {
+ assert.ok(rowingStatistics.getMetrics().metricsContext.isRecoveryStart === expectedValue, `isRecoveryStart should be ${expectedValue} at ${rowingStatistics.getMetrics().totalMovingTime} sec, is ${rowingStatistics.getMetrics().metricsContext.isRecoveryStart}`)
+}
+
+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 testTotalWork (rowingStatistics, expectedValue) {
+ assert.ok(rowingStatistics.getMetrics().totalWork === expectedValue, `totalWork should be ${expectedValue} Joules at ${rowingStatistics.getMetrics().totalMovingTime} sec, is ${rowingStatistics.getMetrics().totalWork}`)
+}
+
+function testTotalCalories (rowingStatistics, expectedValue) {
+ assert.ok(rowingStatistics.getMetrics().totalCalories === expectedValue, `totalCalories should be ${expectedValue} kCal at ${rowingStatistics.getMetrics().totalMovingTime} sec, is ${rowingStatistics.getMetrics().totalCalories}`)
+}
+
+function testTotalCaloriesPerMinute (rowingStatistics, expectedValue) {
+ assert.ok(rowingStatistics.getMetrics().totalCaloriesPerMinute === expectedValue, `totalCaloriesPerMinute should be ${expectedValue} kCal at ${rowingStatistics.getMetrics().totalMovingTime} sec, is ${rowingStatistics.getMetrics().totalCaloriesPerMinute}`)
+}
+
+function testTotalCaloriesPerHour (rowingStatistics, expectedValue) {
+ assert.ok(rowingStatistics.getMetrics().totalCaloriesPerHour === expectedValue, `totalCaloriesPerHour should be ${expectedValue} kCal at ${rowingStatistics.getMetrics().totalMovingTime} sec, is ${rowingStatistics.getMetrics().totalCaloriesPerHour}`)
+}
+
+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 testStrokeWork (rowingStatistics, expectedValue) {
+ assert.ok(rowingStatistics.getMetrics().strokeWork === expectedValue, ` should be ${expectedValue} Joules at ${rowingStatistics.getMetrics().totalMovingTime} sec, is ${rowingStatistics.getMetrics().strokeWork}`)
+}
+
+function testStrokeCalories (rowingStatistics, expectedValue) {
+ assert.ok(rowingStatistics.getMetrics().strokeCalories === expectedValue, `strokeCalories should be ${expectedValue} kCal at ${rowingStatistics.getMetrics().totalMovingTime} sec, is ${rowingStatistics.getMetrics().strokeCalories}`)
+}
+
+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 testCycleStrokeRate (rowingStatistics, expectedValue) {
+ assert.ok(rowingStatistics.getMetrics().cycleStrokeRate === expectedValue, ` should be ${expectedValue} SPM at ${rowingStatistics.getMetrics().totalMovingTime} sec, is ${rowingStatistics.getMetrics().cycleStrokeRate}`)
+}
+
+function testCyclePace (rowingStatistics, expectedValue) {
+ assert.ok(rowingStatistics.getMetrics().cyclePace === expectedValue, `cyclePace should be ${expectedValue} sec/500m at ${rowingStatistics.getMetrics().totalMovingTime} sec, is ${rowingStatistics.getMetrics().cyclePace}`)
+}
+
+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 testDriveLastStartTime (rowingStatistics, expectedValue) {
+ assert.ok(rowingStatistics.getMetrics().driveLastStartTime === expectedValue, `driveLastStartTime should be ${expectedValue} sec at ${rowingStatistics.getMetrics().totalMovingTime} sec, is ${rowingStatistics.getMetrics().driveLastStartTime}`)
+}
+
+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..ea905707c9
--- /dev/null
+++ b/app/engine/SessionManager.js
@@ -0,0 +1,520 @@
+'use strict'
+/**
+ * @copyright [OpenRowingMonitor]{@link https://github.com/JaapvanEkris/openrowingmonitor}
+ *
+ * @file 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
+ let isUnplannedPause = false
+ let splitRemainder = null
+
+ metrics = refreshMetrics()
+ setIntervalParameters([{ type: 'justrow' }])
+ 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'
+ 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'
+ isUnplannedPause = true
+ 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)
+ session.push(baseMetrics)
+ 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)
+ split.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()
+ setIntervalParameters([{ type: 'justrow' }])
+ isUnplannedPause = false
+ splitRemainder = null
+ emitMetrics(metrics)
+ }
+
+ /**
+ * This function processes the currentDt and guards the session, interval and split 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.movingTimeSinceStart(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.movingTimeSinceStart(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 session 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 && isUnplannedPause):
+ // It was a spontanuous pause
+ StartOrResumeTraining()
+ sessionState = 'Rowing'
+ metrics.metricsContext.isPauseEnd = true
+ emitMetrics(metrics)
+ isUnplannedPause = false
+ activateNextSplitParameters(metrics)
+ break
+ case (sessionState === 'Paused' && metrics.metricsContext.isMoving === true):
+ // We are leaving a planned rest interval
+ StartOrResumeTraining()
+ sessionState = 'Rowing'
+ metrics.metricsContext.isPauseEnd = true
+ metrics.metricsContext.isIntervalEnd = true
+ emitMetrics(metrics)
+ activateNextIntervalParameters(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'):
+ // This is an unplanned pause
+ // We do not need to refetch the metrics as RowingStatistics will already have zero-ed the metrics when strokeState = 'WaitingForDrive'
+ pauseTraining(metrics)
+ sessionState = 'Paused'
+ isUnplannedPause = true
+ splitRemainder = split.remainder(metrics)
+ metrics.metricsContext.isPauseStart = true
+ metrics.metricsContext.isSplitEnd = true
+ emitMetrics(metrics)
+ addUnplannedRestSplit(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'
+ isUnplannedPause = false
+ 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
+ splitRemainder = null
+ 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
+ splitRemainder = null
+ activateNextSplitParameters(baseMetrics)
+ } else {
+ log.error('SessionManager: expected a next interval, but did not find one!')
+ }
+ }
+
+ function activateNextSplitParameters (baseMetrics) {
+ splitNumber++
+ log.debug(`Activating split settings for split ${splitNumber + 1}`)
+ split.setStart(baseMetrics)
+ if (splitRemainder !== null && sessionState === 'Rowing') {
+ // We have a part of the split still have to complete
+ split.setEnd(splitRemainder)
+ splitRemainder = null
+ } else {
+ split.setEnd(interval.getSplit())
+ }
+ }
+
+ function addUnplannedRestSplit (baseMetrics) {
+ splitNumber++
+ log.info(`Adding unplanned rest split, split number ${splitNumber + 1}`)
+ split.setStart(baseMetrics)
+ split.setEnd({ type: 'rest' })
+ }
+
+ 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.metricsContext.isUnplannedPause = isUnplannedPause // Indication for the PM5 emulator to distinguish between planned and unplanned pauses
+ 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'
+ isUnplannedPause = true
+ 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 () {
+ clearTimeout(watchdogTimer)
+ 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..3eddd230f5
--- /dev/null
+++ b/app/engine/SessionManager.test.js
@@ -0,0 +1,8052 @@
+'use strict'
+/**
+ * @copyright [OpenRowingMonitor]{@link https://github.com/JaapvanEkris/openrowingmonitor}
+ *
+ * @file 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
+ *
+ * Aside testing all the fully supported machine profiles and verify metrics accuracy, we also test SessionManager's behaviour (in order of increasing complexity):
+ * - C2_ModelC_01: Basic 'justrow' session (basis for metrics of subsequent tests), abrupt data stop (no Flywheel-timeout)
+ * - C2_ModelC_02: Single 2K distance interval, no splits
+ * - C2_ModelC_03: Single time interval, no splits
+ * - C2_ModelC_04: Single Calories interval, no splits
+ * - C2_RowErg_01: Basic 'justrow' session, no splits (basis for metrics of subsequent tests), abrupt data stop (no Flywheel-timeout)
+ * - C2_RowErg_02: Single 2K distance interval, no splits
+ * - C2_RowErg_03: Single time interval, after initially setting a 2K distance interval (i.e. overwriting)
+ * - C2_RowErg_04: Single Calories interval, no splits
+ * - Merarch_R50_01: Single distance interval, including splits
+ * - Oartec_Slider_01: Basic 'justrow' session (basic machine profile test), with Flywheel-timeout
+ * - DKN_R320_01: Basic 'justrow' session (basic machine profile test), abrupt data stop (no Flywheel-timeout)
+ * - NordicT_RX800_01: Basic 'justrow' session (basic machine profile test), abrupt data stop (no Flywheel-timeout)
+ * - NordicT_RX800_02: Single floating point time interval, no splits
+ * - NordicT_RX800_03: Single floating point Calories interval, no splits
+ * - NordicT_RX800_04: Single floating point distance interval, no splits
+ * - Schwinn_Wndrggr_01: Combination of a single time based interval with a floating point calorie split
+ * - TheoreticalModel_01: Session startup behaviour and basic 'justrow' session (basis for metrics of subsequent tests, with perfectly clean data)
+ * - TheoreticalModel_02: Session startup behaviour with a combination of time based interval and a justrow interval, with distance split
+ * - TheoreticalModel_03: Session startup behaviour with a combination of time based interval and a time interval, with distance split
+ * - TheoreticalModel_04: Basic 'justrow' session (basis for metrics of subsequent tests, with random noise injection)
+ * - TheoreticalModel_05: Combination of time based interval and a justrow interval
+ * - TheoreticalModel_06: Session startup behaviour with a combination of time based interval and a time interval, with time split
+ * - TheoreticalModel_07: Basic 'justrow' session (basis for metrics of subsequent tests, with structural noise injection)
+ * - TheoreticalModel_08: Combination of time based interval and a justrow interval
+ * - TheoreticalModel_09: Combination of two distance based intervals, both with a distance split
+ * - Sportstech_WRX700_01: Basic short 'justrow' session (basis for metrics of subsequent tests), abrupt data stop (no Flywheel-timeout), static dragfactor
+ * - Sportstech_WRX700_02: Single distance interval (no splits) after a session reset before rowing start
+ * - Sportstech_WRX700_03: Single distance interval (no splits) after an overwrite of the workout before rowing start
+ * - Sportstech_WRX700_04: Simple single calories interval (no splits) after overwriting a complex workout plan before workout start
+ * - Sportstech_WRX700_05: Basic long 'justrow' session (basis for metrics of subsequent tests), abrupt data stop (no Flywheel-timeout), static dragfactor
+ * - Sportstech_WRX700_06: Short 'justrow' session, followed by a reset, followed by a longer 'justrow' session
+ * - Sportstech_WRX700_07: Short 'justrow' session, followed by a reset, followed by a longer 'distance' session with two intervals and splits
+ * - Sportstech_WRX700_08: Single time interval (no splits) after an overwrite of the workout before rowing start
+ * - Sportstech_WRX700_09: underrun of a time interval with splits
+ * - TopiomV2_01: Stresstest of the number of intervals (more than 100)
+ */
+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'
+
+/**
+ * From this point on, testing is based on a theoretical model where all parameters and results are known in advance
+ * The model and parameters are described in
+ * @see {@link https://github.com/JaapvanEkris/openrowingmonitor/discussions/215|this discussion}
+ */
+
+/**
+ * @description Test against a theoretical model, based on perfect clean data
+ * @see {@link https://github.com/JaapvanEkris/openrowingmonitor/discussions/215|this discussion}
+ */
+test('TheoreticalModel_01: perfect clean data on the Theoretical Model should produce plausible results for an unlimited run with startup noise', async () => {
+ const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Theoretical_Model)
+ const testConfig = {
+ loglevel: {
+ default: 'silent',
+ RowingEngine: 'silent'
+ },
+ numOfPhasesForAveragingScreenData: 2,
+ userSettings: {
+ sex: 'male'
+ },
+ rowerSettings: rowerProfile
+ }
+ const sessionManager = createSessionManager(testConfig)
+
+ // Test Instantanuous metrics
+ testIsMoving(sessionManager, false)
+ testIsDriveStart(sessionManager, false)
+ testIsRecoveryStart(sessionManager, false)
+ testIsSessionStart(sessionManager, false)
+ testIsIntervalEnd(sessionManager, false)
+ testIsSplitEnd(sessionManager, false)
+ testIsPauseStart(sessionManager, false)
+ testIsPauseEnd(sessionManager, false)
+ testIsSessionStop(sessionManager, false)
+ testTotalMovingTime(sessionManager, 0)
+ testTotalLinearDistance(sessionManager, 0)
+ testTotalWork(sessionManager, 0)
+ testTotalCalories(sessionManager, 0)
+ testTotalCaloriesPerMinute(sessionManager, 0)
+ testTotalCaloriesPerHour (sessionManager, 0)
+ testTotalNumberOfStrokes(sessionManager, 0)
+ testStrokeState(sessionManager, 'WaitingForDrive')
+ testStrokeWork(sessionManager, undefined)
+ testStrokeCalories(sessionManager, undefined)
+ testCycleDistance(sessionManager, undefined)
+ testCycleStrokeRate(sessionManager, undefined)
+ testCycleDuration(sessionManager, undefined)
+ testCycleLinearVelocity(sessionManager, undefined)
+ testCyclePace(sessionManager, Infinity)
+ testCyclePower(sessionManager, undefined)
+ testDriveLastStartTime (sessionManager, 0)
+ testDriveDuration(sessionManager, undefined)
+ testDriveLength(sessionManager, undefined)
+ testDriveDistance(sessionManager, undefined)
+ testDriveAverageHandleForce(sessionManager, undefined)
+ testDrivePeakHandleForce(sessionManager, undefined)
+ testRecoveryDuration(sessionManager, undefined)
+ testDragFactor(sessionManager, undefined)
+
+ // Test session metrics
+ testSessionType(sessionManager, 'justrow')
+ testSessionState(sessionManager, 'WaitingForStart')
+ testSessionTime(sessionManager, 0)
+ testSessionMovingTimeTarget(sessionManager, undefined)
+ testSessionMovingTimeToEnd(sessionManager, undefined)
+ testSessionMovingTimeProjectedEnd(sessionManager, undefined)
+ testSessionTimeSpentTotal(sessionManager, 0)
+ testSessionTimeSpentMoving(sessionManager, 0)
+ testSessionTimeSpentRest(sessionManager, 0)
+ testSessionDistance(sessionManager, 0)
+ testSessionDistanceToEnd(sessionManager, undefined)
+ testSessionDistanceTarget(sessionManager, undefined)
+ testSessionDistanceProjectedEnd(sessionManager, undefined)
+ testSessionWorkSinceStart(sessionManager, 0)
+ testSessionCaloriesSinceStart(sessionManager, 0)
+ testSessionCaloriesTarget(sessionManager, undefined)
+ testSessionCaloriesToEnd(sessionManager, undefined)
+ testSessionNumberOfStrokes(sessionManager, 0)
+ testSessionMinimumPace(sessionManager, Infinity)
+ testSessionAveragePace(sessionManager, Infinity)
+ testSessionMaximumPace(sessionManager, Infinity)
+ testSessionMinimumLinearVelocity(sessionManager, 0)
+ testSessionAverageLinearVelocity(sessionManager, 0)
+ testSessionMaximumLinearVelocity(sessionManager, 0)
+ testSessionMinimumPower(sessionManager, 0)
+ testSessionAveragePower(sessionManager, 0)
+ testSessionMaximumPower(sessionManager, 0)
+ testSessionMinimumStrokedistance(sessionManager, 0)
+ testSessionAverageStrokedistance(sessionManager, 0)
+ testSessionMaximumStrokedistance(sessionManager, 0)
+ testSessionMinimumStrokerate(sessionManager, 0)
+ testSessionAverageStrokerate(sessionManager, 0)
+ testSessionMaximumStrokerate(sessionManager, 0)
+ testSessionMinimumDragfactor(sessionManager, 0)
+ testSessionAverageDragfactor(sessionManager, 0)
+ testSessionMaximumDragfactor(sessionManager, 0)
+ testSessionNumberOfIntervals(sessionManager, 1)
+
+ // Test interval metrics
+ testIntervalNumber(sessionManager, 0)
+ testIntervalWorkoutStepNumber(sessionManager, 0)
+ testIntervalType(sessionManager, 'justrow')
+ testIntervalTime(sessionManager, 0)
+ testIntervalMovingTimeTarget(sessionManager, undefined)
+ testIntervalMovingTimeToEnd(sessionManager, undefined)
+ testIntervalMovingTimeProjectedEnd(sessionManager, undefined)
+ testIntervalTimeSpentTotal(sessionManager, 0)
+ testIntervalTimeSpentMoving(sessionManager, 0)
+ testIntervalTimeSpentRest(sessionManager, 0)
+ testIntervalDistance(sessionManager, 0)
+ testIntervalDistanceToEnd(sessionManager, undefined)
+ testIntervalDistanceTarget(sessionManager, undefined)
+ testIntervalDistanceProjectedEnd(sessionManager, undefined)
+ testIntervalWorkSinceStart(sessionManager, 0)
+ testIntervalCaloriesSinceStart(sessionManager, 0)
+ testIntervalCaloriesTarget(sessionManager, undefined)
+ testIntervalCaloriesToEnd(sessionManager, undefined)
+ testIntervalNumberOfStrokes(sessionManager, 0)
+ testIntervalMinimumPace(sessionManager, Infinity)
+ testIntervalAveragePace(sessionManager, Infinity)
+ testIntervalMaximumPace(sessionManager, Infinity)
+ testIntervalMinimumLinearVelocity(sessionManager, 0)
+ testIntervalAverageLinearVelocity(sessionManager, 0)
+ testIntervalMaximumLinearVelocity(sessionManager, 0)
+ testIntervalMinimumPower(sessionManager, 0)
+ testIntervalAveragePower(sessionManager, 0)
+ testIntervalMaximumPower(sessionManager, 0)
+ testIntervalMinimumStrokedistance(sessionManager, 0)
+ testIntervalAverageStrokedistance(sessionManager, 0)
+ testIntervalMaximumStrokedistance(sessionManager, 0)
+ testIntervalMinimumStrokerate(sessionManager, 0)
+ testIntervalAverageStrokerate(sessionManager, 0)
+ testIntervalMaximumStrokerate(sessionManager, 0)
+ testIntervalMinimumDragfactor(sessionManager, 0)
+ testIntervalAverageDragfactor(sessionManager, 0)
+ testIntervalMaximumDragfactor(sessionManager, 0)
+
+ // Test split metrics
+ testSplitNumber(sessionManager, 0)
+ testSplitType(sessionManager, 'justrow')
+ testSplitTime(sessionManager, 0)
+ testSplitMovingTimeTarget(sessionManager, undefined)
+ testSplitMovingTimeToEnd(sessionManager, undefined)
+ testSplitMovingTimeProjectedEnd(sessionManager, undefined)
+ testSplitTimeSpentTotal(sessionManager, 0)
+ testSplitTimeSpentMoving(sessionManager, 0)
+ testSplitTimeSpentRest(sessionManager, 0)
+ testSplitDistance(sessionManager, 0)
+ testSplitDistanceToEnd(sessionManager, undefined)
+ testSplitDistanceTarget(sessionManager, undefined)
+ testSplitDistanceProjectedEnd(sessionManager, undefined)
+ testSplitWorkSinceStart(sessionManager, 0)
+ testSplitCaloriesSinceStart(sessionManager, 0)
+ testSplitCaloriesTarget(sessionManager, undefined)
+ testSplitCaloriesToEnd(sessionManager, undefined)
+ testSplitNumberOfStrokes(sessionManager, 0)
+ testSplitMinimumPace(sessionManager, Infinity)
+ testSplitAveragePace(sessionManager, Infinity)
+ testSplitMaximumPace(sessionManager, Infinity)
+ testSplitMinimumLinearVelocity(sessionManager, 0)
+ testSplitAverageLinearVelocity(sessionManager, 0)
+ testSplitMaximumLinearVelocity(sessionManager, 0)
+ testSplitMinimumPower(sessionManager, 0)
+ testSplitAveragePower(sessionManager, 0)
+ testSplitMaximumPower(sessionManager, 0)
+ testSplitMinimumStrokedistance(sessionManager, 0)
+ testSplitAverageStrokedistance(sessionManager, 0)
+ testSplitMaximumStrokedistance(sessionManager, 0)
+ testSplitMinimumStrokerate(sessionManager, 0)
+ testSplitAverageStrokerate(sessionManager, 0)
+ testSplitMaximumStrokerate(sessionManager, 0)
+ testSplitMinimumDragfactor(sessionManager, 0)
+ testSplitAverageDragfactor(sessionManager, 0)
+ testSplitMaximumDragfactor(sessionManager, 0)
+
+ // Replay a session
+ await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/Theoretical_Simulation_Clean.csv', realtime: false, loop: false })
+
+ // Test Instantanuous metrics
+ testIsMoving(sessionManager, false) // This implies that all instantanuous metrics must be undefined
+ testIsDriveStart(sessionManager, false)
+ testIsRecoveryStart(sessionManager, false)
+ testIsSessionStart(sessionManager, false)
+ testIsIntervalEnd(sessionManager, false)
+ testIsSplitEnd(sessionManager, false)
+ testIsPauseStart(sessionManager, false)
+ testIsPauseEnd(sessionManager, false)
+ testIsSessionStop(sessionManager, false)
+ testTotalMovingTime(sessionManager, 61.343050894547225)
+ testTotalLinearDistance(sessionManager, 235.67436268645264)
+ testTotalWork(sessionManager, 10082.626106960444)
+ testTotalCalories(sessionManager, 14.714421962126979)
+ testTotalCaloriesPerMinute(sessionManager, 14.881045347565594)
+ testTotalCaloriesPerHour (sessionManager, 892.8627208539357)
+ testTotalNumberOfStrokes(sessionManager, 19)
+ testStrokeState(sessionManager, 'WaitingForDrive') // This is normal for an unplanned pause
+ testStrokeWork(sessionManager, undefined)
+ testStrokeCalories(sessionManager, undefined)
+ testCycleDistance(sessionManager, undefined)
+ testCycleStrokeRate(sessionManager, undefined)
+ testCycleDuration(sessionManager, undefined)
+ testCycleLinearVelocity(sessionManager, undefined)
+ testCyclePace(sessionManager, Infinity)
+ testCyclePower(sessionManager, undefined)
+ testDriveLastStartTime (sessionManager, 55.354990361809314)
+ testDriveDuration(sessionManager, undefined)
+ testDriveLength(sessionManager, undefined)
+ testDriveDistance(sessionManager, undefined)
+ testDriveAverageHandleForce(sessionManager, undefined)
+ testDrivePeakHandleForce(sessionManager, undefined)
+ testRecoveryDuration(sessionManager, undefined)
+ testDragFactor(sessionManager, 119.92470860873752) // Theoretical value 120 * 10^−6 [ k g m 2 ]
+
+ // Test session metrics
+ testSessionType(sessionManager, 'justrow')
+ testSessionState(sessionManager, 'Paused') // As we suddenly stop sending data, the sessionManager will assume a forced pause
+ testSessionTime(sessionManager, 61.343050894547225)
+ testSessionMovingTimeTarget(sessionManager, undefined)
+ testSessionMovingTimeToEnd(sessionManager, undefined)
+ testSessionMovingTimeProjectedEnd(sessionManager, undefined)
+ testSessionTimeSpentTotal(sessionManager, 61.343050894547225)
+ testSessionTimeSpentMoving(sessionManager, 61.343050894547225)
+ testSessionTimeSpentRest(sessionManager, 0)
+ testSessionDistance(sessionManager, 235.67436268645264)
+ testSessionDistanceToEnd(sessionManager, undefined)
+ testSessionDistanceTarget(sessionManager, undefined)
+ testSessionDistanceProjectedEnd(sessionManager, undefined)
+ testSessionWorkSinceStart(sessionManager, 10082.626106960444)
+ testSessionCaloriesSinceStart(sessionManager, 14.714421962126979)
+ testSessionCaloriesTarget(sessionManager, undefined)
+ testSessionCaloriesToEnd(sessionManager, undefined)
+ testSessionNumberOfStrokes(sessionManager, 19)
+ testSessionMinimumPace(sessionManager, 144.0892354553984)
+ testSessionAveragePace(sessionManager, 130.14366559709262) // Theoretical value 126.4 sec/500m
+ testSessionMaximumPace(sessionManager, 127.41091348974768)
+ testSessionMinimumLinearVelocity(sessionManager, 3.470071851097931)
+ testSessionAverageLinearVelocity(sessionManager, 3.841908076785951)
+ testSessionMaximumLinearVelocity(sessionManager, 3.9243106128442697)
+ testSessionMinimumPower(sessionManager, 116.99665182631449)
+ testSessionAveragePower(sessionManager, 165.54273711119356) // Theoretical value 173.33 Watts
+ testSessionMaximumPower(sessionManager, 169.2186227233455)
+ testSessionMinimumStrokedistance(sessionManager, 6.587811328583184)
+ testSessionAverageStrokedistance(sessionManager, 11.5140880042863) // Theoretical value 11.77 meters
+ testSessionMaximumStrokedistance(sessionManager, 11.797809243281058)
+ testSessionMinimumStrokerate(sessionManager, 19.934902370540915)
+ testSessionAverageStrokerate(sessionManager, 20.456618908984527) // Theoretical value 20 SPM
+ testSessionMaximumStrokerate(sessionManager, 31.604473880804594)
+ testSessionMinimumDragfactor(sessionManager, 119.53033047834111)
+ testSessionAverageDragfactor(sessionManager, 119.91517741425835) // Theoretical value 120 * 10^−6 [ k g m 2 ]
+ testSessionMaximumDragfactor(sessionManager, 120.10320388420907)
+ testSessionNumberOfIntervals(sessionManager, 1)
+
+ // Test interval metrics
+ // As there is only one interval, this is identical to the Session data
+ testIntervalNumber(sessionManager, 0)
+ testIntervalWorkoutStepNumber(sessionManager, 0)
+ testIntervalType(sessionManager, 'justrow')
+ testIntervalTime(sessionManager, 61.343050894547225)
+ testIntervalMovingTimeTarget(sessionManager, undefined)
+ testIntervalMovingTimeToEnd(sessionManager, undefined)
+ testIntervalMovingTimeProjectedEnd(sessionManager, undefined)
+ testIntervalTimeSpentTotal(sessionManager, 61.343050894547225)
+ testIntervalTimeSpentMoving(sessionManager, 61.343050894547225)
+ testIntervalTimeSpentRest(sessionManager, 0)
+ testIntervalDistance(sessionManager, 235.67436268645264)
+ testIntervalDistanceToEnd(sessionManager, undefined)
+ testIntervalDistanceTarget(sessionManager, undefined)
+ testIntervalDistanceProjectedEnd(sessionManager, undefined)
+ testIntervalWorkSinceStart(sessionManager, 10082.626106960444)
+ testIntervalCaloriesSinceStart(sessionManager, 14.714421962126979)
+ testIntervalCaloriesTarget(sessionManager, undefined)
+ testIntervalCaloriesToEnd(sessionManager, undefined)
+ testIntervalNumberOfStrokes(sessionManager, 19)
+ testIntervalMinimumPace(sessionManager, 144.0892354553984)
+ testIntervalAveragePace(sessionManager, 130.14366559709262) // Theoretical value 126.4 sec/500m
+ testIntervalMaximumPace(sessionManager, 127.41091348974768)
+ testIntervalMinimumLinearVelocity(sessionManager, 3.470071851097931)
+ testIntervalAverageLinearVelocity(sessionManager, 3.841908076785951)
+ testIntervalMaximumLinearVelocity(sessionManager, 3.9243106128442697)
+ testIntervalMinimumPower(sessionManager, 116.99665182631449)
+ testIntervalAveragePower(sessionManager, 165.54273711119356) // Theoretical value 173.33 Watts
+ testIntervalMaximumPower(sessionManager, 169.2186227233455)
+ testIntervalMinimumStrokedistance(sessionManager, 6.587811328583184)
+ testIntervalAverageStrokedistance(sessionManager, 11.5140880042863) // Theoretical value 11.77 meters
+ testIntervalMaximumStrokedistance(sessionManager, 11.797809243281058)
+ testIntervalMinimumStrokerate(sessionManager, 19.934902370540915)
+ testIntervalAverageStrokerate(sessionManager, 20.456618908984527) // Theoretical value 20 SPM
+ testIntervalMaximumStrokerate(sessionManager, 31.604473880804594)
+ testIntervalMinimumDragfactor(sessionManager, 119.53033047834111)
+ testIntervalAverageDragfactor(sessionManager, 119.91517741425835) // Theoretical value 120 * 10^−6 [ k g m 2 ]
+ testIntervalMaximumDragfactor(sessionManager, 120.10320388420907)
+
+ // Test split metrics
+ // As the session has been forcefully stopped, so we have a rest interval without data
+ testSplitNumber(sessionManager, 1)
+ testSplitType(sessionManager, 'rest')
+ testSplitTime(sessionManager, 0)
+ testSplitMovingTimeTarget(sessionManager, undefined)
+ testSplitMovingTimeToEnd(sessionManager, 0)
+ testSplitMovingTimeProjectedEnd(sessionManager, undefined)
+ testSplitTimeSpentTotal(sessionManager, 0)
+ testSplitTimeSpentMoving(sessionManager, 0)
+ testSplitTimeSpentRest(sessionManager, 0)
+ testSplitDistance(sessionManager, 0)
+ testSplitDistanceToEnd(sessionManager, undefined)
+ testSplitDistanceTarget(sessionManager, undefined)
+ testSplitDistanceProjectedEnd(sessionManager, undefined)
+ testSplitWorkSinceStart(sessionManager, 0)
+ testSplitCaloriesSinceStart(sessionManager, 0)
+ testSplitCaloriesTarget(sessionManager, undefined)
+ testSplitCaloriesToEnd(sessionManager, undefined)
+ testSplitNumberOfStrokes(sessionManager, 0)
+ testSplitMinimumPace(sessionManager, Infinity)
+ testSplitAveragePace(sessionManager, Infinity)
+ testSplitMaximumPace(sessionManager, Infinity)
+ testSplitMinimumLinearVelocity(sessionManager, 0)
+ testSplitAverageLinearVelocity(sessionManager, 0)
+ testSplitMaximumLinearVelocity(sessionManager, 0)
+ testSplitMinimumPower(sessionManager, 0)
+ testSplitAveragePower(sessionManager, 0)
+ testSplitMaximumPower(sessionManager, 0)
+ testSplitMinimumStrokedistance(sessionManager, 0)
+ testSplitAverageStrokedistance(sessionManager, 0)
+ testSplitMaximumStrokedistance(sessionManager, 0)
+ testSplitMinimumStrokerate(sessionManager, 0)
+ testSplitAverageStrokerate(sessionManager, 0)
+ testSplitMaximumStrokerate(sessionManager, 0)
+ testSplitMinimumDragfactor(sessionManager, 0)
+ testSplitAverageDragfactor(sessionManager, 0)
+ testSplitMaximumDragfactor(sessionManager, 0)
+})
+
+/**
+ * @description Test against a theoretical model, based on perfect clean data
+ * @see {@link https://github.com/JaapvanEkris/openrowingmonitor/discussions/215|this discussion}
+ */
+test('TheoreticalModel_02: perfect clean data on the Theoretical Model should produce plausible results for an unlimited run without startup noise', async () => {
+ const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Theoretical_Model)
+ const testConfig = {
+ loglevel: {
+ default: 'silent',
+ RowingEngine: 'silent'
+ },
+ numOfPhasesForAveragingScreenData: 2,
+ userSettings: {
+ sex: 'male'
+ },
+ rowerSettings: rowerProfile
+ }
+
+ const sessionManager = createSessionManager(testConfig)
+
+ testSessionType(sessionManager, 'justrow')
+ testSessionTime(sessionManager, 0)
+ testSessionMovingTimeTarget(sessionManager, undefined)
+ testSessionMovingTimeToEnd(sessionManager, undefined)
+ testSessionDistance(sessionManager, 0)
+ testSessionDistanceToEnd(sessionManager, undefined)
+ testSessionDistanceTarget(sessionManager, undefined)
+ testSessionDistanceProjectedEnd(sessionManager, undefined)
+ testSessionCaloriesSinceStart(sessionManager, 0)
+ testSessionCaloriesTarget(sessionManager, undefined)
+ testSessionCaloriesToEnd(sessionManager, undefined)
+ testIntervalType(sessionManager, 'justrow')
+ testIntervalTime(sessionManager, 0)
+ testIntervalMovingTimeTarget(sessionManager, undefined)
+ testIntervalMovingTimeToEnd(sessionManager, undefined)
+ testIntervalMovingTimeProjectedEnd(sessionManager, undefined)
+ testIntervalDistance(sessionManager, 0)
+ testIntervalDistanceTarget(sessionManager, undefined)
+ testIntervalDistanceProjectedEnd(sessionManager, undefined)
+ testIntervalCaloriesSinceStart(sessionManager, 0)
+ testIntervalCaloriesTarget(sessionManager, undefined)
+ testIntervalCaloriesToEnd(sessionManager, undefined)
+ testSplitType(sessionManager, 'justrow')
+ testSplitTime(sessionManager, 0)
+ testSplitMovingTimeTarget(sessionManager, undefined)
+ testSplitMovingTimeToEnd(sessionManager, undefined)
+ testSplitMovingTimeProjectedEnd(sessionManager, undefined)
+ testSplitDistance(sessionManager, 0)
+ testSplitDistanceTarget(sessionManager, undefined)
+ testSplitDistanceProjectedEnd(sessionManager, undefined)
+ testSplitCaloriesSinceStart(sessionManager, 0)
+ testSplitCaloriesTarget(sessionManager, undefined)
+ testSplitCaloriesToEnd(sessionManager, undefined)
+
+ // Cut off the startup noise via a startup interval
+ const intervalSettings = []
+ intervalSettings[0] = {
+ type: 'time',
+ targetTime: 30
+ }
+ intervalSettings[1] = {
+ type: 'justrow',
+ split: {
+ type: 'distance',
+ targetDistance: 100
+ }
+ }
+ sessionManager.handleCommand('updateIntervalSettings', intervalSettings)
+
+ testSessionType(sessionManager, 'justrow')
+ testSessionNumberOfIntervals(sessionManager, 2)
+ testSessionTime(sessionManager, 0)
+ testSessionMovingTimeTarget(sessionManager, undefined)
+ testSessionMovingTimeToEnd(sessionManager, undefined)
+ testSessionDistance(sessionManager, 0)
+ testSessionDistanceToEnd(sessionManager, undefined)
+ testSessionDistanceTarget(sessionManager, undefined)
+ testSessionDistanceProjectedEnd(sessionManager, undefined)
+ testSessionCaloriesSinceStart(sessionManager, 0)
+ testSessionCaloriesTarget(sessionManager, undefined)
+ testSessionCaloriesToEnd(sessionManager, undefined)
+ testIntervalType(sessionManager, 'time')
+ testIntervalTime(sessionManager, 0)
+ testIntervalMovingTimeTarget(sessionManager, 30)
+ testIntervalMovingTimeToEnd(sessionManager, 30)
+ testIntervalMovingTimeProjectedEnd(sessionManager, 30)
+ testIntervalDistance(sessionManager, 0)
+ testIntervalDistanceTarget(sessionManager, undefined)
+ testIntervalDistanceProjectedEnd(sessionManager, undefined)
+ testIntervalCaloriesSinceStart(sessionManager, 0)
+ testIntervalCaloriesTarget(sessionManager, undefined)
+ testIntervalCaloriesToEnd(sessionManager, undefined)
+ testSplitType(sessionManager, 'time')
+ testSplitTime(sessionManager, 0)
+ testSplitMovingTimeTarget(sessionManager, 30)
+ testSplitMovingTimeToEnd(sessionManager, 30)
+ testSplitMovingTimeProjectedEnd(sessionManager, 30)
+ testSplitDistance(sessionManager, 0)
+ testSplitDistanceTarget(sessionManager, undefined)
+ testSplitDistanceProjectedEnd(sessionManager, undefined)
+ testSplitCaloriesSinceStart(sessionManager, 0)
+ testSplitCaloriesTarget(sessionManager, undefined)
+ testSplitCaloriesToEnd(sessionManager, undefined)
+
+ // Test Instantanuous metrics
+ testIsMoving(sessionManager, false)
+ testIsDriveStart(sessionManager, false)
+ testIsRecoveryStart(sessionManager, false)
+ testIsSessionStart(sessionManager, false)
+ testIsIntervalEnd(sessionManager, false)
+ testIsSplitEnd(sessionManager, false)
+ testIsPauseStart(sessionManager, false)
+ testIsPauseEnd(sessionManager, false)
+ testIsSessionStop(sessionManager, false)
+ testTotalMovingTime(sessionManager, 0)
+ testTotalLinearDistance(sessionManager, 0)
+ testTotalWork(sessionManager, 0)
+ testTotalCalories(sessionManager, 0)
+ testTotalCaloriesPerMinute(sessionManager, 0)
+ testTotalCaloriesPerHour (sessionManager, 0)
+ testTotalNumberOfStrokes(sessionManager, 0)
+ testStrokeState(sessionManager, 'WaitingForDrive')
+ testStrokeWork(sessionManager, undefined)
+ testStrokeCalories(sessionManager, undefined)
+ testCycleDistance(sessionManager, undefined)
+ testCycleStrokeRate(sessionManager, undefined)
+ testCycleDuration(sessionManager, undefined)
+ testCycleLinearVelocity(sessionManager, undefined)
+ testCyclePace(sessionManager, Infinity)
+ testCyclePower(sessionManager, undefined)
+ testDriveLastStartTime (sessionManager, 0)
+ testDriveDuration(sessionManager, undefined)
+ testDriveLength(sessionManager, undefined)
+ testDriveDistance(sessionManager, undefined)
+ testDriveAverageHandleForce(sessionManager, undefined)
+ testDrivePeakHandleForce(sessionManager, undefined)
+ testRecoveryDuration(sessionManager, undefined)
+ testDragFactor(sessionManager, undefined)
+
+ // Test session metrics
+ testSessionType(sessionManager, 'justrow')
+ testSessionState(sessionManager, 'WaitingForStart')
+ testSessionTime(sessionManager, 0)
+ testSessionMovingTimeTarget(sessionManager, undefined)
+ testSessionMovingTimeToEnd(sessionManager, undefined)
+ testSessionMovingTimeProjectedEnd(sessionManager, undefined)
+ testSessionTimeSpentTotal(sessionManager, 0)
+ testSessionTimeSpentMoving(sessionManager, 0)
+ testSessionTimeSpentRest(sessionManager, 0)
+ testSessionDistance(sessionManager, 0)
+ testSessionDistanceToEnd(sessionManager, undefined)
+ testSessionDistanceTarget(sessionManager, undefined)
+ testSessionDistanceProjectedEnd(sessionManager, undefined)
+ testSessionWorkSinceStart(sessionManager, 0)
+ testSessionCaloriesSinceStart(sessionManager, 0)
+ testSessionCaloriesTarget(sessionManager, undefined)
+ testSessionCaloriesToEnd(sessionManager, undefined)
+ testSessionNumberOfStrokes(sessionManager, 0)
+ testSessionMinimumPace(sessionManager, Infinity)
+ testSessionAveragePace(sessionManager, Infinity)
+ testSessionMaximumPace(sessionManager, Infinity)
+ testSessionMinimumLinearVelocity(sessionManager, 0)
+ testSessionAverageLinearVelocity(sessionManager, 0)
+ testSessionMaximumLinearVelocity(sessionManager, 0)
+ testSessionMinimumPower(sessionManager, 0)
+ testSessionAveragePower(sessionManager, 0)
+ testSessionMaximumPower(sessionManager, 0)
+ testSessionMinimumStrokedistance(sessionManager, 0)
+ testSessionAverageStrokedistance(sessionManager, 0)
+ testSessionMaximumStrokedistance(sessionManager, 0)
+ testSessionMinimumStrokerate(sessionManager, 0)
+ testSessionAverageStrokerate(sessionManager, 0)
+ testSessionMaximumStrokerate(sessionManager, 0)
+ testSessionMinimumDragfactor(sessionManager, 0)
+ testSessionAverageDragfactor(sessionManager, 0)
+ testSessionMaximumDragfactor(sessionManager, 0)
+ testSessionNumberOfIntervals(sessionManager, 2)
+
+ // Test interval metrics
+ testIntervalNumber(sessionManager, 0)
+ testIntervalWorkoutStepNumber(sessionManager, 0)
+ testIntervalType(sessionManager, 'time')
+ testIntervalTime(sessionManager, 0)
+ testIntervalMovingTimeTarget(sessionManager, 30)
+ testIntervalMovingTimeToEnd(sessionManager, 30)
+ testIntervalMovingTimeProjectedEnd(sessionManager, 30)
+ testIntervalTimeSpentTotal(sessionManager, 0)
+ testIntervalTimeSpentMoving(sessionManager, 0)
+ testIntervalTimeSpentRest(sessionManager, 0)
+ testIntervalDistance(sessionManager, 0)
+ testIntervalDistanceToEnd(sessionManager, undefined)
+ testIntervalDistanceTarget(sessionManager, undefined)
+ testIntervalDistanceProjectedEnd(sessionManager, undefined)
+ testIntervalWorkSinceStart(sessionManager, 0)
+ testIntervalCaloriesSinceStart(sessionManager, 0)
+ testIntervalCaloriesTarget(sessionManager, undefined)
+ testIntervalCaloriesToEnd(sessionManager, undefined)
+ testIntervalNumberOfStrokes(sessionManager, 0)
+ testIntervalMinimumPace(sessionManager, Infinity)
+ testIntervalAveragePace(sessionManager, Infinity)
+ testIntervalMaximumPace(sessionManager, Infinity)
+ testIntervalMinimumLinearVelocity(sessionManager, 0)
+ testIntervalAverageLinearVelocity(sessionManager, 0)
+ testIntervalMaximumLinearVelocity(sessionManager, 0)
+ testIntervalMinimumPower(sessionManager, 0)
+ testIntervalAveragePower(sessionManager, 0)
+ testIntervalMaximumPower(sessionManager, 0)
+ testIntervalMinimumStrokedistance(sessionManager, 0)
+ testIntervalAverageStrokedistance(sessionManager, 0)
+ testIntervalMaximumStrokedistance(sessionManager, 0)
+ testIntervalMinimumStrokerate(sessionManager, 0)
+ testIntervalAverageStrokerate(sessionManager, 0)
+ testIntervalMaximumStrokerate(sessionManager, 0)
+ testIntervalMinimumDragfactor(sessionManager, 0)
+ testIntervalAverageDragfactor(sessionManager, 0)
+ testIntervalMaximumDragfactor(sessionManager, 0)
+
+ // Test split metrics
+ // As the session has been forcefully stopped, so we have a rest interval without data
+ testSplitNumber(sessionManager, 0)
+ testSplitType(sessionManager, 'time')
+ testSplitTime(sessionManager, 0)
+ testSplitMovingTimeTarget(sessionManager, 30)
+ testSplitMovingTimeToEnd(sessionManager, 30)
+ testSplitMovingTimeProjectedEnd(sessionManager, 30)
+ testSplitTimeSpentTotal(sessionManager, 0)
+ testSplitTimeSpentMoving(sessionManager, 0)
+ testSplitTimeSpentRest(sessionManager, 0)
+ testSplitDistance(sessionManager, 0)
+ testSplitDistanceToEnd(sessionManager, undefined)
+ testSplitDistanceTarget(sessionManager, undefined)
+ testSplitDistanceProjectedEnd(sessionManager, undefined)
+ testSplitWorkSinceStart(sessionManager, 0)
+ testSplitCaloriesSinceStart(sessionManager, 0)
+ testSplitCaloriesTarget(sessionManager, undefined)
+ testSplitCaloriesToEnd(sessionManager, undefined)
+ testSplitNumberOfStrokes(sessionManager, 0)
+ testSplitMinimumPace(sessionManager, Infinity)
+ testSplitAveragePace(sessionManager, Infinity)
+ testSplitMaximumPace(sessionManager, Infinity)
+ testSplitMinimumLinearVelocity(sessionManager, 0)
+ testSplitAverageLinearVelocity(sessionManager, 0)
+ testSplitMaximumLinearVelocity(sessionManager, 0)
+ testSplitMinimumPower(sessionManager, 0)
+ testSplitAveragePower(sessionManager, 0)
+ testSplitMaximumPower(sessionManager, 0)
+ testSplitMinimumStrokedistance(sessionManager, 0)
+ testSplitAverageStrokedistance(sessionManager, 0)
+ testSplitMaximumStrokedistance(sessionManager, 0)
+ testSplitMinimumStrokerate(sessionManager, 0)
+ testSplitAverageStrokerate(sessionManager, 0)
+ testSplitMaximumStrokerate(sessionManager, 0)
+ testSplitMinimumDragfactor(sessionManager, 0)
+ testSplitAverageDragfactor(sessionManager, 0)
+ testSplitMaximumDragfactor(sessionManager, 0)
+
+ await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/Theoretical_Simulation_Clean.csv', realtime: false, loop: false })
+
+ testSessionState(sessionManager, 'Paused') // As we suddenly stop sending data, the sessionManager will assume a forced pause
+
+ // Test Instantanuous metrics
+ testIsMoving(sessionManager, false) // This implies that all instantanuous metrics must be undefined
+ testIsDriveStart(sessionManager, false)
+ testIsRecoveryStart(sessionManager, false)
+ testIsSessionStart(sessionManager, false)
+ testIsIntervalEnd(sessionManager, false)
+ testIsSplitEnd(sessionManager, false)
+ testIsPauseStart(sessionManager, false)
+ testIsPauseEnd(sessionManager, false)
+ testIsSessionStop(sessionManager, false)
+ testTotalMovingTime(sessionManager, 61.343050894547225)
+ testTotalLinearDistance(sessionManager, 235.67436268645264)
+ testTotalWork(sessionManager, 10082.626106960444)
+ testTotalCalories(sessionManager, 14.714421962126979)
+ testTotalCaloriesPerMinute(sessionManager, 14.881045347565594)
+ testTotalCaloriesPerHour (sessionManager, 892.8627208539357)
+ testTotalNumberOfStrokes(sessionManager, 19)
+ testStrokeState(sessionManager, 'WaitingForDrive') // This is normal for an unplanned pause
+ testStrokeWork(sessionManager, undefined)
+ testStrokeCalories(sessionManager, undefined)
+ testCycleDistance(sessionManager, undefined)
+ testCycleStrokeRate(sessionManager, undefined)
+ testCycleDuration(sessionManager, undefined)
+ testCycleLinearVelocity(sessionManager, undefined)
+ testCyclePace(sessionManager, Infinity)
+ testCyclePower(sessionManager, undefined)
+ testDriveLastStartTime (sessionManager, 55.354990361809314)
+ testDriveDuration(sessionManager, undefined)
+ testDriveLength(sessionManager, undefined)
+ testDriveDistance(sessionManager, undefined)
+ testDriveAverageHandleForce(sessionManager, undefined)
+ testDrivePeakHandleForce(sessionManager, undefined)
+ testRecoveryDuration(sessionManager, undefined)
+ testDragFactor(sessionManager, 119.92470860873752) // Theoretical value 120 * 10^−6 [ k g m 2 ]
+
+ // Test session metrics
+ testSessionType(sessionManager, 'justrow')
+ testSessionState(sessionManager, 'Paused') // As we suddenly stop sending data, the sessionManager will assume a forced pause
+ testSessionTime(sessionManager, 61.343050894547225)
+ testSessionMovingTimeTarget(sessionManager, undefined)
+ testSessionMovingTimeToEnd(sessionManager, undefined)
+ testSessionMovingTimeProjectedEnd(sessionManager, undefined)
+ testSessionTimeSpentTotal(sessionManager, 61.343050894547225)
+ testSessionTimeSpentMoving(sessionManager, 61.343050894547225)
+ testSessionTimeSpentRest(sessionManager, 0)
+ testSessionDistance(sessionManager, 235.67436268645264)
+ testSessionDistanceToEnd(sessionManager, undefined)
+ testSessionDistanceTarget(sessionManager, undefined)
+ testSessionDistanceProjectedEnd(sessionManager, undefined)
+ testSessionWorkSinceStart(sessionManager, 10082.626106960444)
+ testSessionCaloriesSinceStart(sessionManager, 14.714421962126979)
+ testSessionCaloriesTarget(sessionManager, undefined)
+ testSessionCaloriesToEnd(sessionManager, undefined)
+ testSessionNumberOfStrokes(sessionManager, 19)
+ testSessionMinimumPace(sessionManager, 144.0892354553984)
+ testSessionAveragePace(sessionManager, 130.14366559709262) // Theoretical value 126.4 sec/500m
+ testSessionMaximumPace(sessionManager, 127.41091348974768)
+ testSessionMinimumLinearVelocity(sessionManager, 3.470071851097931)
+ testSessionAverageLinearVelocity(sessionManager, 3.841908076785951)
+ testSessionMaximumLinearVelocity(sessionManager, 3.9243106128442697)
+ testSessionMinimumPower(sessionManager, 116.99665182631449)
+ testSessionAveragePower(sessionManager, 165.54273711119356) // Theoretical value 173.33 Watts
+ testSessionMaximumPower(sessionManager, 169.2186227233455)
+ testSessionMinimumStrokedistance(sessionManager, 6.587811328583184)
+ testSessionAverageStrokedistance(sessionManager, 11.5140880042863) // Theoretical value 11.77 meters
+ testSessionMaximumStrokedistance(sessionManager, 11.797809243281058)
+ testSessionMinimumStrokerate(sessionManager, 19.934902370540915)
+ testSessionAverageStrokerate(sessionManager, 20.456618908984527) // Theoretical value 20 SPM
+ testSessionMaximumStrokerate(sessionManager, 31.604473880804594)
+ testSessionMinimumDragfactor(sessionManager, 119.53033047834111)
+ testSessionAverageDragfactor(sessionManager, 119.91517741425835) // Theoretical value 120 * 10^−6 [ k g m 2 ]
+ testSessionMaximumDragfactor(sessionManager, 120.10320388420907)
+ testSessionNumberOfIntervals(sessionManager, 2)
+
+ // Test interval metrics
+ // This is the second interval, a 'justrow' after the 30 seconds one
+ testIntervalNumber(sessionManager, 1)
+ testIntervalWorkoutStepNumber(sessionManager, 1)
+ testIntervalType(sessionManager, 'justrow')
+ testIntervalTime(sessionManager, 31.343050894547225) // Must be 61.343050894547225 - 30 seconds
+ testIntervalMovingTimeTarget(sessionManager, undefined)
+ testIntervalMovingTimeToEnd(sessionManager, undefined)
+ testIntervalMovingTimeProjectedEnd(sessionManager, undefined)
+ testIntervalTimeSpentTotal(sessionManager, 31.343050894547225) // Must be 61.343050894547225 - 30 seconds
+ testIntervalTimeSpentMoving(sessionManager, 31.343050894547225) // Must be 61.343050894547225 - 30 seconds
+ testIntervalTimeSpentRest(sessionManager, 0)
+ testIntervalDistance(sessionManager, 119.60692098433933) // Should roughly be 50% of 235.67436268645264
+ testIntervalDistanceToEnd(sessionManager, undefined)
+ testIntervalDistanceTarget(sessionManager, undefined)
+ testIntervalDistanceProjectedEnd(sessionManager, undefined)
+ testIntervalWorkSinceStart(sessionManager, 4671.686006844603) // Should roughly be 50% of 10082.626106960444
+ testIntervalCaloriesSinceStart(sessionManager, 7.061145676302369) // Should roughly be 50% of 14.714421962126979
+ testIntervalCaloriesTarget(sessionManager, undefined)
+ testIntervalCaloriesToEnd(sessionManager, undefined)
+ testIntervalNumberOfStrokes(sessionManager, 9) // Should roughly be 50% of 19
+ testIntervalMinimumPace(sessionManager, 127.56507833607847)
+ testIntervalAveragePace(sessionManager, 131.0252393281285) // @ToDo: Find out why the average is not between min and max // Theoretical value 126.4 sec/500m
+ testIntervalMaximumPace(sessionManager, 127.45906821256413)
+ testIntervalMinimumLinearVelocity(sessionManager, 3.919568008124587)
+ testIntervalAverageLinearVelocity(sessionManager, 3.816058665978411) // @ToDo: Find out why the average is not between min and max
+ testIntervalMaximumLinearVelocity(sessionManager, 3.9228279871475875)
+ testIntervalMinimumPower(sessionManager, 168.60585499373533)
+ testIntervalAveragePower(sessionManager, 168.85374966728924) // Theoretical value 173.33 Watts
+ testIntervalMaximumPower(sessionManager, 169.02691185708946)
+ testIntervalMinimumStrokedistance(sessionManager, 11.761170031995)
+ testIntervalAverageStrokedistance(sessionManager, 11.76931211244628) // Theoretical value 11.77 meters
+ testIntervalMaximumStrokedistance(sessionManager, 11.797809243281058)
+ testIntervalMinimumStrokerate(sessionManager, 19.934902370540915)
+ testIntervalAverageStrokerate(sessionManager, 19.991783863976412) // Theoretical value 20 SPM
+ testIntervalMaximumStrokerate(sessionManager, 20.008254387829442)
+ testIntervalMinimumDragfactor(sessionManager, 119.92470619589947)
+ testIntervalAverageDragfactor(sessionManager, 119.92470820224528) // Theoretical value 120 * 10^−6 [ k g m 2 ]
+ testIntervalMaximumDragfactor(sessionManager, 119.92470861917981)
+
+ // Test split metrics
+ // As we are in un unplanned pause, this is an empty split
+ testSplitNumber(sessionManager, 3)
+ testSplitType(sessionManager, 'rest')
+ testSplitTime(sessionManager, 0)
+ testSplitMovingTimeTarget(sessionManager, undefined)
+ testSplitMovingTimeToEnd(sessionManager, 0)
+ testSplitMovingTimeProjectedEnd(sessionManager, undefined)
+ testSplitTimeSpentTotal(sessionManager, 0)
+ testSplitTimeSpentMoving(sessionManager, 0)
+ testSplitTimeSpentRest(sessionManager, 0)
+ testSplitDistance(sessionManager, 0)
+ testSplitDistanceToEnd(sessionManager, undefined)
+ testSplitDistanceTarget(sessionManager, undefined)
+ testSplitDistanceProjectedEnd(sessionManager, undefined)
+ testSplitWorkSinceStart(sessionManager, 0)
+ testSplitCaloriesSinceStart(sessionManager, 0)
+ testSplitCaloriesTarget(sessionManager, undefined)
+ testSplitCaloriesToEnd(sessionManager, undefined)
+ testSplitNumberOfStrokes(sessionManager, 0)
+ testSplitMinimumPace(sessionManager, Infinity)
+ testSplitAveragePace(sessionManager, Infinity)
+ testSplitMaximumPace(sessionManager, Infinity)
+ testSplitMinimumLinearVelocity(sessionManager, 0)
+ testSplitAverageLinearVelocity(sessionManager, 0)
+ testSplitMaximumLinearVelocity(sessionManager, 0)
+ testSplitMinimumPower(sessionManager, 0)
+ testSplitAveragePower(sessionManager, 0)
+ testSplitMaximumPower(sessionManager, 0)
+ testSplitMinimumStrokedistance(sessionManager, 0)
+ testSplitAverageStrokedistance(sessionManager, 0)
+ testSplitMaximumStrokedistance(sessionManager, 0)
+ testSplitMinimumStrokerate(sessionManager, 0)
+ testSplitAverageStrokerate(sessionManager, 0)
+ testSplitMaximumStrokerate(sessionManager, 0)
+ testSplitMinimumDragfactor(sessionManager, 0)
+ testSplitAverageDragfactor(sessionManager, 0)
+ testSplitMaximumDragfactor(sessionManager, 0)
+})
+
+/**
+ * @description Test against a theoretical model, based on perfect clean data
+ * @see {@link https://github.com/JaapvanEkris/openrowingmonitor/discussions/215|this discussion}
+ */
+test('TheoreticalModel_03: perfect clean data on the Theoretical Model should produce plausible results for an one minute run without startup noise', async () => {
+ const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Theoretical_Model)
+ const testConfig = {
+ loglevel: {
+ default: 'silent',
+ RowingEngine: 'silent'
+ },
+ numOfPhasesForAveragingScreenData: 2,
+ userSettings: {
+ sex: 'male'
+ },
+ rowerSettings: rowerProfile
+ }
+
+ const sessionManager = createSessionManager(testConfig)
+
+ testSessionType(sessionManager, 'justrow')
+ testIntervalType(sessionManager, 'justrow')
+ testSplitType(sessionManager, 'justrow')
+
+ // Cut off the startup noise via a startup interval
+ const intervalSettings = []
+ intervalSettings[0] = {
+ type: 'time',
+ targetTime: 30
+ }
+ intervalSettings[1] = {
+ type: 'time',
+ targetTime: 30,
+ split: {
+ type: 'distance',
+ targetDistance: 100
+ }
+ }
+ sessionManager.handleCommand('updateIntervalSettings', intervalSettings)
+
+ testSessionType(sessionManager, 'time')
+ testSessionNumberOfIntervals(sessionManager, 2)
+ testSessionTime(sessionManager, 0)
+ testSessionMovingTimeTarget(sessionManager, 60)
+ testSessionMovingTimeToEnd(sessionManager, 60)
+ testSessionDistance(sessionManager, 0)
+ testSessionDistanceToEnd(sessionManager, undefined)
+ testSessionDistanceTarget(sessionManager, undefined)
+ testSessionDistanceProjectedEnd(sessionManager, undefined)
+ testSessionCaloriesSinceStart(sessionManager, 0)
+ testSessionCaloriesTarget(sessionManager, undefined)
+ testSessionCaloriesToEnd(sessionManager, undefined)
+ testIntervalType(sessionManager, 'time')
+ testIntervalTime(sessionManager, 0)
+ testIntervalMovingTimeTarget(sessionManager, 30)
+ testIntervalMovingTimeToEnd(sessionManager, 30)
+ testIntervalMovingTimeProjectedEnd(sessionManager, 30)
+ testIntervalDistance(sessionManager, 0)
+ testIntervalDistanceTarget(sessionManager, undefined)
+ testIntervalDistanceProjectedEnd(sessionManager, undefined)
+ testIntervalCaloriesSinceStart(sessionManager, 0)
+ testIntervalCaloriesTarget(sessionManager, undefined)
+ testIntervalCaloriesToEnd(sessionManager, undefined)
+ testSplitType(sessionManager, 'time')
+ testSplitTime(sessionManager, 0)
+ testSplitMovingTimeTarget(sessionManager, 30)
+ testSplitMovingTimeToEnd(sessionManager, 30)
+ testSplitMovingTimeProjectedEnd(sessionManager, 30)
+ testSplitDistance(sessionManager, 0)
+ testSplitDistanceTarget(sessionManager, undefined)
+ testSplitDistanceProjectedEnd(sessionManager, undefined)
+ testSplitCaloriesSinceStart(sessionManager, 0)
+ testSplitCaloriesTarget(sessionManager, undefined)
+ testSplitCaloriesToEnd(sessionManager, undefined)
+
+ testSessionState(sessionManager, 'WaitingForStart')
+ testTotalMovingTime(sessionManager, 0)
+ testTotalLinearDistance(sessionManager, 0)
+ testTotalCalories(sessionManager, 0)
+ testTotalNumberOfStrokes(sessionManager, 0)
+ testDragFactor(sessionManager, undefined)
+
+ await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/Theoretical_Simulation_Clean.csv', realtime: false, loop: false })
+
+ // Test Instantanuous metrics
+ testIsMoving(sessionManager, false) // This implies that all instantanuous metrics must be undefined
+ testIsDriveStart(sessionManager, false)
+ testIsRecoveryStart(sessionManager, false)
+ testIsSessionStart(sessionManager, false)
+ testIsIntervalEnd(sessionManager, false)
+ testIsSplitEnd(sessionManager, false)
+ testIsPauseStart(sessionManager, false)
+ testIsPauseEnd(sessionManager, false)
+ testIsSessionStop(sessionManager, false)
+ testTotalMovingTime(sessionManager, 60.00317320053867)
+ testTotalLinearDistance(sessionManager, 232.08372509069608)
+ testTotalWork(sessionManager, 10082.626106960444)
+ testTotalCalories(sessionManager, 14.602765487626264)
+ testTotalCaloriesPerMinute(sessionManager, 14.881045347565594)
+ testTotalCaloriesPerHour (sessionManager, 892.8627208539357)
+ testTotalNumberOfStrokes(sessionManager, 19)
+ testStrokeState(sessionManager, 'Stopped') // This is normal for an unplanned pause
+ testStrokeWork(sessionManager, undefined)
+ testStrokeCalories(sessionManager, undefined)
+ testCycleDistance(sessionManager, undefined)
+ testCycleStrokeRate(sessionManager, undefined)
+ testCycleDuration(sessionManager, undefined)
+ testCycleLinearVelocity(sessionManager, undefined)
+ testCyclePace(sessionManager, Infinity)
+ testCyclePower(sessionManager, undefined)
+ testDriveLastStartTime (sessionManager, 55.354990361809314)
+ testDriveDuration(sessionManager, undefined)
+ testDriveLength(sessionManager, undefined)
+ testDriveDistance(sessionManager, undefined)
+ testDriveAverageHandleForce(sessionManager, undefined)
+ testDrivePeakHandleForce(sessionManager, undefined)
+ testRecoveryDuration(sessionManager, undefined)
+ testDragFactor(sessionManager, 119.92470860873752) // Theoretical value 120 * 10^−6 [ k g m 2 ]
+
+ // Test session metrics
+ testSessionType(sessionManager, 'time')
+ testSessionState(sessionManager, 'Stopped') // As we suddenly stop sending data, the sessionManager will assume a forced pause
+ testSessionTime(sessionManager, 60.00317320053867)
+ testSessionMovingTimeTarget(sessionManager, 60)
+ testSessionMovingTimeToEnd(sessionManager, -0.0031732005386686524) // We overshoot the target due to the test setup
+ testSessionMovingTimeProjectedEnd(sessionManager, 60)
+ testSessionTimeSpentTotal(sessionManager, 60.00317320053867)
+ testSessionTimeSpentMoving(sessionManager, 60.00317320053867)
+ testSessionTimeSpentRest(sessionManager, 0)
+ testSessionDistance(sessionManager, 232.08372509069608)
+ testSessionDistanceToEnd(sessionManager, undefined)
+ testSessionDistanceTarget(sessionManager, undefined)
+ testSessionDistanceProjectedEnd(sessionManager, 232.07241914048825)
+ testSessionWorkSinceStart(sessionManager, 10082.626106960444)
+ testSessionCaloriesSinceStart(sessionManager, 14.602765487626264)
+ testSessionCaloriesTarget(sessionManager, undefined)
+ testSessionCaloriesToEnd(sessionManager, undefined)
+ testSessionNumberOfStrokes(sessionManager, 19)
+ testSessionMinimumPace(sessionManager, 144.0892354553984)
+ testSessionAveragePace(sessionManager, 129.27053195369476) // Theoretical value 126.4 sec/500m
+ testSessionMaximumPace(sessionManager, 127.41091348974768)
+ testSessionMinimumLinearVelocity(sessionManager, 3.470071851097931)
+ testSessionAverageLinearVelocity(sessionManager, 3.86785752671848)
+ testSessionMaximumLinearVelocity(sessionManager, 3.9243106128442697)
+ testSessionMinimumPower(sessionManager, 116.99665182631449)
+ testSessionAveragePower(sessionManager, 165.63151339413506) // Theoretical value 173.33 Watts
+ testSessionMaximumPower(sessionManager, 169.2186227233455)
+ testSessionMinimumStrokedistance(sessionManager, 6.587811328583184)
+ testSessionAverageStrokedistance(sessionManager, 11.520590164280335) // Theoretical value 11.77 meters
+ testSessionMaximumStrokedistance(sessionManager, 11.797809243281058)
+ testSessionMinimumStrokerate(sessionManager, 19.934902370540915)
+ testSessionAverageStrokerate(sessionManager, 20.444814958539755) // Theoretical value 20 SPM
+ testSessionMaximumStrokerate(sessionManager, 31.604473880804594)
+ testSessionMinimumDragfactor(sessionManager, 119.53033047834111)
+ testSessionAverageDragfactor(sessionManager, 119.91517741425835) // Theoretical value 120 * 10^−6 [ k g m 2 ]
+ testSessionMaximumDragfactor(sessionManager, 120.10320388420907)
+ testSessionNumberOfIntervals(sessionManager, 2)
+
+ // Test interval metrics
+ // This is the second interval, a 'time' after the 30 seconds one
+ testIntervalNumber(sessionManager, 1)
+ testIntervalWorkoutStepNumber(sessionManager, 1)
+ testIntervalType(sessionManager, 'time')
+ testIntervalTime(sessionManager, 30.00317320053867) // Must be 30 seconds
+ testIntervalMovingTimeTarget(sessionManager, 30)
+ testIntervalMovingTimeToEnd(sessionManager, -0.0031732005386686524) // This overshoot is due to the testing setup
+ testIntervalMovingTimeProjectedEnd(sessionManager, 30)
+ testIntervalTimeSpentTotal(sessionManager, 30.00317320053867) // Must be 30 seconds
+ testIntervalTimeSpentMoving(sessionManager, 30.00317320053867) // Must be 30 seconds
+ testIntervalTimeSpentRest(sessionManager, 0)
+ testIntervalDistance(sessionManager, 116.01628338858276) // Should roughly be 50% of 232.08372509069608
+ testIntervalDistanceToEnd(sessionManager, undefined)
+ testIntervalDistanceTarget(sessionManager, undefined)
+ testIntervalDistanceProjectedEnd(sessionManager, 116.00497743837494)
+ testIntervalWorkSinceStart(sessionManager, 4671.686006844603) // Should roughly be 50% of 10082.626106960444
+ testIntervalCaloriesSinceStart(sessionManager, 6.949489201801654) // Should roughly be 50% of 14.602765487626264
+ testIntervalCaloriesTarget(sessionManager, undefined)
+ testIntervalCaloriesToEnd(sessionManager, undefined)
+ testIntervalNumberOfStrokes(sessionManager, 9) // Should roughly be 50% of 19
+ testIntervalMinimumPace(sessionManager, 127.56507833607847)
+ testIntervalAveragePace(sessionManager, 129.30587122863867) // @ToDo: Find out why the average is not between min and max // Theoretical value 126.4 sec/500m
+ testIntervalMaximumPace(sessionManager, 127.45906821256413)
+ testIntervalMinimumLinearVelocity(sessionManager, 3.919568008124587)
+ testIntervalAverageLinearVelocity(sessionManager, 3.8668004418445925) // @ToDo: Find out why the average is not between min and max
+ testIntervalMaximumLinearVelocity(sessionManager, 3.9228279871475875)
+ testIntervalMinimumPower(sessionManager, 168.60585499373533)
+ testIntervalAveragePower(sessionManager, 168.85703841443038) // Theoretical value 173.33 Watts
+ testIntervalMaximumPower(sessionManager, 169.02691185708946)
+ testIntervalMinimumStrokedistance(sessionManager, 11.761170031995)
+ testIntervalAverageStrokedistance(sessionManager, 11.768883584636455) // Theoretical value 11.77 meters
+ testIntervalMaximumStrokedistance(sessionManager, 11.797809243281058)
+ testIntervalMinimumStrokerate(sessionManager, 19.934902370540915)
+ testIntervalAverageStrokerate(sessionManager, 19.992640965455724) // Theoretical value 20 SPM
+ testIntervalMaximumStrokerate(sessionManager, 20.008254387829442)
+ testIntervalMinimumDragfactor(sessionManager, 119.92470619589947)
+ testIntervalAverageDragfactor(sessionManager, 119.92470820224528) // Theoretical value 120 * 10^−6 [ k g m 2 ]
+ testIntervalMaximumDragfactor(sessionManager, 119.92470861917981)
+
+ // Test split metrics
+ testSplitNumber(sessionManager, 2)
+ testSplitType(sessionManager, 'distance')
+ testSplitTime(sessionManager, 4.3932161982612215)
+ testSplitMovingTimeTarget(sessionManager, undefined)
+ testSplitMovingTimeToEnd(sessionManager, undefined)
+ testSplitMovingTimeProjectedEnd(sessionManager, 27.964625049896377)
+ testSplitTimeSpentTotal(sessionManager, 4.3932161982612215)
+ testSplitTimeSpentMoving(sessionManager, 4.3932161982612215)
+ testSplitTimeSpentRest(sessionManager, 0)
+ testSplitDistance(sessionManager, 16.016283388582764) // Value must be 116.01628338858276 - 100 meters
+ testSplitDistanceToEnd(sessionManager, 83.98371661141726)
+ testSplitDistanceTarget(sessionManager, 100)
+ testSplitDistanceProjectedEnd(sessionManager, 100)
+ testSplitWorkSinceStart(sessionManager, 445.08219269137953)
+ testSplitCaloriesSinceStart(sessionManager, 0.7847022766955813)
+ testSplitCaloriesTarget(sessionManager, undefined)
+ testSplitCaloriesToEnd(sessionManager, undefined)
+ testSplitNumberOfStrokes(sessionManager, 0)
+ testSplitMinimumPace(sessionManager, 127.48689711011771)
+ testSplitAveragePace(sessionManager, 137.1484286233638) // @ToDo: Find out why the average is not between min and max
+ testSplitMaximumPace(sessionManager, 127.48689711011771)
+ testSplitMinimumLinearVelocity(sessionManager, 3.921971679710123)
+ testSplitAverageLinearVelocity(sessionManager, 3.6456852259904267)
+ testSplitMaximumLinearVelocity(sessionManager, 3.921971679710123)
+ testSplitMinimumPower(sessionManager, 168.91623586297118)
+ testSplitAveragePower(sessionManager, 168.91623586297118)
+ testSplitMaximumPower(sessionManager, 168.91623586297118)
+ testSplitMinimumStrokedistance(sessionManager, 11.761170084059628)
+ testSplitAverageStrokedistance(sessionManager, 11.761170084059628)
+ testSplitMaximumStrokedistance(sessionManager, 11.761170084059628)
+ testSplitMinimumStrokerate(sessionManager, 20.008068792083268)
+ testSplitAverageStrokerate(sessionManager, 20.008068792083268)
+ testSplitMaximumStrokerate(sessionManager, 20.008068792083268)
+ testSplitMinimumDragfactor(sessionManager, 119.92470860873752)
+ testSplitAverageDragfactor(sessionManager, 119.92470860873752)
+ testSplitMaximumDragfactor(sessionManager, 119.92470860873752)
+})
+
+/**
+ * @description Test against a theoretical model, based on noise-injected data
+ * @see {@link https://github.com/JaapvanEkris/openrowingmonitor/discussions/215|this discussion}
+ */
+test('TheoreticalModel_04: Random noise-injected data on the Theoretical Model should produce plausible results for an unlimited run with startup noise', async () => {
+ const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Theoretical_Model)
+ const testConfig = {
+ loglevel: {
+ default: 'silent',
+ RowingEngine: 'silent'
+ },
+ numOfPhasesForAveragingScreenData: 2,
+ userSettings: {
+ sex: 'male'
+ },
+ rowerSettings: rowerProfile
+ }
+ const sessionManager = createSessionManager(testConfig)
+
+ // Test session metrics
+ testSessionType(sessionManager, 'justrow')
+ testSessionState(sessionManager, 'WaitingForStart')
+ testIntervalType(sessionManager, 'justrow')
+ testSplitType(sessionManager, 'justrow')
+ testTotalMovingTime(sessionManager, 0)
+ testTotalLinearDistance(sessionManager, 0)
+ testTotalCalories(sessionManager, 0)
+ testTotalNumberOfStrokes(sessionManager, 0)
+ testDragFactor(sessionManager, undefined)
+
+ await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/Theoretical_Simulation_Random_Noise.csv', realtime: false, loop: false })
+
+ // Test Instantanuous metrics
+ testIsMoving(sessionManager, false) // This implies that all instantanuous metrics must be undefined
+ testIsDriveStart(sessionManager, false)
+ testIsRecoveryStart(sessionManager, false)
+ testIsSessionStart(sessionManager, false)
+ testIsIntervalEnd(sessionManager, false)
+ testIsSplitEnd(sessionManager, false)
+ testIsPauseStart(sessionManager, false)
+ testIsPauseEnd(sessionManager, false)
+ testIsSessionStop(sessionManager, false)
+ testTotalMovingTime(sessionManager, 181.35954479988044)
+ testTotalLinearDistance(sessionManager, 706.3578534826354)
+ testTotalWork(sessionManager, 30830.62794514783)
+ testTotalCalories(sessionManager, 44.47579820489273)
+ testTotalCaloriesPerMinute(sessionManager, 14.889408208674077)
+ testTotalCaloriesPerHour (sessionManager, 893.3644925204446)
+ testTotalNumberOfStrokes(sessionManager, 59)
+ testStrokeState(sessionManager, 'WaitingForDrive') // This is normal for an unplanned pause
+ testStrokeWork(sessionManager, undefined)
+ testStrokeCalories(sessionManager, undefined)
+ testCycleDistance(sessionManager, undefined)
+ testCycleStrokeRate(sessionManager, undefined)
+ testCycleDuration(sessionManager, undefined)
+ testCycleLinearVelocity(sessionManager, undefined)
+ testCyclePace(sessionManager, Infinity)
+ testCyclePower(sessionManager, undefined)
+ testDriveLastStartTime (sessionManager, 175.35692827178198)
+ testDriveDuration(sessionManager, undefined)
+ testDriveLength(sessionManager, undefined)
+ testDriveDistance(sessionManager, undefined)
+ testDriveAverageHandleForce(sessionManager, undefined)
+ testDrivePeakHandleForce(sessionManager, undefined)
+ testRecoveryDuration(sessionManager, undefined)
+ testDragFactor(sessionManager, 119.95812733742444) // Theoretical value 120 * 10^−6 [ k g m 2 ]
+
+ // Test session metrics
+ testSessionType(sessionManager, 'justrow')
+ testSessionState(sessionManager, 'Paused') // As we faded with a tail, the flywheel will assume an unplanned pause/stop
+ testSessionTime(sessionManager, 181.35954479988044)
+ testSessionMovingTimeTarget(sessionManager, undefined)
+ testSessionMovingTimeToEnd(sessionManager, undefined)
+ testSessionMovingTimeProjectedEnd(sessionManager, undefined)
+ testSessionTimeSpentTotal(sessionManager, 181.35954479988044)
+ testSessionTimeSpentMoving(sessionManager, 181.35954479988044)
+ testSessionTimeSpentRest(sessionManager, 0)
+ testSessionDistance(sessionManager, 706.3578534826354)
+ testSessionDistanceToEnd(sessionManager, undefined)
+ testSessionDistanceTarget(sessionManager, undefined)
+ testSessionDistanceProjectedEnd(sessionManager, undefined)
+ testSessionWorkSinceStart(sessionManager, 30830.62794514783)
+ testSessionCaloriesSinceStart(sessionManager, 44.47579820489273)
+ testSessionCaloriesTarget(sessionManager, undefined)
+ testSessionCaloriesToEnd(sessionManager, undefined)
+ testSessionNumberOfStrokes(sessionManager, 59)
+ testSessionMinimumPace(sessionManager, 143.20756199431048)
+ testSessionAveragePace(sessionManager, 128.3765331592925) // Theoretical value 126.4 sec/500m
+ testSessionMaximumPace(sessionManager, 127.26445208111573)
+ testSessionMinimumLinearVelocity(sessionManager, 3.4914357387067634)
+ testSessionAverageLinearVelocity(sessionManager, 3.8947928230745155)
+ testSessionMaximumLinearVelocity(sessionManager, 3.9288268783910714)
+ testSessionMinimumPower(sessionManager, 119.17089214328217)
+ testSessionAveragePower(sessionManager, 167.86656212390972) // Theoretical value 173.33 Watts
+ testSessionMaximumPower(sessionManager, 169.80499196952417)
+ testSessionMinimumStrokedistance(sessionManager, 6.8147639775967415)
+ testSessionAverageStrokedistance(sessionManager, 11.688190521312269) // Theoretical value 11.77 meters
+ testSessionMaximumStrokedistance(sessionManager, 11.926002210270553)
+ testSessionMinimumStrokerate(sessionManager, 19.766111256103418)
+ testSessionAverageStrokerate(sessionManager, 20.136702368594744) // Theoretical value 20 SPM
+ testSessionMaximumStrokerate(sessionManager, 30.74004397086722)
+ testSessionMinimumDragfactor(sessionManager, 119.88119659734743)
+ testSessionAverageDragfactor(sessionManager, 119.92945505951025) // Theoretical value 120 * 10^−6 [ k g m 2 ]
+ testSessionMaximumDragfactor(sessionManager, 119.98099357162658)
+ testSessionNumberOfIntervals(sessionManager, 1)
+
+ // Test interval metrics
+ testIntervalNumber(sessionManager, 0)
+ testIntervalWorkoutStepNumber(sessionManager, 0)
+ testIntervalType(sessionManager, 'justrow')
+ testIntervalTime(sessionManager, 181.35954479988044)
+ testIntervalMovingTimeTarget(sessionManager, undefined)
+ testIntervalMovingTimeToEnd(sessionManager, undefined)
+ testIntervalMovingTimeProjectedEnd(sessionManager, undefined)
+ testIntervalTimeSpentTotal(sessionManager, 181.35954479988044)
+ testIntervalTimeSpentMoving(sessionManager, 181.35954479988044)
+ testIntervalTimeSpentRest(sessionManager, 0)
+ testIntervalDistance(sessionManager, 706.3578534826354)
+ testIntervalDistanceToEnd(sessionManager, undefined)
+ testIntervalDistanceTarget(sessionManager, undefined)
+ testIntervalDistanceProjectedEnd(sessionManager, undefined)
+ testIntervalWorkSinceStart(sessionManager, 30830.62794514783)
+ testIntervalCaloriesSinceStart(sessionManager, 44.47579820489273)
+ testIntervalCaloriesTarget(sessionManager, undefined)
+ testIntervalCaloriesToEnd(sessionManager, undefined)
+ testIntervalNumberOfStrokes(sessionManager, 59)
+ testIntervalMinimumPace(sessionManager, 143.20756199431048)
+ testIntervalAveragePace(sessionManager, 128.3765331592925)
+ testIntervalMaximumPace(sessionManager, 127.26445208111573)
+ testIntervalMinimumLinearVelocity(sessionManager, 3.4914357387067634)
+ testIntervalAverageLinearVelocity(sessionManager, 3.8947928230745155) // @ToDo: Find out why the average is not between min and max
+ testIntervalMaximumLinearVelocity(sessionManager, 3.9288268783910714)
+ testIntervalMinimumPower(sessionManager, 119.17089214328217)
+ testIntervalAveragePower(sessionManager, 167.86656212390972) // Theoretical value 173.33 Watts
+ testIntervalMaximumPower(sessionManager, 169.80499196952417)
+ testIntervalMinimumStrokedistance(sessionManager, 6.8147639775967415)
+ testIntervalAverageStrokedistance(sessionManager, 11.688190521312269) // Theoretical value 11.77 meters
+ testIntervalMaximumStrokedistance(sessionManager, 11.926002210270553)
+ testIntervalMinimumStrokerate(sessionManager, 19.766111256103418)
+ testIntervalAverageStrokerate(sessionManager, 20.136702368594744) // Theoretical value 20 SPM
+ testIntervalMaximumStrokerate(sessionManager, 30.74004397086722)
+ testIntervalMinimumDragfactor(sessionManager, 119.88119659734743)
+ testIntervalAverageDragfactor(sessionManager, 119.92945505951025) // Theoretical value 120 * 10^−6 [ k g m 2 ]
+ testIntervalMaximumDragfactor(sessionManager, 119.98099357162658)
+
+ // Test split metrics
+ // As we are in an unplanned pause, this is the rest split
+ testSplitNumber(sessionManager, 1)
+ testSplitType(sessionManager, 'rest')
+ testSplitTime(sessionManager, 0)
+ testSplitMovingTimeTarget(sessionManager, undefined)
+ testSplitMovingTimeToEnd(sessionManager, 0)
+ testSplitMovingTimeProjectedEnd(sessionManager, undefined)
+ testSplitTimeSpentTotal(sessionManager, 0)
+ testSplitTimeSpentMoving(sessionManager, 0)
+ testSplitTimeSpentRest(sessionManager, 0)
+ testSplitDistance(sessionManager, 0)
+ testSplitDistanceToEnd(sessionManager, undefined)
+ testSplitDistanceTarget(sessionManager, undefined)
+ testSplitDistanceProjectedEnd(sessionManager, undefined)
+ testSplitWorkSinceStart(sessionManager, 0)
+ testSplitCaloriesSinceStart(sessionManager, 0)
+ testSplitCaloriesTarget(sessionManager, undefined)
+ testSplitCaloriesToEnd(sessionManager, undefined)
+ testSplitNumberOfStrokes(sessionManager, 0)
+ testSplitMinimumPace(sessionManager, Infinity)
+ testSplitAveragePace(sessionManager, Infinity)
+ testSplitMaximumPace(sessionManager, Infinity)
+ testSplitMinimumLinearVelocity(sessionManager, 0)
+ testSplitAverageLinearVelocity(sessionManager, 0)
+ testSplitMaximumLinearVelocity(sessionManager, 0)
+ testSplitMinimumPower(sessionManager, 0)
+ testSplitAveragePower(sessionManager, 0)
+ testSplitMaximumPower(sessionManager, 0)
+ testSplitMinimumStrokedistance(sessionManager, 0)
+ testSplitAverageStrokedistance(sessionManager, 0)
+ testSplitMaximumStrokedistance(sessionManager, 0)
+ testSplitMinimumStrokerate(sessionManager, 0)
+ testSplitAverageStrokerate(sessionManager, 0)
+ testSplitMaximumStrokerate(sessionManager, 0)
+ testSplitMinimumDragfactor(sessionManager, 0)
+ testSplitAverageDragfactor(sessionManager, 0)
+ testSplitMaximumDragfactor(sessionManager, 0)
+})
+
+/**
+ * @description Test against a theoretical model, based on noise-injected data
+ * @see {@link https://github.com/JaapvanEkris/openrowingmonitor/discussions/215|this discussion}
+ */
+test('TheoreticalModel_05: Random noise-injected data on the Theoretical Model should produce plausible results for an unlimited run without startup noise', async () => {
+ const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Theoretical_Model)
+ const testConfig = {
+ loglevel: {
+ default: 'silent',
+ RowingEngine: 'silent'
+ },
+ numOfPhasesForAveragingScreenData: 2,
+ userSettings: {
+ sex: 'male'
+ },
+ rowerSettings: rowerProfile
+ }
+
+ const sessionManager = createSessionManager(testConfig)
+
+ testSessionType(sessionManager, 'justrow')
+ testIntervalType(sessionManager, 'justrow')
+ testSplitType(sessionManager, 'justrow')
+
+ // Cut off the startup noise via a startup interval
+ const intervalSettings = []
+ intervalSettings[0] = {
+ type: 'time',
+ targetTime: 60
+ }
+ intervalSettings[1] = {
+ type: 'justrow'
+ }
+ sessionManager.handleCommand('updateIntervalSettings', intervalSettings)
+
+ testSessionType(sessionManager, 'justrow')
+ testIntervalType(sessionManager, 'time')
+ testIntervalMovingTimeTarget(sessionManager, 60)
+ testSplitType(sessionManager, 'time')
+ testSplitMovingTimeTarget(sessionManager, 60)
+
+ testSessionState(sessionManager, 'WaitingForStart')
+ testTotalMovingTime(sessionManager, 0)
+ testTotalLinearDistance(sessionManager, 0)
+ testTotalCalories(sessionManager, 0)
+ testTotalNumberOfStrokes(sessionManager, 0)
+ testDragFactor(sessionManager, undefined)
+
+ await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/Theoretical_Simulation_Random_Noise.csv', realtime: false, loop: false })
+
+ // Test Instantanuous metrics
+ testIsMoving(sessionManager, false) // This implies that all instantanuous metrics must be undefined
+ testIsDriveStart(sessionManager, false)
+ testIsRecoveryStart(sessionManager, false)
+ testIsSessionStart(sessionManager, false)
+ testIsIntervalEnd(sessionManager, false)
+ testIsSplitEnd(sessionManager, false)
+ testIsPauseStart(sessionManager, false)
+ testIsPauseEnd(sessionManager, false)
+ testIsSessionStop(sessionManager, false)
+ testTotalMovingTime(sessionManager, 181.35954479988044)
+ testTotalLinearDistance(sessionManager, 706.3578534826354)
+ testTotalWork(sessionManager, 30830.62794514783)
+ testTotalCalories(sessionManager, 44.47579820489273)
+ testTotalCaloriesPerMinute(sessionManager, 14.889408208674077)
+ testTotalCaloriesPerHour (sessionManager, 893.3644925204446)
+ testTotalNumberOfStrokes(sessionManager, 59)
+ testStrokeState(sessionManager, 'WaitingForDrive') // This is normal for an unplanned pause
+ testStrokeWork(sessionManager, undefined)
+ testStrokeCalories(sessionManager, undefined)
+ testCycleDistance(sessionManager, undefined)
+ testCycleStrokeRate(sessionManager, undefined)
+ testCycleDuration(sessionManager, undefined)
+ testCycleLinearVelocity(sessionManager, undefined)
+ testCyclePace(sessionManager, Infinity)
+ testCyclePower(sessionManager, undefined)
+ testDriveLastStartTime (sessionManager, 175.35692827178198)
+ testDriveDuration(sessionManager, undefined)
+ testDriveLength(sessionManager, undefined)
+ testDriveDistance(sessionManager, undefined)
+ testDriveAverageHandleForce(sessionManager, undefined)
+ testDrivePeakHandleForce(sessionManager, undefined)
+ testRecoveryDuration(sessionManager, undefined)
+ testDragFactor(sessionManager, 119.95812733742444) // Theoretical value 120 * 10^−6 [ k g m 2 ]
+
+ // Test session metrics
+ testSessionType(sessionManager, 'justrow')
+ testSessionState(sessionManager, 'Paused') // As we faded with a tail, the flywheel will assume an unplanned pause/stop
+ testSessionTime(sessionManager, 181.35954479988044)
+ testSessionMovingTimeTarget(sessionManager, undefined)
+ testSessionMovingTimeToEnd(sessionManager, undefined)
+ testSessionMovingTimeProjectedEnd(sessionManager, undefined)
+ testSessionTimeSpentTotal(sessionManager, 181.35954479988044)
+ testSessionTimeSpentMoving(sessionManager, 181.35954479988044)
+ testSessionTimeSpentRest(sessionManager, 0)
+ testSessionDistance(sessionManager, 706.3578534826354)
+ testSessionDistanceToEnd(sessionManager, undefined)
+ testSessionDistanceTarget(sessionManager, undefined)
+ testSessionDistanceProjectedEnd(sessionManager, undefined)
+ testSessionWorkSinceStart(sessionManager, 30830.62794514783)
+ testSessionCaloriesSinceStart(sessionManager, 44.47579820489273)
+ testSessionCaloriesTarget(sessionManager, undefined)
+ testSessionCaloriesToEnd(sessionManager, undefined)
+ testSessionNumberOfStrokes(sessionManager, 59)
+ testSessionMinimumPace(sessionManager, 143.20756199431048)
+ testSessionAveragePace(sessionManager, 128.3765331592925) // Theoretical value 126.4 sec/500m
+ testSessionMaximumPace(sessionManager, 127.26445208111573)
+ testSessionMinimumLinearVelocity(sessionManager, 3.4914357387067634)
+ testSessionAverageLinearVelocity(sessionManager, 3.8947928230745155)
+ testSessionMaximumLinearVelocity(sessionManager, 3.9288268783910714)
+ testSessionMinimumPower(sessionManager, 119.17089214328217)
+ testSessionAveragePower(sessionManager, 167.86656212390972) // Theoretical value 173.33 Watts
+ testSessionMaximumPower(sessionManager, 169.80499196952417)
+ testSessionMinimumStrokedistance(sessionManager, 6.8147639775967415)
+ testSessionAverageStrokedistance(sessionManager, 11.688190521312269) // Theoretical value 11.77 meters
+ testSessionMaximumStrokedistance(sessionManager, 11.926002210270553)
+ testSessionMinimumStrokerate(sessionManager, 19.766111256103418)
+ testSessionAverageStrokerate(sessionManager, 20.136702368594744) // Theoretical value 20 SPM
+ testSessionMaximumStrokerate(sessionManager, 30.74004397086722)
+ testSessionMinimumDragfactor(sessionManager, 119.88119659734743)
+ testSessionAverageDragfactor(sessionManager, 119.92945505951025) // Theoretical value 120 * 10^−6 [ k g m 2 ]
+ testSessionMaximumDragfactor(sessionManager, 119.98099357162658)
+ testSessionNumberOfIntervals(sessionManager, 2)
+
+ // Test interval metrics
+ testIntervalNumber(sessionManager, 1)
+ testIntervalWorkoutStepNumber(sessionManager, 1)
+ testIntervalType(sessionManager, 'justrow')
+ testIntervalTime(sessionManager, 121.35954479988044) // Must be 181.35954479988044 - 60
+ testIntervalMovingTimeTarget(sessionManager, undefined)
+ testIntervalMovingTimeToEnd(sessionManager, undefined)
+ testIntervalMovingTimeProjectedEnd(sessionManager, undefined)
+ testIntervalTimeSpentTotal(sessionManager, 121.35954479988044) // Must be 181.35954479988044 - 60
+ testIntervalTimeSpentMoving(sessionManager, 121.35954479988044) // Must be 181.35954479988044 - 60
+ testIntervalTimeSpentRest(sessionManager, 0)
+ testIntervalDistance(sessionManager, 472.6432562138848)
+ testIntervalDistanceToEnd(sessionManager, undefined)
+ testIntervalDistanceTarget(sessionManager, undefined)
+ testIntervalDistanceProjectedEnd(sessionManager, undefined)
+ testIntervalWorkSinceStart(sessionManager, 20252.714098956265)
+ testIntervalCaloriesSinceStart(sessionManager, 29.40159454185315)
+ testIntervalCaloriesTarget(sessionManager, undefined)
+ testIntervalCaloriesToEnd(sessionManager, undefined)
+ testIntervalNumberOfStrokes(sessionManager, 39)
+ testIntervalMinimumPace(sessionManager, 127.75474337938022)
+ testIntervalAveragePace(sessionManager, 128.38387431149735)
+ testIntervalMaximumPace(sessionManager, 127.26445208111573)
+ testIntervalMinimumLinearVelocity(sessionManager, 3.9137490066822886)
+ testIntervalAverageLinearVelocity(sessionManager, 3.894570113898041) // @ToDo: Find out why the average is not between min and max
+ testIntervalMaximumLinearVelocity(sessionManager, 3.9288268783910714)
+ testIntervalMinimumPower(sessionManager, 167.85749146696816)
+ testIntervalAveragePower(sessionManager, 168.91570965510715) // Theoretical value 173.33 Watts
+ testIntervalMaximumPower(sessionManager, 169.80499196952417)
+ testIntervalMinimumStrokedistance(sessionManager, 11.596244164231889)
+ testIntervalAverageStrokedistance(sessionManager, 11.76579996842933) // Theoretical value 11.77 meters
+ testIntervalMaximumStrokedistance(sessionManager, 11.926002210270553)
+ testIntervalMinimumStrokerate(sessionManager, 19.766111256103418)
+ testIntervalAverageStrokerate(sessionManager, 20.000727150211933) // Theoretical value 20 SPM
+ testIntervalMaximumStrokerate(sessionManager, 20.254747571696083)
+ testIntervalMinimumDragfactor(sessionManager, 119.88119659734743)
+ testIntervalAverageDragfactor(sessionManager, 119.93062630759866) // Theoretical value 120 * 10^−6 [ k g m 2 ]
+ testIntervalMaximumDragfactor(sessionManager, 119.98099357162658)
+
+ // Test split metrics
+ // As we are in an unplanned pause, this is the rest split
+ testSplitNumber(sessionManager, 2)
+ testSplitType(sessionManager, 'rest')
+ testSplitTime(sessionManager, 0)
+ testSplitMovingTimeTarget(sessionManager, undefined)
+ testSplitMovingTimeToEnd(sessionManager, 0)
+ testSplitMovingTimeProjectedEnd(sessionManager, undefined)
+ testSplitTimeSpentTotal(sessionManager, 0)
+ testSplitTimeSpentMoving(sessionManager, 0)
+ testSplitTimeSpentRest(sessionManager, 0)
+ testSplitDistance(sessionManager, 0)
+ testSplitDistanceToEnd(sessionManager, undefined)
+ testSplitDistanceTarget(sessionManager, undefined)
+ testSplitDistanceProjectedEnd(sessionManager, undefined)
+ testSplitWorkSinceStart(sessionManager, 0)
+ testSplitCaloriesSinceStart(sessionManager, 0)
+ testSplitCaloriesTarget(sessionManager, undefined)
+ testSplitCaloriesToEnd(sessionManager, undefined)
+ testSplitNumberOfStrokes(sessionManager, 0)
+ testSplitMinimumPace(sessionManager, Infinity)
+ testSplitAveragePace(sessionManager, Infinity)
+ testSplitMaximumPace(sessionManager, Infinity)
+ testSplitMinimumLinearVelocity(sessionManager, 0)
+ testSplitAverageLinearVelocity(sessionManager, 0)
+ testSplitMaximumLinearVelocity(sessionManager, 0)
+ testSplitMinimumPower(sessionManager, 0)
+ testSplitAveragePower(sessionManager, 0)
+ testSplitMaximumPower(sessionManager, 0)
+ testSplitMinimumStrokedistance(sessionManager, 0)
+ testSplitAverageStrokedistance(sessionManager, 0)
+ testSplitMaximumStrokedistance(sessionManager, 0)
+ testSplitMinimumStrokerate(sessionManager, 0)
+ testSplitAverageStrokerate(sessionManager, 0)
+ testSplitMaximumStrokerate(sessionManager, 0)
+ testSplitMinimumDragfactor(sessionManager, 0)
+ testSplitAverageDragfactor(sessionManager, 0)
+ testSplitMaximumDragfactor(sessionManager, 0)
+})
+
+/**
+ * @description Test against a theoretical model, based on noise-injected data
+ * @see {@link https://github.com/JaapvanEkris/openrowingmonitor/discussions/215|this discussion}
+ */
+test('TheoreticalModel_06: Random noise-injected data on the Theoretical Model should produce plausible results for a 100 seconds run without startup noise', async () => {
+ const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Theoretical_Model)
+ const testConfig = {
+ loglevel: {
+ default: 'silent',
+ RowingEngine: 'silent'
+ },
+ numOfPhasesForAveragingScreenData: 2,
+ userSettings: {
+ sex: 'male'
+ },
+ rowerSettings: rowerProfile
+ }
+
+ const sessionManager = createSessionManager(testConfig)
+
+ testSessionType(sessionManager, 'justrow')
+ testIntervalType(sessionManager, 'justrow')
+ testSplitType(sessionManager, 'justrow')
+
+ // Cut off the startup noise via a startup interval
+ const intervalSettings = []
+ intervalSettings[0] = {
+ type: 'time',
+ targetTime: 60
+ }
+ intervalSettings[1] = {
+ type: 'time',
+ targetTime: 100,
+ split: {
+ type: 'time',
+ targetTime: 25
+ }
+ }
+ sessionManager.handleCommand('updateIntervalSettings', intervalSettings)
+
+ testSessionType(sessionManager, 'time')
+ testSessionMovingTimeTarget(sessionManager, 160)
+ testIntervalType(sessionManager, 'time')
+ testIntervalMovingTimeTarget(sessionManager, 60)
+ testSplitType(sessionManager, 'time')
+ testSplitMovingTimeTarget(sessionManager, 60)
+
+ testSessionState(sessionManager, 'WaitingForStart')
+ testTotalMovingTime(sessionManager, 0)
+ testTotalLinearDistance(sessionManager, 0)
+ testTotalCalories(sessionManager, 0)
+ testTotalNumberOfStrokes(sessionManager, 0)
+ testDragFactor(sessionManager, undefined)
+
+ await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/Theoretical_Simulation_Random_Noise.csv', realtime: false, loop: false })
+
+ // Test Instantanuous metrics
+ testIsMoving(sessionManager, false) // This implies that all instantanuous metrics must be undefined
+ testIsDriveStart(sessionManager, false)
+ testIsRecoveryStart(sessionManager, false)
+ testIsSessionStart(sessionManager, false)
+ testIsIntervalEnd(sessionManager, false)
+ testIsSplitEnd(sessionManager, false)
+ testIsPauseStart(sessionManager, false)
+ testIsPauseEnd(sessionManager, false)
+ testIsSessionStop(sessionManager, false)
+ testTotalMovingTime(sessionManager, 160.00166827178606) // Must be 60 + 100 seconds
+ testTotalLinearDistance(sessionManager, 625.7793542351126)
+ testTotalWork(sessionManager, 27713.910740051204)
+ testTotalCalories(sessionManager, 39.72767306079284)
+ testTotalCaloriesPerMinute(sessionManager, 14.889253924433362)
+ testTotalCaloriesPerHour (sessionManager, 893.355235466002)
+ testTotalNumberOfStrokes(sessionManager, 53)
+ testStrokeState(sessionManager, 'Stopped') // This is normal for an unplanned pause
+ testStrokeWork(sessionManager, undefined)
+ testStrokeCalories(sessionManager, undefined)
+ testCycleDistance(sessionManager, undefined)
+ testCycleStrokeRate(sessionManager, undefined)
+ testCycleDuration(sessionManager, undefined)
+ testCycleLinearVelocity(sessionManager, undefined)
+ testCyclePace(sessionManager, Infinity)
+ testCyclePower(sessionManager, undefined)
+ testDriveLastStartTime (sessionManager, 157.35162666759084)
+ testDriveDuration(sessionManager, undefined)
+ testDriveLength(sessionManager, undefined)
+ testDriveDistance(sessionManager, undefined)
+ testDriveAverageHandleForce(sessionManager, undefined)
+ testDrivePeakHandleForce(sessionManager, undefined)
+ testRecoveryDuration(sessionManager, undefined)
+ testDragFactor(sessionManager, 119.92309925039875) // Theoretical value 120 * 10^−6 [ k g m 2 ]
+
+ // Test session metrics
+ testSessionType(sessionManager, 'time')
+ testSessionState(sessionManager, 'Stopped') // As we faded with a tail, the flywheel will assume an unplanned pause/stop
+ testSessionTime(sessionManager, 160.00166827178606)
+ testSessionMovingTimeTarget(sessionManager, 160)
+ testSessionMovingTimeToEnd(sessionManager, -0.0016682717860589946)
+ testSessionMovingTimeProjectedEnd(sessionManager, 160) // This is a limit of the testing setup
+ testSessionTimeSpentTotal(sessionManager, 160.00166827178606)
+ testSessionTimeSpentMoving(sessionManager, 160.00166827178606)
+ testSessionTimeSpentRest(sessionManager, 0)
+ testSessionDistance(sessionManager, 625.7793542351126)
+ testSessionDistanceToEnd(sessionManager, undefined)
+ testSessionDistanceTarget(sessionManager, undefined)
+ testSessionDistanceProjectedEnd(sessionManager, 625.7726897468015)
+ testSessionWorkSinceStart(sessionManager, 27713.910740051204)
+ testSessionCaloriesSinceStart(sessionManager, 39.72767306079284)
+ testSessionCaloriesTarget(sessionManager, undefined)
+ testSessionCaloriesToEnd(sessionManager, undefined)
+ testSessionNumberOfStrokes(sessionManager, 53)
+ testSessionMinimumPace(sessionManager, 143.20756199431048)
+ testSessionAveragePace(sessionManager, 127.84191999059749) // Theoretical value 126.4 sec/500m
+ testSessionMaximumPace(sessionManager, 127.26445208111573)
+ testSessionMinimumLinearVelocity(sessionManager, 3.4914357387067634)
+ testSessionAverageLinearVelocity(sessionManager, 3.9110801843149257)
+ testSessionMaximumLinearVelocity(sessionManager, 3.9288268783910714)
+ testSessionMinimumPower(sessionManager, 119.17089214328217)
+ testSessionAveragePower(sessionManager, 167.75805401711526) // Theoretical value 173.33 Watts
+ testSessionMaximumPower(sessionManager, 169.80499196952417)
+ testSessionMinimumStrokedistance(sessionManager, 6.8147639775967415)
+ testSessionAverageStrokedistance(sessionManager, 11.678757897679443) // Theoretical value 11.77 meters
+ testSessionMaximumStrokedistance(sessionManager, 11.926002210270553)
+ testSessionMinimumStrokerate(sessionManager, 19.766111256103418)
+ testSessionAverageStrokerate(sessionManager, 20.153223856940368) // Theoretical value 20 SPM
+ testSessionMaximumStrokerate(sessionManager, 30.74004397086722)
+ testSessionMinimumDragfactor(sessionManager, 119.88119659734743)
+ testSessionAverageDragfactor(sessionManager, 119.92544826617716) // Theoretical value 120 * 10^−6 [ k g m 2 ]
+ testSessionMaximumDragfactor(sessionManager, 119.97031693785685)
+ testSessionNumberOfIntervals(sessionManager, 2)
+
+ // Test interval metrics
+ testIntervalNumber(sessionManager, 1)
+ testIntervalWorkoutStepNumber(sessionManager, 1)
+ testIntervalType(sessionManager, 'time')
+ testIntervalTime(sessionManager, 100.00166827178606)
+ testIntervalMovingTimeTarget(sessionManager, 100)
+ testIntervalMovingTimeToEnd(sessionManager, -0.0016682717860589946)
+ testIntervalMovingTimeProjectedEnd(sessionManager, 100)
+ testIntervalTimeSpentTotal(sessionManager, 100.00166827178606)
+ testIntervalTimeSpentMoving(sessionManager, 100.00166827178606)
+ testIntervalTimeSpentRest(sessionManager, 0)
+ testIntervalDistance(sessionManager, 392.064756966362)
+ testIntervalDistanceToEnd(sessionManager, undefined)
+ testIntervalDistanceTarget(sessionManager, undefined)
+ testIntervalDistanceProjectedEnd(sessionManager, 392.05809247805087)
+ testIntervalWorkSinceStart(sessionManager, 17135.99689385964)
+ testIntervalCaloriesSinceStart(sessionManager, 24.653469397753256)
+ testIntervalCaloriesTarget(sessionManager, undefined)
+ testIntervalCaloriesToEnd(sessionManager, undefined)
+ testIntervalNumberOfStrokes(sessionManager, 33)
+ testIntervalMinimumPace(sessionManager, 127.72836794100452)
+ testIntervalAveragePace(sessionManager, 127.5320804725709)
+ testIntervalMaximumPace(sessionManager, 127.26445208111573)
+ testIntervalMinimumLinearVelocity(sessionManager, 3.91455718146294)
+ testIntervalAverageLinearVelocity(sessionManager, 3.9205821636975338)
+ testIntervalMaximumLinearVelocity(sessionManager, 3.9288268783910714)
+ testIntervalMinimumPower(sessionManager, 167.9618742700358)
+ testIntervalAveragePower(sessionManager, 168.91628821515124) // Theoretical value 173.33 Watts
+ testIntervalMaximumPower(sessionManager, 169.80499196952417)
+ testIntervalMinimumStrokedistance(sessionManager, 11.596244164231889)
+ testIntervalAverageStrokedistance(sessionManager, 11.76361856265632) // Theoretical value 11.77 meters
+ testIntervalMaximumStrokedistance(sessionManager, 11.926002210270553)
+ testIntervalMinimumStrokerate(sessionManager, 19.766111256103418)
+ testIntervalAverageStrokerate(sessionManager, 20.004541334725744) // Theoretical value 20 SPM
+ testIntervalMaximumStrokerate(sessionManager, 20.254747571696083)
+ testIntervalMinimumDragfactor(sessionManager, 119.88119659734743)
+ testIntervalAverageDragfactor(sessionManager, 119.92443717461988) // Theoretical value 120 * 10^−6 [ k g m 2 ]
+ testIntervalMaximumDragfactor(sessionManager, 119.97031693785685)
+
+ // Test split metrics
+ // As the session has been stopped by the session limit, the last split must still be filled
+ testSplitNumber(sessionManager, 4)
+ testSplitType(sessionManager, 'time')
+ testSplitTime(sessionManager, 25.00166827178606) // Must be 25 seconds
+ testSplitMovingTimeTarget(sessionManager, 25)
+ testSplitMovingTimeToEnd(sessionManager, -0.0016682717860589946) // This is a testing artifact
+ testSplitMovingTimeProjectedEnd(sessionManager, 25)
+ testSplitTimeSpentTotal(sessionManager, 25.00166827178606) // Must be 25 seconds
+ testSplitTimeSpentMoving(sessionManager, 25.00166827178606) // Must be 25 seconds
+ testSplitTimeSpentRest(sessionManager, 0)
+ testSplitDistance(sessionManager, 97.90846209156177)
+ testSplitDistanceToEnd(sessionManager, undefined)
+ testSplitDistanceTarget(sessionManager, undefined)
+ testSplitDistanceProjectedEnd(sessionManager, 97.90179760325066)
+ testSplitWorkSinceStart(sessionManager, 4155.13417257055)
+ testSplitCaloriesSinceStart(sessionManager, 6.040742996525552)
+ testSplitCaloriesTarget(sessionManager, undefined)
+ testSplitCaloriesToEnd(sessionManager, undefined)
+ testSplitNumberOfStrokes(sessionManager, 8)
+ testSplitMinimumPace(sessionManager, 127.72836794100452)
+ testSplitAveragePace(sessionManager, 127.67879168812327) // Theoretical value 126.4 sec/500m
+ testSplitMaximumPace(sessionManager, 127.26445208111573)
+ testSplitMinimumLinearVelocity(sessionManager, 3.91455718146294)
+ testSplitAverageLinearVelocity(sessionManager, 3.9160771604209206)
+ testSplitMaximumLinearVelocity(sessionManager, 3.9288268783910714)
+ testSplitMinimumPower(sessionManager, 167.9618742700358)
+ testSplitAveragePower(sessionManager, 168.8984890169985) // Theoretical value 173.33 Watts
+ testSplitMaximumPower(sessionManager, 169.80499196952417)
+ testSplitMinimumStrokedistance(sessionManager, 11.596244164231889)
+ testSplitAverageStrokedistance(sessionManager, 11.764571944715392) // Theoretical value 11.77 meters
+ testSplitMaximumStrokedistance(sessionManager, 11.926002210270553)
+ testSplitMinimumStrokerate(sessionManager, 19.766111256103418)
+ testSplitAverageStrokerate(sessionManager, 20.00279031736199) // Theoretical value 20 SPM
+ testSplitMaximumStrokerate(sessionManager, 20.254747571696083)
+ testSplitMinimumDragfactor(sessionManager, 119.91175705511066)
+ testSplitAverageDragfactor(sessionManager, 119.9293850222281) // Theoretical value 120 * 10^−6 [ k g m 2 ]
+ testSplitMaximumDragfactor(sessionManager, 119.96325514444989)
+})
+
+/**
+ * @description Test against a theoretical model, based on a simulation of magnet positioning errors
+ * Magnet errors -0.01, +0.1, +0.2, -0.2, -0.1, +0.01 degrees
+ * @see {@link https://github.com/JaapvanEkris/openrowingmonitor/discussions/215|this discussion}
+ */
+test('TheoreticalModel_07: Data with structural magnet errors on the Theoretical Model should produce plausible results for an unlimited run with startup noise', async () => {
+ const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Theoretical_Model)
+ const testConfig = {
+ loglevel: {
+ default: 'silent',
+ RowingEngine: 'silent'
+ },
+ numOfPhasesForAveragingScreenData: 2,
+ userSettings: {
+ sex: 'male'
+ },
+ rowerSettings: rowerProfile
+ }
+ const sessionManager = createSessionManager(testConfig)
+
+ testSessionType(sessionManager, 'justrow')
+ testIntervalType(sessionManager, 'justrow')
+ testSplitType(sessionManager, 'justrow')
+
+ testSessionState(sessionManager, 'WaitingForStart')
+ testTotalMovingTime(sessionManager, 0)
+ testTotalLinearDistance(sessionManager, 0)
+ testTotalCalories(sessionManager, 0)
+ testTotalNumberOfStrokes(sessionManager, 0)
+ testDragFactor(sessionManager, undefined)
+
+ await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/Theoretical_Simulation_Systematic_Noise.csv', realtime: false, loop: false })
+
+ // Test Instantanuous metrics
+ testIsMoving(sessionManager, false) // This implies that all instantanuous metrics must be undefined
+ testIsDriveStart(sessionManager, false)
+ testIsRecoveryStart(sessionManager, false)
+ testIsSessionStart(sessionManager, false)
+ testIsIntervalEnd(sessionManager, false)
+ testIsSplitEnd(sessionManager, false)
+ testIsPauseStart(sessionManager, false)
+ testIsPauseEnd(sessionManager, false)
+ testIsSessionStop(sessionManager, false)
+ testTotalMovingTime(sessionManager, 181.3424733944077)
+ testTotalLinearDistance(sessionManager, 706.291973992365)
+ testTotalWork(sessionManager, 30825.447009125084)
+ testTotalCalories(sessionManager, 44.46944136298644)
+ testTotalCaloriesPerMinute(sessionManager, 14.887874599873015)
+ testTotalCaloriesPerHour (sessionManager, 893.272475992381)
+ testTotalNumberOfStrokes(sessionManager, 59)
+ testStrokeState(sessionManager, 'WaitingForDrive') // This is normal for an unplanned pause
+ testStrokeWork(sessionManager, undefined)
+ testStrokeCalories(sessionManager, undefined)
+ testCycleDistance(sessionManager, undefined)
+ testCycleStrokeRate(sessionManager, undefined)
+ testCycleDuration(sessionManager, undefined)
+ testCycleLinearVelocity(sessionManager, undefined)
+ testCyclePace(sessionManager, Infinity)
+ testCyclePower(sessionManager, undefined)
+ testDriveLastStartTime (sessionManager, 175.3543851793926)
+ testDriveDuration(sessionManager, undefined)
+ testDriveLength(sessionManager, undefined)
+ testDriveDistance(sessionManager, undefined)
+ testDriveAverageHandleForce(sessionManager, undefined)
+ testDrivePeakHandleForce(sessionManager, undefined)
+ testRecoveryDuration(sessionManager, undefined)
+ testDragFactor(sessionManager, 119.92470860871332) // Theoretical value 120 * 10^−6 [ k g m 2 ]
+
+ // Test session metrics
+ testSessionType(sessionManager, 'justrow')
+ testSessionState(sessionManager, 'Paused') // As we faded with a tail, the flywheel will assume an unplanned pause/stop
+ testSessionTime(sessionManager, 181.3424733944077)
+ testSessionMovingTimeTarget(sessionManager, undefined)
+ testSessionMovingTimeToEnd(sessionManager, undefined)
+ testSessionMovingTimeProjectedEnd(sessionManager, undefined)
+ testSessionTimeSpentTotal(sessionManager, 181.3424733944077)
+ testSessionTimeSpentMoving(sessionManager, 181.3424733944077)
+ testSessionTimeSpentRest(sessionManager, 0)
+ testSessionDistance(sessionManager, 706.291973992365)
+ testSessionDistanceToEnd(sessionManager, undefined)
+ testSessionDistanceTarget(sessionManager, undefined)
+ testSessionDistanceProjectedEnd(sessionManager, undefined)
+ testSessionWorkSinceStart(sessionManager, 30825.447009125084)
+ testSessionCaloriesSinceStart(sessionManager, 44.46944136298644)
+ testSessionCaloriesTarget(sessionManager, undefined)
+ testSessionCaloriesToEnd(sessionManager, undefined)
+ testSessionNumberOfStrokes(sessionManager, 59)
+ testSessionMinimumPace(sessionManager, 144.32053455877914)
+ testSessionAveragePace(sessionManager, 128.37642226723648) // Theoretical value 126.4 sec/500m
+ testSessionMaximumPace(sessionManager, 127.40389706972888)
+ testSessionMinimumLinearVelocity(sessionManager, 3.464510449109784)
+ testSessionAverageLinearVelocity(sessionManager, 3.8947961874118007)
+ testSessionMaximumLinearVelocity(sessionManager, 3.9245267334824705)
+ testSessionMinimumPower(sessionManager, 116.43502959686765)
+ testSessionAveragePower(sessionManager, 167.8340203392529) // Theoretical value 173.33 Watts
+ testSessionMaximumPower(sessionManager, 169.24658408781292)
+ testSessionMinimumStrokedistance(sessionManager, 6.452070266383135)
+ testSessionAverageStrokedistance(sessionManager, 11.684809832020528) // Theoretical value 11.77 meters
+ testSessionMaximumStrokedistance(sessionManager, 11.797809243241765)
+ testSessionMinimumStrokerate(sessionManager, 19.93472003813677)
+ testSessionAverageStrokerate(sessionManager, 20.150540474688565) // Theoretical value 20 SPM
+ testSessionMaximumStrokerate(sessionManager, 32.21766322503396)
+ testSessionMinimumDragfactor(sessionManager, 119.77141609592097)
+ testSessionAverageDragfactor(sessionManager, 119.92305170537736) // Theoretical value 120 * 10^−6 [ k g m 2 ]
+ testSessionMaximumDragfactor(sessionManager, 120.12445748254608)
+ testSessionNumberOfIntervals(sessionManager, 1)
+
+ // Test interval metrics
+ testIntervalNumber(sessionManager, 0)
+ testIntervalWorkoutStepNumber(sessionManager, 0)
+ testIntervalType(sessionManager, 'justrow')
+ testIntervalTime(sessionManager, 181.3424733944077)
+ testIntervalMovingTimeTarget(sessionManager, undefined)
+ testIntervalMovingTimeToEnd(sessionManager, undefined)
+ testIntervalMovingTimeProjectedEnd(sessionManager, undefined)
+ testIntervalTimeSpentTotal(sessionManager, 181.3424733944077)
+ testIntervalTimeSpentMoving(sessionManager, 181.3424733944077)
+ testIntervalTimeSpentRest(sessionManager, 0)
+ testIntervalDistance(sessionManager, 706.291973992365)
+ testIntervalDistanceToEnd(sessionManager, undefined)
+ testIntervalDistanceTarget(sessionManager, undefined)
+ testIntervalDistanceProjectedEnd(sessionManager, undefined)
+ testIntervalWorkSinceStart(sessionManager, 30825.447009125084)
+ testIntervalCaloriesSinceStart(sessionManager, 44.46944136298644)
+ testIntervalCaloriesTarget(sessionManager, undefined)
+ testIntervalCaloriesToEnd(sessionManager, undefined)
+ testIntervalNumberOfStrokes(sessionManager, 59)
+ testIntervalMinimumPace(sessionManager, 144.32053455877914)
+ testIntervalAveragePace(sessionManager, 128.37642226723648) // Theoretical value 126.4 sec/500m
+ testIntervalMaximumPace(sessionManager, 127.40389706972888)
+ testIntervalMinimumLinearVelocity(sessionManager, 3.464510449109784)
+ testIntervalAverageLinearVelocity(sessionManager, 3.8947961874118007) // @ToDo: Find out why the average is not between min and max
+ testIntervalMaximumLinearVelocity(sessionManager, 3.9245267334824705)
+ testIntervalMinimumPower(sessionManager, 116.43502959686765)
+ testIntervalAveragePower(sessionManager, 167.8340203392529) // Theoretical value 173.33 Watts
+ testIntervalMaximumPower(sessionManager, 169.24658408781292)
+ testIntervalMinimumStrokedistance(sessionManager, 6.452070266383135)
+ testIntervalAverageStrokedistance(sessionManager, 11.684809832020528) // Theoretical value 11.77 meters
+ testIntervalMaximumStrokedistance(sessionManager, 11.797809243241765)
+ testIntervalMinimumStrokerate(sessionManager, 19.93472003813677)
+ testIntervalAverageStrokerate(sessionManager, 20.150540474688565) // Theoretical value 20 SPM
+ testIntervalMaximumStrokerate(sessionManager, 32.21766322503396)
+ testIntervalMinimumDragfactor(sessionManager, 119.77141609592097)
+ testIntervalAverageDragfactor(sessionManager, 119.92305170537736) // Theoretical value 120 * 10^−6 [ k g m 2 ]
+ testIntervalMaximumDragfactor(sessionManager, 120.12445748254608)
+
+ // Test split metrics
+ // As we are in an unplanned pause, this is the rest split
+ testSplitNumber(sessionManager, 1)
+ testSplitType(sessionManager, 'rest')
+ testSplitTime(sessionManager, 0)
+ testSplitMovingTimeTarget(sessionManager, undefined)
+ testSplitMovingTimeToEnd(sessionManager, 0)
+ testSplitMovingTimeProjectedEnd(sessionManager, undefined)
+ testSplitTimeSpentTotal(sessionManager, 0)
+ testSplitTimeSpentMoving(sessionManager, 0)
+ testSplitTimeSpentRest(sessionManager, 0)
+ testSplitDistance(sessionManager, 0)
+ testSplitDistanceToEnd(sessionManager, undefined)
+ testSplitDistanceTarget(sessionManager, undefined)
+ testSplitDistanceProjectedEnd(sessionManager, undefined)
+ testSplitWorkSinceStart(sessionManager, 0)
+ testSplitCaloriesSinceStart(sessionManager, 0)
+ testSplitCaloriesTarget(sessionManager, undefined)
+ testSplitCaloriesToEnd(sessionManager, undefined)
+ testSplitNumberOfStrokes(sessionManager, 0)
+ testSplitMinimumPace(sessionManager, Infinity)
+ testSplitAveragePace(sessionManager, Infinity)
+ testSplitMaximumPace(sessionManager, Infinity)
+ testSplitMinimumLinearVelocity(sessionManager, 0)
+ testSplitAverageLinearVelocity(sessionManager, 0)
+ testSplitMaximumLinearVelocity(sessionManager, 0)
+ testSplitMinimumPower(sessionManager, 0)
+ testSplitAveragePower(sessionManager, 0)
+ testSplitMaximumPower(sessionManager, 0)
+ testSplitMinimumStrokedistance(sessionManager, 0)
+ testSplitAverageStrokedistance(sessionManager, 0)
+ testSplitMaximumStrokedistance(sessionManager, 0)
+ testSplitMinimumStrokerate(sessionManager, 0)
+ testSplitAverageStrokerate(sessionManager, 0)
+ testSplitMaximumStrokerate(sessionManager, 0)
+ testSplitMinimumDragfactor(sessionManager, 0)
+ testSplitAverageDragfactor(sessionManager, 0)
+ testSplitMaximumDragfactor(sessionManager, 0)
+})
+
+/**
+ * @description Test against a theoretical model, based on a simulation of magnet positioning errors
+ * Magnet errors -0.01, +0.1, +0.2, -0.2, -0.1, +0.01 degrees
+ * @see {@link https://github.com/JaapvanEkris/openrowingmonitor/discussions/215|this discussion}
+ */
+test('TheoreticalModel_08: Data with structural magnet errors on the Theoretical Model should produce plausible results for an unlimited run without startup noise', async () => {
+ const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Theoretical_Model)
+ const testConfig = {
+ loglevel: {
+ default: 'silent',
+ RowingEngine: 'silent'
+ },
+ numOfPhasesForAveragingScreenData: 2,
+ userSettings: {
+ sex: 'male'
+ },
+ rowerSettings: rowerProfile
+ }
+
+ const sessionManager = createSessionManager(testConfig)
+
+ testSessionType(sessionManager, 'justrow')
+ testIntervalType(sessionManager, 'justrow')
+ testSplitType(sessionManager, 'justrow')
+
+ // Cut off the startup noise via a startup interval
+ const intervalSettings = []
+ intervalSettings[0] = {
+ type: 'time',
+ targetTime: 60
+ }
+ intervalSettings[1] = {
+ type: 'justrow'
+ }
+ sessionManager.handleCommand('updateIntervalSettings', intervalSettings)
+
+ testSessionType(sessionManager, 'justrow')
+ testIntervalType(sessionManager, 'time')
+ testIntervalMovingTimeTarget(sessionManager, 60)
+ testSplitType(sessionManager, 'time')
+ testSplitMovingTimeTarget(sessionManager, 60)
+
+ testSessionState(sessionManager, 'WaitingForStart')
+ testTotalMovingTime(sessionManager, 0)
+ testTotalLinearDistance(sessionManager, 0)
+ testTotalCalories(sessionManager, 0)
+ testTotalNumberOfStrokes(sessionManager, 0)
+ testDragFactor(sessionManager, undefined)
+
+ await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/Theoretical_Simulation_Systematic_Noise.csv', realtime: false, loop: false })
+
+ // Test Instantanuous metrics
+ testIsMoving(sessionManager, false) // This implies that all instantanuous metrics must be undefined
+ testIsDriveStart(sessionManager, false)
+ testIsRecoveryStart(sessionManager, false)
+ testIsSessionStart(sessionManager, false)
+ testIsIntervalEnd(sessionManager, false)
+ testIsSplitEnd(sessionManager, false)
+ testIsPauseStart(sessionManager, false)
+ testIsPauseEnd(sessionManager, false)
+ testIsSessionStop(sessionManager, false)
+ testTotalMovingTime(sessionManager, 181.3424733944077)
+ testTotalLinearDistance(sessionManager, 706.291973992365)
+ testTotalWork(sessionManager, 30825.447009125084)
+ testTotalCalories(sessionManager, 44.46944136298644)
+ testTotalCaloriesPerMinute(sessionManager, 14.887874599873015)
+ testTotalCaloriesPerHour (sessionManager, 893.272475992381)
+ testTotalNumberOfStrokes(sessionManager, 59)
+ testStrokeState(sessionManager, 'WaitingForDrive') // This is normal for an unplanned pause
+ testStrokeWork(sessionManager, undefined)
+ testStrokeCalories(sessionManager, undefined)
+ testCycleDistance(sessionManager, undefined)
+ testCycleStrokeRate(sessionManager, undefined)
+ testCycleDuration(sessionManager, undefined)
+ testCycleLinearVelocity(sessionManager, undefined)
+ testCyclePace(sessionManager, Infinity)
+ testCyclePower(sessionManager, undefined)
+ testDriveLastStartTime (sessionManager, 175.3543851793926)
+ testDriveDuration(sessionManager, undefined)
+ testDriveLength(sessionManager, undefined)
+ testDriveDistance(sessionManager, undefined)
+ testDriveAverageHandleForce(sessionManager, undefined)
+ testDrivePeakHandleForce(sessionManager, undefined)
+ testRecoveryDuration(sessionManager, undefined)
+ testDragFactor(sessionManager, 119.92470860871332) // Theoretical value 120 * 10^−6 [ k g m 2 ]
+
+ // Test session metrics
+ testSessionType(sessionManager, 'justrow')
+ testSessionState(sessionManager, 'Paused') // As we faded with a tail, the flywheel will assume an unplanned pause/stop
+ testSessionTime(sessionManager, 181.3424733944077)
+ testSessionMovingTimeTarget(sessionManager, undefined)
+ testSessionMovingTimeToEnd(sessionManager, undefined)
+ testSessionMovingTimeProjectedEnd(sessionManager, undefined)
+ testSessionTimeSpentTotal(sessionManager, 181.3424733944077)
+ testSessionTimeSpentMoving(sessionManager, 181.3424733944077)
+ testSessionTimeSpentRest(sessionManager, 0)
+ testSessionDistance(sessionManager, 706.291973992365)
+ testSessionDistanceToEnd(sessionManager, undefined)
+ testSessionDistanceTarget(sessionManager, undefined)
+ testSessionDistanceProjectedEnd(sessionManager, undefined)
+ testSessionWorkSinceStart(sessionManager, 30825.447009125084)
+ testSessionCaloriesSinceStart(sessionManager, 44.46944136298644)
+ testSessionCaloriesTarget(sessionManager, undefined)
+ testSessionCaloriesToEnd(sessionManager, undefined)
+ testSessionNumberOfStrokes(sessionManager, 59)
+ testSessionMinimumPace(sessionManager, 144.32053455877914)
+ testSessionAveragePace(sessionManager, 128.37642226723648) // Theoretical value 126.4 sec/500m
+ testSessionMaximumPace(sessionManager, 127.40389706972888)
+ testSessionMinimumLinearVelocity(sessionManager, 3.464510449109784)
+ testSessionAverageLinearVelocity(sessionManager, 3.8947961874118007)
+ testSessionMaximumLinearVelocity(sessionManager, 3.9245267334824705)
+ testSessionMinimumPower(sessionManager, 116.43502959686765)
+ testSessionAveragePower(sessionManager, 167.8340203392529) // Theoretical value 173.33 Watts
+ testSessionMaximumPower(sessionManager, 169.24658408781292)
+ testSessionMinimumStrokedistance(sessionManager, 6.452070266383135)
+ testSessionAverageStrokedistance(sessionManager, 11.684809832020528) // Theoretical value 11.77 meters
+ testSessionMaximumStrokedistance(sessionManager, 11.797809243241765)
+ testSessionMinimumStrokerate(sessionManager, 19.93472003813677)
+ testSessionAverageStrokerate(sessionManager, 20.150540474688565) // Theoretical value 20 SPM
+ testSessionMaximumStrokerate(sessionManager, 32.21766322503396)
+ testSessionMinimumDragfactor(sessionManager, 119.77141609592097)
+ testSessionAverageDragfactor(sessionManager, 119.92305170537736) // Theoretical value 120 * 10^−6 [ k g m 2 ]
+ testSessionMaximumDragfactor(sessionManager, 120.12445748254608)
+ testSessionNumberOfIntervals(sessionManager, 2)
+
+ // Test interval metrics
+ testIntervalNumber(sessionManager, 1)
+ testIntervalWorkoutStepNumber(sessionManager, 1)
+ testIntervalType(sessionManager, 'justrow')
+ testIntervalTime(sessionManager, 121.3424733944077) // Must be 181.3424733944077 - 60
+ testIntervalMovingTimeTarget(sessionManager, undefined)
+ testIntervalMovingTimeToEnd(sessionManager, undefined)
+ testIntervalMovingTimeProjectedEnd(sessionManager, undefined)
+ testIntervalTimeSpentTotal(sessionManager, 121.3424733944077) // Must be 181.3424733944077 - 60
+ testIntervalTimeSpentMoving(sessionManager, 121.3424733944077) // Must be 181.3424733944077 - 60
+ testIntervalTimeSpentRest(sessionManager, 0)
+ testIntervalDistance(sessionManager, 472.59432036681153)
+ testIntervalDistanceToEnd(sessionManager, undefined)
+ testIntervalDistanceTarget(sessionManager, undefined)
+ testIntervalDistanceProjectedEnd(sessionManager, undefined)
+ testIntervalWorkSinceStart(sessionManager, 20249.36413024754)
+ testIntervalCaloriesSinceStart(sessionManager, 29.396981478341157)
+ testIntervalCaloriesTarget(sessionManager, undefined)
+ testIntervalCaloriesToEnd(sessionManager, undefined)
+ testIntervalNumberOfStrokes(sessionManager, 39)
+ testIntervalMinimumPace(sessionManager, 127.56619237664853)
+ testIntervalAveragePace(sessionManager, 128.3791067360118) // @ToDo: Find out why the average is not between min and max // Theoretical value 126.4 sec/500m
+ testIntervalMaximumPace(sessionManager, 127.40758861515249)
+ testIntervalMinimumLinearVelocity(sessionManager, 3.9195337783831734)
+ testIntervalAverageLinearVelocity(sessionManager, 3.8947147453530637) // @ToDo: Find out why the average is not between min and max
+ testIntervalMaximumLinearVelocity(sessionManager, 3.9244130230758905)
+ testIntervalMinimumPower(sessionManager, 168.60143772708057)
+ testIntervalAveragePower(sessionManager, 168.91413373215897) // Theoretical value 173.33 Watts
+ testIntervalMaximumPower(sessionManager, 169.23187165676933)
+ testIntervalMinimumStrokedistance(sessionManager, 11.724530924929411)
+ testIntervalAverageStrokedistance(sessionManager, 11.765867412158823) // Theoretical value 11.77 meters
+ testIntervalMaximumStrokedistance(sessionManager, 11.797809243208839)
+ testIntervalMinimumStrokerate(sessionManager, 19.93472003813677)
+ testIntervalAverageStrokerate(sessionManager, 20.00001841711283) // Theoretical value 20 SPM
+ testIntervalMaximumStrokerate(sessionManager, 20.081870630909517)
+ testIntervalMinimumDragfactor(sessionManager, 119.92470860848056)
+ testIntervalAverageDragfactor(sessionManager, 119.9247086088608) // Theoretical value 120 * 10^−6 [ k g m 2 ]
+ testIntervalMaximumDragfactor(sessionManager, 119.92470860915626)
+
+ // Test split metrics
+ // As we are in an unplanned pause, this is the rest split
+ testSplitNumber(sessionManager, 2)
+ testSplitType(sessionManager, 'rest')
+ testSplitTime(sessionManager, 0)
+ testSplitMovingTimeTarget(sessionManager, undefined)
+ testSplitMovingTimeToEnd(sessionManager, 0)
+ testSplitMovingTimeProjectedEnd(sessionManager, undefined)
+ testSplitTimeSpentTotal(sessionManager, 0)
+ testSplitTimeSpentMoving(sessionManager, 0)
+ testSplitTimeSpentRest(sessionManager, 0)
+ testSplitDistance(sessionManager, 0)
+ testSplitDistanceToEnd(sessionManager, undefined)
+ testSplitDistanceTarget(sessionManager, undefined)
+ testSplitDistanceProjectedEnd(sessionManager, undefined)
+ testSplitWorkSinceStart(sessionManager, 0)
+ testSplitCaloriesSinceStart(sessionManager, 0)
+ testSplitCaloriesTarget(sessionManager, undefined)
+ testSplitCaloriesToEnd(sessionManager, undefined)
+ testSplitNumberOfStrokes(sessionManager, 0)
+ testSplitMinimumPace(sessionManager, Infinity)
+ testSplitAveragePace(sessionManager, Infinity)
+ testSplitMaximumPace(sessionManager, Infinity)
+ testSplitMinimumLinearVelocity(sessionManager, 0)
+ testSplitAverageLinearVelocity(sessionManager, 0)
+ testSplitMaximumLinearVelocity(sessionManager, 0)
+ testSplitMinimumPower(sessionManager, 0)
+ testSplitAveragePower(sessionManager, 0)
+ testSplitMaximumPower(sessionManager, 0)
+ testSplitMinimumStrokedistance(sessionManager, 0)
+ testSplitAverageStrokedistance(sessionManager, 0)
+ testSplitMaximumStrokedistance(sessionManager, 0)
+ testSplitMinimumStrokerate(sessionManager, 0)
+ testSplitAverageStrokerate(sessionManager, 0)
+ testSplitMaximumStrokerate(sessionManager, 0)
+ testSplitMinimumDragfactor(sessionManager, 0)
+ testSplitAverageDragfactor(sessionManager, 0)
+ testSplitMaximumDragfactor(sessionManager, 0)
+})
+
+/**
+ * @description Test against a theoretical model, based on a simulation of magnet positioning errors
+ * Magnet errors -0.01, +0.1, +0.2, -0.2, -0.1, +0.01 degrees
+ * @see {@link https://github.com/JaapvanEkris/openrowingmonitor/discussions/215|this discussion}
+ */
+test('TheoreticalModel_09: Data with structural magnet errors on the Theoretical Model should produce plausible results for an unlimited run without startup noise', async () => {
+ const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Theoretical_Model)
+ const testConfig = {
+ loglevel: {
+ default: 'silent',
+ RowingEngine: 'silent'
+ },
+ numOfPhasesForAveragingScreenData: 2,
+ userSettings: {
+ sex: 'male'
+ },
+ rowerSettings: rowerProfile
+ }
+
+ const sessionManager = createSessionManager(testConfig)
+
+ testSessionType(sessionManager, 'justrow')
+ testIntervalType(sessionManager, 'justrow')
+ testSplitType(sessionManager, 'justrow')
+
+ // Cut off the startup noise via a startup interval
+ const intervalSettings = []
+ intervalSettings[0] = {
+ type: 'distance',
+ targetDistance: 200,
+ split: {
+ type: 'distance',
+ targetDistance: 100
+ }
+ }
+ intervalSettings[1] = {
+ type: 'distance',
+ targetDistance: 500,
+ split: {
+ type: 'distance',
+ targetDistance: 100
+ }
+ }
+ sessionManager.handleCommand('updateIntervalSettings', intervalSettings)
+
+ testSessionType(sessionManager, 'distance')
+ testSessionDistanceTarget(sessionManager, 700)
+ testIntervalType(sessionManager, 'distance')
+ testIntervalDistanceTarget(sessionManager, 200)
+ testSplitType(sessionManager, 'distance')
+ testSplitDistanceTarget(sessionManager, 100)
+
+ testSessionState(sessionManager, 'WaitingForStart')
+ testTotalMovingTime(sessionManager, 0)
+ testTotalLinearDistance(sessionManager, 0)
+ testTotalCalories(sessionManager, 0)
+ testTotalNumberOfStrokes(sessionManager, 0)
+ testDragFactor(sessionManager, undefined)
+
+ await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/Theoretical_Simulation_Systematic_Noise.csv', realtime: false, loop: false })
+
+ // Test Instantanuous metrics
+ testIsMoving(sessionManager, false) // This implies that all instantanuous metrics must be undefined
+ testIsDriveStart(sessionManager, false)
+ testIsRecoveryStart(sessionManager, false)
+ testIsSessionStart(sessionManager, false)
+ testIsIntervalEnd(sessionManager, false)
+ testIsSplitEnd(sessionManager, false)
+ testIsPauseStart(sessionManager, false)
+ testIsPauseEnd(sessionManager, false)
+ testIsSessionStop(sessionManager, false)
+ testTotalMovingTime(sessionManager, 179.10634837290942)
+ testTotalLinearDistance(sessionManager, 700.0266777793616)
+ testTotalWork(sessionManager, 30825.447009125084)
+ testTotalCalories(sessionManager, 44.28309761119491)
+ testTotalCaloriesPerMinute(sessionManager, 14.887874599873015)
+ testTotalCaloriesPerHour (sessionManager, 893.272475992381)
+ testTotalNumberOfStrokes(sessionManager, 59)
+ testStrokeState(sessionManager, 'Stopped')
+ testStrokeWork(sessionManager, undefined)
+ testStrokeCalories(sessionManager, undefined)
+ testCycleDistance(sessionManager, undefined)
+ testCycleStrokeRate(sessionManager, undefined)
+ testCycleDuration(sessionManager, undefined)
+ testCycleLinearVelocity(sessionManager, undefined)
+ testCyclePace(sessionManager, Infinity)
+ testCyclePower(sessionManager, undefined)
+ testDriveLastStartTime (sessionManager, 175.3543851793926)
+ testDriveDuration(sessionManager, undefined)
+ testDriveLength(sessionManager, undefined)
+ testDriveDistance(sessionManager, undefined)
+ testDriveAverageHandleForce(sessionManager, undefined)
+ testDrivePeakHandleForce(sessionManager, undefined)
+ testRecoveryDuration(sessionManager, undefined)
+ testDragFactor(sessionManager, 119.92470860871332) // Theoretical value 120 * 10^−6 [ k g m 2 ]
+
+ // Test session metrics
+ testSessionType(sessionManager, 'distance')
+ testSessionState(sessionManager, 'Stopped')
+ testSessionTime(sessionManager, 179.10634837290942)
+ testSessionMovingTimeTarget(sessionManager, undefined)
+ testSessionMovingTimeToEnd(sessionManager, undefined) // ??
+ testSessionMovingTimeProjectedEnd(sessionManager, 179.09921884268792)
+ testSessionTimeSpentTotal(sessionManager, 179.10634837290942)
+ testSessionTimeSpentMoving(sessionManager, 179.10634837290942)
+ testSessionTimeSpentRest(sessionManager, 0)
+ testSessionDistance(sessionManager, 700.0266777793616) // Must be 700
+ testSessionDistanceToEnd(sessionManager, -0.02667777936164839) // Thi8s is a testing artifact
+ testSessionDistanceTarget(sessionManager, 700)
+ testSessionDistanceProjectedEnd(sessionManager, 700)
+ testSessionWorkSinceStart(sessionManager, 30825.447009125084)
+ testSessionCaloriesSinceStart(sessionManager, 44.28309761119491)
+ testSessionCaloriesTarget(sessionManager, undefined)
+ testSessionCaloriesToEnd(sessionManager, undefined)
+ testSessionNumberOfStrokes(sessionManager, 59)
+ testSessionMinimumPace(sessionManager, 144.32053455877914)
+ testSessionAveragePace(sessionManager, 127.92823049335355) // Theoretical value 126.4 sec/500m
+ testSessionMaximumPace(sessionManager, 127.40389706972888)
+ testSessionMinimumLinearVelocity(sessionManager, 3.464510449109784)
+ testSessionAverageLinearVelocity(sessionManager, 3.9084414602762543)
+ testSessionMaximumLinearVelocity(sessionManager, 3.9245267334824705)
+ testSessionMinimumPower(sessionManager, 116.43502959686765)
+ testSessionAveragePower(sessionManager, 167.84318036808412) // Theoretical value 173.33 Watts
+ testSessionMaximumPower(sessionManager, 169.24658408781292)
+ testSessionMinimumStrokedistance(sessionManager, 6.452070266383135)
+ testSessionAverageStrokedistance(sessionManager, 11.685456952800514) // Theoretical value 11.77 meters
+ testSessionMaximumStrokedistance(sessionManager, 11.797809243241765)
+ testSessionMinimumStrokerate(sessionManager, 19.93472003813677)
+ testSessionAverageStrokerate(sessionManager, 20.149332641731963) // Theoretical value 20 SPM
+ testSessionMaximumStrokerate(sessionManager, 32.21766322503396)
+ testSessionMinimumDragfactor(sessionManager, 119.77141609592097)
+ testSessionAverageDragfactor(sessionManager, 119.92305170537736) // Theoretical value 120 * 10^−6 [ k g m 2 ]
+ testSessionMaximumDragfactor(sessionManager, 120.12445748254608)
+ testSessionNumberOfIntervals(sessionManager, 2)
+
+ // Test interval metrics
+ testIntervalNumber(sessionManager, 1)
+ testIntervalWorkoutStepNumber(sessionManager, 1)
+ testIntervalType(sessionManager, 'distance')
+ testIntervalTime(sessionManager, 127.698945752801)
+ testIntervalMovingTimeTarget(sessionManager, undefined)
+ testIntervalMovingTimeToEnd(sessionManager, undefined)
+ testIntervalMovingTimeProjectedEnd(sessionManager, 127.69181622257949)
+ testIntervalTimeSpentTotal(sessionManager, 127.698945752801)
+ testIntervalTimeSpentMoving(sessionManager, 127.698945752801)
+ testIntervalTimeSpentRest(sessionManager, 0)
+ testIntervalDistance(sessionManager, 500.02667777936165) // Must be 500
+ testIntervalDistanceToEnd(sessionManager, -0.02667777936164839) // This is a testing artifact
+ testIntervalDistanceTarget(sessionManager, 500)
+ testIntervalDistanceProjectedEnd(sessionManager, 500)
+ testIntervalWorkSinceStart(sessionManager, 21805.629791344654)
+ testIntervalCaloriesSinceStart(sessionManager, 31.408845280680705)
+ testIntervalCaloriesTarget(sessionManager, undefined)
+ testIntervalCaloriesToEnd(sessionManager, undefined)
+ testIntervalNumberOfStrokes(sessionManager, 42)
+ testIntervalMinimumPace(sessionManager, 127.56619237664853)
+ testIntervalAveragePace(sessionManager, 127.69213266771794) // @ToDo: Find out why the average is not between min and max // Theoretical value 126.4 sec/500m
+ testIntervalMaximumPace(sessionManager, 127.40758861515249)
+ testIntervalMinimumLinearVelocity(sessionManager, 3.9195337783831734)
+ testIntervalAverageLinearVelocity(sessionManager, 3.9156680177087044) // @ToDo: Find out why the average is not between min and max // Theoretical value 126.4 sec/500m
+ testIntervalMaximumLinearVelocity(sessionManager, 3.9244130230758905)
+ testIntervalMinimumPower(sessionManager, 168.60143772708057)
+ testIntervalAveragePower(sessionManager, 168.9135517818113) // Theoretical value 173.33 Watts
+ testIntervalMaximumPower(sessionManager, 169.23187165676933)
+ testIntervalMinimumStrokedistance(sessionManager, 11.724530924929411)
+ testIntervalAverageStrokedistance(sessionManager, 11.765480573374434) // Theoretical value 11.77 meters
+ testIntervalMaximumStrokedistance(sessionManager, 11.797809243208839)
+ testIntervalMinimumStrokerate(sessionManager, 19.93472003813677)
+ testIntervalAverageStrokerate(sessionManager, 20.000651573915178) // Theoretical value 20 SPM
+ testIntervalMaximumStrokerate(sessionManager, 20.081870630909517)
+ testIntervalMinimumDragfactor(sessionManager, 119.92470860848056)
+ testIntervalAverageDragfactor(sessionManager, 119.92470860886081) // Theoretical value 120 * 10^−6 [ k g m 2 ]
+ testIntervalMaximumDragfactor(sessionManager, 119.92470860915626)
+
+ // Test split metrics
+ testSplitNumber(sessionManager, 6)
+ testSplitType(sessionManager, 'distance')
+ testSplitTime(sessionManager, 25.71247191894588)
+ testSplitMovingTimeTarget(sessionManager, undefined)
+ testSplitMovingTimeToEnd(sessionManager, undefined)
+ testSplitMovingTimeProjectedEnd(sessionManager, 25.705342388724375)
+ testSplitTimeSpentTotal(sessionManager, 25.71247191894588)
+ testSplitTimeSpentMoving(sessionManager, 25.71247191894588)
+ testSplitTimeSpentRest(sessionManager, 0)
+ testSplitDistance(sessionManager, 100.02667777936165) // Must be 100
+ testSplitDistanceToEnd(sessionManager, -0.02667777936164839) // This is a testing artifact
+ testSplitDistanceTarget(sessionManager, 100)
+ testSplitDistanceProjectedEnd(sessionManager, 100)
+ testSplitWorkSinceStart(sessionManager, 4153.888188366189)
+ testSplitCaloriesSinceStart(sessionManager, 6.09878998216567)
+ testSplitCaloriesTarget(sessionManager, undefined)
+ testSplitCaloriesToEnd(sessionManager, undefined)
+ testSplitNumberOfStrokes(sessionManager, 8)
+ testSplitMinimumPace(sessionManager, 127.56619237664853)
+ testSplitAveragePace(sessionManager, 128.5280711594877) // @ToDo: Find out why the average is not between min and max // Theoretical value 126.4 sec/500m
+ testSplitMaximumPace(sessionManager, 127.4603200487888)
+ testSplitMinimumLinearVelocity(sessionManager, 3.9195337783831734)
+ testSplitAverageLinearVelocity(sessionManager, 3.8902007591754866) // Theoretical value 126.4 sec/500m
+ testSplitMaximumLinearVelocity(sessionManager, 3.922789459563665)
+ testSplitMinimumPower(sessionManager, 168.60143772708057)
+ testSplitAveragePower(sessionManager, 168.85290357507074) // Theoretical value 173.33 Watts
+ testSplitMaximumPower(sessionManager, 169.02193163175738)
+ testSplitMinimumStrokedistance(sessionManager, 11.761170084055639)
+ testSplitAverageStrokedistance(sessionManager, 11.766558195702117) // Theoretical value 11.77 meters
+ testSplitMaximumStrokedistance(sessionManager, 11.797809243208839)
+ testSplitMinimumStrokerate(sessionManager, 19.93472003813677)
+ testSplitAverageStrokerate(sessionManager, 19.996423510611475) // Theoretical value 20 SPM
+ testSplitMaximumStrokerate(sessionManager, 20.008067113920635)
+ testSplitMinimumDragfactor(sessionManager, 119.92470860860358)
+ testSplitAverageDragfactor(sessionManager, 119.92470860886526) // Theoretical value 120 * 10^−6 [ k g m 2 ]
+ testSplitMaximumDragfactor(sessionManager, 119.92470860915626)
+})
+
+/**
+ * From this point on, the reference machine (Concept2 RowErg with a PM5) will be used
+ */
+
+/**
+ * @description Test behaviour for the C2 RowErg in a 'Just Row' session
+ */
+test('C2_RowErg_01: 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,
+ userSettings: {
+ sex: 'male'
+ },
+ rowerSettings: rowerProfile
+ }
+ const sessionManager = createSessionManager(testConfig)
+
+ testSessionType(sessionManager, 'justrow')
+ testIntervalType(sessionManager, 'justrow')
+ testSplitType(sessionManager, 'justrow')
+
+ testSessionState(sessionManager, 'WaitingForStart')
+ testTotalMovingTime(sessionManager, 0)
+ testTotalLinearDistance(sessionManager, 0)
+ testTotalCalories(sessionManager, 0)
+ testTotalNumberOfStrokes(sessionManager, 0)
+ testDragFactor(sessionManager, undefined)
+
+ await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/Concept2_RowErg_Session_2000meters.csv', realtime: false, loop: false })
+
+ // Test Instantanuous metrics
+ testIsMoving(sessionManager, true)
+ testIsDriveStart(sessionManager, false)
+ testIsRecoveryStart(sessionManager, false)
+ testIsSessionStart(sessionManager, false)
+ testIsIntervalEnd(sessionManager, false)
+ testIsSplitEnd(sessionManager, false)
+ testIsPauseStart(sessionManager, false)
+ testIsPauseEnd(sessionManager, false)
+ testIsSessionStop(sessionManager, false)
+ testTotalMovingTime(sessionManager, 589.9664157362346)
+ testTotalLinearDistance(sessionManager, 2028.3093228150667)
+ testTotalWork(sessionManager, 67751.31484279277)
+ testTotalCalories(sessionManager, 113.68892973306028)
+ testTotalCaloriesPerMinute(sessionManager, 11.542360132437295)
+ testTotalCaloriesPerHour (sessionManager, 692.5416079462377)
+ testTotalNumberOfStrokes(sessionManager, 205)
+ testStrokeState(sessionManager, 'Recovery')
+ testStrokeWork(sessionManager, 330.7759067800944)
+ testStrokeCalories(sessionManager, 0.5617995397938088)
+ testCycleDistance(sessionManager, 10.261713455631917)
+ testCycleStrokeRate(sessionManager, 20.26138264188829)
+ testCycleDuration(sessionManager, 2.9612984000389133)
+ testCycleLinearVelocity(sessionManager, 3.4650957806968687)
+ testCyclePace(sessionManager, 144.29615561721775)
+ testCyclePower(sessionManager, 116.49419766383924)
+ testDriveLastStartTime (sessionManager, 587.302081036686) // Since the last drive is witin 6 seconds, the session has not been able to pause yet
+ testDriveDuration(sessionManager, 0.7332495742683705)
+ testDriveLength(sessionManager, 1.1728612573401769)
+ testDriveDistance(sessionManager, 2.5693271336160244)
+ testDriveAverageHandleForce(sessionManager, 284.3396357312365)
+ testDrivePeakHandleForce(sessionManager, 444.35598591963236)
+ testRecoveryDuration(sessionManager, 2.2585963714949457)
+ testDragFactor(sessionManager, 80.77192371126783)
+
+ // Test session metrics
+ testSessionType(sessionManager, 'justrow')
+ testSessionState(sessionManager, 'Rowing')
+ testSessionTime(sessionManager, 589.9664157362346)
+ testSessionMovingTimeTarget(sessionManager, undefined)
+ testSessionMovingTimeToEnd(sessionManager, undefined)
+ testSessionMovingTimeProjectedEnd(sessionManager, undefined)
+ testSessionTimeSpentTotal(sessionManager, 589.9664157362346)
+ testSessionTimeSpentMoving(sessionManager, 589.9664157362346)
+ testSessionTimeSpentRest(sessionManager, 0)
+ testSessionDistance(sessionManager, 2028.3093228150667)
+ testSessionDistanceToEnd(sessionManager, undefined)
+ testSessionDistanceTarget(sessionManager, undefined)
+ testSessionDistanceProjectedEnd(sessionManager, undefined)
+ testSessionWorkSinceStart(sessionManager, 67751.31484279277)
+ testSessionCaloriesSinceStart(sessionManager, 113.68892973306028)
+ testSessionCaloriesTarget(sessionManager, undefined)
+ testSessionCaloriesToEnd(sessionManager, undefined)
+ testSessionNumberOfStrokes(sessionManager, 205)
+ testSessionMinimumPace(sessionManager, 179.5376166116343)
+ testSessionAveragePace(sessionManager, 145.43304837681936)
+ testSessionMaximumPace(sessionManager, 142.0274009673654)
+ testSessionMinimumLinearVelocity(sessionManager, 2.7849317008678574)
+ testSessionAverageLinearVelocity(sessionManager, 3.438008111502222)
+ testSessionMaximumLinearVelocity(sessionManager, 3.5204474389761478)
+ testSessionMinimumPower(sessionManager, 60.47859281426262)
+ testSessionAveragePower(sessionManager, 113.90001827057858)
+ testSessionMaximumPower(sessionManager, 122.16651759501134)
+ testSessionMinimumStrokedistance(sessionManager, 7.107647385095529)
+ testSessionAverageStrokedistance(sessionManager, 9.860928675480205)
+ testSessionMaximumStrokedistance(sessionManager, 10.524188926326556)
+ testSessionMinimumStrokerate(sessionManager, 19.73589341291662)
+ testSessionAverageStrokerate(sessionManager, 20.93351037644888)
+ testSessionMaximumStrokerate(sessionManager, 23.509312294032103)
+ testSessionMinimumDragfactor(sessionManager, 80.59518571754946)
+ testSessionAverageDragfactor(sessionManager, 81.607049911396)
+ testSessionMaximumDragfactor(sessionManager, 86.87374547552109)
+ testSessionNumberOfIntervals(sessionManager, 1)
+
+ // Test interval metrics
+ testIntervalNumber(sessionManager, 0)
+ testIntervalWorkoutStepNumber(sessionManager, 0)
+ testIntervalType(sessionManager, 'justrow')
+ testIntervalTime(sessionManager, 589.9664157362346)
+ testIntervalMovingTimeTarget(sessionManager, undefined)
+ testIntervalMovingTimeToEnd(sessionManager, undefined)
+ testIntervalMovingTimeProjectedEnd(sessionManager, undefined)
+ testIntervalTimeSpentTotal(sessionManager, 589.9664157362346)
+ testIntervalTimeSpentMoving(sessionManager, 589.9664157362346)
+ testIntervalTimeSpentRest(sessionManager, 0)
+ testIntervalDistance(sessionManager, 2028.3093228150667)
+ testIntervalDistanceToEnd(sessionManager, undefined)
+ testIntervalDistanceTarget(sessionManager, undefined)
+ testIntervalDistanceProjectedEnd(sessionManager, undefined)
+ testIntervalWorkSinceStart(sessionManager, 67751.31484279277)
+ testIntervalCaloriesSinceStart(sessionManager, 113.68892973306028)
+ testIntervalCaloriesTarget(sessionManager, undefined)
+ testIntervalCaloriesToEnd(sessionManager, undefined)
+ testIntervalNumberOfStrokes(sessionManager, 205)
+ testIntervalMinimumPace(sessionManager, 179.5376166116343)
+ testIntervalAveragePace(sessionManager, 145.43304837681936)
+ testIntervalMaximumPace(sessionManager, 142.0274009673654)
+ testIntervalMinimumLinearVelocity(sessionManager, 2.7849317008678574)
+ testIntervalAverageLinearVelocity(sessionManager, 3.438008111502222)
+ testIntervalMaximumLinearVelocity(sessionManager, 3.5204474389761478)
+ testIntervalMinimumPower(sessionManager, 60.47859281426262)
+ testIntervalAveragePower(sessionManager, 113.90001827057858)
+ testIntervalMaximumPower(sessionManager, 122.16651759501134)
+ testIntervalMinimumStrokedistance(sessionManager, 7.107647385095529)
+ testIntervalAverageStrokedistance(sessionManager, 9.860928675480205)
+ testIntervalMaximumStrokedistance(sessionManager, 10.524188926326556)
+ testIntervalMinimumStrokerate(sessionManager, 19.73589341291662)
+ testIntervalAverageStrokerate(sessionManager, 20.93351037644888)
+ testIntervalMaximumStrokerate(sessionManager, 23.509312294032103)
+ testIntervalMinimumDragfactor(sessionManager, 80.59518571754946)
+ testIntervalAverageDragfactor(sessionManager, 81.607049911396)
+ testIntervalMaximumDragfactor(sessionManager, 86.87374547552109)
+
+ // Test split metrics
+ testSplitNumber(sessionManager, 0)
+ testSplitType(sessionManager, 'justrow')
+ testSplitTime(sessionManager, 589.9664157362346)
+ testSplitMovingTimeTarget(sessionManager, undefined)
+ testSplitMovingTimeToEnd(sessionManager, undefined)
+ testSplitMovingTimeProjectedEnd(sessionManager, undefined)
+ testSplitTimeSpentTotal(sessionManager, 589.9664157362346)
+ testSplitTimeSpentMoving(sessionManager, 589.9664157362346)
+ testSplitTimeSpentRest(sessionManager, 0)
+ testSplitDistance(sessionManager, 2028.3093228150667)
+ testSplitDistanceToEnd(sessionManager, undefined)
+ testSplitDistanceTarget(sessionManager, undefined)
+ testSplitDistanceProjectedEnd(sessionManager, undefined)
+ testSplitWorkSinceStart(sessionManager, 67751.31484279277)
+ testSplitCaloriesSinceStart(sessionManager, 113.68892973306028)
+ testSplitCaloriesTarget(sessionManager, undefined)
+ testSplitCaloriesToEnd(sessionManager, undefined)
+ testSplitNumberOfStrokes(sessionManager, 205)
+ testSplitMinimumPace(sessionManager, 179.5376166116343)
+ testSplitAveragePace(sessionManager, 145.43304837681936)
+ testSplitMaximumPace(sessionManager, 142.0274009673654)
+ testSplitMinimumLinearVelocity(sessionManager, 2.7849317008678574)
+ testSplitAverageLinearVelocity(sessionManager, 3.438008111502222)
+ testSplitMaximumLinearVelocity(sessionManager, 3.5204474389761478)
+ testSplitMinimumPower(sessionManager, 60.47859281426262)
+ testSplitAveragePower(sessionManager, 113.90001827057858)
+ testSplitMaximumPower(sessionManager, 122.16651759501134)
+ testSplitMinimumStrokedistance(sessionManager, 7.107647385095529)
+ testSplitAverageStrokedistance(sessionManager, 9.860928675480205)
+ testSplitMaximumStrokedistance(sessionManager, 10.524188926326556)
+ testSplitMinimumStrokerate(sessionManager, 19.73589341291662)
+ testSplitAverageStrokerate(sessionManager, 20.93351037644888)
+ testSplitMaximumStrokerate(sessionManager, 23.509312294032103)
+ testSplitMinimumDragfactor(sessionManager, 80.59518571754946)
+ testSplitAverageDragfactor(sessionManager, 81.607049911396)
+ testSplitMaximumDragfactor(sessionManager, 86.87374547552109)
+})
+
+/**
+ * @description Test behaviour for the C2 RowErg in a single interval session with a Distance target
+ */
+test('C2_RowErg_02: 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,
+ userSettings: {
+ sex: 'male'
+ },
+ rowerSettings: rowerProfile
+ }
+ const sessionManager = createSessionManager(testConfig)
+
+ const intervalSettings = []
+ intervalSettings[0] = {
+ type: 'distance',
+ targetDistance: 2000,
+ targetTime: 0
+ }
+ sessionManager.handleCommand('updateIntervalSettings', intervalSettings)
+
+ testSessionType(sessionManager, 'distance')
+ testSessionDistanceTarget(sessionManager, 2000)
+ testIntervalType(sessionManager, 'distance')
+ testIntervalDistanceTarget(sessionManager, 2000)
+ testSplitType(sessionManager, 'distance')
+ testSplitDistanceTarget(sessionManager, 2000)
+
+ testSessionState(sessionManager, 'WaitingForStart')
+ testTotalMovingTime(sessionManager, 0)
+ testTotalLinearDistance(sessionManager, 0)
+ testTotalCalories(sessionManager, 0)
+ testTotalNumberOfStrokes(sessionManager, 0)
+ testDragFactor(sessionManager, undefined)
+
+ await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/Concept2_RowErg_Session_2000meters.csv', realtime: false, loop: false })
+
+ // Test Instantanuous metrics
+ testIsMoving(sessionManager, false)
+ testIsDriveStart(sessionManager, false)
+ testIsRecoveryStart(sessionManager, false)
+ testIsSessionStart(sessionManager, false)
+ testIsIntervalEnd(sessionManager, false)
+ testIsSplitEnd(sessionManager, false)
+ testIsPauseStart(sessionManager, false)
+ testIsPauseEnd(sessionManager, false)
+ testIsSessionStop(sessionManager, false)
+ testTotalMovingTime(sessionManager, 581.8270194717597)
+ testTotalLinearDistance(sessionManager, 2000.010942750369)
+ testTotalWork(sessionManager, 66911.3317369586)
+ testTotalCalories(sessionManager, 112.21066280070247)
+ testTotalCaloriesPerMinute(sessionManager, 11.539575094705384)
+ testTotalCaloriesPerHour (sessionManager, 692.3745056823234)
+ testTotalNumberOfStrokes(sessionManager, 203)
+ testStrokeState(sessionManager, 'Stopped')
+ testStrokeWork(sessionManager, undefined)
+ testStrokeCalories(sessionManager, undefined)
+ testCycleDistance(sessionManager, undefined)
+ testCycleStrokeRate(sessionManager, undefined)
+ testCycleDuration(sessionManager, undefined)
+ testCycleLinearVelocity(sessionManager, undefined)
+ testCyclePace(sessionManager, Infinity)
+ testCyclePower(sessionManager, undefined)
+ testDriveLastStartTime (sessionManager, 581.4837629473499)
+ testDriveDuration(sessionManager, undefined)
+ testDriveLength(sessionManager, undefined)
+ testDriveDistance(sessionManager, undefined)
+ testDriveAverageHandleForce(sessionManager, undefined)
+ testDrivePeakHandleForce(sessionManager, undefined)
+ testRecoveryDuration(sessionManager, undefined)
+ testDragFactor(sessionManager, 80.73138415749969)
+
+ // Test session metrics
+ testSessionType(sessionManager, 'distance')
+ testSessionState(sessionManager, 'Stopped')
+ testSessionTime(sessionManager, 581.8270194717597)
+ testSessionMovingTimeTarget(sessionManager, undefined)
+ testSessionMovingTimeToEnd(sessionManager, undefined)
+ testSessionMovingTimeProjectedEnd(sessionManager, 581.8236773633961)
+ testSessionTimeSpentTotal(sessionManager, 581.8270194717597)
+ testSessionTimeSpentMoving(sessionManager, 581.8270194717597)
+ testSessionTimeSpentRest(sessionManager, 0)
+ testSessionDistance(sessionManager, 2000.010942750369)
+ testSessionDistanceToEnd(sessionManager, -0.010942750368940324) // This is a testing artifact
+ testSessionDistanceTarget(sessionManager, 2000)
+ testSessionDistanceProjectedEnd(sessionManager, 2000)
+ testSessionWorkSinceStart(sessionManager, 66911.3317369586)
+ testSessionCaloriesSinceStart(sessionManager, 112.21066280070247)
+ testSessionCaloriesTarget(sessionManager, undefined)
+ testSessionCaloriesToEnd(sessionManager, undefined)
+ testSessionNumberOfStrokes(sessionManager, 203)
+ testSessionMinimumPace(sessionManager, 179.5376166116343)
+ testSessionAveragePace(sessionManager, 145.4559590238153)
+ testSessionMaximumPace(sessionManager, 142.0274009673654)
+ testSessionMinimumLinearVelocity(sessionManager, 2.7849317008678574)
+ testSessionAverageLinearVelocity(sessionManager, 3.437466593707142)
+ testSessionMaximumLinearVelocity(sessionManager, 3.5204474389761478)
+ testSessionMinimumPower(sessionManager, 60.47859281426262)
+ testSessionAveragePower(sessionManager, 113.87181597977373)
+ testSessionMaximumPower(sessionManager, 122.16651759501134)
+ testSessionMinimumStrokedistance(sessionManager, 7.107647385095529)
+ testSessionAverageStrokedistance(sessionManager, 9.859022488181807)
+ testSessionMaximumStrokedistance(sessionManager, 10.524188926326556)
+ testSessionMinimumStrokerate(sessionManager, 19.73589341291662)
+ testSessionAverageStrokerate(sessionManager, 20.935842468347502)
+ testSessionMaximumStrokerate(sessionManager, 23.509312294032103)
+ testSessionMinimumDragfactor(sessionManager, 80.59518571754946)
+ testSessionAverageDragfactor(sessionManager, 81.6148058901703)
+ testSessionMaximumDragfactor(sessionManager, 86.87374547552109)
+ testSessionNumberOfIntervals(sessionManager, 1)
+
+ // Test interval metrics
+ testIntervalNumber(sessionManager, 0)
+ testIntervalWorkoutStepNumber(sessionManager, 0)
+ testIntervalType(sessionManager, 'distance')
+ testIntervalTime(sessionManager, 581.8270194717597)
+ testIntervalMovingTimeTarget(sessionManager, undefined)
+ testIntervalMovingTimeToEnd(sessionManager, undefined)
+ testIntervalMovingTimeProjectedEnd(sessionManager, 581.8236773633961) // @ToDo find out why this deviates from the session projection
+ testIntervalTimeSpentTotal(sessionManager, 581.8270194717597)
+ testIntervalTimeSpentMoving(sessionManager, 581.8270194717597)
+ testIntervalTimeSpentRest(sessionManager, 0)
+ testIntervalDistance(sessionManager, 2000.010942750369)
+ testIntervalDistanceToEnd(sessionManager, -0.010942750368940324) // This is a testing artifact
+ testIntervalDistanceTarget(sessionManager, 2000)
+ testIntervalDistanceProjectedEnd(sessionManager, 2000)
+ testIntervalWorkSinceStart(sessionManager, 66911.3317369586)
+ testIntervalCaloriesSinceStart(sessionManager, 112.21066280070247)
+ testIntervalCaloriesTarget(sessionManager, undefined)
+ testIntervalCaloriesToEnd(sessionManager, undefined)
+ testIntervalNumberOfStrokes(sessionManager, 203)
+ testIntervalMinimumPace(sessionManager, 179.5376166116343)
+ testIntervalAveragePace(sessionManager, 145.4559590238153)
+ testIntervalMaximumPace(sessionManager, 142.0274009673654)
+ testIntervalMinimumLinearVelocity(sessionManager, 2.7849317008678574)
+ testIntervalAverageLinearVelocity(sessionManager, 3.437466593707142)
+ testIntervalMaximumLinearVelocity(sessionManager, 3.5204474389761478)
+ testIntervalMinimumPower(sessionManager, 60.47859281426262)
+ testIntervalAveragePower(sessionManager, 113.87181597977373)
+ testIntervalMaximumPower(sessionManager, 122.16651759501134)
+ testIntervalMinimumStrokedistance(sessionManager, 7.107647385095529)
+ testIntervalAverageStrokedistance(sessionManager, 9.859022488181807)
+ testIntervalMaximumStrokedistance(sessionManager, 10.524188926326556)
+ testIntervalMinimumStrokerate(sessionManager, 19.73589341291662)
+ testIntervalAverageStrokerate(sessionManager, 20.935842468347502)
+ testIntervalMaximumStrokerate(sessionManager, 23.509312294032103)
+ testIntervalMinimumDragfactor(sessionManager, 80.59518571754946)
+ testIntervalAverageDragfactor(sessionManager, 81.6148058901703)
+ testIntervalMaximumDragfactor(sessionManager, 86.87374547552109)
+
+ // Test split metrics
+ testSplitNumber(sessionManager, 0)
+ testSplitType(sessionManager, 'distance')
+ testSplitTime(sessionManager, 581.8270194717597)
+ testSplitMovingTimeTarget(sessionManager, undefined)
+ testSplitMovingTimeToEnd(sessionManager, undefined)
+ testSplitMovingTimeProjectedEnd(sessionManager, 581.8236773633961) // @ToDo find out why this deviates from the session projection
+ testSplitTimeSpentTotal(sessionManager, 581.8270194717597)
+ testSplitTimeSpentMoving(sessionManager, 581.8270194717597)
+ testSplitTimeSpentRest(sessionManager, 0)
+ testSplitDistance(sessionManager, 2000.010942750369)
+ testSplitDistanceToEnd(sessionManager, -0.010942750368940324) // This is a testing artifact
+ testSplitDistanceTarget(sessionManager, 2000)
+ testSplitDistanceProjectedEnd(sessionManager, 2000)
+ testSplitWorkSinceStart(sessionManager, 66911.3317369586)
+ testSplitCaloriesSinceStart(sessionManager, 112.21066280070247)
+ testSplitCaloriesTarget(sessionManager, undefined)
+ testSplitCaloriesToEnd(sessionManager, undefined)
+ testSplitNumberOfStrokes(sessionManager, 203)
+ testSplitMinimumPace(sessionManager, 179.5376166116343)
+ testSplitAveragePace(sessionManager, 145.4559590238153)
+ testSplitMaximumPace(sessionManager, 142.0274009673654)
+ testSplitMinimumLinearVelocity(sessionManager, 2.7849317008678574)
+ testSplitAverageLinearVelocity(sessionManager, 3.437466593707142)
+ testSplitMaximumLinearVelocity(sessionManager, 3.5204474389761478)
+ testSplitMinimumPower(sessionManager, 60.47859281426262)
+ testSplitAveragePower(sessionManager, 113.87181597977373)
+ testSplitMaximumPower(sessionManager, 122.16651759501134)
+ testSplitMinimumStrokedistance(sessionManager, 7.107647385095529)
+ testSplitAverageStrokedistance(sessionManager, 9.859022488181807)
+ testSplitMaximumStrokedistance(sessionManager, 10.524188926326556)
+ testSplitMinimumStrokerate(sessionManager, 19.73589341291662)
+ testSplitAverageStrokerate(sessionManager, 20.935842468347502)
+ testSplitMaximumStrokerate(sessionManager, 23.509312294032103)
+ testSplitMinimumDragfactor(sessionManager, 80.59518571754946)
+ testSplitAverageDragfactor(sessionManager, 81.6148058901703)
+ testSplitMaximumDragfactor(sessionManager, 86.87374547552109)
+})
+
+/**
+ * @description Test behaviour for the C2 RowErg in a single interval session with a Time target
+ * This also tests the repeated updating of the workout plan
+ */
+test('C2_RowErg_03: 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,
+ userSettings: {
+ sex: 'male'
+ },
+ rowerSettings: rowerProfile
+ }
+ const sessionManager = createSessionManager(testConfig)
+
+ const intervalSettings = []
+ intervalSettings[0] = {
+ type: 'distance',
+ targetDistance: 2000,
+ targetTime: 0
+ }
+ sessionManager.handleCommand('updateIntervalSettings', intervalSettings)
+
+ testSessionType(sessionManager, 'distance')
+ testSessionDistanceTarget(sessionManager, 2000)
+ testIntervalType(sessionManager, 'distance')
+ testIntervalDistanceTarget(sessionManager, 2000)
+ testSplitType(sessionManager, 'distance')
+ testSplitDistanceTarget(sessionManager, 2000)
+
+ // Let's reset that workout plan
+ intervalSettings[0] = {
+ type: 'time',
+ targetDistance: 0,
+ targetTime: 580
+ }
+ sessionManager.handleCommand('updateIntervalSettings', intervalSettings)
+
+ testSessionType(sessionManager, 'time')
+ testSessionMovingTimeTarget(sessionManager, 580)
+ testIntervalType(sessionManager, 'time')
+ testIntervalMovingTimeTarget(sessionManager, 580)
+ testSplitType(sessionManager, 'time')
+ testSplitMovingTimeTarget(sessionManager, 580)
+
+ testSessionState(sessionManager, 'WaitingForStart')
+ testTotalMovingTime(sessionManager, 0)
+ testTotalLinearDistance(sessionManager, 0)
+ testTotalCalories(sessionManager, 0)
+ testTotalNumberOfStrokes(sessionManager, 0)
+ testDragFactor(sessionManager, undefined)
+
+ await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/Concept2_RowErg_Session_2000meters.csv', realtime: false, loop: false })
+
+ // Test Instantanuous metrics
+ testIsMoving(sessionManager, false)
+ testIsDriveStart(sessionManager, false)
+ testIsRecoveryStart(sessionManager, false)
+ testIsSessionStart(sessionManager, false)
+ testIsIntervalEnd(sessionManager, false)
+ testIsSplitEnd(sessionManager, false)
+ testIsPauseStart(sessionManager, false)
+ testIsPauseEnd(sessionManager, false)
+ testIsSessionStop(sessionManager, false)
+ testTotalMovingTime(sessionManager, 580.0012592877247)
+ testTotalLinearDistance(sessionManager, 1993.9102063497517)
+ testTotalWork(sessionManager, 66743.55736267459)
+ testTotalCalories(sessionManager, 111.89873100033381)
+ testTotalCaloriesPerMinute(sessionManager, 11.53922973724572)
+ testTotalCaloriesPerHour (sessionManager, 692.353784234743)
+ testTotalNumberOfStrokes(sessionManager, 202)
+ testStrokeState(sessionManager, 'Stopped')
+ testStrokeWork(sessionManager, undefined)
+ testStrokeCalories(sessionManager, undefined)
+ testCycleDistance(sessionManager, undefined)
+ testCycleStrokeRate(sessionManager, undefined)
+ testCycleDuration(sessionManager, undefined)
+ testCycleLinearVelocity(sessionManager, undefined)
+ testCyclePace(sessionManager, Infinity)
+ testCyclePower(sessionManager, undefined)
+ testDriveLastStartTime (sessionManager, 578.6205943255404)
+ testDriveDuration(sessionManager, undefined)
+ testDriveLength(sessionManager, undefined)
+ testDriveDistance(sessionManager, undefined)
+ testDriveAverageHandleForce(sessionManager, undefined)
+ testDrivePeakHandleForce(sessionManager, undefined)
+ testRecoveryDuration(sessionManager, undefined)
+ testDragFactor(sessionManager, 80.76953491531596)
+
+ // Test session metrics
+ testSessionType(sessionManager, 'time')
+ testSessionState(sessionManager, 'Stopped')
+ testSessionTime(sessionManager, 580.0012592877247)
+ testSessionMovingTimeTarget(sessionManager, 580)
+ testSessionMovingTimeToEnd(sessionManager, -0.0012592877246788703) // This is a testing artifact
+ testSessionMovingTimeProjectedEnd(sessionManager, 580)
+ testSessionTimeSpentTotal(sessionManager, 580.0012592877247)
+ testSessionTimeSpentMoving(sessionManager, 580.0012592877247)
+ testSessionTimeSpentRest(sessionManager, 0)
+ testSessionDistance(sessionManager, 1993.9102063497517)
+ testSessionDistanceToEnd(sessionManager, undefined)
+ testSessionDistanceTarget(sessionManager, undefined)
+ testSessionDistanceProjectedEnd(sessionManager, 1993.9055444257651)
+ testSessionWorkSinceStart(sessionManager, 66743.55736267459)
+ testSessionCaloriesSinceStart(sessionManager, 111.89873100033381)
+ testSessionCaloriesTarget(sessionManager, undefined)
+ testSessionCaloriesToEnd(sessionManager, undefined)
+ testSessionNumberOfStrokes(sessionManager, 202)
+ testSessionMinimumPace(sessionManager, 179.5376166116343)
+ testSessionAveragePace(sessionManager, 145.443174281537)
+ testSessionMaximumPace(sessionManager, 142.0274009673654)
+ testSessionMinimumLinearVelocity(sessionManager, 2.7849317008678574)
+ testSessionAverageLinearVelocity(sessionManager, 3.4377687538099306)
+ testSessionMaximumLinearVelocity(sessionManager, 3.5204474389761478)
+ testSessionMinimumPower(sessionManager, 60.47859281426262)
+ testSessionAveragePower(sessionManager, 113.86152135697425)
+ testSessionMaximumPower(sessionManager, 122.16651759501134)
+ testSessionMinimumStrokedistance(sessionManager, 7.107647385095529)
+ testSessionAverageStrokedistance(sessionManager, 9.858745898234758)
+ testSessionMaximumStrokedistance(sessionManager, 10.524188926326556)
+ testSessionMinimumStrokerate(sessionManager, 19.73589341291662)
+ testSessionAverageStrokerate(sessionManager, 20.93582388255797)
+ testSessionMaximumStrokerate(sessionManager, 23.509312294032103)
+ testSessionMinimumDragfactor(sessionManager, 80.59518571754946)
+ testSessionAverageDragfactor(sessionManager, 81.61708137780113)
+ testSessionMaximumDragfactor(sessionManager, 86.87374547552109)
+ testSessionNumberOfIntervals(sessionManager, 1)
+
+ // Test interval metrics
+ testIntervalNumber(sessionManager, 0)
+ testIntervalWorkoutStepNumber(sessionManager, 0)
+ testIntervalType(sessionManager, 'time')
+ testIntervalTime(sessionManager, 580.0012592877247)
+ testIntervalMovingTimeTarget(sessionManager, 580)
+ testIntervalMovingTimeToEnd(sessionManager, -0.0012592877246788703)
+ testIntervalMovingTimeProjectedEnd(sessionManager, 580)
+ testIntervalTimeSpentTotal(sessionManager, 580.0012592877247)
+ testIntervalTimeSpentMoving(sessionManager, 580.0012592877247)
+ testIntervalTimeSpentRest(sessionManager, 0)
+ testIntervalDistance(sessionManager, 1993.9102063497517)
+ testIntervalDistanceToEnd(sessionManager, undefined)
+ testIntervalDistanceTarget(sessionManager, undefined)
+ testIntervalDistanceProjectedEnd(sessionManager, 1993.9055444257651)
+ testIntervalWorkSinceStart(sessionManager, 66743.55736267459)
+ testIntervalCaloriesSinceStart(sessionManager, 111.89873100033381)
+ testIntervalCaloriesTarget(sessionManager, undefined)
+ testIntervalCaloriesToEnd(sessionManager, undefined)
+ testIntervalNumberOfStrokes(sessionManager, 202)
+ testIntervalMinimumPace(sessionManager, 179.5376166116343)
+ testIntervalAveragePace(sessionManager, 145.443174281537)
+ testIntervalMaximumPace(sessionManager, 142.0274009673654)
+ testIntervalMinimumLinearVelocity(sessionManager, 2.7849317008678574)
+ testIntervalAverageLinearVelocity(sessionManager, 3.4377687538099306)
+ testIntervalMaximumLinearVelocity(sessionManager, 3.5204474389761478)
+ testIntervalMinimumPower(sessionManager, 60.47859281426262)
+ testIntervalAveragePower(sessionManager, 113.86152135697425)
+ testIntervalMaximumPower(sessionManager, 122.16651759501134)
+ testIntervalMinimumStrokedistance(sessionManager, 7.107647385095529)
+ testIntervalAverageStrokedistance(sessionManager, 9.858745898234758)
+ testIntervalMaximumStrokedistance(sessionManager, 10.524188926326556)
+ testIntervalMinimumStrokerate(sessionManager, 19.73589341291662)
+ testIntervalAverageStrokerate(sessionManager, 20.93582388255797)
+ testIntervalMaximumStrokerate(sessionManager, 23.509312294032103)
+ testIntervalMinimumDragfactor(sessionManager, 80.59518571754946)
+ testIntervalAverageDragfactor(sessionManager, 81.61708137780113)
+ testIntervalMaximumDragfactor(sessionManager, 86.87374547552109)
+
+ // Test split metrics
+ testSplitNumber(sessionManager, 0)
+ testSplitType(sessionManager, 'time')
+ testSplitTime(sessionManager, 580.0012592877247)
+ testSplitMovingTimeTarget(sessionManager, 580)
+ testSplitMovingTimeToEnd(sessionManager, -0.0012592877246788703)
+ testSplitMovingTimeProjectedEnd(sessionManager, 580)
+ testSplitTimeSpentTotal(sessionManager, 580.0012592877247)
+ testSplitTimeSpentMoving(sessionManager, 580.0012592877247)
+ testSplitTimeSpentRest(sessionManager, 0)
+ testSplitDistance(sessionManager, 1993.9102063497517)
+ testSplitDistanceToEnd(sessionManager, undefined)
+ testSplitDistanceTarget(sessionManager, undefined)
+ testSplitDistanceProjectedEnd(sessionManager, 1993.9055444257651)
+ testSplitWorkSinceStart(sessionManager, 66743.55736267459)
+ testSplitCaloriesSinceStart(sessionManager, 111.89873100033381)
+ testSplitCaloriesTarget(sessionManager, undefined)
+ testSplitCaloriesToEnd(sessionManager, undefined)
+ testSplitNumberOfStrokes(sessionManager, 202)
+ testSplitMinimumPace(sessionManager, 179.5376166116343)
+ testSplitAveragePace(sessionManager, 145.443174281537)
+ testSplitMaximumPace(sessionManager, 142.0274009673654)
+ testSplitMinimumLinearVelocity(sessionManager, 2.7849317008678574)
+ testSplitAverageLinearVelocity(sessionManager, 3.4377687538099306)
+ testSplitMaximumLinearVelocity(sessionManager, 3.5204474389761478)
+ testSplitMinimumPower(sessionManager, 60.47859281426262)
+ testSplitAveragePower(sessionManager, 113.86152135697425)
+ testSplitMaximumPower(sessionManager, 122.16651759501134)
+ testSplitMinimumStrokedistance(sessionManager, 7.107647385095529)
+ testSplitAverageStrokedistance(sessionManager, 9.858745898234758)
+ testSplitMaximumStrokedistance(sessionManager, 10.524188926326556)
+ testSplitMinimumStrokerate(sessionManager, 19.73589341291662)
+ testSplitAverageStrokerate(sessionManager, 20.93582388255797)
+ testSplitMaximumStrokerate(sessionManager, 23.509312294032103)
+ testSplitMinimumDragfactor(sessionManager, 80.59518571754946)
+ testSplitAverageDragfactor(sessionManager, 81.61708137780113)
+ testSplitMaximumDragfactor(sessionManager, 86.87374547552109)
+})
+
+/**
+ * @description Test behaviour for the C2 RowErg in a single interval session with a Calorie target
+ */
+test('C2_RowErg_04: A 100 calories 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,
+ userSettings: {
+ sex: 'male'
+ },
+ rowerSettings: rowerProfile
+ }
+ const sessionManager = createSessionManager(testConfig)
+
+ const intervalSettings = []
+ intervalSettings[0] = {
+ type: 'calories',
+ targetCalories: 100
+ }
+ sessionManager.handleCommand('updateIntervalSettings', intervalSettings)
+
+ testSessionType(sessionManager, 'calories')
+ testSessionCaloriesTarget(sessionManager, 100)
+ testIntervalType(sessionManager, 'calories')
+ testIntervalCaloriesTarget(sessionManager, 100)
+ testSplitType(sessionManager, 'calories')
+ testSplitCaloriesTarget(sessionManager, 100)
+
+ testSessionState(sessionManager, 'WaitingForStart')
+ testTotalMovingTime(sessionManager, 0)
+ testTotalLinearDistance(sessionManager, 0)
+ testTotalCalories(sessionManager, 0)
+ testTotalNumberOfStrokes(sessionManager, 0)
+ testDragFactor(sessionManager, undefined)
+
+ await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/Concept2_RowErg_Session_2000meters.csv', realtime: false, loop: false })
+
+ // Test Instantanuous metrics
+ testIsMoving(sessionManager, false)
+ testIsDriveStart(sessionManager, false)
+ testIsRecoveryStart(sessionManager, false)
+ testIsSessionStart(sessionManager, false)
+ testIsIntervalEnd(sessionManager, false)
+ testIsSplitEnd(sessionManager, false)
+ testIsPauseStart(sessionManager, false)
+ testIsPauseEnd(sessionManager, false)
+ testIsSessionStop(sessionManager, false)
+ testTotalMovingTime(sessionManager, 519.003226746047)
+ testTotalLinearDistance(sessionManager, 1781.9764966241942)
+ testTotalWork(sessionManager, 59587.80767782036)
+ testTotalCalories(sessionManager, 100.00056192199949)
+ testTotalCaloriesPerMinute(sessionManager, 11.517655566822938)
+ testTotalCaloriesPerHour (sessionManager, 691.0593340093769)
+ testTotalNumberOfStrokes(sessionManager, 181)
+ testStrokeState(sessionManager, 'Stopped')
+ testStrokeWork(sessionManager, undefined)
+ testStrokeCalories(sessionManager, undefined)
+ testCycleDistance(sessionManager, undefined)
+ testCycleStrokeRate(sessionManager, undefined)
+ testCycleDuration(sessionManager, undefined)
+ testCycleLinearVelocity(sessionManager, undefined)
+ testCyclePace(sessionManager, Infinity)
+ testCyclePower(sessionManager, undefined)
+ testDriveLastStartTime (sessionManager, 517.925185924641)
+ testDriveDuration(sessionManager, undefined)
+ testDriveLength(sessionManager, undefined)
+ testDriveDistance(sessionManager, undefined)
+ testDriveAverageHandleForce(sessionManager, undefined)
+ testDrivePeakHandleForce(sessionManager, undefined)
+ testRecoveryDuration(sessionManager, undefined)
+ testDragFactor(sessionManager, 80.74590910920787)
+
+ // Test session metrics
+ testSessionType(sessionManager, 'calories')
+ testSessionState(sessionManager, 'Stopped')
+ testSessionTime(sessionManager, 519.003226746047)
+ testSessionMovingTimeTarget(sessionManager, undefined)
+ testSessionMovingTimeToEnd(sessionManager, undefined)
+ testSessionMovingTimeProjectedEnd(sessionManager, 518.9964836821997)
+ testSessionTimeSpentTotal(sessionManager, 519.003226746047)
+ testSessionTimeSpentMoving(sessionManager, 519.003226746047)
+ testSessionTimeSpentRest(sessionManager, 0)
+ testSessionDistance(sessionManager, 1781.9764966241942)
+ testSessionDistanceToEnd(sessionManager, undefined)
+ testSessionDistanceTarget(sessionManager, undefined)
+ testSessionDistanceProjectedEnd(sessionManager, 1781.95105941339)
+ testSessionWorkSinceStart(sessionManager, 59587.80767782036)
+ testSessionCaloriesSinceStart(sessionManager, 100.00056192199949)
+ testSessionCaloriesTarget(sessionManager, 100)
+ testSessionCaloriesToEnd(sessionManager, -0.0005619219994912328)
+ testSessionNumberOfStrokes(sessionManager, 181)
+ testSessionMinimumPace(sessionManager, 179.5376166116343)
+ testSessionAveragePace(sessionManager, 145.62572169982468)
+ testSessionMaximumPace(sessionManager, 143.12937987059576)
+ testSessionMinimumLinearVelocity(sessionManager, 2.7849317008678574)
+ testSessionAverageLinearVelocity(sessionManager, 3.433459379041841)
+ testSessionMaximumLinearVelocity(sessionManager, 3.493342879372868)
+ testSessionMinimumPower(sessionManager, 60.47859281426262)
+ testSessionAveragePower(sessionManager, 113.45327375288849)
+ testSessionMaximumPower(sessionManager, 119.3665674687934)
+ testSessionMinimumStrokedistance(sessionManager, 7.107647385095529)
+ testSessionAverageStrokedistance(sessionManager, 9.837751690285906)
+ testSessionMaximumStrokedistance(sessionManager, 10.524188926326556)
+ testSessionMinimumStrokerate(sessionManager, 19.73589341291662)
+ testSessionAverageStrokerate(sessionManager, 20.955753706412516)
+ testSessionMaximumStrokerate(sessionManager, 23.509312294032103)
+ testSessionMinimumDragfactor(sessionManager, 80.59518571754946)
+ testSessionAverageDragfactor(sessionManager, 81.71102193904294)
+ testSessionMaximumDragfactor(sessionManager, 86.87374547552109)
+ testSessionNumberOfIntervals(sessionManager, 1)
+
+ // Test interval metrics
+ testIntervalNumber(sessionManager, 0)
+ testIntervalWorkoutStepNumber(sessionManager, 0)
+ testIntervalType(sessionManager, 'calories')
+ testIntervalTime(sessionManager, 519.003226746047)
+ testIntervalMovingTimeTarget(sessionManager, undefined)
+ testIntervalMovingTimeToEnd(sessionManager, undefined)
+ testIntervalMovingTimeProjectedEnd(sessionManager, 518.9964836821997)
+ testIntervalTimeSpentTotal(sessionManager, 519.003226746047)
+ testIntervalTimeSpentMoving(sessionManager, 519.003226746047)
+ testIntervalTimeSpentRest(sessionManager, 0)
+ testIntervalDistance(sessionManager, 1781.9764966241942)
+ testIntervalDistanceToEnd(sessionManager, undefined)
+ testIntervalDistanceTarget(sessionManager, undefined)
+ testIntervalDistanceProjectedEnd(sessionManager, 1781.95105941339)
+ testIntervalWorkSinceStart(sessionManager, 59587.80767782036)
+ testIntervalCaloriesSinceStart(sessionManager, 100.00056192199949)
+ testIntervalCaloriesTarget(sessionManager, 100)
+ testIntervalCaloriesToEnd(sessionManager, -0.0005619219994912328)
+ testIntervalNumberOfStrokes(sessionManager, 181)
+ testIntervalMinimumPace(sessionManager, 179.5376166116343)
+ testIntervalAveragePace(sessionManager, 145.62572169982468)
+ testIntervalMaximumPace(sessionManager, 143.12937987059576)
+ testIntervalMinimumLinearVelocity(sessionManager, 2.7849317008678574)
+ testIntervalAverageLinearVelocity(sessionManager, 3.433459379041841)
+ testIntervalMaximumLinearVelocity(sessionManager, 3.493342879372868)
+ testIntervalMinimumPower(sessionManager, 60.47859281426262)
+ testIntervalAveragePower(sessionManager, 113.45327375288849)
+ testIntervalMaximumPower(sessionManager, 119.3665674687934)
+ testIntervalMinimumStrokedistance(sessionManager, 7.107647385095529)
+ testIntervalAverageStrokedistance(sessionManager, 9.837751690285906)
+ testIntervalMaximumStrokedistance(sessionManager, 10.524188926326556)
+ testIntervalMinimumStrokerate(sessionManager, 19.73589341291662)
+ testIntervalAverageStrokerate(sessionManager, 20.955753706412516)
+ testIntervalMaximumStrokerate(sessionManager, 23.509312294032103)
+ testIntervalMinimumDragfactor(sessionManager, 80.59518571754946)
+ testIntervalAverageDragfactor(sessionManager, 81.71102193904294)
+ testIntervalMaximumDragfactor(sessionManager, 86.87374547552109)
+
+ // Test split metrics
+ testSplitNumber(sessionManager, 0)
+ testSplitType(sessionManager, 'calories')
+ testSplitTime(sessionManager, 519.003226746047)
+ testSplitMovingTimeTarget(sessionManager, undefined)
+ testSplitMovingTimeToEnd(sessionManager, undefined)
+ testSplitMovingTimeProjectedEnd(sessionManager, 518.9964836821997)
+ testSplitTimeSpentTotal(sessionManager, 519.003226746047)
+ testSplitTimeSpentMoving(sessionManager, 519.003226746047)
+ testSplitTimeSpentRest(sessionManager, 0)
+ testSplitDistance(sessionManager, 1781.9764966241942)
+ testSplitDistanceToEnd(sessionManager, undefined)
+ testSplitDistanceTarget(sessionManager, undefined)
+ testSplitDistanceProjectedEnd(sessionManager, 1781.95105941339)
+ testSplitWorkSinceStart(sessionManager, 59587.80767782036)
+ testSplitCaloriesSinceStart(sessionManager, 100.00056192199949)
+ testSplitCaloriesTarget(sessionManager, 100)
+ testSplitCaloriesToEnd(sessionManager, -0.0005619219994912328)
+ testSplitNumberOfStrokes(sessionManager, 181)
+ testSplitMinimumPace(sessionManager, 179.5376166116343)
+ testSplitAveragePace(sessionManager, 145.62572169982468)
+ testSplitMaximumPace(sessionManager, 143.12937987059576)
+ testSplitMinimumLinearVelocity(sessionManager, 2.7849317008678574)
+ testSplitAverageLinearVelocity(sessionManager, 3.433459379041841)
+ testSplitMaximumLinearVelocity(sessionManager, 3.493342879372868)
+ testSplitMinimumPower(sessionManager, 60.47859281426262)
+ testSplitAveragePower(sessionManager, 113.45327375288849)
+ testSplitMaximumPower(sessionManager, 119.3665674687934)
+ testSplitMinimumStrokedistance(sessionManager, 7.107647385095529)
+ testSplitAverageStrokedistance(sessionManager, 9.837751690285906)
+ testSplitMaximumStrokedistance(sessionManager, 10.524188926326556)
+ testSplitMinimumStrokerate(sessionManager, 19.73589341291662)
+ testSplitAverageStrokerate(sessionManager, 20.955753706412516)
+ testSplitMaximumStrokerate(sessionManager, 23.509312294032103)
+ testSplitMinimumDragfactor(sessionManager, 80.59518571754946)
+ testSplitAverageDragfactor(sessionManager, 81.71102193904294)
+ testSplitMaximumDragfactor(sessionManager, 86.87374547552109)
+})
+
+/**
+ * From this point on, the regular profiles will be tested
+ */
+
+/**
+ * @description Test behaviour for the C2 Model C in a 'Just Row' session
+ * As a side test, this tests the SessionManager's watchdog functionality by suddenly stopping sending data
+ */
+test('C2_ModelC_01: 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,
+ userSettings: {
+ sex: 'male'
+ },
+ rowerSettings: rowerProfile
+ }
+ const sessionManager = createSessionManager(testConfig)
+
+ testSessionType(sessionManager, 'justrow')
+ testIntervalType(sessionManager, 'justrow')
+ testSplitType(sessionManager, 'justrow')
+
+ testSessionState(sessionManager, 'WaitingForStart')
+ testTotalMovingTime(sessionManager, 0)
+ testTotalLinearDistance(sessionManager, 0)
+ testTotalCalories(sessionManager, 0)
+ testTotalNumberOfStrokes(sessionManager, 0)
+ testDragFactor(sessionManager, undefined)
+
+ await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/Concept2_Model_C.csv', realtime: false, loop: false })
+
+ // Test Instantanuous metrics
+ testIsMoving(sessionManager, true) // As data has stopped abrubtly and the last drive is 4 sec ago, SessionManager's watchdog hasn't been triggered yet
+ testIsDriveStart(sessionManager, false)
+ testIsRecoveryStart(sessionManager, false)
+ testIsSessionStart(sessionManager, false)
+ testIsIntervalEnd(sessionManager, false)
+ testIsSplitEnd(sessionManager, false)
+ testIsPauseStart(sessionManager, false)
+ testIsPauseEnd(sessionManager, false)
+ testIsSessionStop(sessionManager, false)
+ testTotalMovingTime(sessionManager, 181.4588596531004)
+ testTotalLinearDistance(sessionManager, 552.4833037536148)
+ testTotalWork(sessionManager, 20593.15388304616)
+ testTotalCalories(sessionManager, 34.73409914542138)
+ testTotalCaloriesPerMinute(sessionManager, 12.371981079533317)
+ testTotalCaloriesPerHour (sessionManager, 742.318864771999)
+ testTotalNumberOfStrokes(sessionManager, 80)
+ testStrokeState(sessionManager, 'Drive')
+ testStrokeWork(sessionManager, 25.512113604279875)
+ testStrokeCalories(sessionManager, 0.21500383832546952)
+ testCycleDistance(sessionManager, 2.9525168987431822)
+ testCycleStrokeRate(sessionManager, 24.336340661672818)
+ testCycleDuration(sessionManager, 2.465448722719998)
+ testCycleLinearVelocity(sessionManager, 1.2061131266447134)
+ testCyclePace(sessionManager, 414.55481161286275)
+ testCyclePower(sessionManager, 4.971183651233226)
+ testDriveLastStartTime (sessionManager, 177.31209030312513)
+ testDriveDuration(sessionManager, 0.7592322967347798)
+ testDriveLength(sessionManager, 0.38117990863556767)
+ testDriveDistance(sessionManager, 0.9595679920915494)
+ testDriveAverageHandleForce(sessionManager, 102.2713643967354)
+ testDrivePeakHandleForce(sessionManager, 212.5533895691384)
+ testRecoveryDuration(sessionManager, 1.983892772108078)
+ testDragFactor(sessionManager, 122.56864708679767)
+
+ // Test session metrics
+ testSessionType(sessionManager, 'justrow')
+ testSessionState(sessionManager, 'Rowing')
+ testSessionTime(sessionManager, 181.4588596531004)
+ testSessionMovingTimeTarget(sessionManager, undefined)
+ testSessionMovingTimeToEnd(sessionManager, undefined)
+ testSessionMovingTimeProjectedEnd(sessionManager, undefined)
+ testSessionTimeSpentTotal(sessionManager, 181.4588596531004)
+ testSessionTimeSpentMoving(sessionManager, 181.4588596531004)
+ testSessionTimeSpentRest(sessionManager, 0)
+ testSessionDistance(sessionManager, 552.4833037536148)
+ testSessionDistanceToEnd(sessionManager, undefined)
+ testSessionDistanceTarget(sessionManager, undefined)
+ testSessionDistanceProjectedEnd(sessionManager, undefined)
+ testSessionWorkSinceStart(sessionManager, 20593.15388304616)
+ testSessionCaloriesSinceStart(sessionManager, 34.73409914542138)
+ testSessionCaloriesTarget(sessionManager, undefined)
+ testSessionCaloriesToEnd(sessionManager, undefined)
+ testSessionNumberOfStrokes(sessionManager, 80)
+ testSessionMinimumPace(sessionManager, 414.55481161286275)
+ testSessionAveragePace(sessionManager, 164.22112525415224)
+ testSessionMaximumPace(sessionManager, 140.53128594463587)
+ testSessionMinimumLinearVelocity(sessionManager, 1.2061131266447134)
+ testSessionAverageLinearVelocity(sessionManager, 3.0446752768633694)
+ testSessionMaximumLinearVelocity(sessionManager, 3.5579265971918987)
+ testSessionMinimumPower(sessionManager, 4.971183651233226)
+ testSessionAveragePower(sessionManager, 87.66783476317362)
+ testSessionMaximumPower(sessionManager, 126.35684327004489)
+ testSessionMinimumStrokedistance(sessionManager, 2.5834522864002882)
+ testSessionAverageStrokedistance(sessionManager, 6.94152974027156)
+ testSessionMaximumStrokedistance(sessionManager, 13.069984906848259)
+ testSessionMinimumStrokerate(sessionManager, 15.784784897254365)
+ testSessionAverageStrokerate(sessionManager, 29.594256299241724)
+ testSessionMaximumStrokerate(sessionManager, 44.75500508102043)
+ testSessionMinimumDragfactor(sessionManager, 122.17215796334402)
+ testSessionAverageDragfactor(sessionManager, 123.62620664131599)
+ testSessionMaximumDragfactor(sessionManager, 124.90962932377184)
+ testSessionNumberOfIntervals(sessionManager, 1)
+
+ // Test interval metrics
+ testIntervalNumber(sessionManager, 0)
+ testIntervalWorkoutStepNumber(sessionManager, 0)
+ testIntervalType(sessionManager, 'justrow')
+ testIntervalTime(sessionManager, 181.4588596531004)
+ testIntervalMovingTimeTarget(sessionManager, undefined)
+ testIntervalMovingTimeToEnd(sessionManager, undefined)
+ testIntervalMovingTimeProjectedEnd(sessionManager, undefined)
+ testIntervalTimeSpentTotal(sessionManager, 181.4588596531004)
+ testIntervalTimeSpentMoving(sessionManager, 181.4588596531004)
+ testIntervalTimeSpentRest(sessionManager, 0)
+ testIntervalDistance(sessionManager, 552.4833037536148)
+ testIntervalDistanceToEnd(sessionManager, undefined)
+ testIntervalDistanceTarget(sessionManager, undefined)
+ testIntervalDistanceProjectedEnd(sessionManager, undefined)
+ testIntervalWorkSinceStart(sessionManager, 20593.15388304616)
+ testIntervalCaloriesSinceStart(sessionManager, 34.73409914542138)
+ testIntervalCaloriesTarget(sessionManager, undefined)
+ testIntervalCaloriesToEnd(sessionManager, undefined)
+ testIntervalNumberOfStrokes(sessionManager, 80)
+ testIntervalMinimumPace(sessionManager, 414.55481161286275)
+ testIntervalAveragePace(sessionManager, 164.22112525415224)
+ testIntervalMaximumPace(sessionManager, 140.53128594463587)
+ testIntervalMinimumLinearVelocity(sessionManager, 1.2061131266447134)
+ testIntervalAverageLinearVelocity(sessionManager, 3.0446752768633694)
+ testIntervalMaximumLinearVelocity(sessionManager, 3.5579265971918987)
+ testIntervalMinimumPower(sessionManager, 4.971183651233226)
+ testIntervalAveragePower(sessionManager, 87.66783476317362)
+ testIntervalMaximumPower(sessionManager, 126.35684327004489)
+ testIntervalMinimumStrokedistance(sessionManager, 2.5834522864002882)
+ testIntervalAverageStrokedistance(sessionManager, 6.94152974027156)
+ testIntervalMaximumStrokedistance(sessionManager, 13.069984906848259)
+ testIntervalMinimumStrokerate(sessionManager, 15.784784897254365)
+ testIntervalAverageStrokerate(sessionManager, 29.594256299241724)
+ testIntervalMaximumStrokerate(sessionManager, 44.75500508102043)
+ testIntervalMinimumDragfactor(sessionManager, 122.17215796334402)
+ testIntervalAverageDragfactor(sessionManager, 123.62620664131599)
+ testIntervalMaximumDragfactor(sessionManager, 124.90962932377184)
+
+ // Test split metrics
+ testSplitNumber(sessionManager, 0)
+ testSplitType(sessionManager, 'justrow')
+ testSplitTime(sessionManager, 181.4588596531004)
+ testSplitMovingTimeTarget(sessionManager, undefined)
+ testSplitMovingTimeToEnd(sessionManager, undefined)
+ testSplitMovingTimeProjectedEnd(sessionManager, undefined)
+ testSplitTimeSpentTotal(sessionManager, 181.4588596531004)
+ testSplitTimeSpentMoving(sessionManager, 181.4588596531004)
+ testSplitTimeSpentRest(sessionManager, 0)
+ testSplitDistance(sessionManager, 552.4833037536148)
+ testSplitDistanceToEnd(sessionManager, undefined)
+ testSplitDistanceTarget(sessionManager, undefined)
+ testSplitDistanceProjectedEnd(sessionManager, undefined)
+ testSplitWorkSinceStart(sessionManager, 20593.15388304616)
+ testSplitCaloriesSinceStart(sessionManager, 34.73409914542138)
+ testSplitCaloriesTarget(sessionManager, undefined)
+ testSplitCaloriesToEnd(sessionManager, undefined)
+ testSplitNumberOfStrokes(sessionManager, 80)
+ testSplitMinimumPace(sessionManager, 414.55481161286275)
+ testSplitAveragePace(sessionManager, 164.22112525415224)
+ testSplitMaximumPace(sessionManager, 140.53128594463587)
+ testSplitMinimumLinearVelocity(sessionManager, 1.2061131266447134)
+ testSplitAverageLinearVelocity(sessionManager, 3.0446752768633694)
+ testSplitMaximumLinearVelocity(sessionManager, 3.5579265971918987)
+ testSplitMinimumPower(sessionManager, 4.971183651233226)
+ testSplitAveragePower(sessionManager, 87.66783476317362)
+ testSplitMaximumPower(sessionManager, 126.35684327004489)
+ testSplitMinimumStrokedistance(sessionManager, 2.5834522864002882)
+ testSplitAverageStrokedistance(sessionManager, 6.94152974027156)
+ testSplitMaximumStrokedistance(sessionManager, 13.069984906848259)
+ testSplitMinimumStrokerate(sessionManager, 15.784784897254365)
+ testSplitAverageStrokerate(sessionManager, 29.594256299241724)
+ testSplitMaximumStrokerate(sessionManager, 44.75500508102043)
+ testSplitMinimumDragfactor(sessionManager, 122.17215796334402)
+ testSplitAverageDragfactor(sessionManager, 123.62620664131599)
+ testSplitMaximumDragfactor(sessionManager, 124.90962932377184)
+})
+
+/**
+ * @description Test behaviour for the C2 Model C in a single interval session with a Distance target
+ */
+test('C2_ModelC_02: 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,
+ userSettings: {
+ sex: 'male'
+ },
+ rowerSettings: rowerProfile
+ }
+ const sessionManager = createSessionManager(testConfig)
+
+ const intervalSettings = []
+ intervalSettings[0] = {
+ type: 'distance',
+ targetDistance: 500,
+ targetTime: 0
+ }
+ sessionManager.handleCommand('updateIntervalSettings', intervalSettings)
+
+ testSessionType(sessionManager, 'distance')
+ testSessionDistanceTarget(sessionManager, 500)
+ testIntervalType(sessionManager, 'distance')
+ testIntervalDistanceTarget(sessionManager, 500)
+ testSplitType(sessionManager, 'distance')
+ testSplitDistanceTarget(sessionManager, 500)
+
+ testSessionState(sessionManager, 'WaitingForStart')
+ testTotalMovingTime(sessionManager, 0)
+ testTotalLinearDistance(sessionManager, 0)
+ testTotalCalories(sessionManager, 0)
+ testTotalNumberOfStrokes(sessionManager, 0)
+ testDragFactor(sessionManager, undefined)
+
+ await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/Concept2_Model_C.csv', realtime: false, loop: false })
+
+ // Test Instantanuous metrics
+ testIsMoving(sessionManager, false)
+ testIsDriveStart(sessionManager, false)
+ testIsRecoveryStart(sessionManager, false)
+ testIsSessionStart(sessionManager, false)
+ testIsIntervalEnd(sessionManager, false)
+ testIsSplitEnd(sessionManager, false)
+ testIsPauseStart(sessionManager, false)
+ testIsPauseEnd(sessionManager, false)
+ testIsSessionStop(sessionManager, false)
+ testTotalMovingTime(sessionManager, 156.7473648976275)
+ testTotalLinearDistance(sessionManager, 500.03104229779495)
+ testTotalWork(sessionManager, 19457.128401218226)
+ testTotalCalories(sessionManager, 31.592878885486314)
+ testTotalCaloriesPerMinute(sessionManager, 12.381421536256369)
+ testTotalCaloriesPerHour (sessionManager, 742.8852921753821)
+ testTotalNumberOfStrokes(sessionManager, 71)
+ testStrokeState(sessionManager, 'Stopped')
+ testStrokeWork(sessionManager, undefined)
+ testStrokeCalories(sessionManager, undefined)
+ testCycleDistance(sessionManager, undefined)
+ testCycleStrokeRate(sessionManager, undefined)
+ testCycleDuration(sessionManager, undefined)
+ testCycleLinearVelocity(sessionManager, undefined)
+ testCyclePace(sessionManager, Infinity)
+ testCyclePower(sessionManager, undefined)
+ testDriveLastStartTime (sessionManager, 155.29769226061947)
+ testDriveDuration(sessionManager, undefined)
+ testDriveLength(sessionManager, undefined)
+ testDriveDistance(sessionManager, undefined)
+ testDriveAverageHandleForce(sessionManager, undefined)
+ testDrivePeakHandleForce(sessionManager, undefined)
+ testRecoveryDuration(sessionManager, undefined)
+ testDragFactor(sessionManager, 123.25256925094398)
+
+ // Test session metrics
+ testSessionType(sessionManager, 'distance')
+ testSessionState(sessionManager, 'Stopped')
+ testSessionTime(sessionManager, 156.7473648976275)
+ testSessionMovingTimeTarget(sessionManager, undefined)
+ testSessionMovingTimeToEnd(sessionManager, undefined)
+ testSessionMovingTimeProjectedEnd(sessionManager, 156.73945983078266)
+ testSessionTimeSpentTotal(sessionManager, 156.7473648976275)
+ testSessionTimeSpentMoving(sessionManager, 156.7473648976275)
+ testSessionTimeSpentRest(sessionManager, 0)
+ testSessionDistance(sessionManager, 500.03104229779495)
+ testSessionDistanceToEnd(sessionManager, -0.031042297794954266) // This is a testing artifact
+ testSessionDistanceTarget(sessionManager, 500)
+ testSessionDistanceProjectedEnd(sessionManager, 500)
+ testSessionWorkSinceStart(sessionManager, 19457.128401218226)
+ testSessionCaloriesSinceStart(sessionManager, 31.592878885486314)
+ testSessionCaloriesTarget(sessionManager, undefined)
+ testSessionCaloriesToEnd(sessionManager, undefined)
+ testSessionNumberOfStrokes(sessionManager, 71)
+ testSessionMinimumPace(sessionManager, 183.61153074397942)
+ testSessionAveragePace(sessionManager, 156.7376339050128)
+ testSessionMaximumPace(sessionManager, 140.53128594463587)
+ testSessionMinimumLinearVelocity(sessionManager, 2.7231405237679764)
+ testSessionAverageLinearVelocity(sessionManager, 3.1900443278543644)
+ testSessionMaximumLinearVelocity(sessionManager, 3.5579265971918987)
+ testSessionMinimumPower(sessionManager, 56.541612582807076)
+ testSessionAveragePower(sessionManager, 90.83151920453602)
+ testSessionMaximumPower(sessionManager, 126.35684327004489)
+ testSessionMinimumStrokedistance(sessionManager, 3.8823727820117346)
+ testSessionAverageStrokedistance(sessionManager, 6.977796243571697)
+ testSessionMaximumStrokedistance(sessionManager, 12.30927126596217)
+ testSessionMinimumStrokerate(sessionManager, 15.784784897254365)
+ testSessionAverageStrokerate(sessionManager, 29.97910826490652)
+ testSessionMaximumStrokerate(sessionManager, 44.75500508102043)
+ testSessionMinimumDragfactor(sessionManager, 122.17215796334402)
+ testSessionAverageDragfactor(sessionManager, 123.68393578065213)
+ testSessionMaximumDragfactor(sessionManager, 124.90962932377184)
+ testSessionNumberOfIntervals(sessionManager, 1)
+
+ // Test interval metrics
+ testIntervalNumber(sessionManager, 0)
+ testIntervalWorkoutStepNumber(sessionManager, 0)
+ testIntervalType(sessionManager, 'distance')
+ testIntervalTime(sessionManager, 156.7473648976275)
+ testIntervalMovingTimeTarget(sessionManager, undefined)
+ testIntervalMovingTimeToEnd(sessionManager, undefined)
+ testIntervalMovingTimeProjectedEnd(sessionManager, 156.73945983078266)
+ testIntervalTimeSpentTotal(sessionManager, 156.7473648976275)
+ testIntervalTimeSpentMoving(sessionManager, 156.7473648976275)
+ testIntervalTimeSpentRest(sessionManager, 0)
+ testIntervalDistance(sessionManager, 500.03104229779495)
+ testIntervalDistanceToEnd(sessionManager, -0.031042297794954266) // This is a testing artifact
+ testIntervalDistanceTarget(sessionManager, 500)
+ testIntervalDistanceProjectedEnd(sessionManager, 500)
+ testIntervalWorkSinceStart(sessionManager, 19457.128401218226)
+ testIntervalCaloriesSinceStart(sessionManager, 31.592878885486314)
+ testIntervalCaloriesTarget(sessionManager, undefined)
+ testIntervalCaloriesToEnd(sessionManager, undefined)
+ testIntervalNumberOfStrokes(sessionManager, 71)
+ testIntervalMinimumPace(sessionManager, 183.61153074397942)
+ testIntervalAveragePace(sessionManager, 156.7376339050128)
+ testIntervalMaximumPace(sessionManager, 140.53128594463587)
+ testIntervalMinimumLinearVelocity(sessionManager, 2.7231405237679764)
+ testIntervalAverageLinearVelocity(sessionManager, 3.1900443278543644)
+ testIntervalMaximumLinearVelocity(sessionManager, 3.5579265971918987)
+ testIntervalMinimumPower(sessionManager, 56.541612582807076)
+ testIntervalAveragePower(sessionManager, 90.83151920453602)
+ testIntervalMaximumPower(sessionManager, 126.35684327004489)
+ testIntervalMinimumStrokedistance(sessionManager, 3.8823727820117346)
+ testIntervalAverageStrokedistance(sessionManager, 6.977796243571697)
+ testIntervalMaximumStrokedistance(sessionManager, 12.30927126596217)
+ testIntervalMinimumStrokerate(sessionManager, 15.784784897254365)
+ testIntervalAverageStrokerate(sessionManager, 29.97910826490652)
+ testIntervalMaximumStrokerate(sessionManager, 44.75500508102043)
+ testIntervalMinimumDragfactor(sessionManager, 122.17215796334402)
+ testIntervalAverageDragfactor(sessionManager, 123.68393578065213)
+ testIntervalMaximumDragfactor(sessionManager, 124.90962932377184)
+
+ // Test split metrics
+ testSplitNumber(sessionManager, 0)
+ testSplitType(sessionManager, 'distance')
+ testSplitTime(sessionManager, 156.7473648976275)
+ testSplitMovingTimeTarget(sessionManager, undefined)
+ testSplitMovingTimeToEnd(sessionManager, undefined)
+ testSplitMovingTimeProjectedEnd(sessionManager, 156.73945983078266)
+ testSplitTimeSpentTotal(sessionManager, 156.7473648976275)
+ testSplitTimeSpentMoving(sessionManager, 156.7473648976275)
+ testSplitTimeSpentRest(sessionManager, 0)
+ testSplitDistance(sessionManager, 500.03104229779495)
+ testSplitDistanceToEnd(sessionManager, -0.031042297794954266) // This is a testing artifact
+ testSplitDistanceTarget(sessionManager, 500)
+ testSplitDistanceProjectedEnd(sessionManager, 500)
+ testSplitWorkSinceStart(sessionManager, 19457.128401218226)
+ testSplitCaloriesSinceStart(sessionManager, 31.592878885486314)
+ testSplitCaloriesTarget(sessionManager, undefined)
+ testSplitCaloriesToEnd(sessionManager, undefined)
+ testSplitNumberOfStrokes(sessionManager, 71)
+ testSplitMinimumPace(sessionManager, 183.61153074397942)
+ testSplitAveragePace(sessionManager, 156.7376339050128)
+ testSplitMaximumPace(sessionManager, 140.53128594463587)
+ testSplitMinimumLinearVelocity(sessionManager, 2.7231405237679764)
+ testSplitAverageLinearVelocity(sessionManager, 3.1900443278543644)
+ testSplitMaximumLinearVelocity(sessionManager, 3.5579265971918987)
+ testSplitMinimumPower(sessionManager, 56.541612582807076)
+ testSplitAveragePower(sessionManager, 90.83151920453602)
+ testSplitMaximumPower(sessionManager, 126.35684327004489)
+ testSplitMinimumStrokedistance(sessionManager, 3.8823727820117346)
+ testSplitAverageStrokedistance(sessionManager, 6.977796243571697)
+ testSplitMaximumStrokedistance(sessionManager, 12.30927126596217)
+ testSplitMinimumStrokerate(sessionManager, 15.784784897254365)
+ testSplitAverageStrokerate(sessionManager, 29.97910826490652)
+ testSplitMaximumStrokerate(sessionManager, 44.75500508102043)
+ testSplitMinimumDragfactor(sessionManager, 122.17215796334402)
+ testSplitAverageDragfactor(sessionManager, 123.68393578065213)
+ testSplitMaximumDragfactor(sessionManager, 124.90962932377184)
+})
+
+/**
+ * @description Test behaviour for the C2 Model C in a single interval session with a Time target
+ */
+test('C2_ModelC_03: 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,
+ userSettings: {
+ sex: 'male'
+ },
+ rowerSettings: rowerProfile
+ }
+ const sessionManager = createSessionManager(testConfig)
+
+ const intervalSettings = []
+ intervalSettings[0] = {
+ type: 'time',
+ targetDistance: 0,
+ targetTime: 180
+ }
+ sessionManager.handleCommand('updateIntervalSettings', intervalSettings)
+
+ testSessionType(sessionManager, 'time')
+ testSessionMovingTimeTarget(sessionManager, 180)
+ testIntervalType(sessionManager, 'time')
+ testIntervalMovingTimeTarget(sessionManager, 180)
+ testSplitType(sessionManager, 'time')
+ testSplitMovingTimeTarget(sessionManager, 180)
+
+ testSessionState(sessionManager, 'WaitingForStart')
+ testTotalMovingTime(sessionManager, 0)
+ testTotalLinearDistance(sessionManager, 0)
+ testTotalCalories(sessionManager, 0)
+ testTotalNumberOfStrokes(sessionManager, 0)
+ testDragFactor(sessionManager, undefined)
+
+ await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/Concept2_Model_C.csv', realtime: false, loop: false })
+
+ // Test Instantanuous metrics
+ testIsMoving(sessionManager, false)
+ testIsDriveStart(sessionManager, false)
+ testIsRecoveryStart(sessionManager, false)
+ testIsSessionStart(sessionManager, false)
+ testIsIntervalEnd(sessionManager, false)
+ testIsSplitEnd(sessionManager, false)
+ testIsPauseStart(sessionManager, false)
+ testIsPauseEnd(sessionManager, false)
+ testIsSessionStop(sessionManager, false)
+ testTotalMovingTime(sessionManager, 180.95283402881833)
+ testTotalLinearDistance(sessionManager, 552.261864986209)
+ testTotalWork(sessionManager, 20581.907031896142)
+ testTotalCalories(sessionManager, 34.681219056588326)
+ testTotalCaloriesPerMinute(sessionManager, 12.371981079533313)
+ testTotalCaloriesPerHour (sessionManager, 742.318864771999)
+ testTotalNumberOfStrokes(sessionManager, 80)
+ testStrokeState(sessionManager, 'Stopped')
+ testStrokeWork(sessionManager, undefined)
+ testStrokeCalories(sessionManager, undefined)
+ testCycleDistance(sessionManager, undefined)
+ testCycleStrokeRate(sessionManager, undefined)
+ testCycleDuration(sessionManager, undefined)
+ testCycleLinearVelocity(sessionManager, undefined)
+ testCyclePace(sessionManager, Infinity)
+ testCyclePower(sessionManager, undefined)
+ testDriveLastStartTime (sessionManager, 177.31209030312513)
+ testDriveDuration(sessionManager, undefined)
+ testDriveLength(sessionManager, undefined)
+ testDriveDistance(sessionManager, undefined)
+ testDriveAverageHandleForce(sessionManager, undefined)
+ testDrivePeakHandleForce(sessionManager, undefined)
+ testRecoveryDuration(sessionManager, undefined)
+ testDragFactor(sessionManager, 122.56864708679767)
+
+ // Test session metrics
+ testSessionType(sessionManager, 'time')
+ testSessionState(sessionManager, 'Stopped')
+ testSessionTime(sessionManager, 180.95283402881833)
+ testSessionMovingTimeTarget(sessionManager, 180)
+ testSessionMovingTimeToEnd(sessionManager, -0.9528340288183301) // This is a testing artifact
+ testSessionMovingTimeProjectedEnd(sessionManager, 180)
+ testSessionTimeSpentTotal(sessionManager, 180.95283402881833)
+ testSessionTimeSpentMoving(sessionManager, 180.95283402881833)
+ testSessionTimeSpentRest(sessionManager, 0)
+ testSessionDistance(sessionManager, 552.261864986209)
+ testSessionDistanceToEnd(sessionManager, undefined)
+ testSessionDistanceTarget(sessionManager, undefined)
+ testSessionDistanceProjectedEnd(sessionManager, 551.9720967289775)
+ testSessionWorkSinceStart(sessionManager, 20581.907031896142)
+ testSessionCaloriesSinceStart(sessionManager, 34.681219056588326)
+ testSessionCaloriesTarget(sessionManager, undefined)
+ testSessionCaloriesToEnd(sessionManager, undefined)
+ testSessionNumberOfStrokes(sessionManager, 80)
+ testSessionMinimumPace(sessionManager, 414.55481161286275)
+ testSessionAveragePace(sessionManager, 163.82883329572016)
+ testSessionMaximumPace(sessionManager, 140.53128594463587)
+ testSessionMinimumLinearVelocity(sessionManager, 1.2061131266447134)
+ testSessionAverageLinearVelocity(sessionManager, 3.051965822752776)
+ testSessionMaximumLinearVelocity(sessionManager, 3.5579265971918987)
+ testSessionMinimumPower(sessionManager, 4.971183651233226)
+ testSessionAveragePower(sessionManager, 87.14773003919915)
+ testSessionMaximumPower(sessionManager, 126.35684327004489)
+ testSessionMinimumStrokedistance(sessionManager, 2.5834522864002882)
+ testSessionAverageStrokedistance(sessionManager, 6.9164416091927645)
+ testSessionMaximumStrokedistance(sessionManager, 13.069984906848259)
+ testSessionMinimumStrokerate(sessionManager, 15.784784897254365)
+ testSessionAverageStrokerate(sessionManager, 29.561187647433115)
+ testSessionMaximumStrokerate(sessionManager, 44.75500508102043)
+ testSessionMinimumDragfactor(sessionManager, 122.17215796334402)
+ testSessionAverageDragfactor(sessionManager, 123.61959689410025)
+ testSessionMaximumDragfactor(sessionManager, 124.90962932377184)
+ testSessionNumberOfIntervals(sessionManager, 1)
+
+ // Test interval metrics
+ testIntervalNumber(sessionManager, 0)
+ testIntervalWorkoutStepNumber(sessionManager, 0)
+ testIntervalType(sessionManager, 'time')
+ testIntervalTime(sessionManager, 180.95283402881833)
+ testIntervalMovingTimeTarget(sessionManager, 180)
+ testIntervalMovingTimeToEnd(sessionManager, -0.9528340288183301) // This is a testing artifact
+ testIntervalMovingTimeProjectedEnd(sessionManager, 180)
+ testIntervalTimeSpentTotal(sessionManager, 180.95283402881833)
+ testIntervalTimeSpentMoving(sessionManager, 180.95283402881833)
+ testIntervalTimeSpentRest(sessionManager, 0)
+ testIntervalDistance(sessionManager, 552.261864986209)
+ testIntervalDistanceToEnd(sessionManager, undefined)
+ testIntervalDistanceTarget(sessionManager, undefined)
+ testIntervalDistanceProjectedEnd(sessionManager, 551.9720967289775)
+ testIntervalWorkSinceStart(sessionManager, 20581.907031896142)
+ testIntervalCaloriesSinceStart(sessionManager, 34.681219056588326)
+ testIntervalCaloriesTarget(sessionManager, undefined)
+ testIntervalCaloriesToEnd(sessionManager, undefined)
+ testIntervalNumberOfStrokes(sessionManager, 80)
+ testIntervalMinimumPace(sessionManager, 414.55481161286275)
+ testIntervalAveragePace(sessionManager, 163.82883329572016)
+ testIntervalMaximumPace(sessionManager, 140.53128594463587)
+ testIntervalMinimumLinearVelocity(sessionManager, 1.2061131266447134)
+ testIntervalAverageLinearVelocity(sessionManager, 3.051965822752776)
+ testIntervalMaximumLinearVelocity(sessionManager, 3.5579265971918987)
+ testIntervalMinimumPower(sessionManager, 4.971183651233226)
+ testIntervalAveragePower(sessionManager, 87.14773003919915)
+ testIntervalMaximumPower(sessionManager, 126.35684327004489)
+ testIntervalMinimumStrokedistance(sessionManager, 2.5834522864002882)
+ testIntervalAverageStrokedistance(sessionManager, 6.9164416091927645)
+ testIntervalMaximumStrokedistance(sessionManager, 13.069984906848259)
+ testIntervalMinimumStrokerate(sessionManager, 15.784784897254365)
+ testIntervalAverageStrokerate(sessionManager, 29.561187647433115)
+ testIntervalMaximumStrokerate(sessionManager, 44.75500508102043)
+ testIntervalMinimumDragfactor(sessionManager, 122.17215796334402)
+ testIntervalAverageDragfactor(sessionManager, 123.61959689410025)
+ testIntervalMaximumDragfactor(sessionManager, 124.90962932377184)
+
+ // Test split metrics
+ testSplitNumber(sessionManager, 0)
+ testSplitType(sessionManager, 'time')
+ testSplitTime(sessionManager, 180.95283402881833)
+ testSplitMovingTimeTarget(sessionManager, 180)
+ testSplitMovingTimeToEnd(sessionManager, -0.9528340288183301) // This is a testing artifact
+ testSplitMovingTimeProjectedEnd(sessionManager, 180)
+ testSplitTimeSpentTotal(sessionManager, 180.95283402881833)
+ testSplitTimeSpentMoving(sessionManager, 180.95283402881833)
+ testSplitTimeSpentRest(sessionManager, 0)
+ testSplitDistance(sessionManager, 552.261864986209)
+ testSplitDistanceToEnd(sessionManager, undefined)
+ testSplitDistanceTarget(sessionManager, undefined)
+ testSplitDistanceProjectedEnd(sessionManager, 551.9720967289775)
+ testSplitWorkSinceStart(sessionManager, 20581.907031896142)
+ testSplitCaloriesSinceStart(sessionManager, 34.681219056588326)
+ testSplitCaloriesTarget(sessionManager, undefined)
+ testSplitCaloriesToEnd(sessionManager, undefined)
+ testSplitNumberOfStrokes(sessionManager, 80)
+ testSplitMinimumPace(sessionManager, 414.55481161286275)
+ testSplitAveragePace(sessionManager, 163.82883329572016)
+ testSplitMaximumPace(sessionManager, 140.53128594463587)
+ testSplitMinimumLinearVelocity(sessionManager, 1.2061131266447134)
+ testSplitAverageLinearVelocity(sessionManager, 3.051965822752776)
+ testSplitMaximumLinearVelocity(sessionManager, 3.5579265971918987)
+ testSplitMinimumPower(sessionManager, 4.971183651233226)
+ testSplitAveragePower(sessionManager, 87.14773003919915)
+ testSplitMaximumPower(sessionManager, 126.35684327004489)
+ testSplitMinimumStrokedistance(sessionManager, 2.5834522864002882)
+ testSplitAverageStrokedistance(sessionManager, 6.9164416091927645)
+ testSplitMaximumStrokedistance(sessionManager, 13.069984906848259)
+ testSplitMinimumStrokerate(sessionManager, 15.784784897254365)
+ testSplitAverageStrokerate(sessionManager, 29.561187647433115)
+ testSplitMaximumStrokerate(sessionManager, 44.75500508102043)
+ testSplitMinimumDragfactor(sessionManager, 122.17215796334402)
+ testSplitAverageDragfactor(sessionManager, 123.61959689410025)
+ testSplitMaximumDragfactor(sessionManager, 124.90962932377184)
+})
+
+/**
+ * @description Test behaviour for the C2 Model C in a single interval session with a Calorie target
+ */
+test('C2_ModelC_04: A 30 calorie 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,
+ userSettings: {
+ sex: 'male'
+ },
+ rowerSettings: rowerProfile
+ }
+ const sessionManager = createSessionManager(testConfig)
+
+ const intervalSettings = []
+ intervalSettings[0] = {
+ type: 'calories',
+ targetCalories: 30
+ }
+ sessionManager.handleCommand('updateIntervalSettings', intervalSettings)
+
+ testSessionType(sessionManager, 'calories')
+ testSessionCaloriesTarget(sessionManager, 30)
+ testIntervalType(sessionManager, 'calories')
+ testIntervalCaloriesTarget(sessionManager, 30)
+ testSplitType(sessionManager, 'calories')
+ testSplitCaloriesTarget(sessionManager, 30)
+
+ testSessionState(sessionManager, 'WaitingForStart')
+ testTotalMovingTime(sessionManager, 0)
+ testTotalLinearDistance(sessionManager, 0)
+ testTotalCalories(sessionManager, 0)
+ testTotalNumberOfStrokes(sessionManager, 0)
+ testDragFactor(sessionManager, undefined)
+
+ await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/Concept2_Model_C.csv', realtime: false, loop: false })
+
+ // Test Instantanuous metrics
+ testIsMoving(sessionManager, false)
+ testIsDriveStart(sessionManager, false)
+ testIsRecoveryStart(sessionManager, false)
+ testIsSessionStart(sessionManager, false)
+ testIsIntervalEnd(sessionManager, false)
+ testIsSplitEnd(sessionManager, false)
+ testIsPauseStart(sessionManager, false)
+ testIsPauseEnd(sessionManager, false)
+ testIsSessionStop(sessionManager, false)
+ testTotalMovingTime(sessionManager, 150.40548514616387)
+ testTotalLinearDistance(sessionManager, 479.39900408481833)
+ testTotalWork(sessionManager, 18345.892946257805)
+ testTotalCalories(sessionManager, 30.006069425282995)
+ testTotalCaloriesPerMinute(sessionManager, 12.32490350424709)
+ testTotalCaloriesPerHour (sessionManager, 739.4942102548252)
+ testTotalNumberOfStrokes(sessionManager, 68)
+ testStrokeState(sessionManager, 'Stopped')
+ testStrokeWork(sessionManager, undefined)
+ testStrokeCalories(sessionManager, undefined)
+ testCycleDistance(sessionManager, undefined)
+ testCycleStrokeRate(sessionManager, undefined)
+ testCycleDuration(sessionManager, undefined)
+ testCycleLinearVelocity(sessionManager, undefined)
+ testCyclePace(sessionManager, Infinity)
+ testCyclePower(sessionManager, undefined)
+ testDriveLastStartTime (sessionManager, 150.10276006263302)
+ testDriveDuration(sessionManager, undefined)
+ testDriveLength(sessionManager, undefined)
+ testDriveDistance(sessionManager, undefined)
+ testDriveAverageHandleForce(sessionManager, undefined)
+ testDrivePeakHandleForce(sessionManager, undefined)
+ testRecoveryDuration(sessionManager, undefined)
+ testDragFactor(sessionManager, 123.25256925094398)
+
+ // Test session metrics
+ testSessionType(sessionManager, 'calories')
+ testSessionState(sessionManager, 'Stopped')
+ testSessionTime(sessionManager, 150.40548514616387)
+ testSessionMovingTimeTarget(sessionManager, undefined)
+ testSessionMovingTimeToEnd(sessionManager, undefined)
+ testSessionMovingTimeProjectedEnd(sessionManager, 150.39875115395606)
+ testSessionTimeSpentTotal(sessionManager, 150.40548514616387)
+ testSessionTimeSpentMoving(sessionManager, 150.40548514616387)
+ testSessionTimeSpentRest(sessionManager, 0)
+ testSessionDistance(sessionManager, 479.39900408481833)
+ testSessionDistanceToEnd(sessionManager, undefined)
+ testSessionDistanceTarget(sessionManager, undefined)
+ testSessionDistanceProjectedEnd(sessionManager, 479.38090924189413)
+ testSessionWorkSinceStart(sessionManager, 18345.892946257805)
+ testSessionCaloriesSinceStart(sessionManager, 30.006069425282995)
+ testSessionCaloriesTarget(sessionManager, 30)
+ testSessionCaloriesToEnd(sessionManager, -0.0060694252829947) // This is a testing artifact
+ testSessionNumberOfStrokes(sessionManager, 68)
+ testSessionMinimumPace(sessionManager, 183.61153074397942)
+ testSessionAveragePace(sessionManager, 156.86879182539266)
+ testSessionMaximumPace(sessionManager, 140.53128594463587)
+ testSessionMinimumLinearVelocity(sessionManager, 2.7231405237679764)
+ testSessionAverageLinearVelocity(sessionManager, 3.1873771333468253)
+ testSessionMaximumLinearVelocity(sessionManager, 3.5579265971918987)
+ testSessionMinimumPower(sessionManager, 56.541612582807076)
+ testSessionAveragePower(sessionManager, 91.38602718035276)
+ testSessionMaximumPower(sessionManager, 126.35684327004489)
+ testSessionMinimumStrokedistance(sessionManager, 4.147187873987795)
+ testSessionAverageStrokedistance(sessionManager, 7.073114164003991)
+ testSessionMaximumStrokedistance(sessionManager, 12.30927126596217)
+ testSessionMinimumStrokerate(sessionManager, 15.784784897254365)
+ testSessionAverageStrokerate(sessionManager, 29.66050862798517)
+ testSessionMaximumStrokerate(sessionManager, 44.75500508102043)
+ testSessionMinimumDragfactor(sessionManager, 122.17215796334402)
+ testSessionAverageDragfactor(sessionManager, 123.7061384696812)
+ testSessionMaximumDragfactor(sessionManager, 124.90962932377184)
+ testSessionNumberOfIntervals(sessionManager, 1)
+
+ // Test interval metrics
+ testIntervalNumber(sessionManager, 0)
+ testIntervalWorkoutStepNumber(sessionManager, 0)
+ testIntervalType(sessionManager, 'calories')
+ testIntervalTime(sessionManager, 150.40548514616387)
+ testIntervalMovingTimeTarget(sessionManager, undefined)
+ testIntervalMovingTimeToEnd(sessionManager, undefined)
+ testIntervalMovingTimeProjectedEnd(sessionManager, 150.39875115395606)
+ testIntervalTimeSpentTotal(sessionManager, 150.40548514616387)
+ testIntervalTimeSpentMoving(sessionManager, 150.40548514616387)
+ testIntervalTimeSpentRest(sessionManager, 0)
+ testIntervalDistance(sessionManager, 479.39900408481833)
+ testIntervalDistanceToEnd(sessionManager, undefined)
+ testIntervalDistanceTarget(sessionManager, undefined)
+ testIntervalDistanceProjectedEnd(sessionManager, 479.38090924189413)
+ testIntervalWorkSinceStart(sessionManager, 18345.892946257805)
+ testIntervalCaloriesSinceStart(sessionManager, 30.006069425282995)
+ testIntervalCaloriesTarget(sessionManager, 30)
+ testIntervalCaloriesToEnd(sessionManager, -0.0060694252829947) // This is a testing artifact
+ testIntervalNumberOfStrokes(sessionManager, 68)
+ testIntervalMinimumPace(sessionManager, 183.61153074397942)
+ testIntervalAveragePace(sessionManager, 156.86879182539266)
+ testIntervalMaximumPace(sessionManager, 140.53128594463587)
+ testIntervalMinimumLinearVelocity(sessionManager, 2.7231405237679764)
+ testIntervalAverageLinearVelocity(sessionManager, 3.1873771333468253)
+ testIntervalMaximumLinearVelocity(sessionManager, 3.5579265971918987)
+ testIntervalMinimumPower(sessionManager, 56.541612582807076)
+ testIntervalAveragePower(sessionManager, 91.38602718035276)
+ testIntervalMaximumPower(sessionManager, 126.35684327004489)
+ testIntervalMinimumStrokedistance(sessionManager, 4.147187873987795)
+ testIntervalAverageStrokedistance(sessionManager, 7.073114164003991)
+ testIntervalMaximumStrokedistance(sessionManager, 12.30927126596217)
+ testIntervalMinimumStrokerate(sessionManager, 15.784784897254365)
+ testIntervalAverageStrokerate(sessionManager, 29.66050862798517)
+ testIntervalMaximumStrokerate(sessionManager, 44.75500508102043)
+ testIntervalMinimumDragfactor(sessionManager, 122.17215796334402)
+ testIntervalAverageDragfactor(sessionManager, 123.7061384696812)
+ testIntervalMaximumDragfactor(sessionManager, 124.90962932377184)
+
+ // Test split metrics
+ testSplitNumber(sessionManager, 0)
+ testSplitType(sessionManager, 'calories')
+ testSplitTime(sessionManager, 150.40548514616387)
+ testSplitMovingTimeTarget(sessionManager, undefined)
+ testSplitMovingTimeToEnd(sessionManager, undefined)
+ testSplitMovingTimeProjectedEnd(sessionManager, 150.39875115395606)
+ testSplitTimeSpentTotal(sessionManager, 150.40548514616387)
+ testSplitTimeSpentMoving(sessionManager, 150.40548514616387)
+ testSplitTimeSpentRest(sessionManager, 0)
+ testSplitDistance(sessionManager, 479.39900408481833)
+ testSplitDistanceToEnd(sessionManager, undefined)
+ testSplitDistanceTarget(sessionManager, undefined)
+ testSplitDistanceProjectedEnd(sessionManager, 479.38090924189413)
+ testSplitWorkSinceStart(sessionManager, 18345.892946257805)
+ testSplitCaloriesSinceStart(sessionManager, 30.006069425282995)
+ testSplitCaloriesTarget(sessionManager, 30)
+ testSplitCaloriesToEnd(sessionManager, -0.0060694252829947) // This is a testing artifact
+ testSplitNumberOfStrokes(sessionManager, 68)
+ testSplitMinimumPace(sessionManager, 183.61153074397942)
+ testSplitAveragePace(sessionManager, 156.86879182539266)
+ testSplitMaximumPace(sessionManager, 140.53128594463587)
+ testSplitMinimumLinearVelocity(sessionManager, 2.7231405237679764)
+ testSplitAverageLinearVelocity(sessionManager, 3.1873771333468253)
+ testSplitMaximumLinearVelocity(sessionManager, 3.5579265971918987)
+ testSplitMinimumPower(sessionManager, 56.541612582807076)
+ testSplitAveragePower(sessionManager, 91.38602718035276)
+ testSplitMaximumPower(sessionManager, 126.35684327004489)
+ testSplitMinimumStrokedistance(sessionManager, 4.147187873987795)
+ testSplitAverageStrokedistance(sessionManager, 7.073114164003991)
+ testSplitMaximumStrokedistance(sessionManager, 12.30927126596217)
+ testSplitMinimumStrokerate(sessionManager, 15.784784897254365)
+ testSplitAverageStrokerate(sessionManager, 29.66050862798517)
+ testSplitMaximumStrokerate(sessionManager, 44.75500508102043)
+ testSplitMinimumDragfactor(sessionManager, 122.17215796334402)
+ testSplitAverageDragfactor(sessionManager, 123.7061384696812)
+ testSplitMaximumDragfactor(sessionManager, 124.90962932377184)
+})
+
+/**
+ * @description Test behaviour for the DKN R-320 in a 'Just Row' session
+ */
+test('DKN_R320_01: 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,
+ userSettings: {
+ sex: 'male'
+ },
+ rowerSettings: rowerProfile
+ }
+ const sessionManager = createSessionManager(testConfig)
+
+ testSessionType(sessionManager, 'justrow')
+ testIntervalType(sessionManager, 'justrow')
+ testSplitType(sessionManager, 'justrow')
+
+ testSessionState(sessionManager, 'WaitingForStart')
+ testTotalMovingTime(sessionManager, 0)
+ testTotalLinearDistance(sessionManager, 0)
+ testTotalCalories(sessionManager, 0)
+ testTotalNumberOfStrokes(sessionManager, 0)
+ testDragFactor(sessionManager, undefined)
+
+ await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/DKNR320.csv', realtime: false, loop: false })
+
+ // Test Instantanuous metrics
+ testIsMoving(sessionManager, true) // The recording stops abrubtly, and SessionManager's watchdog hasn't kicked in yet
+ testIsDriveStart(sessionManager, false)
+ testIsRecoveryStart(sessionManager, false)
+ testIsSessionStart(sessionManager, false)
+ testIsIntervalEnd(sessionManager, false)
+ testIsSplitEnd(sessionManager, false)
+ testIsPauseStart(sessionManager, false)
+ testIsPauseEnd(sessionManager, false)
+ testIsSessionStop(sessionManager, false)
+ testTotalMovingTime(sessionManager, 21.701535821)
+ testTotalLinearDistance(sessionManager, 69.20242183779045)
+ testTotalWork(sessionManager, 5200.736822863752)
+ testTotalCalories(sessionManager, 6.761544006858335)
+ testTotalCaloriesPerMinute(sessionManager, 19.726851654537608)
+ testTotalCaloriesPerHour (sessionManager, 1183.6110992722565)
+ testTotalNumberOfStrokes(sessionManager, 9)
+ testStrokeState(sessionManager, 'Recovery')
+ testStrokeWork(sessionManager, 646.220649414442)
+ testStrokeCalories(sessionManager, 0.8038903655792305)
+ testCycleDistance(sessionManager, 7.284465456609526)
+ testCycleStrokeRate(sessionManager, 26.533345021949888)
+ testCycleDuration(sessionManager, 2.2613055365)
+ testCycleLinearVelocity(sessionManager, 3.2218652746225382)
+ testCyclePace(sessionManager, 155.189605207368)
+ testCyclePower(sessionManager, 93.68863152770354)
+ testDriveLastStartTime (sessionManager, 19.511980053000002)
+ testDriveDuration(sessionManager, 1.2778628719999965)
+ testDriveLength(sessionManager, 1.7592918860102824)
+ testDriveDistance(sessionManager, 3.642232728304757)
+ testDriveAverageHandleForce(sessionManager, 385.62555350976965)
+ testDrivePeakHandleForce(sessionManager, 604.6392224523523)
+ testRecoveryDuration(sessionManager, 1.011930944000003)
+ testDragFactor(sessionManager, rowerProfiles.DKN_R320.dragFactor) // This is a fixed-drag machine
+
+ // Test session metrics
+ testSessionType(sessionManager, 'justrow')
+ testSessionState(sessionManager, 'Rowing')
+ testSessionTime(sessionManager, 21.701535821)
+ testSessionMovingTimeTarget(sessionManager, undefined)
+ testSessionMovingTimeToEnd(sessionManager, undefined)
+ testSessionMovingTimeProjectedEnd(sessionManager, undefined)
+ testSessionTimeSpentTotal(sessionManager, 21.701535821)
+ testSessionTimeSpentMoving(sessionManager, 21.701535821)
+ testSessionTimeSpentRest(sessionManager, 0)
+ testSessionDistance(sessionManager, 69.20242183779045)
+ testSessionDistanceToEnd(sessionManager, undefined)
+ testSessionDistanceTarget(sessionManager, undefined)
+ testSessionDistanceProjectedEnd(sessionManager, undefined)
+ testSessionWorkSinceStart(sessionManager, 5200.736822863752)
+ testSessionCaloriesSinceStart(sessionManager, 6.761544006858335)
+ testSessionCaloriesTarget(sessionManager, undefined)
+ testSessionCaloriesToEnd(sessionManager, undefined)
+ testSessionNumberOfStrokes(sessionManager, 9)
+ testSessionMinimumPace(sessionManager, 183.18916429517017)
+ testSessionAveragePace(sessionManager, 156.79751694144542)
+ testSessionMaximumPace(sessionManager, 149.0674414849483)
+ testSessionMinimumLinearVelocity(sessionManager, 2.7294190784906736)
+ testSessionAverageLinearVelocity(sessionManager, 3.1888260079189927)
+ testSessionMaximumLinearVelocity(sessionManager, 3.3541865012185523)
+ testSessionMinimumPower(sessionManager, 56.93360711900691)
+ testSessionAveragePower(sessionManager, 92.49881644788539)
+ testSessionMaximumPower(sessionManager, 105.68200941707414)
+ testSessionMinimumStrokedistance(sessionManager, 5.463349092457141)
+ testSessionAverageStrokedistance(sessionManager, 7.123778718596076)
+ testSessionMaximumStrokedistance(sessionManager, 7.739744547647621)
+ testSessionMinimumStrokerate(sessionManager, 24.844269740570216)
+ testSessionAverageStrokerate(sessionManager, 27.108243568576246)
+ testSessionMaximumStrokerate(sessionManager, 33.14364602350064)
+ testSessionMinimumDragfactor(sessionManager, rowerProfiles.DKN_R320.dragFactor)
+ testSessionAverageDragfactor(sessionManager, rowerProfiles.DKN_R320.dragFactor)
+ testSessionMaximumDragfactor(sessionManager, rowerProfiles.DKN_R320.dragFactor)
+ testSessionNumberOfIntervals(sessionManager, 1)
+
+ // Test interval metrics
+ testIntervalNumber(sessionManager, 0)
+ testIntervalWorkoutStepNumber(sessionManager, 0)
+ testIntervalType(sessionManager, 'justrow')
+ testIntervalTime(sessionManager, 21.701535821)
+ testIntervalMovingTimeTarget(sessionManager, undefined)
+ testIntervalMovingTimeToEnd(sessionManager, undefined)
+ testIntervalMovingTimeProjectedEnd(sessionManager, undefined)
+ testIntervalTimeSpentTotal(sessionManager, 21.701535821)
+ testIntervalTimeSpentMoving(sessionManager, 21.701535821)
+ testIntervalTimeSpentRest(sessionManager, 0)
+ testIntervalDistance(sessionManager, 69.20242183779045)
+ testIntervalDistanceToEnd(sessionManager, undefined)
+ testIntervalDistanceTarget(sessionManager, undefined)
+ testIntervalDistanceProjectedEnd(sessionManager, undefined)
+ testIntervalWorkSinceStart(sessionManager, 5200.736822863752)
+ testIntervalCaloriesSinceStart(sessionManager, 6.761544006858335)
+ testIntervalCaloriesTarget(sessionManager, undefined)
+ testIntervalCaloriesToEnd(sessionManager, undefined)
+ testIntervalNumberOfStrokes(sessionManager, 9)
+ testIntervalMinimumPace(sessionManager, 183.18916429517017)
+ testIntervalAveragePace(sessionManager, 156.79751694144542)
+ testIntervalMaximumPace(sessionManager, 149.0674414849483)
+ testIntervalMinimumLinearVelocity(sessionManager, 2.7294190784906736)
+ testIntervalAverageLinearVelocity(sessionManager, 3.1888260079189927)
+ testIntervalMaximumLinearVelocity(sessionManager, 3.3541865012185523)
+ testIntervalMinimumPower(sessionManager, 56.93360711900691)
+ testIntervalAveragePower(sessionManager, 92.49881644788539)
+ testIntervalMaximumPower(sessionManager, 105.68200941707414)
+ testIntervalMinimumStrokedistance(sessionManager, 5.463349092457141)
+ testIntervalAverageStrokedistance(sessionManager, 7.123778718596076)
+ testIntervalMaximumStrokedistance(sessionManager, 7.739744547647621)
+ testIntervalMinimumStrokerate(sessionManager, 24.844269740570216)
+ testIntervalAverageStrokerate(sessionManager, 27.108243568576246)
+ testIntervalMaximumStrokerate(sessionManager, 33.14364602350064)
+ testIntervalMinimumDragfactor(sessionManager, rowerProfiles.DKN_R320.dragFactor)
+ testIntervalAverageDragfactor(sessionManager, rowerProfiles.DKN_R320.dragFactor)
+ testIntervalMaximumDragfactor(sessionManager, rowerProfiles.DKN_R320.dragFactor)
+
+ // Test split metrics
+ testSplitNumber(sessionManager, 0)
+ testSplitType(sessionManager, 'justrow')
+ testSplitTime(sessionManager, 21.701535821)
+ testSplitMovingTimeTarget(sessionManager, undefined)
+ testSplitMovingTimeToEnd(sessionManager, undefined)
+ testSplitMovingTimeProjectedEnd(sessionManager, undefined)
+ testSplitTimeSpentTotal(sessionManager, 21.701535821)
+ testSplitTimeSpentMoving(sessionManager, 21.701535821)
+ testSplitTimeSpentRest(sessionManager, 0)
+ testSplitDistance(sessionManager, 69.20242183779045)
+ testSplitDistanceToEnd(sessionManager, undefined)
+ testSplitDistanceTarget(sessionManager, undefined)
+ testSplitDistanceProjectedEnd(sessionManager, undefined)
+ testSplitWorkSinceStart(sessionManager, 5200.736822863752)
+ testSplitCaloriesSinceStart(sessionManager, 6.761544006858335)
+ testSplitCaloriesTarget(sessionManager, undefined)
+ testSplitCaloriesToEnd(sessionManager, undefined)
+ testSplitNumberOfStrokes(sessionManager, 9)
+ testSplitMinimumPace(sessionManager, 183.18916429517017)
+ testSplitAveragePace(sessionManager, 156.79751694144542)
+ testSplitMaximumPace(sessionManager, 149.0674414849483)
+ testSplitMinimumLinearVelocity(sessionManager, 2.7294190784906736)
+ testSplitAverageLinearVelocity(sessionManager, 3.1888260079189927)
+ testSplitMaximumLinearVelocity(sessionManager, 3.3541865012185523)
+ testSplitMinimumPower(sessionManager, 56.93360711900691)
+ testSplitAveragePower(sessionManager, 92.49881644788539)
+ testSplitMaximumPower(sessionManager, 105.68200941707414)
+ testSplitMinimumStrokedistance(sessionManager, 5.463349092457141)
+ testSplitAverageStrokedistance(sessionManager, 7.123778718596076)
+ testSplitMaximumStrokedistance(sessionManager, 7.739744547647621)
+ testSplitMinimumStrokerate(sessionManager, 24.844269740570216)
+ testSplitAverageStrokerate(sessionManager, 27.108243568576246)
+ testSplitMaximumStrokerate(sessionManager, 33.14364602350064)
+ testSplitMinimumDragfactor(sessionManager, rowerProfiles.DKN_R320.dragFactor)
+ testSplitAverageDragfactor(sessionManager, rowerProfiles.DKN_R320.dragFactor)
+ testSplitMaximumDragfactor(sessionManager, rowerProfiles.DKN_R320.dragFactor)
+})
+
+/**
+ * @description Test behaviour for the NordicTrack RX800 in a 'Just Row' session
+ */
+test('NordicT_RX800_01: 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,
+ userSettings: {
+ sex: 'male'
+ },
+ rowerSettings: rowerProfile
+ }
+ const sessionManager = createSessionManager(testConfig)
+
+ testSessionType(sessionManager, 'justrow')
+ testIntervalType(sessionManager, 'justrow')
+ testSplitType(sessionManager, 'justrow')
+
+ testSessionState(sessionManager, 'WaitingForStart')
+ testTotalMovingTime(sessionManager, 0)
+ testTotalLinearDistance(sessionManager, 0)
+ testTotalCalories(sessionManager, 0)
+ testTotalNumberOfStrokes(sessionManager, 0)
+ testDragFactor(sessionManager, undefined)
+
+ await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/RX800.csv', realtime: false, loop: false })
+
+ // Test Instantanuous metrics
+ testIsMoving(sessionManager, true) // The recording stops abrubtly, and SessionManager's watchdog hasn't kicked in yet
+ testIsDriveStart(sessionManager, false)
+ testIsRecoveryStart(sessionManager, false)
+ testIsSessionStart(sessionManager, false)
+ testIsIntervalEnd(sessionManager, false)
+ testIsSplitEnd(sessionManager, false)
+ testIsPauseStart(sessionManager, false)
+ testIsPauseEnd(sessionManager, false)
+ testIsSessionStop(sessionManager, false)
+ testTotalMovingTime(sessionManager, 22.368358745999995)
+ testTotalLinearDistance(sessionManager, 80.8365747440095)
+ testTotalWork(sessionManager, 3132.9891152627774)
+ testTotalCalories(sessionManager, 4.847829052893121)
+ testTotalCaloriesPerMinute(sessionManager, 12.915884067211993)
+ testTotalCaloriesPerHour (sessionManager, 774.9530440327195)
+ testTotalNumberOfStrokes(sessionManager, 9)
+ testStrokeState(sessionManager, 'Recovery')
+ testStrokeWork(sessionManager, 298.42006474992013)
+ testStrokeCalories(sessionManager, 0.47141003264278075)
+ testCycleDistance(sessionManager, 8.14484194775459)
+ testCycleStrokeRate(sessionManager, 26.709337908517135)
+ testCycleDuration(sessionManager, 2.246405365999996)
+ testCycleLinearVelocity(sessionManager, 3.627196444262048)
+ testCyclePace(sessionManager, 137.84751051765127)
+ testCyclePower(sessionManager, 133.6207873388181)
+ testDriveLastStartTime (sessionManager, 20.24632183399998)
+ testDriveDuration(sessionManager, 0.6251555590000031)
+ testDriveLength(sessionManager, 1.2252211349000253)
+ testDriveDistance(sessionManager, 2.290304983280253)
+ testDriveAverageHandleForce(sessionManager, 242.21443734968256)
+ testDrivePeakHandleForce(sessionManager, 389.6437952657745)
+ testRecoveryDuration(sessionManager, 1.612378919999994)
+ testDragFactor(sessionManager, 493.8082148322739)
+
+ // Test session metrics
+ testSessionType(sessionManager, 'justrow')
+ testSessionState(sessionManager, 'Rowing')
+ testSessionTime(sessionManager, 22.368358745999995)
+ testSessionMovingTimeTarget(sessionManager, undefined)
+ testSessionMovingTimeToEnd(sessionManager, undefined)
+ testSessionMovingTimeProjectedEnd(sessionManager, undefined)
+ testSessionTimeSpentTotal(sessionManager, 22.368358745999995)
+ testSessionTimeSpentMoving(sessionManager, 22.368358745999995)
+ testSessionTimeSpentRest(sessionManager, 0)
+ testSessionDistance(sessionManager, 80.8365747440095)
+ testSessionDistanceToEnd(sessionManager, undefined)
+ testSessionDistanceTarget(sessionManager, undefined)
+ testSessionDistanceProjectedEnd(sessionManager, undefined)
+ testSessionWorkSinceStart(sessionManager, 3132.9891152627774)
+ testSessionCaloriesSinceStart(sessionManager, 4.847829052893121)
+ testSessionCaloriesTarget(sessionManager, undefined)
+ testSessionCaloriesToEnd(sessionManager, undefined)
+ testSessionNumberOfStrokes(sessionManager, 9)
+ testSessionMinimumPace(sessionManager, 147.67157107875008)
+ testSessionAveragePace(sessionManager, 138.35543389137496)
+ testSessionMaximumPace(sessionManager, 132.19298595053067)
+ testSessionMinimumLinearVelocity(sessionManager, 3.385892059977887)
+ testSessionAverageLinearVelocity(sessionManager, 3.613880466686678)
+ testSessionMaximumLinearVelocity(sessionManager, 3.7823489378408492)
+ testSessionMinimumPower(sessionManager, 108.68693914003735)
+ testSessionAveragePower(sessionManager, 137.38710962037004)
+ testSessionMaximumPower(sessionManager, 151.51288596463914)
+ testSessionMinimumStrokedistance(sessionManager, 7.777415453835244)
+ testSessionAverageStrokedistance(sessionManager, 8.270198787251077)
+ testSessionMaximumStrokedistance(sessionManager, 8.642321255152048)
+ testSessionMinimumStrokerate(sessionManager, 26.06112916090677)
+ testSessionAverageStrokerate(sessionManager, 26.56482741163621)
+ testSessionMaximumStrokerate(sessionManager, 27.7589108235975)
+ testSessionMinimumDragfactor(sessionManager, 486.2899746779819)
+ testSessionAverageDragfactor(sessionManager, 504.66471631556203)
+ testSessionMaximumDragfactor(sessionManager, 553.4106584852259)
+ testSessionNumberOfIntervals(sessionManager, 1)
+
+ // Test interval metrics
+ testIntervalNumber(sessionManager, 0)
+ testIntervalWorkoutStepNumber(sessionManager, 0)
+ testIntervalType(sessionManager, 'justrow')
+ testIntervalTime(sessionManager, 22.368358745999995)
+ testIntervalMovingTimeTarget(sessionManager, undefined)
+ testIntervalMovingTimeToEnd(sessionManager, undefined)
+ testIntervalMovingTimeProjectedEnd(sessionManager, undefined)
+ testIntervalTimeSpentTotal(sessionManager, 22.368358745999995)
+ testIntervalTimeSpentMoving(sessionManager, 22.368358745999995)
+ testIntervalTimeSpentRest(sessionManager, 0)
+ testIntervalDistance(sessionManager, 80.8365747440095)
+ testIntervalDistanceToEnd(sessionManager, undefined)
+ testIntervalDistanceTarget(sessionManager, undefined)
+ testIntervalDistanceProjectedEnd(sessionManager, undefined)
+ testIntervalWorkSinceStart(sessionManager, 3132.9891152627774)
+ testIntervalCaloriesSinceStart(sessionManager, 4.847829052893121)
+ testIntervalCaloriesTarget(sessionManager, undefined)
+ testIntervalCaloriesToEnd(sessionManager, undefined)
+ testIntervalNumberOfStrokes(sessionManager, 9)
+ testIntervalMinimumPace(sessionManager, 147.67157107875008)
+ testIntervalAveragePace(sessionManager, 138.35543389137496)
+ testIntervalMaximumPace(sessionManager, 132.19298595053067)
+ testIntervalMinimumLinearVelocity(sessionManager, 3.385892059977887)
+ testIntervalAverageLinearVelocity(sessionManager, 3.613880466686678)
+ testIntervalMaximumLinearVelocity(sessionManager, 3.7823489378408492)
+ testIntervalMinimumPower(sessionManager, 108.68693914003735)
+ testIntervalAveragePower(sessionManager, 137.38710962037004)
+ testIntervalMaximumPower(sessionManager, 151.51288596463914)
+ testIntervalMinimumStrokedistance(sessionManager, 7.777415453835244)
+ testIntervalAverageStrokedistance(sessionManager, 8.270198787251077)
+ testIntervalMaximumStrokedistance(sessionManager, 8.642321255152048)
+ testIntervalMinimumStrokerate(sessionManager, 26.06112916090677)
+ testIntervalAverageStrokerate(sessionManager, 26.56482741163621)
+ testIntervalMaximumStrokerate(sessionManager, 27.7589108235975)
+ testIntervalMinimumDragfactor(sessionManager, 486.2899746779819)
+ testIntervalAverageDragfactor(sessionManager, 504.66471631556203)
+ testIntervalMaximumDragfactor(sessionManager, 553.4106584852259)
+
+ // Test split metrics
+ testSplitNumber(sessionManager, 0)
+ testSplitType(sessionManager, 'justrow')
+ testSplitTime(sessionManager, 22.368358745999995)
+ testSplitMovingTimeTarget(sessionManager, undefined)
+ testSplitMovingTimeToEnd(sessionManager, undefined)
+ testSplitMovingTimeProjectedEnd(sessionManager, undefined)
+ testSplitTimeSpentTotal(sessionManager, 22.368358745999995)
+ testSplitTimeSpentMoving(sessionManager, 22.368358745999995)
+ testSplitTimeSpentRest(sessionManager, 0)
+ testSplitDistance(sessionManager, 80.8365747440095)
+ testSplitDistanceToEnd(sessionManager, undefined)
+ testSplitDistanceTarget(sessionManager, undefined)
+ testSplitDistanceProjectedEnd(sessionManager, undefined)
+ testSplitWorkSinceStart(sessionManager, 3132.9891152627774)
+ testSplitCaloriesSinceStart(sessionManager, 4.847829052893121)
+ testSplitCaloriesTarget(sessionManager, undefined)
+ testSplitCaloriesToEnd(sessionManager, undefined)
+ testSplitNumberOfStrokes(sessionManager, 9)
+ testSplitMinimumPace(sessionManager, 147.67157107875008)
+ testSplitAveragePace(sessionManager, 138.35543389137496)
+ testSplitMaximumPace(sessionManager, 132.19298595053067)
+ testSplitMinimumLinearVelocity(sessionManager, 3.385892059977887)
+ testSplitAverageLinearVelocity(sessionManager, 3.613880466686678)
+ testSplitMaximumLinearVelocity(sessionManager, 3.7823489378408492)
+ testSplitMinimumPower(sessionManager, 108.68693914003735)
+ testSplitAveragePower(sessionManager, 137.38710962037004)
+ testSplitMaximumPower(sessionManager, 151.51288596463914)
+ testSplitMinimumStrokedistance(sessionManager, 7.777415453835244)
+ testSplitAverageStrokedistance(sessionManager, 8.270198787251077)
+ testSplitMaximumStrokedistance(sessionManager, 8.642321255152048)
+ testSplitMinimumStrokerate(sessionManager, 26.06112916090677)
+ testSplitAverageStrokerate(sessionManager, 26.56482741163621)
+ testSplitMaximumStrokerate(sessionManager, 27.7589108235975)
+ testSplitMinimumDragfactor(sessionManager, 486.2899746779819)
+ testSplitAverageDragfactor(sessionManager, 504.66471631556203)
+ testSplitMaximumDragfactor(sessionManager, 553.4106584852259)
+})
+
+/**
+ * @description Test behaviour for the NordicTrack RX800 in a single interval session with a float Time target
+ */
+test('NordicT_RX800_02: sample data for NordicTrack RX800 should produce plausible results for a 20.1 seconds session', async () => {
+ const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.NordicTrack_RX800)
+ const testConfig = {
+ loglevel: {
+ default: 'silent',
+ RowingEngine: 'silent'
+ },
+ numOfPhasesForAveragingScreenData: 2,
+ userSettings: {
+ sex: 'male'
+ },
+ rowerSettings: rowerProfile
+ }
+ const sessionManager = createSessionManager(testConfig)
+
+ const intervalSettings = []
+ intervalSettings[0] = {
+ type: 'time',
+ targetTime: 20.1
+ }
+ sessionManager.handleCommand('updateIntervalSettings', intervalSettings)
+
+ testSessionType(sessionManager, 'time')
+ testSessionMovingTimeTarget(sessionManager, 20.1)
+ testIntervalType(sessionManager, 'time')
+ testIntervalMovingTimeTarget(sessionManager, 20.1)
+ testSplitType(sessionManager, 'time')
+ testSplitMovingTimeTarget(sessionManager, 20.1)
+
+ testSessionState(sessionManager, 'WaitingForStart')
+ testTotalMovingTime(sessionManager, 0)
+ testTotalLinearDistance(sessionManager, 0)
+ testTotalCalories(sessionManager, 0)
+ testTotalNumberOfStrokes(sessionManager, 0)
+ testDragFactor(sessionManager, undefined)
+
+ await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/RX800.csv', realtime: false, loop: false })
+
+ // Test Instantanuous metrics
+ testIsMoving(sessionManager, false)
+ testIsDriveStart(sessionManager, false)
+ testIsRecoveryStart(sessionManager, false)
+ testIsSessionStart(sessionManager, false)
+ testIsIntervalEnd(sessionManager, false)
+ testIsSplitEnd(sessionManager, false)
+ testIsPauseStart(sessionManager, false)
+ testIsPauseEnd(sessionManager, false)
+ testIsSessionStop(sessionManager, false)
+ testTotalMovingTime(sessionManager, 20.10698646999998)
+ testTotalLinearDistance(sessionManager, 72.62915467127921)
+ testTotalWork(sessionManager, 2834.5690505128573)
+ testTotalCalories(sessionManager, 4.375171777750339)
+ testTotalCaloriesPerMinute(sessionManager, 12.965723467320839)
+ testTotalCaloriesPerHour (sessionManager, 777.9434080392504)
+ testTotalNumberOfStrokes(sessionManager, 8)
+ testStrokeState(sessionManager, 'Stopped')
+ testStrokeWork(sessionManager, undefined)
+ testStrokeCalories(sessionManager, undefined)
+ testCycleDistance(sessionManager, undefined)
+ testCycleStrokeRate(sessionManager, undefined)
+ testCycleDuration(sessionManager, undefined)
+ testCycleLinearVelocity(sessionManager, undefined)
+ testCyclePace(sessionManager, Infinity)
+ testCyclePower(sessionManager, undefined)
+ testDriveLastStartTime (sessionManager, 17.991045580999984)
+ testDriveDuration(sessionManager, undefined)
+ testDriveLength(sessionManager, undefined)
+ testDriveDistance(sessionManager, undefined)
+ testDriveAverageHandleForce(sessionManager, undefined)
+ testDrivePeakHandleForce(sessionManager, undefined)
+ testRecoveryDuration(sessionManager, undefined)
+ testDragFactor(sessionManager, 489.6362497474688)
+
+ // Test session metrics
+ testSessionType(sessionManager, 'time')
+ testSessionState(sessionManager, 'Stopped')
+ testSessionTime(sessionManager, 20.10698646999998)
+ testSessionMovingTimeTarget(sessionManager, 20.1)
+ testSessionMovingTimeToEnd(sessionManager, -0.006986469999979761) // This is a test artifact
+ testSessionMovingTimeProjectedEnd(sessionManager, 20.1)
+ testSessionTimeSpentTotal(sessionManager, 20.10698646999998)
+ testSessionTimeSpentMoving(sessionManager, 20.10698646999998)
+ testSessionTimeSpentRest(sessionManager, 0)
+ testSessionDistance(sessionManager, 72.62915467127921)
+ testSessionDistanceToEnd(sessionManager, undefined)
+ testSessionDistanceTarget(sessionManager, undefined)
+ testSessionDistanceProjectedEnd(sessionManager, 72.60374121313309)
+ testSessionWorkSinceStart(sessionManager, 2834.5690505128573)
+ testSessionCaloriesSinceStart(sessionManager, 4.375171777750339)
+ testSessionCaloriesTarget(sessionManager, undefined)
+ testSessionCaloriesToEnd(sessionManager, undefined)
+ testSessionNumberOfStrokes(sessionManager, 8)
+ testSessionMinimumPace(sessionManager, 147.67157107875008)
+ testSessionAveragePace(sessionManager, 138.422280701218)
+ testSessionMaximumPace(sessionManager, 132.19298595053067)
+ testSessionMinimumLinearVelocity(sessionManager, 3.385892059977887)
+ testSessionAverageLinearVelocity(sessionManager, 3.6121352535668803)
+ testSessionMaximumLinearVelocity(sessionManager, 3.7823489378408492)
+ testSessionMinimumPower(sessionManager, 108.68693914003735)
+ testSessionAveragePower(sessionManager, 137.69966645388217)
+ testSessionMaximumPower(sessionManager, 151.51288596463914)
+ testSessionMinimumStrokedistance(sessionManager, 7.777415453835244)
+ testSessionAverageStrokedistance(sessionManager, 8.277374474122523)
+ testSessionMaximumStrokedistance(sessionManager, 8.642321255152048)
+ testSessionMinimumStrokerate(sessionManager, 26.06112916090677)
+ testSessionAverageStrokerate(sessionManager, 26.563783064771165)
+ testSessionMaximumStrokerate(sessionManager, 27.7589108235975)
+ testSessionMinimumDragfactor(sessionManager, 486.2899746779819)
+ testSessionAverageDragfactor(sessionManager, 505.0579243390023)
+ testSessionMaximumDragfactor(sessionManager, 553.4106584852259)
+ testSessionNumberOfIntervals(sessionManager, 1)
+
+ // Test interval metrics
+ testIntervalNumber(sessionManager, 0)
+ testIntervalWorkoutStepNumber(sessionManager, 0)
+ testIntervalType(sessionManager, 'time')
+ testIntervalTime(sessionManager, 20.10698646999998)
+ testIntervalMovingTimeTarget(sessionManager, 20.1)
+ testIntervalMovingTimeToEnd(sessionManager, -0.006986469999979761) // This is a test artifact
+ testIntervalMovingTimeProjectedEnd(sessionManager, 20.1)
+ testIntervalTimeSpentTotal(sessionManager, 20.10698646999998)
+ testIntervalTimeSpentMoving(sessionManager, 20.10698646999998)
+ testIntervalTimeSpentRest(sessionManager, 0)
+ testIntervalDistance(sessionManager, 72.62915467127921)
+ testIntervalDistanceToEnd(sessionManager, undefined)
+ testIntervalDistanceTarget(sessionManager, undefined)
+ testIntervalDistanceProjectedEnd(sessionManager, 72.60374121313309)
+ testIntervalWorkSinceStart(sessionManager, 2834.5690505128573)
+ testIntervalCaloriesSinceStart(sessionManager, 4.375171777750339)
+ testIntervalCaloriesTarget(sessionManager, undefined)
+ testIntervalCaloriesToEnd(sessionManager, undefined)
+ testIntervalNumberOfStrokes(sessionManager, 8)
+ testIntervalMinimumPace(sessionManager, 147.67157107875008)
+ testIntervalAveragePace(sessionManager, 138.422280701218)
+ testIntervalMaximumPace(sessionManager, 132.19298595053067)
+ testIntervalMinimumLinearVelocity(sessionManager, 3.385892059977887)
+ testIntervalAverageLinearVelocity(sessionManager, 3.6121352535668803)
+ testIntervalMaximumLinearVelocity(sessionManager, 3.7823489378408492)
+ testIntervalMinimumPower(sessionManager, 108.68693914003735)
+ testIntervalAveragePower(sessionManager, 137.69966645388217)
+ testIntervalMaximumPower(sessionManager, 151.51288596463914)
+ testIntervalMinimumStrokedistance(sessionManager, 7.777415453835244)
+ testIntervalAverageStrokedistance(sessionManager, 8.277374474122523)
+ testIntervalMaximumStrokedistance(sessionManager, 8.642321255152048)
+ testIntervalMinimumStrokerate(sessionManager, 26.06112916090677)
+ testIntervalAverageStrokerate(sessionManager, 26.563783064771165)
+ testIntervalMaximumStrokerate(sessionManager, 27.7589108235975)
+ testIntervalMinimumDragfactor(sessionManager, 486.2899746779819)
+ testIntervalAverageDragfactor(sessionManager, 505.0579243390023)
+ testIntervalMaximumDragfactor(sessionManager, 553.4106584852259)
+
+ // Test split metrics
+ testSplitNumber(sessionManager, 0)
+ testSplitType(sessionManager, 'time')
+ testSplitTime(sessionManager, 20.10698646999998)
+ testSplitMovingTimeTarget(sessionManager, 20.1)
+ testSplitMovingTimeToEnd(sessionManager, -0.006986469999979761) // This is a test artifact
+ testSplitMovingTimeProjectedEnd(sessionManager, 20.1)
+ testSplitTimeSpentTotal(sessionManager, 20.10698646999998)
+ testSplitTimeSpentMoving(sessionManager, 20.10698646999998)
+ testSplitTimeSpentRest(sessionManager, 0)
+ testSplitDistance(sessionManager, 72.62915467127921)
+ testSplitDistanceToEnd(sessionManager, undefined)
+ testSplitDistanceTarget(sessionManager, undefined)
+ testSplitDistanceProjectedEnd(sessionManager, 72.60374121313309)
+ testSplitWorkSinceStart(sessionManager, 2834.5690505128573)
+ testSplitCaloriesSinceStart(sessionManager, 4.375171777750339)
+ testSplitCaloriesTarget(sessionManager, undefined)
+ testSplitCaloriesToEnd(sessionManager, undefined)
+ testSplitNumberOfStrokes(sessionManager, 8)
+ testSplitMinimumPace(sessionManager, 147.67157107875008)
+ testSplitAveragePace(sessionManager, 138.422280701218)
+ testSplitMaximumPace(sessionManager, 132.19298595053067)
+ testSplitMinimumLinearVelocity(sessionManager, 3.385892059977887)
+ testSplitAverageLinearVelocity(sessionManager, 3.6121352535668803)
+ testSplitMaximumLinearVelocity(sessionManager, 3.7823489378408492)
+ testSplitMinimumPower(sessionManager, 108.68693914003735)
+ testSplitAveragePower(sessionManager, 137.69966645388217)
+ testSplitMaximumPower(sessionManager, 151.51288596463914)
+ testSplitMinimumStrokedistance(sessionManager, 7.777415453835244)
+ testSplitAverageStrokedistance(sessionManager, 8.277374474122523)
+ testSplitMaximumStrokedistance(sessionManager, 8.642321255152048)
+ testSplitMinimumStrokerate(sessionManager, 26.06112916090677)
+ testSplitAverageStrokerate(sessionManager, 26.563783064771165)
+ testSplitMaximumStrokerate(sessionManager, 27.7589108235975)
+ testSplitMinimumDragfactor(sessionManager, 486.2899746779819)
+ testSplitAverageDragfactor(sessionManager, 505.0579243390023)
+ testSplitMaximumDragfactor(sessionManager, 553.4106584852259)
+})
+
+/**
+ * @description Test behaviour for the NordicTrack RX800 in a single interval session with a float Calorie target
+ */
+test('NordicT_RX800_03: sample data for NordicTrack RX800 should produce plausible results for a 4.8 calories session', async () => {
+ const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.NordicTrack_RX800)
+ const testConfig = {
+ loglevel: {
+ default: 'silent',
+ RowingEngine: 'silent'
+ },
+ numOfPhasesForAveragingScreenData: 2,
+ userSettings: {
+ sex: 'male'
+ },
+ rowerSettings: rowerProfile
+ }
+ const sessionManager = createSessionManager(testConfig)
+
+ const intervalSettings = []
+ intervalSettings[0] = {
+ type: 'calories',
+ targetCalories: 4.8
+ }
+ sessionManager.handleCommand('updateIntervalSettings', intervalSettings)
+
+ testSessionType(sessionManager, 'calories')
+ testSessionCaloriesTarget(sessionManager, 4.8)
+ testIntervalType(sessionManager, 'calories')
+ testIntervalCaloriesTarget(sessionManager, 4.8)
+ testSplitType(sessionManager, 'calories')
+ testSplitCaloriesTarget(sessionManager, 4.8)
+
+ testSessionState(sessionManager, 'WaitingForStart')
+ testTotalMovingTime(sessionManager, 0)
+ testTotalLinearDistance(sessionManager, 0)
+ testTotalCalories(sessionManager, 0)
+ testTotalNumberOfStrokes(sessionManager, 0)
+ testDragFactor(sessionManager, undefined)
+
+ await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/RX800.csv', realtime: false, loop: false })
+
+ // Test Instantanuous metrics
+ testIsMoving(sessionManager, false)
+ testIsDriveStart(sessionManager, false)
+ testIsRecoveryStart(sessionManager, false)
+ testIsSessionStart(sessionManager, false)
+ testIsIntervalEnd(sessionManager, false)
+ testIsSplitEnd(sessionManager, false)
+ testIsPauseStart(sessionManager, false)
+ testIsPauseEnd(sessionManager, false)
+ testIsSessionStop(sessionManager, false)
+ testTotalMovingTime(sessionManager, 21.816067524999994)
+ testTotalLinearDistance(sessionManager, 78.9867130267447)
+ testTotalWork(sessionManager, 3132.9891152627774)
+ testTotalCalories(sessionManager, 4.801804784476454)
+ testTotalCaloriesPerMinute(sessionManager, 12.915884067211993)
+ testTotalCaloriesPerHour (sessionManager, 774.9530440327195)
+ testTotalNumberOfStrokes(sessionManager, 9)
+ testStrokeState(sessionManager, 'Stopped')
+ testStrokeWork(sessionManager, undefined)
+ testStrokeCalories(sessionManager, undefined)
+ testCycleDistance(sessionManager, undefined)
+ testCycleStrokeRate(sessionManager, undefined)
+ testCycleDuration(sessionManager, undefined)
+ testCycleLinearVelocity(sessionManager, undefined)
+ testCyclePace(sessionManager, Infinity)
+ testCyclePower(sessionManager, undefined)
+ testDriveLastStartTime (sessionManager, 20.24632183399998)
+ testDriveDuration(sessionManager, undefined)
+ testDriveLength(sessionManager, undefined)
+ testDriveDistance(sessionManager, undefined)
+ testDriveAverageHandleForce(sessionManager, undefined)
+ testDrivePeakHandleForce(sessionManager, undefined)
+ testRecoveryDuration(sessionManager, undefined)
+ testDragFactor(sessionManager, 493.8082148322739)
+
+ // Test session metrics
+ testSessionType(sessionManager, 'calories')
+ testSessionState(sessionManager, 'Stopped')
+ testSessionTime(sessionManager, 21.816067524999994)
+ testSessionMovingTimeTarget(sessionManager, undefined)
+ testSessionMovingTimeToEnd(sessionManager, undefined)
+ testSessionMovingTimeProjectedEnd(sessionManager, 21.794410111282083)
+ testSessionTimeSpentTotal(sessionManager, 21.816067524999994)
+ testSessionTimeSpentMoving(sessionManager, 21.816067524999994)
+ testSessionTimeSpentRest(sessionManager, 0)
+ testSessionDistance(sessionManager, 78.9867130267447)
+ testSessionDistanceToEnd(sessionManager, undefined)
+ testSessionDistanceTarget(sessionManager, undefined)
+ testSessionDistanceProjectedEnd(sessionManager, 78.90390603352311)
+ testSessionWorkSinceStart(sessionManager, 3132.9891152627774)
+ testSessionCaloriesSinceStart(sessionManager, 4.801804784476454)
+ testSessionCaloriesTarget(sessionManager, 4.8)
+ testSessionCaloriesToEnd(sessionManager, -0.0018047844764543797) // This is atesting artifact
+ testSessionNumberOfStrokes(sessionManager, 9)
+ testSessionMinimumPace(sessionManager, 147.67157107875008)
+ testSessionAveragePace(sessionManager, 138.0996036486107)
+ testSessionMaximumPace(sessionManager, 132.19298595053067)
+ testSessionMinimumLinearVelocity(sessionManager, 3.385892059977887)
+ testSessionAverageLinearVelocity(sessionManager, 3.620575199275962)
+ testSessionMaximumLinearVelocity(sessionManager, 3.7823489378408492)
+ testSessionMinimumPower(sessionManager, 108.68693914003735)
+ testSessionAveragePower(sessionManager, 137.17786949361715)
+ testSessionMaximumPower(sessionManager, 151.51288596463914)
+ testSessionMinimumStrokedistance(sessionManager, 7.777415453835244)
+ testSessionAverageStrokedistance(sessionManager, 8.263234518390162)
+ testSessionMaximumStrokedistance(sessionManager, 8.642321255152048)
+ testSessionMinimumStrokerate(sessionManager, 26.06112916090677)
+ testSessionAverageStrokerate(sessionManager, 26.572855772574037)
+ testSessionMaximumStrokerate(sessionManager, 27.7589108235975)
+ testSessionMinimumDragfactor(sessionManager, 486.2899746779819)
+ testSessionAverageDragfactor(sessionManager, 504.0933215006521)
+ testSessionMaximumDragfactor(sessionManager, 553.4106584852259)
+ testSessionNumberOfIntervals(sessionManager, 1)
+
+ // Test interval metrics
+ testIntervalNumber(sessionManager, 0)
+ testIntervalWorkoutStepNumber(sessionManager, 0)
+ testIntervalType(sessionManager, 'calories')
+ testIntervalTime(sessionManager, 21.816067524999994)
+ testIntervalMovingTimeTarget(sessionManager, undefined)
+ testIntervalMovingTimeToEnd(sessionManager, undefined)
+ testIntervalMovingTimeProjectedEnd(sessionManager, 21.794410111282083)
+ testIntervalTimeSpentTotal(sessionManager, 21.816067524999994)
+ testIntervalTimeSpentMoving(sessionManager, 21.816067524999994)
+ testIntervalTimeSpentRest(sessionManager, 0)
+ testIntervalDistance(sessionManager, 78.9867130267447)
+ testIntervalDistanceToEnd(sessionManager, undefined)
+ testIntervalDistanceTarget(sessionManager, undefined)
+ testIntervalDistanceProjectedEnd(sessionManager, 78.90390603352311)
+ testIntervalWorkSinceStart(sessionManager, 3132.9891152627774)
+ testIntervalCaloriesSinceStart(sessionManager, 4.801804784476454)
+ testIntervalCaloriesTarget(sessionManager, 4.8)
+ testIntervalCaloriesToEnd(sessionManager, -0.0018047844764543797) // This is a testing artifact
+ testIntervalNumberOfStrokes(sessionManager, 9)
+ testIntervalMinimumPace(sessionManager, 147.67157107875008)
+ testIntervalAveragePace(sessionManager, 138.0996036486107)
+ testIntervalMaximumPace(sessionManager, 132.19298595053067)
+ testIntervalMinimumLinearVelocity(sessionManager, 3.385892059977887)
+ testIntervalAverageLinearVelocity(sessionManager, 3.620575199275962)
+ testIntervalMaximumLinearVelocity(sessionManager, 3.7823489378408492)
+ testIntervalMinimumPower(sessionManager, 108.68693914003735)
+ testIntervalAveragePower(sessionManager, 137.17786949361715)
+ testIntervalMaximumPower(sessionManager, 151.51288596463914)
+ testIntervalMinimumStrokedistance(sessionManager, 7.777415453835244)
+ testIntervalAverageStrokedistance(sessionManager, 8.263234518390162)
+ testIntervalMaximumStrokedistance(sessionManager, 8.642321255152048)
+ testIntervalMinimumStrokerate(sessionManager, 26.06112916090677)
+ testIntervalAverageStrokerate(sessionManager, 26.572855772574037)
+ testIntervalMaximumStrokerate(sessionManager, 27.7589108235975)
+ testIntervalMinimumDragfactor(sessionManager, 486.2899746779819)
+ testIntervalAverageDragfactor(sessionManager, 504.0933215006521)
+ testIntervalMaximumDragfactor(sessionManager, 553.4106584852259)
+
+ // Test split metrics
+ testSplitNumber(sessionManager, 0)
+ testSplitType(sessionManager, 'calories')
+ testSplitTime(sessionManager, 21.816067524999994)
+ testSplitMovingTimeTarget(sessionManager, undefined)
+ testSplitMovingTimeToEnd(sessionManager, undefined)
+ testSplitMovingTimeProjectedEnd(sessionManager, 21.794410111282083)
+ testSplitTimeSpentTotal(sessionManager, 21.816067524999994)
+ testSplitTimeSpentMoving(sessionManager, 21.816067524999994)
+ testSplitTimeSpentRest(sessionManager, 0)
+ testSplitDistance(sessionManager, 78.9867130267447)
+ testSplitDistanceToEnd(sessionManager, undefined)
+ testSplitDistanceTarget(sessionManager, undefined)
+ testSplitDistanceProjectedEnd(sessionManager, 78.90390603352311)
+ testSplitWorkSinceStart(sessionManager, 3132.9891152627774)
+ testSplitCaloriesSinceStart(sessionManager, 4.801804784476454)
+ testSplitCaloriesTarget(sessionManager, 4.8)
+ testSplitCaloriesToEnd(sessionManager, -0.0018047844764543797) // This is a testing artifact
+ testSplitNumberOfStrokes(sessionManager, 9)
+ testSplitMinimumPace(sessionManager, 147.67157107875008)
+ testSplitAveragePace(sessionManager, 138.0996036486107)
+ testSplitMaximumPace(sessionManager, 132.19298595053067)
+ testSplitMinimumLinearVelocity(sessionManager, 3.385892059977887)
+ testSplitAverageLinearVelocity(sessionManager, 3.620575199275962)
+ testSplitMaximumLinearVelocity(sessionManager, 3.7823489378408492)
+ testSplitMinimumPower(sessionManager, 108.68693914003735)
+ testSplitAveragePower(sessionManager, 137.17786949361715)
+ testSplitMaximumPower(sessionManager, 151.51288596463914)
+ testSplitMinimumStrokedistance(sessionManager, 7.777415453835244)
+ testSplitAverageStrokedistance(sessionManager, 8.263234518390162)
+ testSplitMaximumStrokedistance(sessionManager, 8.642321255152048)
+ testSplitMinimumStrokerate(sessionManager, 26.06112916090677)
+ testSplitAverageStrokerate(sessionManager, 26.572855772574037)
+ testSplitMaximumStrokerate(sessionManager, 27.7589108235975)
+ testSplitMinimumDragfactor(sessionManager, 486.2899746779819)
+ testSplitAverageDragfactor(sessionManager, 504.0933215006521)
+ testSplitMaximumDragfactor(sessionManager, 553.4106584852259)
+})
+
+/**
+ * @description Test behaviour for the NordicTrack RX800 in a single interval session with a float Distance target
+ */
+test('NordicT_RX800_04: sample data for NordicTrack RX800 should produce plausible results for a 78.9 meter session', async () => {
+ const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.NordicTrack_RX800)
+ const testConfig = {
+ loglevel: {
+ default: 'silent',
+ RowingEngine: 'silent'
+ },
+ numOfPhasesForAveragingScreenData: 2,
+ userSettings: {
+ sex: 'male'
+ },
+ rowerSettings: rowerProfile
+ }
+ const sessionManager = createSessionManager(testConfig)
+
+ const intervalSettings = []
+ intervalSettings[0] = {
+ type: 'distance',
+ targetDistance: 78.9
+ }
+ sessionManager.handleCommand('updateIntervalSettings', intervalSettings)
+
+ testSessionType(sessionManager, 'distance')
+ testSessionDistanceTarget(sessionManager, 78.9)
+ testIntervalType(sessionManager, 'distance')
+ testIntervalDistanceTarget(sessionManager, 78.9)
+ testSplitType(sessionManager, 'distance')
+ testSplitDistanceTarget(sessionManager, 78.9)
+
+ testSessionState(sessionManager, 'WaitingForStart')
+ testTotalMovingTime(sessionManager, 0)
+ testTotalLinearDistance(sessionManager, 0)
+ testTotalCalories(sessionManager, 0)
+ testTotalNumberOfStrokes(sessionManager, 0)
+ testDragFactor(sessionManager, undefined)
+
+ await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/RX800.csv', realtime: false, loop: false })
+
+ // Test Instantanuous metrics
+ testIsMoving(sessionManager, false)
+ testIsDriveStart(sessionManager, false)
+ testIsRecoveryStart(sessionManager, false)
+ testIsSessionStart(sessionManager, false)
+ testIsIntervalEnd(sessionManager, false)
+ testIsSplitEnd(sessionManager, false)
+ testIsPauseStart(sessionManager, false)
+ testIsPauseEnd(sessionManager, false)
+ testIsSessionStop(sessionManager, false)
+ testTotalMovingTime(sessionManager, 21.816067524999994)
+ testTotalLinearDistance(sessionManager, 78.9867130267447)
+ testTotalWork(sessionManager, 3132.9891152627774)
+ testTotalCalories(sessionManager, 4.801804784476454)
+ testTotalCaloriesPerMinute(sessionManager, 12.915884067211993)
+ testTotalCaloriesPerHour (sessionManager, 774.9530440327195)
+ testTotalNumberOfStrokes(sessionManager, 9)
+ testStrokeState(sessionManager, 'Stopped')
+ testStrokeWork(sessionManager, undefined)
+ testStrokeCalories(sessionManager, undefined)
+ testCycleDistance(sessionManager, undefined)
+ testCycleStrokeRate(sessionManager, undefined)
+ testCycleDuration(sessionManager, undefined)
+ testCycleLinearVelocity(sessionManager, undefined)
+ testCyclePace(sessionManager, Infinity)
+ testCyclePower(sessionManager, undefined)
+ testDriveLastStartTime (sessionManager, 20.24632183399998)
+ testDriveDuration(sessionManager, undefined)
+ testDriveLength(sessionManager, undefined)
+ testDriveDistance(sessionManager, undefined)
+ testDriveAverageHandleForce(sessionManager, undefined)
+ testDrivePeakHandleForce(sessionManager, undefined)
+ testRecoveryDuration(sessionManager, undefined)
+ testDragFactor(sessionManager, 493.8082148322739)
+
+ // Test session metrics
+ testSessionType(sessionManager, 'distance')
+ testSessionState(sessionManager, 'Stopped')
+ testSessionTime(sessionManager, 21.816067524999994)
+ testSessionMovingTimeTarget(sessionManager, undefined)
+ testSessionMovingTimeToEnd(sessionManager, undefined)
+ testSessionMovingTimeProjectedEnd(sessionManager, 21.793388523844655)
+ testSessionTimeSpentTotal(sessionManager, 21.816067524999994)
+ testSessionTimeSpentMoving(sessionManager, 21.816067524999994)
+ testSessionTimeSpentRest(sessionManager, 0)
+ testSessionDistance(sessionManager, 78.9867130267447)
+ testSessionDistanceToEnd(sessionManager, -0.08671302674468961) // This is a testing artifact)
+ testSessionDistanceTarget(sessionManager, 78.9)
+ testSessionDistanceProjectedEnd(sessionManager, 78.9)
+ testSessionWorkSinceStart(sessionManager, 3132.9891152627774)
+ testSessionCaloriesSinceStart(sessionManager, 4.801804784476454)
+ testSessionCaloriesTarget(sessionManager, undefined)
+ testSessionCaloriesToEnd(sessionManager, undefined)
+ testSessionNumberOfStrokes(sessionManager, 9)
+ testSessionMinimumPace(sessionManager, 147.67157107875008)
+ testSessionAveragePace(sessionManager, 138.0996036486107)
+ testSessionMaximumPace(sessionManager, 132.19298595053067)
+ testSessionMinimumLinearVelocity(sessionManager, 3.385892059977887)
+ testSessionAverageLinearVelocity(sessionManager, 3.620575199275962)
+ testSessionMaximumLinearVelocity(sessionManager, 3.7823489378408492)
+ testSessionMinimumPower(sessionManager, 108.68693914003735)
+ testSessionAveragePower(sessionManager, 137.17786949361715)
+ testSessionMaximumPower(sessionManager, 151.51288596463914)
+ testSessionMinimumStrokedistance(sessionManager, 7.777415453835244)
+ testSessionAverageStrokedistance(sessionManager, 8.263234518390162)
+ testSessionMaximumStrokedistance(sessionManager, 8.642321255152048)
+ testSessionMinimumStrokerate(sessionManager, 26.06112916090677)
+ testSessionAverageStrokerate(sessionManager, 26.572855772574037)
+ testSessionMaximumStrokerate(sessionManager, 27.7589108235975)
+ testSessionMinimumDragfactor(sessionManager, 486.2899746779819)
+ testSessionAverageDragfactor(sessionManager, 504.0933215006521)
+ testSessionMaximumDragfactor(sessionManager, 553.4106584852259)
+ testSessionNumberOfIntervals(sessionManager, 1)
+
+ // Test interval metrics
+ testIntervalNumber(sessionManager, 0)
+ testIntervalWorkoutStepNumber(sessionManager, 0)
+ testIntervalType(sessionManager, 'distance')
+ testIntervalTime(sessionManager, 21.816067524999994)
+ testIntervalMovingTimeTarget(sessionManager, undefined)
+ testIntervalMovingTimeToEnd(sessionManager, undefined)
+ testIntervalMovingTimeProjectedEnd(sessionManager, 21.793388523844655)
+ testIntervalTimeSpentTotal(sessionManager, 21.816067524999994)
+ testIntervalTimeSpentMoving(sessionManager, 21.816067524999994)
+ testIntervalTimeSpentRest(sessionManager, 0)
+ testIntervalDistance(sessionManager, 78.9867130267447)
+ testIntervalDistanceToEnd(sessionManager, -0.08671302674468961) // This is a testing artifact
+ testIntervalDistanceTarget(sessionManager, 78.9)
+ testIntervalDistanceProjectedEnd(sessionManager, 78.9)
+ testIntervalWorkSinceStart(sessionManager, 3132.9891152627774)
+ testIntervalCaloriesSinceStart(sessionManager, 4.801804784476454)
+ testIntervalCaloriesTarget(sessionManager, undefined)
+ testIntervalCaloriesToEnd(sessionManager, undefined)
+ testIntervalNumberOfStrokes(sessionManager, 9)
+ testIntervalMinimumPace(sessionManager, 147.67157107875008)
+ testIntervalAveragePace(sessionManager, 138.0996036486107)
+ testIntervalMaximumPace(sessionManager, 132.19298595053067)
+ testIntervalMinimumLinearVelocity(sessionManager, 3.385892059977887)
+ testIntervalAverageLinearVelocity(sessionManager, 3.620575199275962)
+ testIntervalMaximumLinearVelocity(sessionManager, 3.7823489378408492)
+ testIntervalMinimumPower(sessionManager, 108.68693914003735)
+ testIntervalAveragePower(sessionManager, 137.17786949361715)
+ testIntervalMaximumPower(sessionManager, 151.51288596463914)
+ testIntervalMinimumStrokedistance(sessionManager, 7.777415453835244)
+ testIntervalAverageStrokedistance(sessionManager, 8.263234518390162)
+ testIntervalMaximumStrokedistance(sessionManager, 8.642321255152048)
+ testIntervalMinimumStrokerate(sessionManager, 26.06112916090677)
+ testIntervalAverageStrokerate(sessionManager, 26.572855772574037)
+ testIntervalMaximumStrokerate(sessionManager, 27.7589108235975)
+ testIntervalMinimumDragfactor(sessionManager, 486.2899746779819)
+ testIntervalAverageDragfactor(sessionManager, 504.0933215006521)
+ testIntervalMaximumDragfactor(sessionManager, 553.4106584852259)
+
+ // Test split metrics
+ testSplitNumber(sessionManager, 0)
+ testSplitType(sessionManager, 'distance')
+ testSplitTime(sessionManager, 21.816067524999994)
+ testSplitMovingTimeTarget(sessionManager, undefined)
+ testSplitMovingTimeToEnd(sessionManager, undefined)
+ testSplitMovingTimeProjectedEnd(sessionManager, 21.793388523844655)
+ testSplitTimeSpentTotal(sessionManager, 21.816067524999994)
+ testSplitTimeSpentMoving(sessionManager, 21.816067524999994)
+ testSplitTimeSpentRest(sessionManager, 0)
+ testSplitDistance(sessionManager, 78.9867130267447)
+ testSplitDistanceToEnd(sessionManager, -0.08671302674468961) // This is a testing artifact
+ testSplitDistanceTarget(sessionManager, 78.9)
+ testSplitDistanceProjectedEnd(sessionManager, 78.9)
+ testSplitWorkSinceStart(sessionManager, 3132.9891152627774)
+ testSplitCaloriesSinceStart(sessionManager, 4.801804784476454)
+ testSplitCaloriesTarget(sessionManager, undefined)
+ testSplitCaloriesToEnd(sessionManager, undefined)
+ testSplitNumberOfStrokes(sessionManager, 9)
+ testSplitMinimumPace(sessionManager, 147.67157107875008)
+ testSplitAveragePace(sessionManager, 138.0996036486107)
+ testSplitMaximumPace(sessionManager, 132.19298595053067)
+ testSplitMinimumLinearVelocity(sessionManager, 3.385892059977887)
+ testSplitAverageLinearVelocity(sessionManager, 3.620575199275962)
+ testSplitMaximumLinearVelocity(sessionManager, 3.7823489378408492)
+ testSplitMinimumPower(sessionManager, 108.68693914003735)
+ testSplitAveragePower(sessionManager, 137.17786949361715)
+ testSplitMaximumPower(sessionManager, 151.51288596463914)
+ testSplitMinimumStrokedistance(sessionManager, 7.777415453835244)
+ testSplitAverageStrokedistance(sessionManager, 8.263234518390162)
+ testSplitMaximumStrokedistance(sessionManager, 8.642321255152048)
+ testSplitMinimumStrokerate(sessionManager, 26.06112916090677)
+ testSplitAverageStrokerate(sessionManager, 26.572855772574037)
+ testSplitMaximumStrokerate(sessionManager, 27.7589108235975)
+ testSplitMinimumDragfactor(sessionManager, 486.2899746779819)
+ testSplitAverageDragfactor(sessionManager, 504.0933215006521)
+ testSplitMaximumDragfactor(sessionManager, 553.4106584852259)
+})
+
+/**
+ * @description Test against the Merarch R50
+ * @see {@link https://github.com/JaapvanEkris/openrowingmonitor/discussions/140|this discussion}
+ */
+test('Merarch_R50_01: Data with Merarch R50 should produce plausible results for a 500 meter session', async () => {
+ const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Merach_R50)
+ const testConfig = {
+ loglevel: {
+ default: 'silent',
+ RowingEngine: 'silent'
+ },
+ numOfPhasesForAveragingScreenData: 2,
+ userSettings: {
+ sex: 'male'
+ },
+ rowerSettings: rowerProfile
+ }
+
+ const sessionManager = createSessionManager(testConfig)
+
+ testSessionType(sessionManager, 'justrow')
+ testIntervalType(sessionManager, 'justrow')
+ testSplitType(sessionManager, 'justrow')
+
+ const intervalSettings = []
+ intervalSettings[0] = {
+ type: 'distance',
+ targetDistance: 500,
+ split: {
+ type: 'distance',
+ targetDistance: 100
+ }
+ }
+ sessionManager.handleCommand('updateIntervalSettings', intervalSettings)
+
+ testSessionType(sessionManager, 'distance')
+ testSessionDistanceTarget(sessionManager, 500)
+ testIntervalType(sessionManager, 'distance')
+ testIntervalDistanceTarget(sessionManager, 500)
+ testSplitType(sessionManager, 'distance')
+ testSplitDistanceTarget(sessionManager, 100)
+
+ testSessionState(sessionManager, 'WaitingForStart')
+ testTotalMovingTime(sessionManager, 0)
+ testTotalLinearDistance(sessionManager, 0)
+ testTotalCalories(sessionManager, 0)
+ testTotalNumberOfStrokes(sessionManager, 0)
+ testDragFactor(sessionManager, undefined)
+
+ await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/Merach_R50_510m.csv', realtime: false, loop: false })
+
+ // Test Instantanuous metrics
+ testIsMoving(sessionManager, false)
+ testIsDriveStart(sessionManager, false)
+ testIsRecoveryStart(sessionManager, false)
+ testIsSessionStart(sessionManager, false)
+ testIsIntervalEnd(sessionManager, false)
+ testIsSplitEnd(sessionManager, false)
+ testIsPauseStart(sessionManager, false)
+ testIsPauseEnd(sessionManager, false)
+ testIsSessionStop(sessionManager, false)
+ testTotalMovingTime(sessionManager, 125.72261800000001)
+ testTotalLinearDistance(sessionManager, 500.1592245940827)
+ testTotalWork(sessionManager, 22829.81352857388)
+ testTotalCalories(sessionManager, 32.219564384356076)
+ testTotalCaloriesPerMinute(sessionManager, 15.52334646861831)
+ testTotalCaloriesPerHour (sessionManager, 931.4007881170988)
+ testTotalNumberOfStrokes(sessionManager, 49)
+ testStrokeState(sessionManager, 'Stopped')
+ testStrokeWork(sessionManager, undefined)
+ testStrokeCalories(sessionManager, undefined)
+ testCycleDistance(sessionManager, undefined)
+ testCycleStrokeRate(sessionManager, undefined)
+ testCycleDuration(sessionManager, undefined)
+ testCycleLinearVelocity(sessionManager, undefined)
+ testCyclePace(sessionManager, Infinity)
+ testCyclePower(sessionManager, undefined)
+ testDriveLastStartTime (sessionManager, 123.22668199999998)
+ testDriveDuration(sessionManager, undefined)
+ testDriveLength(sessionManager, undefined)
+ testDriveDistance(sessionManager, undefined)
+ testDriveAverageHandleForce(sessionManager, undefined)
+ testDrivePeakHandleForce(sessionManager, undefined)
+ testRecoveryDuration(sessionManager, undefined)
+ testDragFactor(sessionManager, 123.2635201451201)
+
+ // Test session metrics
+ testSessionType(sessionManager, 'distance')
+ testSessionState(sessionManager, 'Stopped')
+ testSessionTime(sessionManager, 125.72261800000001)
+ testSessionMovingTimeTarget(sessionManager, undefined)
+ testSessionMovingTimeToEnd(sessionManager, undefined)
+ testSessionMovingTimeProjectedEnd(sessionManager, 125.6836508203873)
+ testSessionTimeSpentTotal(sessionManager, 125.72261800000001)
+ testSessionTimeSpentMoving(sessionManager, 125.72261800000001)
+ testSessionTimeSpentRest(sessionManager, 0)
+ testSessionDistance(sessionManager, 500.1592245940827)
+ testSessionDistanceToEnd(sessionManager, -0.15922459408267287)
+ testSessionDistanceTarget(sessionManager, 500)
+ testSessionDistanceProjectedEnd(sessionManager, 500)
+ testSessionWorkSinceStart(sessionManager, 22829.81352857388)
+ testSessionCaloriesSinceStart(sessionManager, 32.219564384356076)
+ testSessionCaloriesTarget(sessionManager, undefined)
+ testSessionCaloriesToEnd(sessionManager, undefined)
+ testSessionNumberOfStrokes(sessionManager, 49)
+ testSessionMinimumPace(sessionManager, 155.82248412524578)
+ testSessionAveragePace(sessionManager, 125.68259447982139)
+ testSessionMaximumPace(sessionManager, 121.53313334054032)
+ testSessionMinimumLinearVelocity(sessionManager, 3.2087795468471283)
+ testSessionAverageLinearVelocity(sessionManager, 3.978275608244832)
+ testSessionMaximumLinearVelocity(sessionManager, 4.114104411337619)
+ testSessionMinimumPower(sessionManager, 92.5076553187539)
+ testSessionAveragePower(sessionManager, 178.2374659740029)
+ testSessionMaximumPower(sessionManager, 194.9772627729816)
+ testSessionMinimumStrokedistance(sessionManager, 9.347453983785956)
+ testSessionAverageStrokedistance(sessionManager, 10.046672970165618)
+ testSessionMaximumStrokedistance(sessionManager, 10.427050626616515)
+ testSessionMinimumStrokerate(sessionManager, 20.5967072044199)
+ testSessionAverageStrokerate(sessionManager, 23.817939816423333)
+ testSessionMaximumStrokerate(sessionManager, 25.503578789694632)
+ testSessionMinimumDragfactor(sessionManager, 123.11994765671123)
+ testSessionAverageDragfactor(sessionManager, 123.54922724050179)
+ testSessionMaximumDragfactor(sessionManager, 124.43729521393021)
+ testSessionNumberOfIntervals(sessionManager, 1)
+
+ // Test interval metrics
+ testIntervalNumber(sessionManager, 0)
+ testIntervalWorkoutStepNumber(sessionManager, 0)
+ testIntervalType(sessionManager, 'distance')
+ testIntervalTime(sessionManager, 125.72261800000001)
+ testIntervalMovingTimeTarget(sessionManager, undefined)
+ testIntervalMovingTimeToEnd(sessionManager, undefined)
+ testIntervalMovingTimeProjectedEnd(sessionManager, 125.6836508203873)
+ testIntervalTimeSpentTotal(sessionManager, 125.72261800000001)
+ testIntervalTimeSpentMoving(sessionManager, 125.72261800000001)
+ testIntervalTimeSpentRest(sessionManager, 0)
+ testIntervalDistance(sessionManager, 500.1592245940827)
+ testIntervalDistanceToEnd(sessionManager, -0.15922459408267287)
+ testIntervalDistanceTarget(sessionManager, 500)
+ testIntervalDistanceProjectedEnd(sessionManager, 500)
+ testIntervalWorkSinceStart(sessionManager, 22829.81352857388)
+ testIntervalCaloriesSinceStart(sessionManager, 32.219564384356076)
+ testIntervalCaloriesTarget(sessionManager, undefined)
+ testIntervalCaloriesToEnd(sessionManager, undefined)
+ testIntervalNumberOfStrokes(sessionManager, 49)
+ testIntervalMinimumPace(sessionManager, 155.82248412524578)
+ testIntervalAveragePace(sessionManager, 125.68259447982139)
+ testIntervalMaximumPace(sessionManager, 121.53313334054032)
+ testIntervalMinimumLinearVelocity(sessionManager, 3.2087795468471283)
+ testIntervalAverageLinearVelocity(sessionManager, 3.978275608244832)
+ testIntervalMaximumLinearVelocity(sessionManager, 4.114104411337619)
+ testIntervalMinimumPower(sessionManager, 92.5076553187539)
+ testIntervalAveragePower(sessionManager, 178.2374659740029)
+ testIntervalMaximumPower(sessionManager, 194.9772627729816)
+ testIntervalMinimumStrokedistance(sessionManager, 9.347453983785956)
+ testIntervalAverageStrokedistance(sessionManager, 10.046672970165618)
+ testIntervalMaximumStrokedistance(sessionManager, 10.427050626616515)
+ testIntervalMinimumStrokerate(sessionManager, 20.5967072044199)
+ testIntervalAverageStrokerate(sessionManager, 23.817939816423333)
+ testIntervalMaximumStrokerate(sessionManager, 25.503578789694632)
+ testIntervalMinimumDragfactor(sessionManager, 123.11994765671123)
+ testIntervalAverageDragfactor(sessionManager, 123.54922724050179)
+ testIntervalMaximumDragfactor(sessionManager, 124.43729521393021)
+
+ // Test split metrics
+ testSplitNumber(sessionManager, 4)
+ testSplitType(sessionManager, 'distance')
+ testSplitTime(sessionManager, 24.77682730146141)
+ testSplitMovingTimeTarget(sessionManager, undefined)
+ testSplitMovingTimeToEnd(sessionManager, undefined)
+ testSplitMovingTimeProjectedEnd(sessionManager, 24.737860121848698)
+ testSplitTimeSpentTotal(sessionManager, 24.77682730146141)
+ testSplitTimeSpentMoving(sessionManager, 24.77682730146141)
+ testSplitTimeSpentRest(sessionManager, 0)
+ testSplitDistance(sessionManager, 100.15922459408267)
+ testSplitDistanceToEnd(sessionManager, -0.15922459408267287)
+ testSplitDistanceTarget(sessionManager, 100)
+ testSplitDistanceProjectedEnd(sessionManager, 100)
+ testSplitWorkSinceStart(sessionManager, 4353.603332462535)
+ testSplitCaloriesSinceStart(sessionManager, 6.202787432133093)
+ testSplitCaloriesTarget(sessionManager, undefined)
+ testSplitCaloriesToEnd(sessionManager, undefined)
+ testSplitNumberOfStrokes(sessionManager, 9)
+ testSplitMinimumPace(sessionManager, 124.84188607444194)
+ testSplitAveragePace(sessionManager, 123.68719607143008)
+ testSplitMaximumPace(sessionManager, 123.2415163916658)
+ testSplitMinimumLinearVelocity(sessionManager, 4.005066053726993)
+ testSplitAverageLinearVelocity(sessionManager, 4.042455612877238)
+ testSplitMaximumLinearVelocity(sessionManager, 4.0570743905080064)
+ testSplitMinimumPower(sessionManager, 179.88177956899153)
+ testSplitAveragePower(sessionManager, 183.7756302352862)
+ testSplitMaximumPower(sessionManager, 186.98167445385002)
+ testSplitMinimumStrokedistance(sessionManager, 10.206559417946657)
+ testSplitAverageStrokedistance(sessionManager, 10.301192730215913)
+ testSplitMaximumStrokedistance(sessionManager, 10.427050626616515)
+ testSplitMinimumStrokerate(sessionManager, 23.20711050394371)
+ testSplitAverageStrokerate(sessionManager, 23.496556552618888)
+ testSplitMaximumStrokerate(sessionManager, 23.725763391265964)
+ testSplitMinimumDragfactor(sessionManager, 123.11994765671123)
+ testSplitAverageDragfactor(sessionManager, 123.31281478300096)
+ testSplitMaximumDragfactor(sessionManager, 123.45997545234641)
+})
+
+/**
+ * @description Test against the Oartec Slider
+ * @see {@link https://github.com/JaapvanEkris/openrowingmonitor/discussions/218|this discussion}
+ */
+test('Oartec_Slider_01: Test for the Oartec Slider on a 190 meters run', async () => {
+ const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Oartec_Slider)
+ const testConfig = {
+ loglevel: {
+ default: 'silent',
+ RowingEngine: 'silent'
+ },
+ numOfPhasesForAveragingScreenData: 2,
+ userSettings: {
+ sex: 'male'
+ },
+ rowerSettings: rowerProfile
+ }
+
+ const sessionManager = createSessionManager(testConfig)
+
+ testSessionType(sessionManager, 'justrow')
+ testIntervalType(sessionManager, 'justrow')
+ testSplitType(sessionManager, 'justrow')
+
+ const intervalSettings = []
+ intervalSettings[0] = {
+ type: 'distance',
+ targetDistance: 190,
+ split: {
+ type: 'distance',
+ targetDistance: 85
+ }
+ }
+ sessionManager.handleCommand('updateIntervalSettings', intervalSettings)
+
+ testSessionType(sessionManager, 'distance')
+ testSessionDistanceTarget(sessionManager, 190)
+ testIntervalType(sessionManager, 'distance')
+ testIntervalDistanceTarget(sessionManager, 190)
+ testSplitType(sessionManager, 'distance')
+ testSplitDistanceTarget(sessionManager, 85)
+
+ testSessionState(sessionManager, 'WaitingForStart')
+ testTotalMovingTime(sessionManager, 0)
+ testTotalLinearDistance(sessionManager, 0)
+ testTotalCalories(sessionManager, 0)
+ testTotalNumberOfStrokes(sessionManager, 0)
+ testDragFactor(sessionManager, undefined)
+
+ await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/Oartec_Slider.csv', realtime: false, loop: false })
+
+ // Test Instantanuous metrics
+ testIsMoving(sessionManager, false)
+ testIsDriveStart(sessionManager, false)
+ testIsRecoveryStart(sessionManager, false)
+ testIsSessionStart(sessionManager, false)
+ testIsIntervalEnd(sessionManager, false)
+ testIsSplitEnd(sessionManager, false)
+ testIsPauseStart(sessionManager, false)
+ testIsPauseEnd(sessionManager, false)
+ testIsSessionStop(sessionManager, false)
+ testTotalMovingTime(sessionManager, 54.977195480302356)
+ testTotalLinearDistance(sessionManager, 190.05153764993952)
+ testTotalWork(sessionManager, 7140.670099435771)
+ testTotalCalories(sessionManager, 11.382071146630693)
+ testTotalCaloriesPerMinute(sessionManager, 13.934098177873182)
+ testTotalCaloriesPerHour (sessionManager, 836.0458906723909)
+ testTotalNumberOfStrokes(sessionManager, 13)
+ testStrokeState(sessionManager, 'Stopped')
+ testStrokeWork(sessionManager, undefined)
+ testStrokeCalories(sessionManager, undefined)
+ testCycleDistance(sessionManager, undefined)
+ testCycleStrokeRate(sessionManager, undefined)
+ testCycleDuration(sessionManager, undefined)
+ testCycleLinearVelocity(sessionManager, undefined)
+ testCyclePace(sessionManager, Infinity)
+ testCyclePower(sessionManager, undefined)
+ testDriveLastStartTime (sessionManager, 44.9507183497444)
+ testDriveDuration(sessionManager, undefined)
+ testDriveLength(sessionManager, undefined)
+ testDriveDistance(sessionManager, undefined)
+ testDriveAverageHandleForce(sessionManager, undefined)
+ testDrivePeakHandleForce(sessionManager, undefined)
+ testRecoveryDuration(sessionManager, undefined)
+ testDragFactor(sessionManager, 171.65666790675232)
+
+ // Test session metrics
+ testSessionType(sessionManager, 'distance')
+ testSessionState(sessionManager, 'Stopped')
+ testSessionTime(sessionManager, 54.977195480302356)
+ testSessionMovingTimeTarget(sessionManager, undefined)
+ testSessionMovingTimeToEnd(sessionManager, undefined)
+ testSessionMovingTimeProjectedEnd(sessionManager, 54.957992672672596)
+ testSessionTimeSpentTotal(sessionManager, 54.977195480302356)
+ testSessionTimeSpentMoving(sessionManager, 54.977195480302356)
+ testSessionTimeSpentRest(sessionManager, 0)
+ testSessionDistance(sessionManager, 190.05153764993952)
+ testSessionDistanceToEnd(sessionManager, -0.051537649939518815)
+ testSessionDistanceTarget(sessionManager, 190)
+ testSessionDistanceProjectedEnd(sessionManager, 190)
+ testSessionWorkSinceStart(sessionManager, 7140.670099435771)
+ testSessionCaloriesSinceStart(sessionManager, 11.382071146630693)
+ testSessionCaloriesTarget(sessionManager, undefined)
+ testSessionCaloriesToEnd(sessionManager, undefined)
+ testSessionNumberOfStrokes(sessionManager, 13)
+ testSessionMinimumPace(sessionManager, 173.9443854055811)
+ testSessionAveragePace(sessionManager, 144.6375971489538)
+ testSessionMaximumPace(sessionManager, 130.18330972516975)
+ testSessionMinimumLinearVelocity(sessionManager, 2.8744819721209423)
+ testSessionAverageLinearVelocity(sessionManager, 3.4569158355491707)
+ testSessionMaximumLinearVelocity(sessionManager, 3.840738118085575)
+ testSessionMinimumPower(sessionManager, 66.50232040715504)
+ testSessionAveragePower(sessionManager, 137.28524561571436)
+ testSessionMaximumPower(sessionManager, 158.63917245072105)
+ testSessionMinimumStrokedistance(sessionManager, 9.976113710936815)
+ testSessionAverageStrokedistance(sessionManager, 12.666224909593485)
+ testSessionMaximumStrokedistance(sessionManager, 13.500348187012701)
+ testSessionMinimumStrokerate(sessionManager, 16.873797116170685)
+ testSessionAverageStrokerate(sessionManager, 17.261321624223008)
+ testSessionMaximumStrokerate(sessionManager, 17.69470146563009)
+ testSessionMinimumDragfactor(sessionManager, 171.1367791084345)
+ testSessionAverageDragfactor(sessionManager, 172.6199488651739)
+ testSessionMaximumDragfactor(sessionManager, 175.906384020954)
+ testSessionNumberOfIntervals(sessionManager, 1)
+
+ // Test interval metrics
+ testIntervalNumber(sessionManager, 0)
+ testIntervalWorkoutStepNumber(sessionManager, 0)
+ testIntervalType(sessionManager, 'distance')
+ testIntervalTime(sessionManager, 54.977195480302356)
+ testIntervalMovingTimeTarget(sessionManager, undefined)
+ testIntervalMovingTimeToEnd(sessionManager, undefined)
+ testIntervalMovingTimeProjectedEnd(sessionManager, 54.957992672672596)
+ testIntervalTimeSpentTotal(sessionManager, 54.977195480302356)
+ testIntervalTimeSpentMoving(sessionManager, 54.977195480302356)
+ testIntervalTimeSpentRest(sessionManager, 0)
+ testIntervalDistance(sessionManager, 190.05153764993952)
+ testIntervalDistanceToEnd(sessionManager, -0.051537649939518815)
+ testIntervalDistanceTarget(sessionManager, 190)
+ testIntervalDistanceProjectedEnd(sessionManager, 190)
+ testIntervalWorkSinceStart(sessionManager, 7140.670099435771)
+ testIntervalCaloriesSinceStart(sessionManager, 11.382071146630693)
+ testIntervalCaloriesTarget(sessionManager, undefined)
+ testIntervalCaloriesToEnd(sessionManager, undefined)
+ testIntervalNumberOfStrokes(sessionManager, 13)
+ testIntervalMinimumPace(sessionManager, 173.9443854055811)
+ testIntervalAveragePace(sessionManager, 144.6375971489538)
+ testIntervalMaximumPace(sessionManager, 130.18330972516975)
+ testIntervalMinimumLinearVelocity(sessionManager, 2.8744819721209423)
+ testIntervalAverageLinearVelocity(sessionManager, 3.4569158355491707)
+ testIntervalMaximumLinearVelocity(sessionManager, 3.840738118085575)
+ testIntervalMinimumPower(sessionManager, 66.50232040715504)
+ testIntervalAveragePower(sessionManager, 137.28524561571436)
+ testIntervalMaximumPower(sessionManager, 158.63917245072105)
+ testIntervalMinimumStrokedistance(sessionManager, 9.976113710936815)
+ testIntervalAverageStrokedistance(sessionManager, 12.666224909593485)
+ testIntervalMaximumStrokedistance(sessionManager, 13.500348187012701)
+ testIntervalMinimumStrokerate(sessionManager, 16.873797116170685)
+ testIntervalAverageStrokerate(sessionManager, 17.261321624223008)
+ testIntervalMaximumStrokerate(sessionManager, 17.69470146563009)
+ testIntervalMinimumDragfactor(sessionManager, 171.1367791084345)
+ testIntervalAverageDragfactor(sessionManager, 172.6199488651739)
+ testIntervalMaximumDragfactor(sessionManager, 175.906384020954)
+
+ // Test split metrics
+ testSplitNumber(sessionManager, 2)
+ testSplitType(sessionManager, 'distance')
+ testSplitTime(sessionManager, 8.0007716125439)
+ testSplitMovingTimeTarget(sessionManager, undefined)
+ testSplitMovingTimeToEnd(sessionManager, undefined)
+ testSplitMovingTimeProjectedEnd(sessionManager, 32.200416528712395)
+ testSplitTimeSpentTotal(sessionManager, 8.0007716125439)
+ testSplitTimeSpentMoving(sessionManager, 8.0007716125439)
+ testSplitTimeSpentRest(sessionManager, 0)
+ testSplitDistance(sessionManager, 20.05153764993952)
+ testSplitDistanceToEnd(sessionManager, 64.94846235006048)
+ testSplitDistanceTarget(sessionManager, 85)
+ testSplitDistanceProjectedEnd(sessionManager, 85)
+ testSplitWorkSinceStart(sessionManager, 0) // ToDo: explain this
+ testSplitCaloriesSinceStart(sessionManager, 0.6667309677119935)
+ testSplitCaloriesTarget(sessionManager, undefined)
+ testSplitCaloriesToEnd(sessionManager, undefined)
+ testSplitNumberOfStrokes(sessionManager, 0)
+ testSplitMinimumPace(sessionManager, 131.9999805439805)
+ testSplitAveragePace(sessionManager, 199.50518888430565) // ToDo: explain this
+ testSplitMaximumPace(sessionManager, 131.9999805439805)
+ testSplitMinimumLinearVelocity(sessionManager, 3.787879346189806)
+ testSplitAverageLinearVelocity(sessionManager, 2.5062004792765227)
+ testSplitMaximumLinearVelocity(sessionManager, 3.787879346189806)
+ testSplitMinimumPower(sessionManager, 152.1766130323092)
+ testSplitAveragePower(sessionManager, 152.1766130323092)
+ testSplitMaximumPower(sessionManager, 152.1766130323092)
+ testSplitMinimumStrokedistance(sessionManager, 13.295419847178342)
+ testSplitAverageStrokedistance(sessionManager, 13.295419847178342)
+ testSplitMaximumStrokedistance(sessionManager, 13.295419847178342)
+ testSplitMinimumStrokerate(sessionManager, 17.093502438589454)
+ testSplitAverageStrokerate(sessionManager, 17.093502438589454)
+ testSplitMaximumStrokerate(sessionManager, 17.093502438589454)
+ testSplitMinimumDragfactor(sessionManager, 171.65666790675232)
+ testSplitAverageDragfactor(sessionManager, 171.65666790675232)
+ testSplitMaximumDragfactor(sessionManager, 171.65666790675232)
+})
+
+/**
+ * @description Test against the Schwinn Windrigger
+ * Additionally tests the calorie split
+ * @see {@link https://github.com/JaapvanEkris/openrowingmonitor/discussions/159|this discussion}
+ */
+test('Schwinn_Wndrggr_01: Data for the Schwinn Windrigger should produce plausible results for 120 seconds run', async () => {
+ const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Schwinn_Windrigger)
+ const testConfig = {
+ loglevel: {
+ default: 'silent',
+ RowingEngine: 'silent'
+ },
+ numOfPhasesForAveragingScreenData: 2,
+ userSettings: {
+ sex: 'male'
+ },
+ rowerSettings: rowerProfile
+ }
+
+ const sessionManager = createSessionManager(testConfig)
+
+ testSessionType(sessionManager, 'justrow')
+ testIntervalType(sessionManager, 'justrow')
+ testSplitType(sessionManager, 'justrow')
+
+ const intervalSettings = []
+ intervalSettings[0] = {
+ type: 'time',
+ targetTime: 120,
+ split: {
+ type: 'calories',
+ targetCalories: 3.14
+ }
+ }
+ sessionManager.handleCommand('updateIntervalSettings', intervalSettings)
+
+ testSessionType(sessionManager, 'time')
+ testSessionMovingTimeTarget(sessionManager, 120)
+ testIntervalType(sessionManager, 'time')
+ testIntervalMovingTimeTarget(sessionManager, 120)
+ testSplitType(sessionManager, 'calories')
+ testSplitCaloriesTarget(sessionManager, 3.14)
+
+ testSessionState(sessionManager, 'WaitingForStart')
+ testTotalMovingTime(sessionManager, 0)
+ testTotalLinearDistance(sessionManager, 0)
+ testTotalCalories(sessionManager, 0)
+ testTotalNumberOfStrokes(sessionManager, 0)
+ testDragFactor(sessionManager, undefined)
+
+ await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/Schwinn_Windrigger.csv', realtime: false, loop: false })
+
+ // Test Instantanuous metrics
+ testIsMoving(sessionManager, false)
+ testIsDriveStart(sessionManager, false)
+ testIsRecoveryStart(sessionManager, false)
+ testIsSessionStart(sessionManager, false)
+ testIsIntervalEnd(sessionManager, false)
+ testIsSplitEnd(sessionManager, false)
+ testIsPauseStart(sessionManager, false)
+ testIsPauseEnd(sessionManager, false)
+ testIsSessionStop(sessionManager, false)
+ testTotalMovingTime(sessionManager, 120.01874599999996)
+ testTotalLinearDistance(sessionManager, 431.23010439799225)
+ testTotalWork(sessionManager, 18455.110628084884)
+ testTotalCalories(sessionManager, 27.577858002937983)
+ testTotalCaloriesPerMinute(sessionManager, 14.955024168523664)
+ testTotalCaloriesPerHour (sessionManager, 897.30145011142)
+ testTotalNumberOfStrokes(sessionManager, 46)
+ testStrokeState(sessionManager, 'Stopped')
+ testStrokeWork(sessionManager, undefined)
+ testStrokeCalories(sessionManager, undefined)
+ testCycleDistance(sessionManager, undefined)
+ testCycleStrokeRate(sessionManager, undefined)
+ testCycleDuration(sessionManager, undefined)
+ testCycleLinearVelocity(sessionManager, undefined)
+ testCyclePace(sessionManager, Infinity)
+ testCyclePower(sessionManager, undefined)
+ testDriveLastStartTime (sessionManager, 111.44649600000002)
+ testDriveDuration(sessionManager, undefined)
+ testDriveLength(sessionManager, undefined)
+ testDriveDistance(sessionManager, undefined)
+ testDriveAverageHandleForce(sessionManager, undefined)
+ testDrivePeakHandleForce(sessionManager, undefined)
+ testRecoveryDuration(sessionManager, undefined)
+ testDragFactor(sessionManager, 229.67514256584752)
+
+ // Test session metrics
+ testSessionType(sessionManager, 'time')
+ testSessionState(sessionManager, 'Stopped')
+ testSessionTime(sessionManager, 120.01874599999996)
+ testSessionMovingTimeTarget(sessionManager, 120)
+ testSessionMovingTimeToEnd(sessionManager, -0.018745999999964624)
+ testSessionMovingTimeProjectedEnd(sessionManager, 120)
+ testSessionTimeSpentTotal(sessionManager, 120.01874599999996)
+ testSessionTimeSpentMoving(sessionManager, 120.01874599999996)
+ testSessionTimeSpentRest(sessionManager, 0)
+ testSessionDistance(sessionManager, 431.23010439799225)
+ testSessionDistanceToEnd(sessionManager, undefined)
+ testSessionDistanceTarget(sessionManager, undefined)
+ testSessionDistanceProjectedEnd(sessionManager, 431.19392247166047)
+ testSessionWorkSinceStart(sessionManager, 18455.110628084884)
+ testSessionCaloriesSinceStart(sessionManager, 27.577858002937983)
+ testSessionCaloriesTarget(sessionManager, undefined)
+ testSessionCaloriesToEnd(sessionManager, undefined)
+ testSessionNumberOfStrokes(sessionManager, 46)
+ testSessionMinimumPace(sessionManager, 186.17656608397135)
+ testSessionAveragePace(sessionManager, 139.15858931920937)
+ testSessionMaximumPace(sessionManager, 113.2497408547144)
+ testSessionMinimumLinearVelocity(sessionManager, 2.685622635098365)
+ testSessionAverageLinearVelocity(sessionManager, 3.593022913253838)
+ testSessionMaximumLinearVelocity(sessionManager, 4.415021140237654)
+ testSessionMinimumPower(sessionManager, 54.32601467750247)
+ testSessionAveragePower(sessionManager, 163.15761770457485)
+ testSessionMaximumPower(sessionManager, 240.96768506129658)
+ testSessionMinimumStrokedistance(sessionManager, 6.499008533910098)
+ testSessionAverageStrokedistance(sessionManager, 8.83875618371168)
+ testSessionMaximumStrokedistance(sessionManager, 16.069668808959207)
+ testSessionMinimumStrokerate(sessionManager, 10.96166204373795)
+ testSessionAverageStrokerate(sessionManager, 26.187946271820365)
+ testSessionMaximumStrokerate(sessionManager, 28.94477481688812)
+ testSessionMinimumDragfactor(sessionManager, 210.65756253850174)
+ testSessionAverageDragfactor(sessionManager, 217.44483049749743)
+ testSessionMaximumDragfactor(sessionManager, 231.73602571367536)
+ testSessionNumberOfIntervals(sessionManager, 1)
+
+ // Test interval metrics
+ testIntervalNumber(sessionManager, 0)
+ testIntervalWorkoutStepNumber(sessionManager, 0)
+ testIntervalType(sessionManager, 'time')
+ testIntervalTime(sessionManager, 120.01874599999996)
+ testIntervalMovingTimeTarget(sessionManager, 120)
+ testIntervalMovingTimeToEnd(sessionManager, -0.018745999999964624)
+ testIntervalMovingTimeProjectedEnd(sessionManager, 120)
+ testIntervalTimeSpentTotal(sessionManager, 120.01874599999996)
+ testIntervalTimeSpentMoving(sessionManager, 120.01874599999996)
+ testIntervalTimeSpentRest(sessionManager, 0)
+ testIntervalDistance(sessionManager, 431.23010439799225)
+ testIntervalDistanceToEnd(sessionManager, undefined)
+ testIntervalDistanceTarget(sessionManager, undefined)
+ testIntervalDistanceProjectedEnd(sessionManager, 431.19392247166047)
+ testIntervalWorkSinceStart(sessionManager, 18455.110628084884)
+ testIntervalCaloriesSinceStart(sessionManager, 27.577858002937983)
+ testIntervalCaloriesTarget(sessionManager, undefined)
+ testIntervalCaloriesToEnd(sessionManager, undefined)
+ testIntervalNumberOfStrokes(sessionManager, 46)
+ testIntervalMinimumPace(sessionManager, 186.17656608397135)
+ testIntervalAveragePace(sessionManager, 139.15858931920937)
+ testIntervalMaximumPace(sessionManager, 113.2497408547144)
+ testIntervalMinimumLinearVelocity(sessionManager, 2.685622635098365)
+ testIntervalAverageLinearVelocity(sessionManager, 3.593022913253838)
+ testIntervalMaximumLinearVelocity(sessionManager, 4.415021140237654)
+ testIntervalMinimumPower(sessionManager, 54.32601467750247)
+ testIntervalAveragePower(sessionManager, 163.15761770457485)
+ testIntervalMaximumPower(sessionManager, 240.96768506129658)
+ testIntervalMinimumStrokedistance(sessionManager, 6.499008533910098)
+ testIntervalAverageStrokedistance(sessionManager, 8.83875618371168)
+ testIntervalMaximumStrokedistance(sessionManager, 16.069668808959207)
+ testIntervalMinimumStrokerate(sessionManager, 10.96166204373795)
+ testIntervalAverageStrokerate(sessionManager, 26.187946271820365)
+ testIntervalMaximumStrokerate(sessionManager, 28.94477481688812)
+ testIntervalMinimumDragfactor(sessionManager, 210.65756253850174)
+ testIntervalAverageDragfactor(sessionManager, 217.44483049749743)
+ testIntervalMaximumDragfactor(sessionManager, 231.73602571367536)
+
+ // Test split metrics
+ testSplitNumber(sessionManager, 8)
+ testSplitType(sessionManager, 'calories')
+ testSplitTime(sessionManager, 18.751774983553844)
+ testSplitMovingTimeTarget(sessionManager, undefined)
+ testSplitMovingTimeToEnd(sessionManager, undefined)
+ testSplitMovingTimeProjectedEnd(sessionManager, 26.93747894829525)
+ testSplitTimeSpentTotal(sessionManager, 18.751774983553844)
+ testSplitTimeSpentMoving(sessionManager, 18.751774983553844)
+ testSplitTimeSpentRest(sessionManager, 0)
+ testSplitDistance(sessionManager, 47.17359551527596)
+ testSplitDistanceToEnd(sessionManager, undefined)
+ testSplitDistanceTarget(sessionManager, undefined)
+ testSplitDistanceProjectedEnd(sessionManager, 62.972941393292615)
+ testSplitWorkSinceStart(sessionManager, 939.9705920239176)
+ testSplitCaloriesSinceStart(sessionManager, 2.457858002937982)
+ testSplitCaloriesTarget(sessionManager, 3.14)
+ testSplitCaloriesToEnd(sessionManager, 0.6821419970620184)
+ testSplitNumberOfStrokes(sessionManager, 5)
+ testSplitMinimumPace(sessionManager, 186.17656608397135)
+ testSplitAveragePace(sessionManager, 198.75286989181862) // ToDo: Find out why this isn't between Min and Max pace
+ testSplitMaximumPace(sessionManager, 167.24919852117975)
+ testSplitMinimumLinearVelocity(sessionManager, 2.685622635098365)
+ testSplitAverageLinearVelocity(sessionManager, 2.515686944657204) // ToDo: Find out why this isn't between Min and Max velocity
+ testSplitMaximumLinearVelocity(sessionManager, 2.989550948052418)
+ testSplitMinimumPower(sessionManager, 54.32601467750247)
+ testSplitAveragePower(sessionManager, 68.72151923315722)
+ testSplitMaximumPower(sessionManager, 74.81312047512183)
+ testSplitMinimumStrokedistance(sessionManager, 6.499008533910098)
+ testSplitAverageStrokedistance(sessionManager, 7.587165390577207)
+ testSplitMaximumStrokedistance(sessionManager, 11.150696968157495)
+ testSplitMinimumStrokerate(sessionManager, 14.862162729535287)
+ testSplitAverageStrokerate(sessionManager, 23.379319186588383)
+ testSplitMaximumStrokerate(sessionManager, 24.818001323626664)
+ testSplitMinimumDragfactor(sessionManager, 225.50332469798812)
+ testSplitAverageDragfactor(sessionManager, 229.3535354525803)
+ testSplitMaximumDragfactor(sessionManager, 231.73602571367536)
+})
+
+/**
+ * @description Test behaviour for the Sportstech WRX700 in a 'Just Row' session
+ */
+test('Sportstech_WRX700_01: sample data for Sportstech WRX700 should produce plausible results for a short unlimited run', async () => {
+ const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Sportstech_WRX700)
+ const testConfig = {
+ loglevel: {
+ default: 'silent',
+ RowingEngine: 'silent'
+ },
+ numOfPhasesForAveragingScreenData: 2,
+ userSettings: {
+ sex: 'male'
+ },
+ rowerSettings: rowerProfile
+ }
+ const sessionManager = createSessionManager(testConfig)
+
+ testSessionType(sessionManager, 'justrow')
+ testIntervalType(sessionManager, 'justrow')
+ testSplitType(sessionManager, 'justrow')
+
+ testSessionState(sessionManager, 'WaitingForStart')
+ testTotalMovingTime(sessionManager, 0)
+ testTotalLinearDistance(sessionManager, 0)
+ testTotalCalories(sessionManager, 0)
+ testTotalNumberOfStrokes(sessionManager, 0)
+ testDragFactor(sessionManager, undefined)
+
+ await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/WRX700_2magnets.csv', realtime: false, loop: false })
+
+ // Test Instantanuous metrics
+ testIsMoving(sessionManager, true) // The recording stops abrubtly, and SessionManager's watchdog hasn't kicked in yet
+ testIsDriveStart(sessionManager, false)
+ testIsRecoveryStart(sessionManager, false)
+ testIsSessionStart(sessionManager, false)
+ testIsIntervalEnd(sessionManager, false)
+ testIsSplitEnd(sessionManager, false)
+ testIsPauseStart(sessionManager, false)
+ testIsPauseEnd(sessionManager, false)
+ testIsSessionStop(sessionManager, false)
+ testTotalMovingTime(sessionManager, 46.302522627)
+ testTotalLinearDistance(sessionManager, 165.58832475070278)
+ testTotalWork(sessionManager, 9748.548017262458)
+ testTotalCalories(sessionManager, 13.142874997261865)
+ testTotalCaloriesPerMinute(sessionManager, 16.06317491735509)
+ testTotalCaloriesPerHour (sessionManager, 963.7904950413053)
+ testTotalNumberOfStrokes(sessionManager, 15)
+ testStrokeState(sessionManager, 'Recovery')
+ testStrokeWork(sessionManager, 763.0876311342454)
+ testStrokeCalories(sessionManager, 0.9544143736814243)
+ testCycleDistance(sessionManager, 10.968457408700388)
+ testCycleStrokeRate(sessionManager, 21.96216589358063)
+ testCycleDuration(sessionManager, 2.731970985500002)
+ testCycleLinearVelocity(sessionManager, 4.015620240977591)
+ testCyclePace(sessionManager, 124.51376624156981)
+ testCyclePower(sessionManager, 181.32184485208828)
+ testDriveLastStartTime (sessionManager, 42.953401899000006)
+ testDriveDuration(sessionManager, 1.297822779999997)
+ testDriveLength(sessionManager, 1.7592918860102864)
+ testDriveDistance(sessionManager, 5.661139307716341)
+ testDriveAverageHandleForce(sessionManager, 347.2288656270118)
+ testDrivePeakHandleForce(sessionManager, 634.0186435697934)
+ testRecoveryDuration(sessionManager, 1.332040928000005)
+ testDragFactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+
+ // Test session metrics
+ testSessionType(sessionManager, 'justrow')
+ testSessionState(sessionManager, 'Rowing')
+ testSessionTime(sessionManager, 46.302522627)
+ testSessionMovingTimeTarget(sessionManager, undefined)
+ testSessionMovingTimeToEnd(sessionManager, undefined)
+ testSessionMovingTimeProjectedEnd(sessionManager, undefined)
+ testSessionTimeSpentTotal(sessionManager, 46.302522627)
+ testSessionTimeSpentMoving(sessionManager, 46.302522627)
+ testSessionTimeSpentRest(sessionManager, 0)
+ testSessionDistance(sessionManager, 165.58832475070278)
+ testSessionDistanceToEnd(sessionManager, undefined)
+ testSessionDistanceTarget(sessionManager, undefined)
+ testSessionDistanceProjectedEnd(sessionManager, undefined)
+ testSessionWorkSinceStart(sessionManager, 9748.548017262458)
+ testSessionCaloriesSinceStart(sessionManager, 13.142874997261865)
+ testSessionCaloriesTarget(sessionManager, undefined)
+ testSessionCaloriesToEnd(sessionManager, undefined)
+ testSessionNumberOfStrokes(sessionManager, 15)
+ testSessionMinimumPace(sessionManager, 180.24261586109614)
+ testSessionAveragePace(sessionManager, 139.81215975434733)
+ testSessionMaximumPace(sessionManager, 115.80722039956261)
+ testSessionMinimumLinearVelocity(sessionManager, 2.7740387455612865)
+ testSessionAverageLinearVelocity(sessionManager, 3.5762268523604077)
+ testSessionMaximumLinearVelocity(sessionManager, 4.317520084454841)
+ testSessionMinimumPower(sessionManager, 59.79167863880113)
+ testSessionAveragePower(sessionManager, 141.54132092961044)
+ testSessionMaximumPower(sessionManager, 225.37999917458814)
+ testSessionMinimumStrokedistance(sessionManager, 8.491708961574506)
+ testSessionAverageStrokedistance(sessionManager, 10.370621576635525)
+ testSessionMaximumStrokedistance(sessionManager, 11.32227861543267)
+ testSessionMinimumStrokerate(sessionManager, 17.11018345586753)
+ testSessionAverageStrokerate(sessionManager, 20.973328465999273)
+ testSessionMaximumStrokerate(sessionManager, 23.947414069812904)
+ testSessionMinimumDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+ testSessionAverageDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+ testSessionMaximumDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+ testSessionNumberOfIntervals(sessionManager, 1)
+
+ // Test interval metrics
+ testIntervalNumber(sessionManager, 0)
+ testIntervalWorkoutStepNumber(sessionManager, 0)
+ testIntervalType(sessionManager, 'justrow')
+ testIntervalTime(sessionManager, 46.302522627)
+ testIntervalMovingTimeTarget(sessionManager, undefined)
+ testIntervalMovingTimeToEnd(sessionManager, undefined)
+ testIntervalMovingTimeProjectedEnd(sessionManager, undefined)
+ testIntervalTimeSpentTotal(sessionManager, 46.302522627)
+ testIntervalTimeSpentMoving(sessionManager, 46.302522627)
+ testIntervalTimeSpentRest(sessionManager, 0)
+ testIntervalDistance(sessionManager, 165.58832475070278)
+ testIntervalDistanceToEnd(sessionManager, undefined)
+ testIntervalDistanceTarget(sessionManager, undefined)
+ testIntervalDistanceProjectedEnd(sessionManager, undefined)
+ testIntervalWorkSinceStart(sessionManager, 9748.548017262458)
+ testIntervalCaloriesSinceStart(sessionManager, 13.142874997261865)
+ testIntervalCaloriesTarget(sessionManager, undefined)
+ testIntervalCaloriesToEnd(sessionManager, undefined)
+ testIntervalNumberOfStrokes(sessionManager, 15)
+ testIntervalMinimumPace(sessionManager, 180.24261586109614)
+ testIntervalAveragePace(sessionManager, 139.81215975434733)
+ testIntervalMaximumPace(sessionManager, 115.80722039956261)
+ testIntervalMinimumLinearVelocity(sessionManager, 2.7740387455612865)
+ testIntervalAverageLinearVelocity(sessionManager, 3.5762268523604077)
+ testIntervalMaximumLinearVelocity(sessionManager, 4.317520084454841)
+ testIntervalMinimumPower(sessionManager, 59.79167863880113)
+ testIntervalAveragePower(sessionManager, 141.54132092961044)
+ testIntervalMaximumPower(sessionManager, 225.37999917458814)
+ testIntervalMinimumStrokedistance(sessionManager, 8.491708961574506)
+ testIntervalAverageStrokedistance(sessionManager, 10.370621576635525)
+ testIntervalMaximumStrokedistance(sessionManager, 11.32227861543267)
+ testIntervalMinimumStrokerate(sessionManager, 17.11018345586753)
+ testIntervalAverageStrokerate(sessionManager, 20.973328465999273)
+ testIntervalMaximumStrokerate(sessionManager, 23.947414069812904)
+ testIntervalMinimumDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+ testIntervalAverageDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+ testIntervalMaximumDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+
+ // Test split metrics
+ testSplitNumber(sessionManager, 0)
+ testSplitType(sessionManager, 'justrow')
+ testSplitTime(sessionManager, 46.302522627)
+ testSplitMovingTimeTarget(sessionManager, undefined)
+ testSplitMovingTimeToEnd(sessionManager, undefined)
+ testSplitMovingTimeProjectedEnd(sessionManager, undefined)
+ testSplitTimeSpentTotal(sessionManager, 46.302522627)
+ testSplitTimeSpentMoving(sessionManager, 46.302522627)
+ testSplitTimeSpentRest(sessionManager, 0)
+ testSplitDistance(sessionManager, 165.58832475070278)
+ testSplitDistanceToEnd(sessionManager, undefined)
+ testSplitDistanceTarget(sessionManager, undefined)
+ testSplitDistanceProjectedEnd(sessionManager, undefined)
+ testSplitWorkSinceStart(sessionManager, 9748.548017262458)
+ testSplitCaloriesSinceStart(sessionManager, 13.142874997261865)
+ testSplitCaloriesTarget(sessionManager, undefined)
+ testSplitCaloriesToEnd(sessionManager, undefined)
+ testSplitNumberOfStrokes(sessionManager, 15)
+ testSplitMinimumPace(sessionManager, 180.24261586109614)
+ testSplitAveragePace(sessionManager, 139.81215975434733)
+ testSplitMaximumPace(sessionManager, 115.80722039956261)
+ testSplitMinimumLinearVelocity(sessionManager, 2.7740387455612865)
+ testSplitAverageLinearVelocity(sessionManager, 3.5762268523604077)
+ testSplitMaximumLinearVelocity(sessionManager, 4.317520084454841)
+ testSplitMinimumPower(sessionManager, 59.79167863880113)
+ testSplitAveragePower(sessionManager, 141.54132092961044)
+ testSplitMaximumPower(sessionManager, 225.37999917458814)
+ testSplitMinimumStrokedistance(sessionManager, 8.491708961574506)
+ testSplitAverageStrokedistance(sessionManager, 10.370621576635525)
+ testSplitMaximumStrokedistance(sessionManager, 11.32227861543267)
+ testSplitMinimumStrokerate(sessionManager, 17.11018345586753)
+ testSplitAverageStrokerate(sessionManager, 20.973328465999273)
+ testSplitMaximumStrokerate(sessionManager, 23.947414069812904)
+ testSplitMinimumDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+ testSplitAverageDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+ testSplitMaximumDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+})
+
+/**
+ * @description Test behaviour for the Sportstech WRX700 in a single interval session with a Distance target
+ */
+test('Sportstech_WRX700_02: sample data for Sportstech WRX700 should produce plausible results for a 150 meter session, with sessionManager reset', async () => {
+ const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Sportstech_WRX700)
+ const testConfig = {
+ loglevel: {
+ default: 'silent',
+ RowingEngine: 'silent'
+ },
+ numOfPhasesForAveragingScreenData: 2,
+ userSettings: {
+ sex: 'male'
+ },
+ rowerSettings: rowerProfile
+ }
+ const sessionManager = createSessionManager(testConfig)
+
+ testSessionType(sessionManager, 'justrow')
+ testIntervalType(sessionManager, 'justrow')
+ testSplitType(sessionManager, 'justrow')
+
+ const intervalSettings = []
+ intervalSettings[0] = {
+ type: 'distance',
+ targetDistance: 100
+ }
+ sessionManager.handleCommand('updateIntervalSettings', intervalSettings)
+
+ testSessionType(sessionManager, 'distance')
+ testSessionDistanceTarget(sessionManager, 100)
+ testIntervalType(sessionManager, 'distance')
+ testIntervalDistanceTarget(sessionManager, 100)
+ testSplitType(sessionManager, 'distance')
+ testSplitDistanceTarget(sessionManager, 100)
+
+ testSessionState(sessionManager, 'WaitingForStart')
+ testTotalMovingTime(sessionManager, 0)
+ testTotalLinearDistance(sessionManager, 0)
+ testTotalCalories(sessionManager, 0)
+ testTotalNumberOfStrokes(sessionManager, 0)
+ testDragFactor(sessionManager, undefined)
+
+ sessionManager.handleCommand('reset')
+
+ testSessionType(sessionManager, 'justrow')
+ testIntervalType(sessionManager, 'justrow')
+ testSplitType(sessionManager, 'justrow')
+
+ testSessionState(sessionManager, 'WaitingForStart')
+ testTotalMovingTime(sessionManager, 0)
+ testTotalLinearDistance(sessionManager, 0)
+ testTotalCalories(sessionManager, 0)
+ testTotalNumberOfStrokes(sessionManager, 0)
+ testDragFactor(sessionManager, undefined)
+
+ intervalSettings[0] = {
+ type: 'distance',
+ targetDistance: 165
+ }
+ sessionManager.handleCommand('updateIntervalSettings', intervalSettings)
+
+ testSessionType(sessionManager, 'distance')
+ testSessionDistanceTarget(sessionManager, 165)
+ testIntervalType(sessionManager, 'distance')
+ testIntervalDistanceTarget(sessionManager, 165)
+ testSplitType(sessionManager, 'distance')
+ testSplitDistanceTarget(sessionManager, 165)
+
+ testSessionState(sessionManager, 'WaitingForStart')
+ testTotalMovingTime(sessionManager, 0)
+ testTotalLinearDistance(sessionManager, 0)
+ testTotalCalories(sessionManager, 0)
+ testTotalNumberOfStrokes(sessionManager, 0)
+ testDragFactor(sessionManager, undefined)
+
+ await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/WRX700_2magnets.csv', realtime: false, loop: false })
+
+ // Test Instantanuous metrics
+ testIsMoving(sessionManager, true) // The recording stops abrubtly, and SessionManager's watchdog hasn't kicked in yet
+ testIsDriveStart(sessionManager, false)
+ testIsRecoveryStart(sessionManager, false)
+ testIsSessionStart(sessionManager, false)
+ testIsIntervalEnd(sessionManager, false)
+ testIsSplitEnd(sessionManager, false)
+ testIsPauseStart(sessionManager, false)
+ testIsPauseEnd(sessionManager, false)
+ testIsSessionStop(sessionManager, false)
+ testTotalMovingTime(sessionManager, 46.302522627)
+ testTotalLinearDistance(sessionManager, 165.58832475070278)
+ testTotalWork(sessionManager, 9748.548017262458)
+ testTotalCalories(sessionManager, 13.142874997261865)
+ testTotalCaloriesPerMinute(sessionManager, 16.06317491735509)
+ testTotalCaloriesPerHour (sessionManager, 963.7904950413053)
+ testTotalNumberOfStrokes(sessionManager, 15)
+ testStrokeState(sessionManager, 'Recovery')
+ testStrokeWork(sessionManager, 763.0876311342454)
+ testStrokeCalories(sessionManager, 0.9544143736814243)
+ testCycleDistance(sessionManager, 10.968457408700388)
+ testCycleStrokeRate(sessionManager, 21.96216589358063)
+ testCycleDuration(sessionManager, 2.731970985500002)
+ testCycleLinearVelocity(sessionManager, 4.015620240977591)
+ testCyclePace(sessionManager, 124.51376624156981)
+ testCyclePower(sessionManager, 181.32184485208828)
+ testDriveLastStartTime (sessionManager, 42.953401899000006)
+ testDriveDuration(sessionManager, 1.297822779999997)
+ testDriveLength(sessionManager, 1.7592918860102864)
+ testDriveDistance(sessionManager, 5.661139307716341)
+ testDriveAverageHandleForce(sessionManager, 347.2288656270118)
+ testDrivePeakHandleForce(sessionManager, 634.0186435697934)
+ testRecoveryDuration(sessionManager, 1.332040928000005)
+ testDragFactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+
+ // Test session metrics
+ testSessionType(sessionManager, 'distance')
+ testSessionState(sessionManager, 'Stopped')
+ testSessionTime(sessionManager, 46.302522627)
+ testSessionMovingTimeTarget(sessionManager, undefined)
+ testSessionMovingTimeToEnd(sessionManager, undefined)
+ testSessionMovingTimeProjectedEnd(sessionManager, 46.11303122869046)
+ testSessionTimeSpentTotal(sessionManager, 46.302522627)
+ testSessionTimeSpentMoving(sessionManager, 46.302522627)
+ testSessionTimeSpentRest(sessionManager, 0)
+ testSessionDistance(sessionManager, 165.58832475070278)
+ testSessionDistanceToEnd(sessionManager, -0.5883247507027818) // This is a testing artifact
+ testSessionDistanceTarget(sessionManager, 165)
+ testSessionDistanceProjectedEnd(sessionManager, 165)
+ testSessionWorkSinceStart(sessionManager, 9748.548017262458)
+ testSessionCaloriesSinceStart(sessionManager, 13.142874997261865)
+ testSessionCaloriesTarget(sessionManager, undefined)
+ testSessionCaloriesToEnd(sessionManager, undefined)
+ testSessionNumberOfStrokes(sessionManager, 15)
+ testSessionMinimumPace(sessionManager, 180.24261586109614)
+ testSessionAveragePace(sessionManager, 139.81215975434733)
+ testSessionMaximumPace(sessionManager, 115.80722039956261)
+ testSessionMinimumLinearVelocity(sessionManager, 2.7740387455612865)
+ testSessionAverageLinearVelocity(sessionManager, 3.5762268523604077)
+ testSessionMaximumLinearVelocity(sessionManager, 4.317520084454841)
+ testSessionMinimumPower(sessionManager, 59.79167863880113)
+ testSessionAveragePower(sessionManager, 142.86733839369302)
+ testSessionMaximumPower(sessionManager, 225.37999917458814)
+ testSessionMinimumStrokedistance(sessionManager, 8.491708961574506)
+ testSessionAverageStrokedistance(sessionManager, 10.390549437704353)
+ testSessionMaximumStrokedistance(sessionManager, 11.32227861543267)
+ testSessionMinimumStrokerate(sessionManager, 17.11018345586753)
+ testSessionAverageStrokerate(sessionManager, 21.006289713585318)
+ testSessionMaximumStrokerate(sessionManager, 23.947414069812904)
+ testSessionMinimumDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+ testSessionAverageDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+ testSessionMaximumDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+ testSessionNumberOfIntervals(sessionManager, 1)
+
+ // Test interval metrics
+ testIntervalNumber(sessionManager, 0)
+ testIntervalWorkoutStepNumber(sessionManager, 0)
+ testIntervalType(sessionManager, 'distance')
+ testIntervalTime(sessionManager, 46.302522627)
+ testIntervalMovingTimeTarget(sessionManager, undefined)
+ testIntervalMovingTimeToEnd(sessionManager, undefined)
+ testIntervalMovingTimeProjectedEnd(sessionManager, 46.11303122869046)
+ testIntervalTimeSpentTotal(sessionManager, 46.302522627)
+ testIntervalTimeSpentMoving(sessionManager, 46.302522627)
+ testIntervalTimeSpentRest(sessionManager, 0)
+ testIntervalDistance(sessionManager, 165.58832475070278)
+ testIntervalDistanceToEnd(sessionManager, -0.5883247507027818) // This is a testing artifact
+ testIntervalDistanceTarget(sessionManager, 165)
+ testIntervalDistanceProjectedEnd(sessionManager, 165)
+ testIntervalWorkSinceStart(sessionManager, 9748.548017262458)
+ testIntervalCaloriesSinceStart(sessionManager, 13.142874997261865)
+ testIntervalCaloriesTarget(sessionManager, undefined)
+ testIntervalCaloriesToEnd(sessionManager, undefined)
+ testIntervalNumberOfStrokes(sessionManager, 15)
+ testIntervalMinimumPace(sessionManager, 180.24261586109614)
+ testIntervalAveragePace(sessionManager, 139.81215975434733)
+ testIntervalMaximumPace(sessionManager, 115.80722039956261)
+ testIntervalMinimumLinearVelocity(sessionManager, 2.7740387455612865)
+ testIntervalAverageLinearVelocity(sessionManager, 3.5762268523604077)
+ testIntervalMaximumLinearVelocity(sessionManager, 4.317520084454841)
+ testIntervalMinimumPower(sessionManager, 59.79167863880113)
+ testIntervalAveragePower(sessionManager, 142.86733839369302)
+ testIntervalMaximumPower(sessionManager, 225.37999917458814)
+ testIntervalMinimumStrokedistance(sessionManager, 8.491708961574506)
+ testIntervalAverageStrokedistance(sessionManager, 10.390549437704353)
+ testIntervalMaximumStrokedistance(sessionManager, 11.32227861543267)
+ testIntervalMinimumStrokerate(sessionManager, 17.11018345586753)
+ testIntervalAverageStrokerate(sessionManager, 21.006289713585318)
+ testIntervalMaximumStrokerate(sessionManager, 23.947414069812904)
+ testIntervalMinimumDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+ testIntervalAverageDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+ testIntervalMaximumDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+
+ // Test split metrics
+ testSplitNumber(sessionManager, 0)
+ testSplitType(sessionManager, 'distance')
+ testSplitTime(sessionManager, 46.302522627)
+ testSplitMovingTimeTarget(sessionManager, undefined)
+ testSplitMovingTimeToEnd(sessionManager, undefined)
+ testSplitMovingTimeProjectedEnd(sessionManager, 46.11303122869046)
+ testSplitTimeSpentTotal(sessionManager, 46.302522627)
+ testSplitTimeSpentMoving(sessionManager, 46.302522627)
+ testSplitTimeSpentRest(sessionManager, 0)
+ testSplitDistance(sessionManager, 165.58832475070278)
+ testSplitDistanceToEnd(sessionManager, -0.5883247507027818) // This is a testing artifact
+ testSplitDistanceTarget(sessionManager, 165)
+ testSplitDistanceProjectedEnd(sessionManager, 165)
+ testSplitWorkSinceStart(sessionManager, 9748.548017262458)
+ testSplitCaloriesSinceStart(sessionManager, 13.142874997261865)
+ testSplitCaloriesTarget(sessionManager, undefined)
+ testSplitCaloriesToEnd(sessionManager, undefined)
+ testSplitNumberOfStrokes(sessionManager, 15)
+ testSplitMinimumPace(sessionManager, 180.24261586109614)
+ testSplitAveragePace(sessionManager, 139.81215975434733)
+ testSplitMaximumPace(sessionManager, 115.80722039956261)
+ testSplitMinimumLinearVelocity(sessionManager, 2.7740387455612865)
+ testSplitAverageLinearVelocity(sessionManager, 3.5762268523604077)
+ testSplitMaximumLinearVelocity(sessionManager, 4.317520084454841)
+ testSplitMinimumPower(sessionManager, 59.79167863880113)
+ testSplitAveragePower(sessionManager, 142.86733839369302)
+ testSplitMaximumPower(sessionManager, 225.37999917458814)
+ testSplitMinimumStrokedistance(sessionManager, 8.491708961574506)
+ testSplitAverageStrokedistance(sessionManager, 10.390549437704353)
+ testSplitMaximumStrokedistance(sessionManager, 11.32227861543267)
+ testSplitMinimumStrokerate(sessionManager, 17.11018345586753)
+ testSplitAverageStrokerate(sessionManager, 21.006289713585318)
+ testSplitMaximumStrokerate(sessionManager, 23.947414069812904)
+ testSplitMinimumDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+ testSplitAverageDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+ testSplitMaximumDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+})
+
+/**
+ * @description Test behaviour for the Sportstech WRX700 in a single interval session with overwriting a distance session with a Time interval
+ */
+test('Sportstech_WRX700_03: sample data for Sportstech WRX700 should produce plausible results for a 46 seconds session after a interval overwrite', async () => {
+ const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Sportstech_WRX700)
+ const testConfig = {
+ loglevel: {
+ default: 'silent',
+ RowingEngine: 'silent'
+ },
+ numOfPhasesForAveragingScreenData: 2,
+ userSettings: {
+ sex: 'male'
+ },
+ rowerSettings: rowerProfile
+ }
+ const sessionManager = createSessionManager(testConfig)
+
+ const intervalSettings = []
+ intervalSettings[0] = {
+ type: 'distance',
+ targetDistance: 100
+ }
+ sessionManager.handleCommand('updateIntervalSettings', intervalSettings)
+
+ testSessionType(sessionManager, 'distance')
+ testSessionDistanceTarget(sessionManager, 100)
+ testIntervalType(sessionManager, 'distance')
+ testIntervalDistanceTarget(sessionManager, 100)
+ testSplitType(sessionManager, 'distance')
+ testSplitDistanceTarget(sessionManager, 100)
+
+ // overwrite the interval settings
+ intervalSettings[0] = {
+ type: 'time',
+ targetTime: 46
+ }
+ sessionManager.handleCommand('updateIntervalSettings', intervalSettings)
+
+ testSessionType(sessionManager, 'time')
+ testSessionMovingTimeTarget(sessionManager, 46)
+ testIntervalType(sessionManager, 'time')
+ testIntervalMovingTimeTarget(sessionManager, 46)
+ testSplitType(sessionManager, 'time')
+ testSplitMovingTimeTarget(sessionManager, 46)
+
+ testSessionState(sessionManager, 'WaitingForStart')
+ testTotalMovingTime(sessionManager, 0)
+ testTotalLinearDistance(sessionManager, 0)
+ testTotalCalories(sessionManager, 0)
+ testTotalNumberOfStrokes(sessionManager, 0)
+ testDragFactor(sessionManager, undefined)
+
+ await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/WRX700_2magnets.csv', realtime: false, loop: false })
+
+ // Test Instantanuous metrics
+ testIsMoving(sessionManager, true) // The recording stops abrubtly, and SessionManager's watchdog hasn't kicked in yet
+ testIsDriveStart(sessionManager, false)
+ testIsRecoveryStart(sessionManager, false)
+ testIsSessionStart(sessionManager, false)
+ testIsIntervalEnd(sessionManager, false)
+ testIsSplitEnd(sessionManager, false)
+ testIsPauseStart(sessionManager, false)
+ testIsPauseEnd(sessionManager, false)
+ testIsSessionStop(sessionManager, false)
+ testTotalMovingTime(sessionManager, 46.302522627)
+ testTotalLinearDistance(sessionManager, 165.58832475070278)
+ testTotalWork(sessionManager, 9748.548017262458)
+ testTotalCalories(sessionManager, 13.142874997261865)
+ testTotalCaloriesPerMinute(sessionManager, 16.06317491735509)
+ testTotalCaloriesPerHour (sessionManager, 963.7904950413053)
+ testTotalNumberOfStrokes(sessionManager, 15)
+ testStrokeState(sessionManager, 'Recovery')
+ testStrokeWork(sessionManager, 763.0876311342454)
+ testStrokeCalories(sessionManager, 0.9544143736814243)
+ testCycleDistance(sessionManager, 10.968457408700388)
+ testCycleStrokeRate(sessionManager, 21.96216589358063)
+ testCycleDuration(sessionManager, 2.731970985500002)
+ testCycleLinearVelocity(sessionManager, 4.015620240977591)
+ testCyclePace(sessionManager, 124.51376624156981)
+ testCyclePower(sessionManager, 181.32184485208828)
+ testDriveLastStartTime (sessionManager, 42.953401899000006)
+ testDriveDuration(sessionManager, 1.297822779999997)
+ testDriveLength(sessionManager, 1.7592918860102864)
+ testDriveDistance(sessionManager, 5.661139307716341)
+ testDriveAverageHandleForce(sessionManager, 347.2288656270118)
+ testDrivePeakHandleForce(sessionManager, 634.0186435697934)
+ testRecoveryDuration(sessionManager, 1.332040928000005)
+ testDragFactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+
+ // Test session metrics
+ testSessionType(sessionManager, 'time')
+ testSessionState(sessionManager, 'Stopped')
+ testSessionTime(sessionManager, 46.302522627)
+ testSessionMovingTimeTarget(sessionManager, 46)
+ testSessionMovingTimeToEnd(sessionManager, -0.3025226270000019) // This is a testing artifact
+ testSessionMovingTimeProjectedEnd(sessionManager, 46)
+ testSessionTimeSpentTotal(sessionManager, 46.302522627)
+ testSessionTimeSpentMoving(sessionManager, 46.302522627)
+ testSessionTimeSpentRest(sessionManager, 0)
+ testSessionDistance(sessionManager, 165.58832475070278)
+ testSessionDistanceToEnd(sessionManager, undefined)
+ testSessionDistanceTarget(sessionManager, undefined)
+ testSessionDistanceProjectedEnd(sessionManager, 164.6490654983064)
+ testSessionWorkSinceStart(sessionManager, 9748.548017262458)
+ testSessionCaloriesSinceStart(sessionManager, 13.142874997261865)
+ testSessionCaloriesTarget(sessionManager, undefined)
+ testSessionCaloriesToEnd(sessionManager, undefined)
+ testSessionNumberOfStrokes(sessionManager, 15)
+ testSessionMinimumPace(sessionManager, 180.24261586109614)
+ testSessionAveragePace(sessionManager, 139.81215975434733)
+ testSessionMaximumPace(sessionManager, 115.80722039956261)
+ testSessionMinimumLinearVelocity(sessionManager, 2.7740387455612865)
+ testSessionAverageLinearVelocity(sessionManager, 3.5762268523604077)
+ testSessionMaximumLinearVelocity(sessionManager, 4.317520084454841)
+ testSessionMinimumPower(sessionManager, 59.79167863880113)
+ testSessionAveragePower(sessionManager, 142.86733839369302)
+ testSessionMaximumPower(sessionManager, 225.37999917458814)
+ testSessionMinimumStrokedistance(sessionManager, 8.491708961574506)
+ testSessionAverageStrokedistance(sessionManager, 10.390549437704353)
+ testSessionMaximumStrokedistance(sessionManager, 11.32227861543267)
+ testSessionMinimumStrokerate(sessionManager, 17.11018345586753)
+ testSessionAverageStrokerate(sessionManager, 21.006289713585318)
+ testSessionMaximumStrokerate(sessionManager, 23.947414069812904)
+ testSessionMinimumDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+ testSessionAverageDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+ testSessionMaximumDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+ testSessionNumberOfIntervals(sessionManager, 1)
+
+ // Test interval metrics
+ testIntervalNumber(sessionManager, 0)
+ testIntervalWorkoutStepNumber(sessionManager, 0)
+ testIntervalType(sessionManager, 'time')
+ testIntervalTime(sessionManager, 46.302522627)
+ testIntervalMovingTimeTarget(sessionManager, 46)
+ testIntervalMovingTimeToEnd(sessionManager, -0.3025226270000019) // This is a testing artifact
+ testIntervalMovingTimeProjectedEnd(sessionManager, 46)
+ testIntervalTimeSpentTotal(sessionManager, 46.302522627)
+ testIntervalTimeSpentMoving(sessionManager, 46.302522627)
+ testIntervalTimeSpentRest(sessionManager, 0)
+ testIntervalDistance(sessionManager, 165.58832475070278)
+ testIntervalDistanceToEnd(sessionManager, undefined)
+ testIntervalDistanceTarget(sessionManager, undefined)
+ testIntervalDistanceProjectedEnd(sessionManager, 164.6490654983064)
+ testIntervalWorkSinceStart(sessionManager, 9748.548017262458)
+ testIntervalCaloriesSinceStart(sessionManager, 13.142874997261865)
+ testIntervalCaloriesTarget(sessionManager, undefined)
+ testIntervalCaloriesToEnd(sessionManager, undefined)
+ testIntervalNumberOfStrokes(sessionManager, 15)
+ testIntervalMinimumPace(sessionManager, 180.24261586109614)
+ testIntervalAveragePace(sessionManager, 139.81215975434733)
+ testIntervalMaximumPace(sessionManager, 115.80722039956261)
+ testIntervalMinimumLinearVelocity(sessionManager, 2.7740387455612865)
+ testIntervalAverageLinearVelocity(sessionManager, 3.5762268523604077)
+ testIntervalMaximumLinearVelocity(sessionManager, 4.317520084454841)
+ testIntervalMinimumPower(sessionManager, 59.79167863880113)
+ testIntervalAveragePower(sessionManager, 142.86733839369302)
+ testIntervalMaximumPower(sessionManager, 225.37999917458814)
+ testIntervalMinimumStrokedistance(sessionManager, 8.491708961574506)
+ testIntervalAverageStrokedistance(sessionManager, 10.390549437704353)
+ testIntervalMaximumStrokedistance(sessionManager, 11.32227861543267)
+ testIntervalMinimumStrokerate(sessionManager, 17.11018345586753)
+ testIntervalAverageStrokerate(sessionManager, 21.006289713585318)
+ testIntervalMaximumStrokerate(sessionManager, 23.947414069812904)
+ testIntervalMinimumDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+ testIntervalAverageDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+ testIntervalMaximumDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+
+ // Test split metrics
+ testSplitNumber(sessionManager, 0)
+ testSplitType(sessionManager, 'time')
+ testSplitTime(sessionManager, 46.302522627)
+ testSplitMovingTimeTarget(sessionManager, 46)
+ testSplitMovingTimeToEnd(sessionManager, -0.3025226270000019) // This is a testing artifact
+ testSplitMovingTimeProjectedEnd(sessionManager, 46)
+ testSplitTimeSpentTotal(sessionManager, 46.302522627)
+ testSplitTimeSpentMoving(sessionManager, 46.302522627)
+ testSplitTimeSpentRest(sessionManager, 0)
+ testSplitDistance(sessionManager, 165.58832475070278)
+ testSplitDistanceToEnd(sessionManager, undefined)
+ testSplitDistanceTarget(sessionManager, undefined)
+ testSplitDistanceProjectedEnd(sessionManager, 164.6490654983064)
+ testSplitWorkSinceStart(sessionManager, 9748.548017262458)
+ testSplitCaloriesSinceStart(sessionManager, 13.142874997261865)
+ testSplitCaloriesTarget(sessionManager, undefined)
+ testSplitCaloriesToEnd(sessionManager, undefined)
+ testSplitNumberOfStrokes(sessionManager, 15)
+ testSplitMinimumPace(sessionManager, 180.24261586109614)
+ testSplitAveragePace(sessionManager, 139.81215975434733)
+ testSplitMaximumPace(sessionManager, 115.80722039956261)
+ testSplitMinimumLinearVelocity(sessionManager, 2.7740387455612865)
+ testSplitAverageLinearVelocity(sessionManager, 3.5762268523604077)
+ testSplitMaximumLinearVelocity(sessionManager, 4.317520084454841)
+ testSplitMinimumPower(sessionManager, 59.79167863880113)
+ testSplitAveragePower(sessionManager, 142.86733839369302)
+ testSplitMaximumPower(sessionManager, 225.37999917458814)
+ testSplitMinimumStrokedistance(sessionManager, 8.491708961574506)
+ testSplitAverageStrokedistance(sessionManager, 10.390549437704353)
+ testSplitMaximumStrokedistance(sessionManager, 11.32227861543267)
+ testSplitMinimumStrokerate(sessionManager, 17.11018345586753)
+ testSplitAverageStrokerate(sessionManager, 21.006289713585318)
+ testSplitMaximumStrokerate(sessionManager, 23.947414069812904)
+ testSplitMinimumDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+ testSplitAverageDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+ testSplitMaximumDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+})
+
+/**
+ * @description Test behaviour for the SportsTech WRX700 in a single interval session with a Calorie target after a complex intervallan
+ */
+test('Sportstech_WRX700_04: sample data for Sportstech WRX700 should produce plausible results for a 13.14 calories session', async () => {
+ const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Sportstech_WRX700)
+ const testConfig = {
+ loglevel: {
+ default: 'silent',
+ RowingEngine: 'silent'
+ },
+ numOfPhasesForAveragingScreenData: 2,
+ userSettings: {
+ sex: 'male'
+ },
+ rowerSettings: rowerProfile
+ }
+ const sessionManager = createSessionManager(testConfig)
+
+ let intervalSettings = []
+ intervalSettings[0] = {
+ type: 'distance',
+ targetDistance: 10,
+ split: {
+ type: 'distance',
+ targetDistance: 5
+ }
+ }
+ intervalSettings[1] = {
+ type: 'distance',
+ targetDistance: 30,
+ split: {
+ type: 'distance',
+ targetDistance: 15
+ }
+ }
+ intervalSettings[2] = {
+ type: 'distance',
+ targetDistance: 50,
+ split: {
+ type: 'distance',
+ targetDistance: 25
+ }
+ }
+ intervalSettings[3] = {
+ type: 'distance',
+ targetDistance: 70,
+ split: {
+ type: 'distance',
+ targetDistance: 35
+ }
+ }
+ sessionManager.handleCommand('updateIntervalSettings', intervalSettings)
+
+ testSessionType(sessionManager, 'distance')
+ testSessionDistanceTarget(sessionManager, 160)
+ testIntervalType(sessionManager, 'distance')
+ testIntervalDistanceTarget(sessionManager, 10)
+ testSplitType(sessionManager, 'distance')
+ testSplitDistanceTarget(sessionManager, 5)
+
+ intervalSettings = []
+ intervalSettings[0] = {
+ type: 'calories',
+ targetCalories: 13.14
+ }
+ sessionManager.handleCommand('updateIntervalSettings', intervalSettings)
+
+ testSessionType(sessionManager, 'calories')
+ testSessionCaloriesTarget(sessionManager, 13.14)
+ testIntervalType(sessionManager, 'calories')
+ testIntervalCaloriesTarget(sessionManager, 13.14)
+ testSplitType(sessionManager, 'calories')
+ testSplitCaloriesTarget(sessionManager, 13.14)
+
+ testSessionState(sessionManager, 'WaitingForStart')
+ testTotalMovingTime(sessionManager, 0)
+ testTotalLinearDistance(sessionManager, 0)
+ testTotalCalories(sessionManager, 0)
+ testTotalNumberOfStrokes(sessionManager, 0)
+ testDragFactor(sessionManager, undefined)
+
+ await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/WRX700_2magnets.csv', realtime: false, loop: false })
+
+ // Test Instantanuous metrics
+ testIsMoving(sessionManager, true)
+ testIsDriveStart(sessionManager, false)
+ testIsRecoveryStart(sessionManager, false)
+ testIsSessionStart(sessionManager, false)
+ testIsIntervalEnd(sessionManager, false)
+ testIsSplitEnd(sessionManager, false)
+ testIsPauseStart(sessionManager, false)
+ testIsPauseEnd(sessionManager, false)
+ testIsSessionStop(sessionManager, false)
+ testTotalMovingTime(sessionManager, 46.302522627)
+ testTotalLinearDistance(sessionManager, 165.58832475070278)
+ testTotalWork(sessionManager, 9748.548017262458)
+ testTotalCalories(sessionManager, 13.142874997261865)
+ testTotalCaloriesPerMinute(sessionManager, 16.06317491735509)
+ testTotalCaloriesPerHour (sessionManager, 963.7904950413053)
+ testTotalNumberOfStrokes(sessionManager, 15)
+ testStrokeState(sessionManager, 'Recovery')
+ testStrokeWork(sessionManager, 763.0876311342454)
+ testStrokeCalories(sessionManager, 0.9544143736814243)
+ testCycleDistance(sessionManager, 10.968457408700388)
+ testCycleStrokeRate(sessionManager, 21.96216589358063)
+ testCycleDuration(sessionManager, 2.731970985500002)
+ testCycleLinearVelocity(sessionManager, 4.015620240977591)
+ testCyclePace(sessionManager, 124.51376624156981)
+ testCyclePower(sessionManager, 181.32184485208828)
+ testDriveLastStartTime (sessionManager, 42.953401899000006)
+ testDriveDuration(sessionManager, 1.297822779999997)
+ testDriveLength(sessionManager, 1.7592918860102864)
+ testDriveDistance(sessionManager, 5.661139307716341)
+ testDriveAverageHandleForce(sessionManager, 347.2288656270118)
+ testDrivePeakHandleForce(sessionManager, 634.0186435697934)
+ testRecoveryDuration(sessionManager, 1.332040928000005)
+ testDragFactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+
+ // Test session metrics
+ testSessionType(sessionManager, 'calories')
+ testSessionState(sessionManager, 'Stopped')
+ testSessionTime(sessionManager, 46.302522627)
+ testSessionMovingTimeTarget(sessionManager, undefined)
+ testSessionMovingTimeToEnd(sessionManager, undefined)
+ testSessionMovingTimeProjectedEnd(sessionManager, 46.26802265985762)
+ testSessionTimeSpentTotal(sessionManager, 46.302522627)
+ testSessionTimeSpentMoving(sessionManager, 46.302522627)
+ testSessionTimeSpentRest(sessionManager, 0)
+ testSessionDistance(sessionManager, 165.58832475070278)
+ testSessionDistanceToEnd(sessionManager, undefined)
+ testSessionDistanceTarget(sessionManager, undefined)
+ testSessionDistanceProjectedEnd(sessionManager, 165.48121073524186)
+ testSessionWorkSinceStart(sessionManager, 9748.548017262458)
+ testSessionCaloriesSinceStart(sessionManager, 13.142874997261865)
+ testSessionCaloriesTarget(sessionManager, 13.14)
+ testSessionCaloriesToEnd(sessionManager, -0.002874997261864465)
+ testSessionNumberOfStrokes(sessionManager, 15)
+ testSessionMinimumPace(sessionManager, 180.24261586109614)
+ testSessionAveragePace(sessionManager, 139.81215975434733)
+ testSessionMaximumPace(sessionManager, 115.80722039956261)
+ testSessionMinimumLinearVelocity(sessionManager, 2.7740387455612865)
+ testSessionAverageLinearVelocity(sessionManager, 3.5762268523604077)
+ testSessionMaximumLinearVelocity(sessionManager, 4.317520084454841)
+ testSessionMinimumPower(sessionManager, 59.79167863880113)
+ testSessionAveragePower(sessionManager, 142.86733839369302)
+ testSessionMaximumPower(sessionManager, 225.37999917458814)
+ testSessionMinimumStrokedistance(sessionManager, 8.491708961574506)
+ testSessionAverageStrokedistance(sessionManager, 10.390549437704353)
+ testSessionMaximumStrokedistance(sessionManager, 11.32227861543267)
+ testSessionMinimumStrokerate(sessionManager, 17.11018345586753)
+ testSessionAverageStrokerate(sessionManager, 21.006289713585318)
+ testSessionMaximumStrokerate(sessionManager, 23.947414069812904)
+ testSessionMinimumDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+ testSessionAverageDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+ testSessionMaximumDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+ testSessionNumberOfIntervals(sessionManager, 1)
+
+ // Test interval metrics
+ testIntervalNumber(sessionManager, 0)
+ testIntervalWorkoutStepNumber(sessionManager, 0)
+ testIntervalType(sessionManager, 'calories')
+ testIntervalTime(sessionManager, 46.302522627)
+ testIntervalMovingTimeTarget(sessionManager, undefined)
+ testIntervalMovingTimeToEnd(sessionManager, undefined)
+ testIntervalMovingTimeProjectedEnd(sessionManager, 46.26802265985762)
+ testIntervalTimeSpentTotal(sessionManager, 46.302522627)
+ testIntervalTimeSpentMoving(sessionManager, 46.302522627)
+ testIntervalTimeSpentRest(sessionManager, 0)
+ testIntervalDistance(sessionManager, 165.58832475070278)
+ testIntervalDistanceToEnd(sessionManager, undefined)
+ testIntervalDistanceTarget(sessionManager, undefined)
+ testIntervalDistanceProjectedEnd(sessionManager, 165.48121073524186)
+ testIntervalWorkSinceStart(sessionManager, 9748.548017262458)
+ testIntervalCaloriesSinceStart(sessionManager, 13.142874997261865)
+ testIntervalCaloriesTarget(sessionManager, 13.14)
+ testIntervalCaloriesToEnd(sessionManager, -0.002874997261864465)
+ testIntervalNumberOfStrokes(sessionManager, 15)
+ testIntervalMinimumPace(sessionManager, 180.24261586109614)
+ testIntervalAveragePace(sessionManager, 139.81215975434733)
+ testIntervalMaximumPace(sessionManager, 115.80722039956261)
+ testIntervalMinimumLinearVelocity(sessionManager, 2.7740387455612865)
+ testIntervalAverageLinearVelocity(sessionManager, 3.5762268523604077)
+ testIntervalMaximumLinearVelocity(sessionManager, 4.317520084454841)
+ testIntervalMinimumPower(sessionManager, 59.79167863880113)
+ testIntervalAveragePower(sessionManager, 142.86733839369302)
+ testIntervalMaximumPower(sessionManager, 225.37999917458814)
+ testIntervalMinimumStrokedistance(sessionManager, 8.491708961574506)
+ testIntervalAverageStrokedistance(sessionManager, 10.390549437704353)
+ testIntervalMaximumStrokedistance(sessionManager, 11.32227861543267)
+ testIntervalMinimumStrokerate(sessionManager, 17.11018345586753)
+ testIntervalAverageStrokerate(sessionManager, 21.006289713585318)
+ testIntervalMaximumStrokerate(sessionManager, 23.947414069812904)
+ testIntervalMinimumDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+ testIntervalAverageDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+ testIntervalMaximumDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+
+ // Test split metrics
+ testSplitNumber(sessionManager, 0)
+ testSplitType(sessionManager, 'calories')
+ testSplitTime(sessionManager, 46.302522627)
+ testSplitMovingTimeTarget(sessionManager, undefined)
+ testSplitMovingTimeToEnd(sessionManager, undefined)
+ testSplitMovingTimeProjectedEnd(sessionManager, 46.26802265985762)
+ testSplitTimeSpentTotal(sessionManager, 46.302522627)
+ testSplitTimeSpentMoving(sessionManager, 46.302522627)
+ testSplitTimeSpentRest(sessionManager, 0)
+ testSplitDistance(sessionManager, 165.58832475070278)
+ testSplitDistanceToEnd(sessionManager, undefined)
+ testSplitDistanceTarget(sessionManager, undefined)
+ testSplitDistanceProjectedEnd(sessionManager, 165.48121073524186)
+ testSplitWorkSinceStart(sessionManager, 9748.548017262458)
+ testSplitCaloriesSinceStart(sessionManager, 13.142874997261865)
+ testSplitCaloriesTarget(sessionManager, 13.14)
+ testSplitCaloriesToEnd(sessionManager, -0.002874997261864465)
+ testSplitNumberOfStrokes(sessionManager, 15)
+ testSplitMinimumPace(sessionManager, 180.24261586109614)
+ testSplitAveragePace(sessionManager, 139.81215975434733)
+ testSplitMaximumPace(sessionManager, 115.80722039956261)
+ testSplitMinimumLinearVelocity(sessionManager, 2.7740387455612865)
+ testSplitAverageLinearVelocity(sessionManager, 3.5762268523604077)
+ testSplitMaximumLinearVelocity(sessionManager, 4.317520084454841)
+ testSplitMinimumPower(sessionManager, 59.79167863880113)
+ testSplitAveragePower(sessionManager, 142.86733839369302)
+ testSplitMaximumPower(sessionManager, 225.37999917458814)
+ testSplitMinimumStrokedistance(sessionManager, 8.491708961574506)
+ testSplitAverageStrokedistance(sessionManager, 10.390549437704353)
+ testSplitMaximumStrokedistance(sessionManager, 11.32227861543267)
+ testSplitMinimumStrokerate(sessionManager, 17.11018345586753)
+ testSplitAverageStrokerate(sessionManager, 21.006289713585318)
+ testSplitMaximumStrokerate(sessionManager, 23.947414069812904)
+ testSplitMinimumDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+ testSplitAverageDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+ testSplitMaximumDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+})
+
+/**
+ * @description Test behaviour for the SportsTech WRX700 in a 'Just Row' session
+ */
+test('Sportstech_WRX700_05: 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,
+ userSettings: {
+ sex: 'male'
+ },
+ rowerSettings: rowerProfile
+ }
+ const sessionManager = createSessionManager(testConfig)
+
+ testSessionType(sessionManager, 'justrow')
+ testIntervalType(sessionManager, 'justrow')
+ testSplitType(sessionManager, 'justrow')
+
+ testSessionState(sessionManager, 'WaitingForStart')
+ testTotalMovingTime(sessionManager, 0)
+ testTotalLinearDistance(sessionManager, 0)
+ testTotalCalories(sessionManager, 0)
+ testTotalNumberOfStrokes(sessionManager, 0)
+ testDragFactor(sessionManager, undefined)
+
+ await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/WRX700_2magnets_session.csv', realtime: false, loop: false })
+
+ // Test Instantanuous metrics
+ testIsMoving(sessionManager, true) // There were no timeouts yet
+ testIsDriveStart(sessionManager, false)
+ testIsRecoveryStart(sessionManager, false)
+ testIsSessionStart(sessionManager, false)
+ testIsIntervalEnd(sessionManager, false)
+ testIsSplitEnd(sessionManager, false)
+ testIsPauseStart(sessionManager, false)
+ testIsPauseEnd(sessionManager, false)
+ testIsSessionStop(sessionManager, false)
+ testTotalMovingTime(sessionManager, 2340.0100514160117)
+ testTotalLinearDistance(sessionManager, 8406.084229545408)
+ testTotalWork(sessionManager, 487699.09367508633)
+ testTotalCalories(sessionManager, 659.476164927607)
+ testTotalCaloriesPerMinute(sessionManager, 17.199317176667705)
+ testTotalCaloriesPerHour (sessionManager, 1031.959030600064)
+ testTotalNumberOfStrokes(sessionManager, 845)
+ testStrokeState(sessionManager, 'Recovery')
+ testStrokeWork(sessionManager, 395.80950222397223)
+ testStrokeCalories(sessionManager, 0.5966066672311777)
+ testCycleDistance(sessionManager, 9.553172581771296)
+ testCycleStrokeRate(sessionManager, 22.763981039266426)
+ testCycleDuration(sessionManager, 2.635742838500164)
+ testCycleLinearVelocity(sessionManager, 3.624974543637083)
+ testCyclePace(sessionManager, 137.93200310265624)
+ testCyclePower(sessionManager, 134.02976389064236)
+ testDriveLastStartTime (sessionManager, 2336.8188294280117)
+ testDriveDuration(sessionManager, 1.3936550620001071)
+ testDriveLength(sessionManager, 1.5393804002590334)
+ testDriveDistance(sessionManager, 4.953496894251904)
+ testDriveAverageHandleForce(sessionManager, 210.6300325410566)
+ testDrivePeakHandleForce(sessionManager, 352.68327935116827)
+ testRecoveryDuration(sessionManager, 1.251141552000263)
+ testDragFactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+
+ // Test session metrics
+ testSessionType(sessionManager, 'justrow')
+ testSessionState(sessionManager, 'Rowing')
+ testSessionTime(sessionManager, 2340.0100514160117)
+ testSessionMovingTimeTarget(sessionManager, undefined)
+ testSessionMovingTimeToEnd(sessionManager, undefined)
+ testSessionMovingTimeProjectedEnd(sessionManager, undefined)
+ testSessionTimeSpentTotal(sessionManager, 2340.0100514160117)
+ testSessionTimeSpentMoving(sessionManager, 2340.0100514160117)
+ testSessionTimeSpentRest(sessionManager, 0)
+ testSessionDistance(sessionManager, 8406.084229545408)
+ testSessionDistanceToEnd(sessionManager, undefined)
+ testSessionDistanceTarget(sessionManager, undefined)
+ testSessionDistanceProjectedEnd(sessionManager, undefined)
+ testSessionWorkSinceStart(sessionManager, 487699.09367508633)
+ testSessionCaloriesSinceStart(sessionManager, 659.476164927607)
+ testSessionCaloriesTarget(sessionManager, undefined)
+ testSessionCaloriesToEnd(sessionManager, undefined)
+ testSessionNumberOfStrokes(sessionManager, 845)
+ testSessionMinimumPace(sessionManager, 241.8898638189788)
+ testSessionAveragePace(sessionManager, 139.1854987124342)
+ testSessionMaximumPace(sessionManager, 114.36541624043336)
+ testSessionMinimumLinearVelocity(sessionManager, 2.067056436784722)
+ testSessionAverageLinearVelocity(sessionManager, 3.5923282570767716)
+ testSessionMaximumLinearVelocity(sessionManager, 4.37195103586942)
+ testSessionMinimumPower(sessionManager, 24.73144671899844)
+ testSessionAveragePower(sessionManager, 137.31551165546605)
+ testSessionMaximumPower(sessionManager, 234.15919484264248)
+ testSessionMinimumStrokedistance(sessionManager, 8.137887754842472)
+ testSessionAverageStrokedistance(sessionManager, 9.936566278218478)
+ testSessionMaximumStrokedistance(sessionManager, 12.383742235629555)
+ testSessionMinimumStrokerate(sessionManager, 11.507551179373843)
+ testSessionAverageStrokerate(sessionManager, 21.978699599238304)
+ testSessionMaximumStrokerate(sessionManager, 28.507568204527697)
+ testSessionMinimumDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+ testSessionAverageDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+ testSessionMaximumDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+
+ // Test interval metrics
+ testIntervalNumber(sessionManager, 0)
+ testIntervalWorkoutStepNumber(sessionManager, 0)
+ testIntervalType(sessionManager, 'justrow')
+ testIntervalTime(sessionManager, 2340.0100514160117)
+ testIntervalMovingTimeTarget(sessionManager, undefined)
+ testIntervalMovingTimeToEnd(sessionManager, undefined)
+ testIntervalMovingTimeProjectedEnd(sessionManager, undefined)
+ testIntervalTimeSpentTotal(sessionManager, 2340.0100514160117)
+ testIntervalTimeSpentMoving(sessionManager, 2340.0100514160117)
+ testIntervalTimeSpentRest(sessionManager, 0)
+ testIntervalDistance(sessionManager, 8406.084229545408)
+ testIntervalDistanceToEnd(sessionManager, undefined)
+ testIntervalDistanceTarget(sessionManager, undefined)
+ testIntervalDistanceProjectedEnd(sessionManager, undefined)
+ testIntervalWorkSinceStart(sessionManager, 487699.09367508633)
+ testIntervalCaloriesSinceStart(sessionManager, 659.476164927607)
+ testIntervalCaloriesTarget(sessionManager, undefined)
+ testIntervalCaloriesToEnd(sessionManager, undefined)
+ testIntervalNumberOfStrokes(sessionManager, 845)
+ testIntervalMinimumPace(sessionManager, 241.8898638189788)
+ testIntervalAveragePace(sessionManager, 139.1854987124342)
+ testIntervalMaximumPace(sessionManager, 114.36541624043336)
+ testIntervalMinimumLinearVelocity(sessionManager, 2.067056436784722)
+ testIntervalAverageLinearVelocity(sessionManager, 3.5923282570767716)
+ testIntervalMaximumLinearVelocity(sessionManager, 4.37195103586942)
+ testIntervalMinimumPower(sessionManager, 24.73144671899844)
+ testIntervalAveragePower(sessionManager, 137.31551165546605)
+ testIntervalMaximumPower(sessionManager, 234.15919484264248)
+ testIntervalMinimumStrokedistance(sessionManager, 8.137887754842472)
+ testIntervalAverageStrokedistance(sessionManager, 9.936566278218478)
+ testIntervalMaximumStrokedistance(sessionManager, 12.383742235629555)
+ testIntervalMinimumStrokerate(sessionManager, 11.507551179373843)
+ testIntervalAverageStrokerate(sessionManager, 21.978699599238304)
+ testIntervalMaximumStrokerate(sessionManager, 28.507568204527697)
+ testIntervalMinimumDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+ testIntervalAverageDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+ testIntervalMaximumDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+
+ // Test split metrics
+ testSplitNumber(sessionManager, 0)
+ testSplitType(sessionManager, 'justrow')
+ testSplitTime(sessionManager, 2340.0100514160117)
+ testSplitMovingTimeTarget(sessionManager, undefined)
+ testSplitMovingTimeToEnd(sessionManager, undefined)
+ testSplitMovingTimeProjectedEnd(sessionManager, undefined)
+ testSplitTimeSpentTotal(sessionManager, 2340.0100514160117)
+ testSplitTimeSpentMoving(sessionManager, 2340.0100514160117)
+ testSplitTimeSpentRest(sessionManager, 0)
+ testSplitDistance(sessionManager, 8406.084229545408)
+ testSplitDistanceToEnd(sessionManager, undefined)
+ testSplitDistanceTarget(sessionManager, undefined)
+ testSplitDistanceProjectedEnd(sessionManager, undefined)
+ testSplitWorkSinceStart(sessionManager, 487699.09367508633)
+ testSplitCaloriesSinceStart(sessionManager, 659.476164927607)
+ testSplitCaloriesTarget(sessionManager, undefined)
+ testSplitCaloriesToEnd(sessionManager, undefined)
+ testSplitNumberOfStrokes(sessionManager, 845)
+ testSplitMinimumPace(sessionManager, 241.8898638189788)
+ testSplitAveragePace(sessionManager, 139.1854987124342)
+ testSplitMaximumPace(sessionManager, 114.36541624043336)
+ testSplitMinimumLinearVelocity(sessionManager, 2.067056436784722)
+ testSplitAverageLinearVelocity(sessionManager, 3.5923282570767716)
+ testSplitMaximumLinearVelocity(sessionManager, 4.37195103586942)
+ testSplitMinimumPower(sessionManager, 24.73144671899844)
+ testSplitAveragePower(sessionManager, 137.31551165546605)
+ testSplitMaximumPower(sessionManager, 234.15919484264248)
+ testSplitMinimumStrokedistance(sessionManager, 8.137887754842472)
+ testSplitAverageStrokedistance(sessionManager, 9.936566278218478)
+ testSplitMaximumStrokedistance(sessionManager, 12.383742235629555)
+ testSplitMinimumStrokerate(sessionManager, 11.507551179373843)
+ testSplitAverageStrokerate(sessionManager, 21.978699599238304)
+ testSplitMaximumStrokerate(sessionManager, 28.507568204527697)
+ testSplitMinimumDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+ testSplitAverageDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+ testSplitMaximumDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+})
+
+/**
+ * @description Test behaviour for the SportsTech WRX700 in a 'Just Row' session
+ */
+test('Sportstech_WRX700_06: a succesfull session with a reset 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,
+ userSettings: {
+ sex: 'male'
+ },
+ rowerSettings: rowerProfile
+ }
+ const sessionManager = createSessionManager(testConfig)
+
+ testSessionType(sessionManager, 'justrow')
+ testIntervalType(sessionManager, 'justrow')
+ testSplitType(sessionManager, 'justrow')
+
+ testSessionState(sessionManager, 'WaitingForStart')
+ testTotalMovingTime(sessionManager, 0)
+ testTotalLinearDistance(sessionManager, 0)
+ testTotalCalories(sessionManager, 0)
+ testTotalNumberOfStrokes(sessionManager, 0)
+ testDragFactor(sessionManager, undefined)
+
+ await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/WRX700_2magnets.csv', realtime: false, loop: false })
+
+ testSessionState(sessionManager, 'Rowing')
+ testTotalMovingTime(sessionManager, 46.302522627)
+ testTotalLinearDistance(sessionManager, 165.58832475070278)
+ testTotalCalories(sessionManager, 13.142874997261865)
+ testTotalNumberOfStrokes(sessionManager, 15)
+ testDragFactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+
+ sessionManager.handleCommand('reset')
+
+ testSessionType(sessionManager, 'justrow')
+ testIntervalType(sessionManager, 'justrow')
+ testSplitType(sessionManager, 'justrow')
+
+ testSessionState(sessionManager, 'WaitingForStart')
+ testTotalMovingTime(sessionManager, 0)
+ testTotalLinearDistance(sessionManager, 0)
+ testTotalCalories(sessionManager, 0)
+ testTotalNumberOfStrokes(sessionManager, 0)
+ testDragFactor(sessionManager, undefined)
+
+ await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/WRX700_2magnets_session.csv', realtime: false, loop: false })
+
+ // Test Instantanuous metrics
+ testIsMoving(sessionManager, true) // There were no timeouts yet
+ testIsDriveStart(sessionManager, false)
+ testIsRecoveryStart(sessionManager, false)
+ testIsSessionStart(sessionManager, false)
+ testIsIntervalEnd(sessionManager, false)
+ testIsSplitEnd(sessionManager, false)
+ testIsPauseStart(sessionManager, false)
+ testIsPauseEnd(sessionManager, false)
+ testIsSessionStop(sessionManager, false)
+ testTotalMovingTime(sessionManager, 2340.0100514160117)
+ testTotalLinearDistance(sessionManager, 8406.084229545408)
+ testTotalWork(sessionManager, 487699.09367508633)
+ testTotalCalories(sessionManager, 659.476164927607)
+ testTotalCaloriesPerMinute(sessionManager, 17.199317176667705)
+ testTotalCaloriesPerHour (sessionManager, 1031.959030600064)
+ testTotalNumberOfStrokes(sessionManager, 845)
+ testStrokeState(sessionManager, 'Recovery')
+ testStrokeWork(sessionManager, 395.80950222397223)
+ testStrokeCalories(sessionManager, 0.5966066672311777)
+ testCycleDistance(sessionManager, 9.553172581771296)
+ testCycleStrokeRate(sessionManager, 22.763981039266426)
+ testCycleDuration(sessionManager, 2.635742838500164)
+ testCycleLinearVelocity(sessionManager, 3.624974543637083)
+ testCyclePace(sessionManager, 137.93200310265624)
+ testCyclePower(sessionManager, 134.02976389064236)
+ testDriveLastStartTime (sessionManager, 2336.8188294280117)
+ testDriveDuration(sessionManager, 1.3936550620001071)
+ testDriveLength(sessionManager, 1.5393804002590334)
+ testDriveDistance(sessionManager, 4.953496894251904)
+ testDriveAverageHandleForce(sessionManager, 210.6300325410566)
+ testDrivePeakHandleForce(sessionManager, 352.68327935116827)
+ testRecoveryDuration(sessionManager, 1.251141552000263)
+ testDragFactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+
+ // Test session metrics
+ testSessionType(sessionManager, 'justrow')
+ testSessionState(sessionManager, 'Rowing')
+ testSessionTime(sessionManager, 2340.0100514160117)
+ testSessionMovingTimeTarget(sessionManager, undefined)
+ testSessionMovingTimeToEnd(sessionManager, undefined)
+ testSessionMovingTimeProjectedEnd(sessionManager, undefined)
+ testSessionTimeSpentTotal(sessionManager, 2340.0100514160117)
+ testSessionTimeSpentMoving(sessionManager, 2340.0100514160117)
+ testSessionTimeSpentRest(sessionManager, 0)
+ testSessionDistance(sessionManager, 8406.084229545408)
+ testSessionDistanceToEnd(sessionManager, undefined)
+ testSessionDistanceTarget(sessionManager, undefined)
+ testSessionDistanceProjectedEnd(sessionManager, undefined)
+ testSessionWorkSinceStart(sessionManager, 487699.09367508633)
+ testSessionCaloriesSinceStart(sessionManager, 659.476164927607)
+ testSessionCaloriesTarget(sessionManager, undefined)
+ testSessionCaloriesToEnd(sessionManager, undefined)
+ testSessionNumberOfStrokes(sessionManager, 845)
+ testSessionMinimumPace(sessionManager, 241.8898638189788)
+ testSessionAveragePace(sessionManager, 139.1854987124342)
+ testSessionMaximumPace(sessionManager, 114.36541624043336)
+ testSessionMinimumLinearVelocity(sessionManager, 2.067056436784722)
+ testSessionAverageLinearVelocity(sessionManager, 3.5923282570767716)
+ testSessionMaximumLinearVelocity(sessionManager, 4.37195103586942)
+ testSessionMinimumPower(sessionManager, 24.73144671899844)
+ testSessionAveragePower(sessionManager, 137.31551165546605)
+ testSessionMaximumPower(sessionManager, 234.15919484264248)
+ testSessionMinimumStrokedistance(sessionManager, 8.137887754842472)
+ testSessionAverageStrokedistance(sessionManager, 9.936566278218478)
+ testSessionMaximumStrokedistance(sessionManager, 12.383742235629555)
+ testSessionMinimumStrokerate(sessionManager, 11.507551179373843)
+ testSessionAverageStrokerate(sessionManager, 21.978699599238304)
+ testSessionMaximumStrokerate(sessionManager, 28.507568204527697)
+ testSessionMinimumDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+ testSessionAverageDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+ testSessionMaximumDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+
+ // Test interval metrics
+ testIntervalNumber(sessionManager, 0)
+ testIntervalWorkoutStepNumber(sessionManager, 0)
+ testIntervalType(sessionManager, 'justrow')
+ testIntervalTime(sessionManager, 2340.0100514160117)
+ testIntervalMovingTimeTarget(sessionManager, undefined)
+ testIntervalMovingTimeToEnd(sessionManager, undefined)
+ testIntervalMovingTimeProjectedEnd(sessionManager, undefined)
+ testIntervalTimeSpentTotal(sessionManager, 2340.0100514160117)
+ testIntervalTimeSpentMoving(sessionManager, 2340.0100514160117)
+ testIntervalTimeSpentRest(sessionManager, 0)
+ testIntervalDistance(sessionManager, 8406.084229545408)
+ testIntervalDistanceToEnd(sessionManager, undefined)
+ testIntervalDistanceTarget(sessionManager, undefined)
+ testIntervalDistanceProjectedEnd(sessionManager, undefined)
+ testIntervalWorkSinceStart(sessionManager, 487699.09367508633)
+ testIntervalCaloriesSinceStart(sessionManager, 659.476164927607)
+ testIntervalCaloriesTarget(sessionManager, undefined)
+ testIntervalCaloriesToEnd(sessionManager, undefined)
+ testIntervalNumberOfStrokes(sessionManager, 845)
+ testIntervalMinimumPace(sessionManager, 241.8898638189788)
+ testIntervalAveragePace(sessionManager, 139.1854987124342)
+ testIntervalMaximumPace(sessionManager, 114.36541624043336)
+ testIntervalMinimumLinearVelocity(sessionManager, 2.067056436784722)
+ testIntervalAverageLinearVelocity(sessionManager, 3.5923282570767716)
+ testIntervalMaximumLinearVelocity(sessionManager, 4.37195103586942)
+ testIntervalMinimumPower(sessionManager, 24.73144671899844)
+ testIntervalAveragePower(sessionManager, 137.31551165546605)
+ testIntervalMaximumPower(sessionManager, 234.15919484264248)
+ testIntervalMinimumStrokedistance(sessionManager, 8.137887754842472)
+ testIntervalAverageStrokedistance(sessionManager, 9.936566278218478)
+ testIntervalMaximumStrokedistance(sessionManager, 12.383742235629555)
+ testIntervalMinimumStrokerate(sessionManager, 11.507551179373843)
+ testIntervalAverageStrokerate(sessionManager, 21.978699599238304)
+ testIntervalMaximumStrokerate(sessionManager, 28.507568204527697)
+ testIntervalMinimumDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+ testIntervalAverageDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+ testIntervalMaximumDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+
+ // Test split metrics
+ testSplitNumber(sessionManager, 0)
+ testSplitType(sessionManager, 'justrow')
+ testSplitTime(sessionManager, 2340.0100514160117)
+ testSplitMovingTimeTarget(sessionManager, undefined)
+ testSplitMovingTimeToEnd(sessionManager, undefined)
+ testSplitMovingTimeProjectedEnd(sessionManager, undefined)
+ testSplitTimeSpentTotal(sessionManager, 2340.0100514160117)
+ testSplitTimeSpentMoving(sessionManager, 2340.0100514160117)
+ testSplitTimeSpentRest(sessionManager, 0)
+ testSplitDistance(sessionManager, 8406.084229545408)
+ testSplitDistanceToEnd(sessionManager, undefined)
+ testSplitDistanceTarget(sessionManager, undefined)
+ testSplitDistanceProjectedEnd(sessionManager, undefined)
+ testSplitWorkSinceStart(sessionManager, 487699.09367508633)
+ testSplitCaloriesSinceStart(sessionManager, 659.476164927607)
+ testSplitCaloriesTarget(sessionManager, undefined)
+ testSplitCaloriesToEnd(sessionManager, undefined)
+ testSplitNumberOfStrokes(sessionManager, 845)
+ testSplitMinimumPace(sessionManager, 241.8898638189788)
+ testSplitAveragePace(sessionManager, 139.1854987124342)
+ testSplitMaximumPace(sessionManager, 114.36541624043336)
+ testSplitMinimumLinearVelocity(sessionManager, 2.067056436784722)
+ testSplitAverageLinearVelocity(sessionManager, 3.5923282570767716)
+ testSplitMaximumLinearVelocity(sessionManager, 4.37195103586942)
+ testSplitMinimumPower(sessionManager, 24.73144671899844)
+ testSplitAveragePower(sessionManager, 137.31551165546605)
+ testSplitMaximumPower(sessionManager, 234.15919484264248)
+ testSplitMinimumStrokedistance(sessionManager, 8.137887754842472)
+ testSplitAverageStrokedistance(sessionManager, 9.936566278218478)
+ testSplitMaximumStrokedistance(sessionManager, 12.383742235629555)
+ testSplitMinimumStrokerate(sessionManager, 11.507551179373843)
+ testSplitAverageStrokerate(sessionManager, 21.978699599238304)
+ testSplitMaximumStrokerate(sessionManager, 28.507568204527697)
+ testSplitMinimumDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+ testSplitAverageDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+ testSplitMaximumDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+})
+
+/**
+ * @description Test behaviour for the SportsTech WRX700 in a single interval session with a Distance target
+ */
+test('Sportstech_WRX700_07: 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,
+ userSettings: {
+ sex: 'male'
+ },
+ rowerSettings: rowerProfile
+ }
+ const sessionManager = createSessionManager(testConfig)
+
+ testSessionType(sessionManager, 'justrow')
+ testIntervalType(sessionManager, 'justrow')
+ testSplitType(sessionManager, 'justrow')
+
+ testSessionState(sessionManager, 'WaitingForStart')
+ testTotalMovingTime(sessionManager, 0)
+ testTotalLinearDistance(sessionManager, 0)
+ testTotalCalories(sessionManager, 0)
+ testTotalNumberOfStrokes(sessionManager, 0)
+ testDragFactor(sessionManager, undefined)
+
+ await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/WRX700_2magnets.csv', realtime: false, loop: false })
+
+ testSessionState(sessionManager, 'Rowing')
+ testTotalMovingTime(sessionManager, 46.302522627)
+ testTotalLinearDistance(sessionManager, 165.58832475070278)
+ testTotalCalories(sessionManager, 13.142874997261865)
+ testTotalNumberOfStrokes(sessionManager, 15)
+ testDragFactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+
+ sessionManager.handleCommand('reset')
+
+ testSessionType(sessionManager, 'justrow')
+ testIntervalType(sessionManager, 'justrow')
+ testSplitType(sessionManager, 'justrow')
+
+ testSessionState(sessionManager, 'WaitingForStart')
+ testTotalMovingTime(sessionManager, 0)
+ testTotalLinearDistance(sessionManager, 0)
+ testTotalCalories(sessionManager, 0)
+ testTotalNumberOfStrokes(sessionManager, 0)
+ testDragFactor(sessionManager, undefined)
+
+ const intervalSettings = []
+ intervalSettings[0] = {
+ type: 'distance',
+ targetDistance: 4000,
+ split: {
+ type: 'distance',
+ targetDistance: 500
+ }
+ }
+ intervalSettings[1] = {
+ type: 'distance',
+ targetDistance: 4000,
+ split: {
+ type: 'distance',
+ targetDistance: 500
+ }
+ }
+ sessionManager.handleCommand('updateIntervalSettings', intervalSettings)
+
+ testSessionType(sessionManager, 'distance')
+ testSessionDistanceTarget(sessionManager, 8000)
+ testIntervalType(sessionManager, 'distance')
+ testIntervalDistanceTarget(sessionManager, 4000)
+ testSplitType(sessionManager, 'distance')
+ testSplitDistanceTarget(sessionManager, 500)
+
+ testSessionState(sessionManager, 'WaitingForStart')
+ testTotalMovingTime(sessionManager, 0)
+ testTotalLinearDistance(sessionManager, 0)
+ testTotalCalories(sessionManager, 0)
+ testTotalNumberOfStrokes(sessionManager, 0)
+ testDragFactor(sessionManager, undefined)
+
+ await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/WRX700_2magnets_session.csv', realtime: false, loop: false })
+
+ // Test Instantanuous metrics
+ testIsMoving(sessionManager, false)
+ testIsDriveStart(sessionManager, false)
+ testIsRecoveryStart(sessionManager, false)
+ testIsSessionStart(sessionManager, false)
+ testIsIntervalEnd(sessionManager, false)
+ testIsSplitEnd(sessionManager, false)
+ testIsPauseStart(sessionManager, false)
+ testIsPauseEnd(sessionManager, false)
+ testIsSessionStop(sessionManager, false)
+ testTotalMovingTime(sessionManager, 2236.631120457007)
+ testTotalLinearDistance(sessionManager, 8000.605126630226)
+ testTotalWork(sessionManager, 461136.62533351587)
+ testTotalCalories(sessionManager, 625.5636651176229)
+ testTotalCaloriesPerMinute(sessionManager, 17.214588573735455)
+ testTotalCaloriesPerHour (sessionManager, 1032.8753144241257)
+ testTotalNumberOfStrokes(sessionManager, 804)
+ testStrokeState(sessionManager, 'Stopped')
+ testStrokeWork(sessionManager, undefined)
+ testStrokeCalories(sessionManager, undefined)
+ testCycleDistance(sessionManager, undefined)
+ testCycleStrokeRate(sessionManager, undefined)
+ testCycleDuration(sessionManager, undefined)
+ testCycleLinearVelocity(sessionManager, undefined)
+ testCyclePace(sessionManager, Infinity)
+ testCyclePower(sessionManager, undefined)
+ testDriveLastStartTime (sessionManager, 2235.5285743140066)
+ testDriveDuration(sessionManager, undefined)
+ testDriveLength(sessionManager, undefined)
+ testDriveDistance(sessionManager, undefined)
+ testDriveAverageHandleForce(sessionManager, undefined)
+ testDrivePeakHandleForce(sessionManager, undefined)
+ testRecoveryDuration(sessionManager, undefined)
+ testDragFactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+
+ // Test session metrics
+ testSessionType(sessionManager, 'distance')
+ testSessionState(sessionManager, 'Stopped')
+ testSessionTime(sessionManager, 2236.631120457007)
+ testSessionMovingTimeTarget(sessionManager, undefined)
+ testSessionMovingTimeToEnd(sessionManager, undefined)
+ testSessionMovingTimeProjectedEnd(sessionManager, 2236.4425562856004)
+ testSessionTimeSpentTotal(sessionManager, 2236.631120457007)
+ testSessionTimeSpentMoving(sessionManager, 2236.631120457007)
+ testSessionTimeSpentRest(sessionManager, 0)
+ testSessionDistance(sessionManager, 8000.605126630226)
+ testSessionDistanceToEnd(sessionManager, -0.6051266302256408) // This is a testing artifact
+ testSessionDistanceTarget(sessionManager, 8000)
+ testSessionDistanceProjectedEnd(sessionManager, 8000)
+ testSessionWorkSinceStart(sessionManager, 461136.62533351587)
+ testSessionCaloriesSinceStart(sessionManager, 625.5636651176229)
+ testSessionCaloriesTarget(sessionManager, undefined)
+ testSessionCaloriesToEnd(sessionManager, undefined)
+ testSessionNumberOfStrokes(sessionManager, 804)
+ testSessionMinimumPace(sessionManager, 241.8898638189788)
+ testSessionAveragePace(sessionManager, 139.77887203883623)
+ testSessionMaximumPace(sessionManager, 114.36541624043336)
+ testSessionMinimumLinearVelocity(sessionManager, 2.067056436784722)
+ testSessionAverageLinearVelocity(sessionManager, 3.577078514849367)
+ testSessionMaximumLinearVelocity(sessionManager, 4.37195103586942)
+ testSessionMinimumPower(sessionManager, 24.73144671899844)
+ testSessionAveragePower(sessionManager, 135.5459464551185)
+ testSessionMaximumPower(sessionManager, 234.15919484264248)
+ testSessionMinimumStrokedistance(sessionManager, 8.137887754842472)
+ testSessionAverageStrokedistance(sessionManager, 9.947115570201449)
+ testSessionMaximumStrokedistance(sessionManager, 12.383742235629555)
+ testSessionMinimumStrokerate(sessionManager, 11.507551179373843)
+ testSessionAverageStrokerate(sessionManager, 21.858844027291234)
+ testSessionMaximumStrokerate(sessionManager, 28.507568204527697)
+ testSessionMinimumDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+ testSessionAverageDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+ testSessionMaximumDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+
+ // Test interval metrics
+ testIntervalNumber(sessionManager, 1)
+ testIntervalWorkoutStepNumber(sessionManager, 1)
+ testIntervalType(sessionManager, 'distance')
+ testIntervalTime(sessionManager, 1109.77121002217) // Should be roughly 50% of 2236.631120457007
+ testIntervalMovingTimeTarget(sessionManager, undefined)
+ testIntervalMovingTimeToEnd(sessionManager, undefined)
+ testIntervalMovingTimeProjectedEnd(sessionManager, 1109.5826458507634)
+ testIntervalTimeSpentTotal(sessionManager, 1109.77121002217)
+ testIntervalTimeSpentMoving(sessionManager, 1109.77121002217)
+ testIntervalTimeSpentRest(sessionManager, 0)
+ testIntervalDistance(sessionManager, 4000.6051266302256) // Should be 4000
+ testIntervalDistanceToEnd(sessionManager, -0.6051266302256408) // This is a testing artifact
+ testIntervalDistanceTarget(sessionManager, 4000)
+ testIntervalDistanceProjectedEnd(sessionManager, 4000)
+ testIntervalWorkSinceStart(sessionManager, 230548.01178845193) // Should be roughly 50% of 461136.62533351587
+ testIntervalCaloriesSinceStart(sessionManager, 312.05046920513513) // Should be roughly 50% of 625.5636651176229
+ testIntervalCaloriesTarget(sessionManager, undefined)
+ testIntervalCaloriesToEnd(sessionManager, undefined)
+ testIntervalNumberOfStrokes(sessionManager, 403)
+ testIntervalMinimumPace(sessionManager, 241.8898638189788)
+ testIntervalAveragePace(sessionManager, 138.70041842356835)
+ testIntervalMaximumPace(sessionManager, 114.36541624043336)
+ testIntervalMinimumLinearVelocity(sessionManager, 2.067056436784722)
+ testIntervalAverageLinearVelocity(sessionManager, 3.6048917925617348)
+ testIntervalMaximumLinearVelocity(sessionManager, 4.37195103586942)
+ testIntervalMinimumPower(sessionManager, 24.73144671899844)
+ testIntervalAveragePower(sessionManager, 137.1389340161279)
+ testIntervalMaximumPower(sessionManager, 234.15919484264248)
+ testIntervalMinimumStrokedistance(sessionManager, 8.137887754842472)
+ testIntervalAverageStrokedistance(sessionManager, 9.940356582190555)
+ testIntervalMaximumStrokedistance(sessionManager, 12.029921028897247)
+ testIntervalMinimumStrokerate(sessionManager, 11.992139312176349)
+ testIntervalAverageStrokerate(sessionManager, 21.942774295166227)
+ testIntervalMaximumStrokerate(sessionManager, 28.507568204527697)
+ testIntervalMinimumDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+ testIntervalAverageDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+ testIntervalMaximumDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+
+ // Test split metrics
+ testSplitNumber(sessionManager, 15)
+ testSplitType(sessionManager, 'distance')
+ testSplitTime(sessionManager, 142.36663269382916)
+ testSplitMovingTimeTarget(sessionManager, undefined)
+ testSplitMovingTimeToEnd(sessionManager, undefined)
+ testSplitMovingTimeProjectedEnd(sessionManager, 142.17806852242256)
+ testSplitTimeSpentTotal(sessionManager, 142.36663269382916)
+ testSplitTimeSpentMoving(sessionManager, 142.36663269382916)
+ testSplitTimeSpentRest(sessionManager, 0)
+ testSplitDistance(sessionManager, 500.60512663022564)
+ testSplitDistanceToEnd(sessionManager, -0.6051266302256408)
+ testSplitDistanceTarget(sessionManager, 500)
+ testSplitDistanceProjectedEnd(sessionManager, 500)
+ testSplitWorkSinceStart(sessionManager, 27314.17745368625)
+ testSplitCaloriesSinceStart(sessionManager, 37.824668332509304)
+ testSplitCaloriesTarget(sessionManager, undefined)
+ testSplitCaloriesToEnd(sessionManager, undefined)
+ testSplitNumberOfStrokes(sessionManager, 50)
+ testSplitMinimumPace(sessionManager, 150.38315673744688)
+ testSplitAveragePace(sessionManager, 142.19454128661866)
+ testSplitMaximumPace(sessionManager, 121.87524493832613)
+ testSplitMinimumLinearVelocity(sessionManager, 3.324840433247104)
+ testSplitAverageLinearVelocity(sessionManager, 3.5163093848459352)
+ testSplitMaximumLinearVelocity(sessionManager, 4.102555857450957)
+ testSplitMinimumPower(sessionManager, 102.92907911021074)
+ testSplitAveragePower(sessionManager, 123.62454928153953)
+ testSplitMaximumPower(sessionManager, 193.544929845238)
+ testSplitMinimumStrokedistance(sessionManager, 9.199351375038782)
+ testSplitAverageStrokedistance(sessionManager, 9.984063754326442)
+ testSplitMaximumStrokedistance(sessionManager, 10.614636201968015)
+ testSplitMinimumStrokerate(sessionManager, 18.829658892542405)
+ testSplitAverageStrokerate(sessionManager, 21.17988526584888)
+ testSplitMaximumStrokerate(sessionManager, 26.497167887310965)
+ testSplitMinimumDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+ testSplitAverageDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+ testSplitMaximumDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+})
+
+/**
+ * @description Test behaviour for the SportsTech WRX700 in a single interval session with a Time target
+ * Additional check is the behaviour with providing a second workoutplan, overwriting the second one
+ */
+test('Sportstech_WRX700_08: 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,
+ userSettings: {
+ sex: 'male'
+ },
+ rowerSettings: rowerProfile
+ }
+ const sessionManager = createSessionManager(testConfig)
+
+ const intervalSettings = []
+ intervalSettings[0] = {
+ type: 'distance',
+ targetDistance: 8000,
+ split: {
+ type: 'distance',
+ targetDistance: 500
+ }
+ }
+ sessionManager.handleCommand('updateIntervalSettings', intervalSettings)
+
+ testSessionType(sessionManager, 'distance')
+ testSessionDistanceTarget(sessionManager, 8000)
+ testIntervalType(sessionManager, 'distance')
+ testIntervalDistanceTarget(sessionManager, 8000)
+ testSplitType(sessionManager, 'distance')
+ testSplitDistanceTarget(sessionManager, 500)
+
+ intervalSettings[0] = {
+ type: 'time',
+ targetDistance: 0,
+ targetTime: 2300,
+ split: {
+ type: 'distance',
+ targetDistance: 500
+ }
+ }
+ sessionManager.handleCommand('updateIntervalSettings', intervalSettings)
+
+ testSessionType(sessionManager, 'time')
+ testSessionMovingTimeTarget(sessionManager, 2300)
+ testIntervalType(sessionManager, 'time')
+ testIntervalMovingTimeTarget(sessionManager, 2300)
+ testSplitType(sessionManager, 'distance')
+ testSplitDistanceTarget(sessionManager, 500)
+
+ testSessionState(sessionManager, 'WaitingForStart')
+ testTotalMovingTime(sessionManager, 0)
+ testTotalLinearDistance(sessionManager, 0)
+ testTotalCalories(sessionManager, 0)
+ testTotalNumberOfStrokes(sessionManager, 0)
+ testDragFactor(sessionManager, undefined)
+
+ await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/WRX700_2magnets_session.csv', realtime: false, loop: false })
+
+ // Test Instantanuous metrics
+ testIsMoving(sessionManager, false)
+ testIsDriveStart(sessionManager, false)
+ testIsRecoveryStart(sessionManager, false)
+ testIsSessionStart(sessionManager, false)
+ testIsIntervalEnd(sessionManager, false)
+ testIsSplitEnd(sessionManager, false)
+ testIsPauseStart(sessionManager, false)
+ testIsPauseEnd(sessionManager, false)
+ testIsSessionStop(sessionManager, false)
+ testTotalMovingTime(sessionManager, 2300.00695516701)
+ testTotalLinearDistance(sessionManager, 8251.818183410143)
+ testTotalWork(sessionManager, 477910.9434562283)
+ testTotalCalories(sessionManager, 646.8205257460397)
+ testTotalCaloriesPerMinute(sessionManager, 17.19999562179794)
+ testTotalCaloriesPerHour (sessionManager, 1031.9997373078784)
+ testTotalNumberOfStrokes(sessionManager, 830)
+ testStrokeState(sessionManager, 'Stopped')
+ testStrokeWork(sessionManager, undefined)
+ testStrokeCalories(sessionManager, undefined)
+ testCycleDistance(sessionManager, undefined)
+ testCycleStrokeRate(sessionManager, undefined)
+ testCycleDuration(sessionManager, undefined)
+ testCycleLinearVelocity(sessionManager, undefined)
+ testCyclePace(sessionManager, Infinity)
+ testCyclePower(sessionManager, undefined)
+ testDriveLastStartTime (sessionManager, 2299.40197893501)
+ testDriveDuration(sessionManager, undefined)
+ testDriveLength(sessionManager, undefined)
+ testDriveDistance(sessionManager, undefined)
+ testDriveAverageHandleForce(sessionManager, undefined)
+ testDrivePeakHandleForce(sessionManager, undefined)
+ testRecoveryDuration(sessionManager, undefined)
+ testDragFactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+
+ // Test session metrics
+ testSessionType(sessionManager, 'time')
+ testSessionState(sessionManager, 'Stopped')
+ testSessionTime(sessionManager, 2300.00695516701)
+ testSessionMovingTimeTarget(sessionManager, 2300)
+ testSessionMovingTimeToEnd(sessionManager, -0.006955167009891738) // This is a testing artifact
+ testSessionMovingTimeProjectedEnd(sessionManager, 2300)
+ testSessionTimeSpentTotal(sessionManager, 2300.00695516701)
+ testSessionTimeSpentMoving(sessionManager, 2300.00695516701)
+ testSessionTimeSpentRest(sessionManager, 0)
+ testSessionDistance(sessionManager, 8251.818183410143)
+ testSessionDistanceToEnd(sessionManager, undefined)
+ testSessionDistanceTarget(sessionManager, undefined)
+ testSessionDistanceProjectedEnd(sessionManager, 8251.801912458166)
+ testSessionWorkSinceStart(sessionManager, 477910.9434562283)
+ testSessionCaloriesSinceStart(sessionManager, 646.8205257460397)
+ testSessionCaloriesTarget(sessionManager, undefined)
+ testSessionCaloriesToEnd(sessionManager, undefined)
+ testSessionNumberOfStrokes(sessionManager, 830)
+ testSessionMinimumPace(sessionManager, 241.8898638189788)
+ testSessionAveragePace(sessionManager, 139.3636471408844)
+ testSessionMaximumPace(sessionManager, 114.36541624043336)
+ testSessionMinimumLinearVelocity(sessionManager, 2.067056436784722)
+ testSessionAverageLinearVelocity(sessionManager, 3.587736187002511)
+ testSessionMaximumLinearVelocity(sessionManager, 4.37195103586942)
+ testSessionMinimumPower(sessionManager, 24.73144671899844)
+ testSessionAveragePower(sessionManager, 136.80207530368608)
+ testSessionMaximumPower(sessionManager, 234.15919484264248)
+ testSessionMinimumStrokedistance(sessionManager, 8.137887754842472)
+ testSessionAverageStrokedistance(sessionManager, 9.940731718897963)
+ testSessionMaximumStrokedistance(sessionManager, 12.383742235629555)
+ testSessionMinimumStrokerate(sessionManager, 11.507551179373843)
+ testSessionAverageStrokerate(sessionManager, 21.940539134796552)
+ testSessionMaximumStrokerate(sessionManager, 28.507568204527697)
+ testSessionMinimumDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+ testSessionAverageDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+ testSessionMaximumDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+
+ // Test interval metrics
+ testIntervalNumber(sessionManager, 0)
+ testIntervalWorkoutStepNumber(sessionManager, 0)
+ testIntervalType(sessionManager, 'time')
+ testIntervalTime(sessionManager, 2300.00695516701)
+ testIntervalMovingTimeTarget(sessionManager, 2300)
+ testIntervalMovingTimeToEnd(sessionManager, -0.006955167009891738) // This is a testing artifact
+ testIntervalMovingTimeProjectedEnd(sessionManager, 2300)
+ testIntervalTimeSpentTotal(sessionManager, 2300.00695516701)
+ testIntervalTimeSpentMoving(sessionManager, 2300.00695516701)
+ testIntervalTimeSpentRest(sessionManager, 0)
+ testIntervalDistance(sessionManager, 8251.818183410143)
+ testIntervalDistanceToEnd(sessionManager, undefined)
+ testIntervalDistanceTarget(sessionManager, undefined)
+ testIntervalDistanceProjectedEnd(sessionManager, 8251.801912458166)
+ testIntervalWorkSinceStart(sessionManager, 477910.9434562283)
+ testIntervalCaloriesSinceStart(sessionManager, 646.8205257460397)
+ testIntervalCaloriesTarget(sessionManager, undefined)
+ testIntervalCaloriesToEnd(sessionManager, undefined)
+ testIntervalNumberOfStrokes(sessionManager, 830)
+ testIntervalMinimumPace(sessionManager, 241.8898638189788)
+ testIntervalAveragePace(sessionManager, 139.3636471408844)
+ testIntervalMaximumPace(sessionManager, 114.36541624043336)
+ testIntervalMinimumLinearVelocity(sessionManager, 2.067056436784722)
+ testIntervalAverageLinearVelocity(sessionManager, 3.587736187002511)
+ testIntervalMaximumLinearVelocity(sessionManager, 4.37195103586942)
+ testIntervalMinimumPower(sessionManager, 24.73144671899844)
+ testIntervalAveragePower(sessionManager, 136.80207530368608)
+ testIntervalMaximumPower(sessionManager, 234.15919484264248)
+ testIntervalMinimumStrokedistance(sessionManager, 8.137887754842472)
+ testIntervalAverageStrokedistance(sessionManager, 9.940731718897963)
+ testIntervalMaximumStrokedistance(sessionManager, 12.383742235629555)
+ testIntervalMinimumStrokerate(sessionManager, 11.507551179373843)
+ testIntervalAverageStrokerate(sessionManager, 21.940539134796552)
+ testIntervalMaximumStrokerate(sessionManager, 28.507568204527697)
+ testIntervalMinimumDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+ testIntervalAverageDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+ testIntervalMaximumDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+
+ // Test split metrics
+ testSplitNumber(sessionManager, 16)
+ testSplitType(sessionManager, 'distance')
+ testSplitTime(sessionManager, 63.47999194274462)
+ testSplitMovingTimeTarget(sessionManager, undefined)
+ testSplitMovingTimeToEnd(sessionManager, undefined)
+ testSplitMovingTimeProjectedEnd(sessionManager, 169.56754143965827)
+ testSplitTimeSpentTotal(sessionManager, 63.47999194274462)
+ testSplitTimeSpentMoving(sessionManager, 63.47999194274462)
+ testSplitTimeSpentRest(sessionManager, 0)
+ testSplitDistance(sessionManager, 251.81818341014332)
+ testSplitDistanceToEnd(sessionManager, 248.18181658985668)
+ testSplitDistanceTarget(sessionManager, 500)
+ testSplitDistanceProjectedEnd(sessionManager, 500)
+ testSplitWorkSinceStart(sessionManager, 16915.97308304807)
+ testSplitCaloriesSinceStart(sessionManager, 21.380905617753342)
+ testSplitCaloriesTarget(sessionManager, undefined)
+ testSplitCaloriesToEnd(sessionManager, undefined)
+ testSplitNumberOfStrokes(sessionManager, 26)
+ testSplitMinimumPace(sessionManager, 132.3175856307246)
+ testSplitAveragePace(sessionManager, 126.04330450464926)
+ testSplitMaximumPace(sessionManager, 121.4244967719201)
+ testSplitMinimumLinearVelocity(sessionManager, 3.7787872081902485)
+ testSplitAverageLinearVelocity(sessionManager, 3.9668906013294576)
+ testSplitMaximumLinearVelocity(sessionManager, 4.117785235208213)
+ testSplitMinimumPower(sessionManager, 151.08898444599478)
+ testSplitAveragePower(sessionManager, 175.69706315753768)
+ testSplitMaximumPower(sessionManager, 195.68893992339216)
+ testSplitMinimumStrokedistance(sessionManager, 9.199351375038782)
+ testSplitAverageStrokedistance(sessionManager, 9.746772864700318)
+ testSplitMaximumStrokedistance(sessionManager, 9.906993788503808)
+ testSplitMinimumStrokerate(sessionManager, 23.174387764436602)
+ testSplitAverageStrokerate(sessionManager, 24.46011598727192)
+ testSplitMaximumStrokerate(sessionManager, 26.84163443333606)
+ testSplitMinimumDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+ testSplitAverageDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+ testSplitMaximumDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+})
+
+/**
+ * @description Test behaviour for the SportsTech WRX700 in a single interval session with a Time target, which will not be reached (test of stopping behaviour)
+ */
+test('Sportstech_WRX700_09: A 2400 sec session with premature stop 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,
+ userSettings: {
+ sex: 'male'
+ },
+ rowerSettings: rowerProfile
+ }
+ const sessionManager = createSessionManager(testConfig)
+
+ const intervalSettings = []
+ intervalSettings[0] = {
+ type: 'time',
+ targetDistance: 0,
+ targetTime: 2400,
+ split: {
+ type: 'distance',
+ targetDistance: 500
+ }
+ }
+ sessionManager.handleCommand('updateIntervalSettings', intervalSettings)
+
+ testSessionType(sessionManager, 'time')
+ testSessionMovingTimeTarget(sessionManager, 2400)
+ testIntervalType(sessionManager, 'time')
+ testIntervalMovingTimeTarget(sessionManager, 2400)
+ testSplitType(sessionManager, 'distance')
+ testSplitDistanceTarget(sessionManager, 500)
+
+ testSessionState(sessionManager, 'WaitingForStart')
+ testTotalMovingTime(sessionManager, 0)
+ testTotalLinearDistance(sessionManager, 0)
+ testTotalCalories(sessionManager, 0)
+ testTotalNumberOfStrokes(sessionManager, 0)
+ testDragFactor(sessionManager, undefined)
+
+ await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/WRX700_2magnets_session.csv', realtime: false, loop: false })
+
+ // Test Instantanuous metrics
+ testIsMoving(sessionManager, true) // There were no timeouts yet
+ testIsDriveStart(sessionManager, false)
+ testIsRecoveryStart(sessionManager, false)
+ testIsSessionStart(sessionManager, false)
+ testIsIntervalEnd(sessionManager, false)
+ testIsSplitEnd(sessionManager, false)
+ testIsPauseStart(sessionManager, false)
+ testIsPauseEnd(sessionManager, false)
+ testIsSessionStop(sessionManager, false)
+ testTotalMovingTime(sessionManager, 2340.0100514160117)
+ testTotalLinearDistance(sessionManager, 8406.084229545408)
+ testTotalWork(sessionManager, 487699.09367508633)
+ testTotalCalories(sessionManager, 659.476164927607)
+ testTotalCaloriesPerMinute(sessionManager, 17.199317176667705)
+ testTotalCaloriesPerHour (sessionManager, 1031.959030600064)
+ testTotalNumberOfStrokes(sessionManager, 845)
+ testStrokeState(sessionManager, 'Recovery')
+ testStrokeWork(sessionManager, 395.80950222397223)
+ testStrokeCalories(sessionManager, 0.5966066672311777)
+ testCycleDistance(sessionManager, 9.553172581771296)
+ testCycleStrokeRate(sessionManager, 22.763981039266426)
+ testCycleDuration(sessionManager, 2.635742838500164)
+ testCycleLinearVelocity(sessionManager, 3.624974543637083)
+ testCyclePace(sessionManager, 137.93200310265624)
+ testCyclePower(sessionManager, 134.02976389064236)
+ testDriveLastStartTime (sessionManager, 2336.8188294280117)
+ testDriveDuration(sessionManager, 1.3936550620001071)
+ testDriveLength(sessionManager, 1.5393804002590334)
+ testDriveDistance(sessionManager, 4.953496894251904)
+ testDriveAverageHandleForce(sessionManager, 210.6300325410566)
+ testDrivePeakHandleForce(sessionManager, 352.68327935116827)
+ testRecoveryDuration(sessionManager, 1.251141552000263)
+ testDragFactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+
+ // Test session metrics
+ testSessionType(sessionManager, 'time')
+ testSessionState(sessionManager, 'Rowing')
+ testSessionTime(sessionManager, 2340.0100514160117)
+ testSessionMovingTimeTarget(sessionManager, 2400)
+ testSessionMovingTimeToEnd(sessionManager, 59.98994858398828)
+ testSessionMovingTimeProjectedEnd(sessionManager, 2400)
+ testSessionTimeSpentTotal(sessionManager, 2340.0100514160117)
+ testSessionTimeSpentMoving(sessionManager, 2340.0100514160117)
+ testSessionTimeSpentRest(sessionManager, 0)
+ testSessionDistance(sessionManager, 8406.084229545408)
+ testSessionDistanceToEnd(sessionManager, undefined)
+ testSessionDistanceTarget(sessionManager, undefined)
+ testSessionDistanceProjectedEnd(sessionManager, 8620.743372892788)
+ testSessionWorkSinceStart(sessionManager, 487699.09367508633)
+ testSessionCaloriesSinceStart(sessionManager, 659.476164927607)
+ testSessionCaloriesTarget(sessionManager, undefined)
+ testSessionCaloriesToEnd(sessionManager, undefined)
+ testSessionNumberOfStrokes(sessionManager, 845)
+ testSessionMinimumPace(sessionManager, 241.8898638189788)
+ testSessionAveragePace(sessionManager, 139.1854987124342)
+ testSessionMaximumPace(sessionManager, 114.36541624043336)
+ testSessionMinimumLinearVelocity(sessionManager, 2.067056436784722)
+ testSessionAverageLinearVelocity(sessionManager, 3.5923282570767716)
+ testSessionMaximumLinearVelocity(sessionManager, 4.37195103586942)
+ testSessionMinimumPower(sessionManager, 24.73144671899844)
+ testSessionAveragePower(sessionManager, 137.31551165546605)
+ testSessionMaximumPower(sessionManager, 234.15919484264248)
+ testSessionMinimumStrokedistance(sessionManager, 8.137887754842472)
+ testSessionAverageStrokedistance(sessionManager, 9.936566278218478)
+ testSessionMaximumStrokedistance(sessionManager, 12.383742235629555)
+ testSessionMinimumStrokerate(sessionManager, 11.507551179373843)
+ testSessionAverageStrokerate(sessionManager, 21.978699599238304)
+ testSessionMaximumStrokerate(sessionManager, 28.507568204527697)
+ testSessionMinimumDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+ testSessionAverageDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+ testSessionMaximumDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+
+ // Test interval metrics
+ testIntervalNumber(sessionManager, 0)
+ testIntervalWorkoutStepNumber(sessionManager, 0)
+ testIntervalType(sessionManager, 'time')
+ testIntervalTime(sessionManager, 2340.0100514160117)
+ testIntervalMovingTimeTarget(sessionManager, 2400)
+ testIntervalMovingTimeToEnd(sessionManager, 59.98994858398828)
+ testIntervalMovingTimeProjectedEnd(sessionManager, 2400)
+ testIntervalTimeSpentTotal(sessionManager, 2340.0100514160117)
+ testIntervalTimeSpentMoving(sessionManager, 2340.0100514160117)
+ testIntervalTimeSpentRest(sessionManager, 0)
+ testIntervalDistance(sessionManager, 8406.084229545408)
+ testIntervalDistanceToEnd(sessionManager, undefined)
+ testIntervalDistanceTarget(sessionManager, undefined)
+ testIntervalDistanceProjectedEnd(sessionManager, 8620.743372892788)
+ testIntervalWorkSinceStart(sessionManager, 487699.09367508633)
+ testIntervalCaloriesSinceStart(sessionManager, 659.476164927607)
+ testIntervalCaloriesTarget(sessionManager, undefined)
+ testIntervalCaloriesToEnd(sessionManager, undefined)
+ testIntervalNumberOfStrokes(sessionManager, 845)
+ testIntervalMinimumPace(sessionManager, 241.8898638189788)
+ testIntervalAveragePace(sessionManager, 139.1854987124342)
+ testIntervalMaximumPace(sessionManager, 114.36541624043336)
+ testIntervalMinimumLinearVelocity(sessionManager, 2.067056436784722)
+ testIntervalAverageLinearVelocity(sessionManager, 3.5923282570767716)
+ testIntervalMaximumLinearVelocity(sessionManager, 4.37195103586942)
+ testIntervalMinimumPower(sessionManager, 24.73144671899844)
+ testIntervalAveragePower(sessionManager, 137.31551165546605)
+ testIntervalMaximumPower(sessionManager, 234.15919484264248)
+ testIntervalMinimumStrokedistance(sessionManager, 8.137887754842472)
+ testIntervalAverageStrokedistance(sessionManager, 9.936566278218478)
+ testIntervalMaximumStrokedistance(sessionManager, 12.383742235629555)
+ testIntervalMinimumStrokerate(sessionManager, 11.507551179373843)
+ testIntervalAverageStrokerate(sessionManager, 21.978699599238304)
+ testIntervalMaximumStrokerate(sessionManager, 28.507568204527697)
+ testIntervalMinimumDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+ testIntervalAverageDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+ testIntervalMaximumDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+
+ // Test split metrics
+ testSplitNumber(sessionManager, 16)
+ testSplitType(sessionManager, 'distance')
+ testSplitTime(sessionManager, 103.48308819174645)
+ testSplitMovingTimeTarget(sessionManager, undefined)
+ testSplitMovingTimeToEnd(sessionManager, undefined)
+ testSplitMovingTimeProjectedEnd(sessionManager, 129.5021641631215)
+ testSplitTimeSpentTotal(sessionManager, 103.48308819174645)
+ testSplitTimeSpentMoving(sessionManager, 103.48308819174645)
+ testSplitTimeSpentRest(sessionManager, 0)
+ testSplitDistance(sessionManager, 406.08422954540765)
+ testSplitDistanceToEnd(sessionManager, 93.91577045459235)
+ testSplitDistanceTarget(sessionManager, 500)
+ testSplitDistanceProjectedEnd(sessionManager, 500)
+ testSplitWorkSinceStart(sessionManager, 26704.123301906104)
+ testSplitCaloriesSinceStart(sessionManager, 34.03654479932061)
+ testSplitCaloriesTarget(sessionManager, undefined)
+ testSplitCaloriesToEnd(sessionManager, undefined)
+ testSplitNumberOfStrokes(sessionManager, 41)
+ testSplitMinimumPace(sessionManager, 137.93200310265624)
+ testSplitAveragePace(sessionManager, 127.41579291024296)
+ testSplitMaximumPace(sessionManager, 121.4244967719201)
+ testSplitMinimumLinearVelocity(sessionManager, 3.624974543637083)
+ testSplitAverageLinearVelocity(sessionManager, 3.924160330362038)
+ testSplitMaximumLinearVelocity(sessionManager, 4.117785235208213)
+ testSplitMinimumPower(sessionManager, 134.02976389064236)
+ testSplitAveragePower(sessionManager, 172.0743823122024)
+ testSplitMaximumPower(sessionManager, 195.68893992339216)
+ testSplitMinimumStrokedistance(sessionManager, 9.199351375038782)
+ testSplitAverageStrokedistance(sessionManager, 9.732214638190051)
+ testSplitMaximumStrokedistance(sessionManager, 9.906993788503808)
+ testSplitMinimumStrokerate(sessionManager, 22.763981039266426)
+ testSplitAverageStrokerate(sessionManager, 24.325048492560988)
+ testSplitMaximumStrokerate(sessionManager, 26.84163443333606)
+ testSplitMinimumDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+ testSplitAverageDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+ testSplitMaximumDragfactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor)
+})
+
+/**
+ * @description Test against the Topiom V2
+ * @see {@link https://github.com/JaapvanEkris/openrowingmonitor/discussions/144|this discussion}
+ */
+test('TopiomV2_01: Data for the Topiom V2 should produce plausible results for 9750 meter run', async () => {
+ const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Topiom_V2)
+ const testConfig = {
+ loglevel: {
+ default: 'silent',
+ RowingEngine: 'silent'
+ },
+ numOfPhasesForAveragingScreenData: 2,
+ userSettings: {
+ sex: 'male'
+ },
+ rowerSettings: rowerProfile
+ }
+
+ const sessionManager = createSessionManager(testConfig)
+
+ testSessionType(sessionManager, 'justrow')
+ testIntervalType(sessionManager, 'justrow')
+ testSplitType(sessionManager, 'justrow')
+
+ const intervalSettings = []
+ intervalSettings[0] = {
+ type: 'distance',
+ targetDistance: 500,
+ split: {
+ type: 'distance',
+ targetDistance: 100
+ }
+ }
+ intervalSettings[1] = {
+ type: 'distance',
+ targetDistance: 9000,
+ split: {
+ type: 'distance',
+ targetDistance: 100
+ }
+ }
+ intervalSettings[2] = {
+ type: 'distance',
+ targetDistance: 250,
+ split: {
+ type: 'distance',
+ targetDistance: 50
+ }
+ }
+
+ sessionManager.handleCommand('updateIntervalSettings', intervalSettings)
+
+ testSessionType(sessionManager, 'distance')
+ testSessionDistanceTarget(sessionManager, 9750)
+ testIntervalType(sessionManager, 'distance')
+ testIntervalDistanceTarget(sessionManager, 500)
+ testSplitType(sessionManager, 'distance')
+ testSplitDistanceTarget(sessionManager, 100)
+
+ testSessionState(sessionManager, 'WaitingForStart')
+ testTotalMovingTime(sessionManager, 0)
+ testTotalLinearDistance(sessionManager, 0)
+ testTotalCalories(sessionManager, 0)
+ testTotalNumberOfStrokes(sessionManager, 0)
+ testDragFactor(sessionManager, undefined)
+
+ await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/Topiom_V2_1magnet.csv', realtime: false, loop: false })
+
+ // Test Instantanuous metrics
+ testIsMoving(sessionManager, false)
+ testIsDriveStart(sessionManager, false)
+ testIsRecoveryStart(sessionManager, false)
+ testIsSessionStart(sessionManager, false)
+ testIsIntervalEnd(sessionManager, false)
+ testIsSplitEnd(sessionManager, false)
+ testIsPauseStart(sessionManager, false)
+ testIsPauseEnd(sessionManager, false)
+ testIsSessionStop(sessionManager, false)
+ testTotalMovingTime(sessionManager, 2432.9155719999935)
+ testTotalLinearDistance(sessionManager, 9750.32925067486)
+ testTotalWork(sessionManager, 389658.6272505622)
+ testTotalCalories(sessionManager, 573.8464188576778)
+ testTotalCaloriesPerMinute(sessionManager, 14.089001689755378)
+ testTotalCaloriesPerHour (sessionManager, 845.3401013853281)
+ testTotalNumberOfStrokes(sessionManager, 1197)
+ testStrokeState(sessionManager, 'Stopped')
+ testStrokeWork(sessionManager, undefined)
+ testStrokeCalories(sessionManager, undefined)
+ testCycleDistance(sessionManager, undefined)
+ testCycleStrokeRate(sessionManager, undefined)
+ testCycleDuration(sessionManager, undefined)
+ testCycleLinearVelocity(sessionManager, undefined)
+ testCyclePace(sessionManager, Infinity)
+ testCyclePower(sessionManager, undefined)
+ testDriveLastStartTime (sessionManager, 2431.637963999994)
+ testDriveDuration(sessionManager, undefined)
+ testDriveLength(sessionManager, undefined)
+ testDriveDistance(sessionManager, undefined)
+ testDriveAverageHandleForce(sessionManager, undefined)
+ testDrivePeakHandleForce(sessionManager, undefined)
+ testRecoveryDuration(sessionManager, undefined)
+ testDragFactor(sessionManager, rowerProfiles.Topiom_V2.dragFactor)
+
+ // Test session metrics
+ testSessionType(sessionManager, 'distance')
+ testSessionState(sessionManager, 'Stopped')
+ testSessionTime(sessionManager, 2432.9155719999935)
+ testSessionMovingTimeTarget(sessionManager, undefined)
+ testSessionMovingTimeToEnd(sessionManager, undefined)
+ testSessionMovingTimeProjectedEnd(sessionManager, undefined) // ToDo: this shouldn't happen here
+ testSessionTimeSpentTotal(sessionManager, 2432.9155719999935)
+ testSessionTimeSpentMoving(sessionManager, 2432.9155719999935)
+ testSessionTimeSpentRest(sessionManager, 0)
+ testSessionDistance(sessionManager, 9750.32925067486)
+ testSessionDistanceToEnd(sessionManager, -0.32925067486030457)
+ testSessionDistanceTarget(sessionManager, 9750)
+ testSessionDistanceProjectedEnd(sessionManager, 9750)
+ testSessionWorkSinceStart(sessionManager, 389658.6272505622)
+ testSessionCaloriesSinceStart(sessionManager, 573.8464188576778)
+ testSessionCaloriesTarget(sessionManager, undefined)
+ testSessionCaloriesToEnd(sessionManager, undefined)
+ testSessionNumberOfStrokes(sessionManager, 1197)
+ testSessionMinimumPace(sessionManager, 147.9541799693056)
+ testSessionAveragePace(sessionManager, 124.76068804710371)
+ testSessionMaximumPace(sessionManager, 111.12145640036526)
+ testSessionMinimumLinearVelocity(sessionManager, 3.3794246306777502)
+ testSessionAverageLinearVelocity(sessionManager, 4.00767267178924)
+ testSessionMaximumLinearVelocity(sessionManager, 4.499581054792191)
+ testSessionMinimumPower(sessionManager, 108.06531570463927)
+ testSessionAveragePower(sessionManager, 181.19846622634458)
+ testSessionMaximumPower(sessionManager, 255.1001602742786)
+ testSessionMinimumStrokedistance(sessionManager, 6.972670768262592)
+ testSessionAverageStrokedistance(sessionManager, 8.145903250004125)
+ testSessionMaximumStrokedistance(sessionManager, 10.142066572018908)
+ testSessionMinimumStrokerate(sessionManager, 22.84859530644136)
+ testSessionAverageStrokerate(sessionManager, 29.608922791741428)
+ testSessionMaximumStrokerate(sessionManager, 34.282599466678214)
+ testSessionMinimumDragfactor(sessionManager, rowerProfiles.Topiom_V2.dragFactor)
+ testSessionAverageDragfactor(sessionManager, rowerProfiles.Topiom_V2.dragFactor)
+ testSessionMaximumDragfactor(sessionManager, rowerProfiles.Topiom_V2.dragFactor)
+ testSessionNumberOfIntervals(sessionManager, 3)
+
+ // Test interval metrics
+ testIntervalNumber(sessionManager, 2)
+ testIntervalWorkoutStepNumber(sessionManager, 2)
+ testIntervalType(sessionManager, 'distance')
+ testIntervalTime(sessionManager, 64.48709848756016)
+ testIntervalMovingTimeTarget(sessionManager, undefined)
+ testIntervalMovingTimeToEnd(sessionManager, undefined)
+ testIntervalMovingTimeProjectedEnd(sessionManager, undefined)
+ testIntervalTimeSpentTotal(sessionManager, 64.48709848756016)
+ testIntervalTimeSpentMoving(sessionManager, 64.48709848756016)
+ testIntervalTimeSpentRest(sessionManager, 0)
+ testIntervalDistance(sessionManager, 250.3292506748603)
+ testIntervalDistanceToEnd(sessionManager, -0.32925067486030457)
+ testIntervalDistanceTarget(sessionManager, 250)
+ testIntervalDistanceProjectedEnd(sessionManager, 250)
+ testIntervalWorkSinceStart(sessionManager, 9227.220060500782)
+ testIntervalCaloriesSinceStart(sessionManager, 14.161753503011823)
+ testIntervalCaloriesTarget(sessionManager, undefined)
+ testIntervalCaloriesToEnd(sessionManager, undefined)
+ testIntervalNumberOfStrokes(sessionManager, 32)
+ testIntervalMinimumPace(sessionManager, 133.61334401029237)
+ testIntervalAveragePace(sessionManager, 128.8045610205559)
+ testIntervalMaximumPace(sessionManager, 123.55398008655672)
+ testIntervalMinimumLinearVelocity(sessionManager, 3.742141203811833)
+ testIntervalAverageLinearVelocity(sessionManager, 3.8818501149210474)
+ testIntervalMaximumLinearVelocity(sessionManager, 4.046814191252447)
+ testIntervalMinimumPower(sessionManager, 146.73036837002923)
+ testIntervalAveragePower(sessionManager, 163.8471136333915)
+ testIntervalMaximumPower(sessionManager, 185.5762542665804)
+ testIntervalMinimumStrokedistance(sessionManager, 7.60654992901347)
+ testIntervalAverageStrokedistance(sessionManager, 7.879605567491548)
+ testIntervalMaximumStrokedistance(sessionManager, 8.874308250516695)
+ testIntervalMinimumStrokerate(sessionManager, 25.698286695229694)
+ testIntervalAverageStrokerate(sessionManager, 29.621301413174997)
+ testIntervalMaximumStrokerate(sessionManager, 31.92042026424608)
+ testIntervalMinimumDragfactor(sessionManager, rowerProfiles.Topiom_V2.dragFactor)
+ testIntervalAverageDragfactor(sessionManager, rowerProfiles.Topiom_V2.dragFactor)
+ testIntervalMaximumDragfactor(sessionManager, rowerProfiles.Topiom_V2.dragFactor)
+
+ // Test split metrics
+ testSplitNumber(sessionManager, 99)
+ testSplitType(sessionManager, 'distance')
+ testSplitTime(sessionManager, 12.648056983593506)
+ testSplitMovingTimeTarget(sessionManager, undefined)
+ testSplitMovingTimeToEnd(sessionManager, undefined)
+ testSplitMovingTimeProjectedEnd(sessionManager, undefined)
+ testSplitTimeSpentTotal(sessionManager, 12.648056983593506)
+ testSplitTimeSpentMoving(sessionManager, 12.648056983593506)
+ testSplitTimeSpentRest(sessionManager, 0)
+ testSplitDistance(sessionManager, 50.329250674860305)
+ testSplitDistanceToEnd(sessionManager, -0.32925067486030457)
+ testSplitDistanceTarget(sessionManager, 50)
+ testSplitDistanceProjectedEnd(sessionManager, 50)
+ testSplitWorkSinceStart(sessionManager, 2029.636451979808)
+ testSplitCaloriesSinceStart(sessionManager, 2.9585661590861037)
+ testSplitCaloriesTarget(sessionManager, undefined)
+ testSplitCaloriesToEnd(sessionManager, undefined)
+ testSplitNumberOfStrokes(sessionManager, 6)
+ testSplitMinimumPace(sessionManager, 130.63855511990764)
+ testSplitAveragePace(sessionManager, 125.65314219859098)
+ testSplitMaximumPace(sessionManager, 124.33928429500929)
+ testSplitMinimumLinearVelocity(sessionManager, 3.8273540268496618)
+ testSplitAverageLinearVelocity(sessionManager, 3.9792080902343465)
+ testSplitMaximumLinearVelocity(sessionManager, 4.02125525199013)
+ testSplitMinimumPower(sessionManager, 156.98409507930535)
+ testSplitAveragePower(sessionManager, 170.9494558731423)
+ testSplitMaximumPower(sessionManager, 182.073722133524)
+ testSplitMinimumStrokedistance(sessionManager, 7.60654992901347)
+ testSplitAverageStrokedistance(sessionManager, 7.968766592300473)
+ testSplitMaximumStrokedistance(sessionManager, 8.874308250516695)
+ testSplitMinimumStrokerate(sessionManager, 26.504301316804746)
+ testSplitAverageStrokerate(sessionManager, 29.73253731594018)
+ testSplitMaximumStrokerate(sessionManager, 31.490574870944158)
+ testSplitMinimumDragfactor(sessionManager, rowerProfiles.Topiom_V2.dragFactor)
+ testSplitAverageDragfactor(sessionManager, rowerProfiles.Topiom_V2.dragFactor)
+ testSplitMaximumDragfactor(sessionManager, rowerProfiles.Topiom_V2.dragFactor)
+})
+
+/**
+ * @todo Add tests for rest intervals
+ */
+
+function testSessionState (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().sessionState === expectedValue, `session state should be ${expectedValue} at ${sessionManager.getMetrics().totalMovingTime} is ${sessionManager.getMetrics().sessionState}`)
+
+ switch (expectedValue) {
+ case ('Stopped'):
+ if (sessionManager.getMetrics().metricsContext.isSessionStop) { assert.ok(!sessionManager.getMetrics().metricsContext.isMoving, `isMoving must be false if session state is ${expectedValue} at ${sessionManager.getMetrics().totalMovingTime}`) }
+ break
+ case ('Paused'):
+ assert.ok(!sessionManager.getMetrics().metricsContext.isSessionStop, `isSessionStop must be false if session state is ${expectedValue} at ${sessionManager.getMetrics().totalMovingTime}`)
+ break
+ case ('WaitingForStart'):
+ assert.ok(!sessionManager.getMetrics().metricsContext.isSessionStop, `isSessionStop must be false if session state is ${expectedValue} at ${sessionManager.getMetrics().totalMovingTime}`)
+ break
+ // No default
+ }
+}
+
+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 testIsMoving (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().metricsContext.isMoving === expectedValue, `isMoving should be ${expectedValue} at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().metricsContext.isMoving}`)
+}
+
+function testIsDriveStart (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().metricsContext.isDriveStart === expectedValue, `isDriveStart should be ${expectedValue} at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().metricsContext.isDriveStart}`)
+}
+
+function testIsRecoveryStart (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().metricsContext.isRecoveryStart === expectedValue, `isRecoveryStart should be ${expectedValue} at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().metricsContext.isRecoveryStart}`)
+}
+
+function testIsSessionStart (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().metricsContext.isSessionStart === expectedValue, `isSessionStart should be ${expectedValue} at ${sessionManager.getMetrics().metricsContext.isSessionStart} seconds, is ${sessionManager.getMetrics().metricsContext.isSessionStart}`)
+}
+
+function testIsIntervalEnd (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().metricsContext.isIntervalEnd === expectedValue, `isIntervalEnd should be ${expectedValue} at ${sessionManager.getMetrics().metricsContext.isSessionStart} seconds, is ${sessionManager.getMetrics().metricsContext.isIntervalEnd}`)
+}
+
+function testIsSplitEnd (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().metricsContext.isSplitEnd === expectedValue, `isSplitEnd should be ${expectedValue} at ${sessionManager.getMetrics().metricsContext.isSessionStart} seconds, is ${sessionManager.getMetrics().metricsContext.isSplitEnd}`)
+}
+
+function testIsPauseStart (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().metricsContext.isPauseStart === expectedValue, `isPauseStart should be ${expectedValue} at ${sessionManager.getMetrics().metricsContext.isSessionStart} seconds, is ${sessionManager.getMetrics().metricsContext.isPauseStart}`)
+}
+
+function testIsPauseEnd (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().metricsContext.isPauseEnd === expectedValue, `isPauseEnd should be ${expectedValue} at ${sessionManager.getMetrics().metricsContext.isSessionStart} seconds, is ${sessionManager.getMetrics().metricsContext.isPauseEnd}`)
+}
+
+function testIsSessionStop (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().metricsContext.isSessionStop === expectedValue, `isSessionStop should be ${expectedValue} at ${sessionManager.getMetrics().metricsContext.isSessionStart} seconds, is ${sessionManager.getMetrics().metricsContext.isSessionStop}`)
+}
+
+function testStrokeState (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().strokeState === expectedValue, `strokeState should be ${expectedValue} at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().strokeState}`)
+}
+
+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 testTotalWork (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().totalWork === expectedValue, `totalWork should be ${expectedValue} Joules at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().totalWork}`)
+}
+
+function testTotalCalories (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().totalCalories === expectedValue, `totalCalories should be ${expectedValue} kCal at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().totalCalories}`)
+}
+
+function testTotalCaloriesPerMinute (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().totalCaloriesPerMinute === expectedValue, `totalCaloriesPerMinute should be ${expectedValue} kCal at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().totalCaloriesPerMinute}`)
+}
+
+function testTotalCaloriesPerHour (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().totalCaloriesPerHour === expectedValue, `totalCaloriesPerHour should be ${expectedValue} kCal at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().totalCaloriesPerHour}`)
+}
+
+function testStrokeWork (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().strokeWork === expectedValue, ` should be ${expectedValue} Joules at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().strokeWork}`)
+}
+
+function testStrokeCalories (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().strokeCalories === expectedValue, `strokeCalories should be ${expectedValue} kCal at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().strokeCalories}`)
+}
+
+function testCycleDistance (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().cycleDistance === expectedValue, `cycleDistance should be ${expectedValue} meters at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().cycleDistance}`)
+}
+
+function testCycleStrokeRate (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().cycleStrokeRate === expectedValue, ` should be ${expectedValue} SPM at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().cycleStrokeRate}`)
+}
+
+function testCycleDuration (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().cycleDuration === expectedValue, `cycleDuration should be ${expectedValue} sec at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().cycleDuration}`)
+}
+
+function testCycleLinearVelocity (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().cycleLinearVelocity === expectedValue, `cycleLinearVelocity should be ${expectedValue} m/s at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().cycleLinearVelocity}`)
+}
+
+function testCyclePace (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().cyclePace === expectedValue, `cyclePace should be ${expectedValue} sec/500m at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().cyclePace}`)
+}
+
+function testCyclePower (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().cyclePower === expectedValue, `cyclePower should be ${expectedValue} Watt at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().cyclePower}`)
+}
+
+function testDriveLastStartTime (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().driveLastStartTime === expectedValue, `driveLastStartTime should be ${expectedValue} sec at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().driveLastStartTime}`)
+}
+
+function testDriveDuration (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().driveDuration === expectedValue, `driveDuration should be ${expectedValue} sec at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().driveDuration}`)
+}
+
+function testDriveLength (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().driveLength === expectedValue, `driveLength should be ${expectedValue} meters at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().driveLength}`)
+}
+
+function testDriveDistance (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().driveDistance === expectedValue, `driveDistance should be ${expectedValue} meters at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().driveDistance}`)
+}
+
+function testDriveAverageHandleForce (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().driveAverageHandleForce === expectedValue, `driveAverageHandleForce should be ${expectedValue} Newton at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().driveAverageHandleForce}`)
+}
+
+function testDrivePeakHandleForce (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().drivePeakHandleForce === expectedValue, `drivePeakHandleForce should be ${expectedValue} Newton at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().drivePeakHandleForce}`)
+}
+
+function testRecoveryDuration (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().recoveryDuration === expectedValue, `recoveryDuration should be ${expectedValue} sec at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().recoveryDuration}`)
+}
+
+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 testSessionType (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().workout.type === expectedValue, `Session type should be ${expectedValue}, is ${sessionManager.getMetrics().workout.type}`)
+}
+
+function testSessionTime (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().workout.movingTime.sinceStart === expectedValue, `Session moving time should be ${expectedValue} seconds at ${sessionManager.getMetrics().workout.movingTime.sinceStart} seconds`)
+}
+
+function testSessionMovingTimeTarget (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().workout.movingTime.target === expectedValue, `workout.movingTime.target should be ${expectedValue} at ${sessionManager.getMetrics().totalMovingTime} seconds, is ${sessionManager.getMetrics().workout.movingTime.target}`)
+}
+
+function testSessionMovingTimeToEnd (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().workout.movingTime.toEnd === expectedValue, `workout.movingTime.toEnd should be ${expectedValue} at ${sessionManager.getMetrics().totalMovingTime} seconds, is ${sessionManager.getMetrics().workout.movingTime.toEnd}`)
+}
+
+function testSessionMovingTimeProjectedEnd (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().workout.movingTime.projectedEnd === expectedValue, `workout.movingTime.projectedEnd should be ${expectedValue} at ${sessionManager.getMetrics().totalMovingTime} seconds, is ${sessionManager.getMetrics().workout.movingTime.projectedEnd}`)
+}
+
+function testSessionTimeSpentTotal (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().workout.timeSpent.total === expectedValue, `workout.timeSpent.total should be ${expectedValue} at ${sessionManager.getMetrics().totalMovingTime} seconds, is ${sessionManager.getMetrics().workout.timeSpent.total}`)
+}
+
+function testSessionTimeSpentMoving (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().workout.timeSpent.moving === expectedValue, `workout.timeSpent.moving should be ${expectedValue} at ${sessionManager.getMetrics().totalMovingTime} seconds, is ${sessionManager.getMetrics().workout.timeSpent.moving}`)
+}
+
+function testSessionTimeSpentRest (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().workout.timeSpent.rest === expectedValue, `workout.timeSpent.rest should be ${expectedValue} at ${sessionManager.getMetrics().totalMovingTime} seconds, is ${sessionManager.getMetrics().workout.timeSpent.rest}`)
+}
+
+function testSessionDistance (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().workout.distance.fromStart === expectedValue, `Session distance should be ${expectedValue} meters at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().workout.distance.fromStart} meters`)
+}
+
+function testSessionDistanceToEnd (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().workout.distance.toEnd === expectedValue, `workout.distance.toEnd should be ${expectedValue} at ${sessionManager.getMetrics().totalMovingTime} seconds, is ${sessionManager.getMetrics().workout.distance.toEnd}`)
+}
+
+function testSessionDistanceTarget (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().workout.distance.target === expectedValue, `workout.distance.target should be ${expectedValue} at ${sessionManager.getMetrics().totalMovingTime} seconds, is ${sessionManager.getMetrics().workout.distance.target}`)
+}
+
+function testSessionDistanceProjectedEnd (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().workout.distance.projectedEnd === expectedValue, `workout.distance.projectedEnd should be ${expectedValue} at ${sessionManager.getMetrics().totalMovingTime} seconds, is ${sessionManager.getMetrics().workout.distance.projectedEnd}`)
+}
+
+function testSessionWorkSinceStart (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().workout.work.sinceStart === expectedValue, `workout.work.sinceStart should be ${expectedValue} at ${sessionManager.getMetrics().totalMovingTime} seconds, is ${sessionManager.getMetrics().workout.work.sinceStart}`)
+}
+
+function testSessionCaloriesSinceStart (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().workout.calories.sinceStart === expectedValue, `workout.calories.sinceStart should be ${expectedValue} at ${sessionManager.getMetrics().totalMovingTime} seconds, is ${sessionManager.getMetrics().workout.calories.sinceStart}`)
+}
+
+function testSessionCaloriesTarget (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().workout.calories.target === expectedValue, `workout.calories.target should be ${expectedValue} at ${sessionManager.getMetrics().totalMovingTime} seconds, is ${sessionManager.getMetrics().workout.calories.target}`)
+}
+
+function testSessionCaloriesToEnd (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().workout.calories.toEnd === expectedValue, `workout.calories.toEnd should be ${expectedValue} at ${sessionManager.getMetrics().totalMovingTime} seconds, is ${sessionManager.getMetrics().workout.calories.toEnd}`)
+}
+
+function testSessionNumberOfStrokes (sessionManager, expectedValue) {
+ // Please note there is a stroke 0
+ assert.ok(sessionManager.getMetrics().workout.numberOfStrokes === expectedValue, `Session numberOfStrokes should be ${expectedValue} at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().workout.numberOfStrokes}`)
+}
+
+function testSessionAveragePace (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().workout.pace.average === expectedValue, `Session average pace should be ${expectedValue} meters at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().workout.pace.average} sec/500 meters`)
+}
+
+function testSessionMinimumPace (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().workout.pace.minimum === expectedValue, `Session minimum pace should be ${expectedValue} meters at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().workout.pace.minimum} sec/500 meters`)
+}
+
+function testSessionMaximumPace (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().workout.pace.maximum === expectedValue, `Session maximum pace should be ${expectedValue} meters at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().workout.pace.maximum} sec/500 meters`)
+}
+
+function testSessionMinimumLinearVelocity (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().workout.linearVelocity.minimum === expectedValue, `workout.linearVelocity.minimum should be ${expectedValue} at ${sessionManager.getMetrics().totalMovingTime} seconds, is ${sessionManager.getMetrics().workout.linearVelocity.minimum}`)
+}
+
+function testSessionAverageLinearVelocity (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().workout.linearVelocity.average === expectedValue, `workout.linearVelocity.average should be ${expectedValue} at ${sessionManager.getMetrics().totalMovingTime} seconds, is ${sessionManager.getMetrics().workout.linearVelocity.average}`)
+}
+
+function testSessionMaximumLinearVelocity (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().workout.linearVelocity.maximum === expectedValue, `workout.linearVelocity.maximum should be ${expectedValue} at ${sessionManager.getMetrics().totalMovingTime} seconds, is ${sessionManager.getMetrics().workout.linearVelocity.maximum}`)
+}
+
+function testSessionAveragePower (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().workout.power.average === expectedValue, `Session average power should be ${expectedValue} meters at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().workout.power.average} Watt`)
+}
+
+function testSessionMinimumPower (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().workout.power.minimum === expectedValue, `Session minimum power should be ${expectedValue} meters at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().workout.power.minimum} Watt`)
+}
+
+function testSessionMaximumPower (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().workout.power.maximum === expectedValue, `Session maximum power should be ${expectedValue} meters at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().workout.power.maximum} Watt`)
+}
+
+function testSessionAverageStrokedistance (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().workout.strokeDistance.average === expectedValue, `Session average strokeDistance should be ${expectedValue} meters at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().workout.strokeDistance.average} meters`)
+}
+
+function testSessionMinimumStrokedistance (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().workout.strokeDistance.minimum === expectedValue, `Session minimum strokeDistance should be ${expectedValue} meters at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().workout.strokeDistance.minimum} meters`)
+}
+
+function testSessionMaximumStrokedistance (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().workout.strokeDistance.maximum === expectedValue, `Session maximum strokeDistance should be ${expectedValue} meters at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().workout.strokeDistance.maximum} meters`)
+}
+
+function testSessionAverageStrokerate (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().workout.strokerate.average === expectedValue, `Session average strokerate should be ${expectedValue} meters at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().workout.strokerate.average} SPM`)
+}
+
+function testSessionMinimumStrokerate (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().workout.strokerate.minimum === expectedValue, `Session minimum strokerate should be ${expectedValue} meters at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().workout.strokerate.minimum} SPM`)
+}
+
+function testSessionMaximumStrokerate (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().workout.strokerate.maximum === expectedValue, `Session maximum strokerate should be ${expectedValue} meters at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().workout.strokerate.maximum} SPM`)
+}
+
+function testSessionAverageDragfactor (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().workout.dragfactor.average === expectedValue, `Session average dragfactor should be ${expectedValue} meters at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().workout.dragfactor.average}`)
+}
+
+function testSessionMinimumDragfactor (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().workout.dragfactor.minimum === expectedValue, `Session minimum dragfactor should be ${expectedValue} meters at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().workout.dragfactor.minimum}`)
+}
+
+function testSessionMaximumDragfactor (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().workout.dragfactor.maximum === expectedValue, `Session maximum dragfactor should be ${expectedValue} meters at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().workout.dragfactor.maximum}`)
+}
+
+function testSessionNumberOfIntervals (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().workout.numberOfIntervals === expectedValue, `workout.numberOfIntervals should be ${expectedValue} at ${sessionManager.getMetrics().totalMovingTime} seconds, is ${sessionManager.getMetrics().workout.numberOfIntervals}`)
+}
+
+function testIntervalNumber (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().interval.workoutStepNumber === expectedValue, `Last interval number should be ${expectedValue} at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().interval.workoutStepNumber}`)
+}
+
+function testIntervalWorkoutStepNumber (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().interval.workoutStepNumber === expectedValue, `interval.workoutStepNumber should be ${expectedValue} at ${sessionManager.getMetrics().totalMovingTime} seconds, is ${sessionManager.getMetrics().interval.workoutStepNumber}`)
+}
+
+function testIntervalType (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().interval.type === expectedValue, `Interval type should be ${expectedValue}, is ${sessionManager.getMetrics().interval.type}`)
+}
+
+function testIntervalTime (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().interval.movingTime.sinceStart === expectedValue, `Last interval moving time should be ${expectedValue} seconds at ${sessionManager.getMetrics().interval.movingTime.sinceStart} seconds`)
+}
+
+function testIntervalMovingTimeTarget (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().interval.movingTime.target === expectedValue, `interval.movingTime.target should be ${expectedValue} at ${sessionManager.getMetrics().totalMovingTime} seconds, is ${sessionManager.getMetrics().interval.movingTime.target}`)
+}
+
+function testIntervalMovingTimeToEnd (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().interval.movingTime.toEnd === expectedValue, `interval.movingTime.toEnd should be ${expectedValue} at ${sessionManager.getMetrics().totalMovingTime} seconds, is ${sessionManager.getMetrics().interval.movingTime.toEnd}`)
+}
+
+function testIntervalMovingTimeProjectedEnd (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().interval.movingTime.projectedEnd === expectedValue, `interval.movingTime.projectedEnd should be ${expectedValue} at ${sessionManager.getMetrics().totalMovingTime} seconds, is ${sessionManager.getMetrics().interval.movingTime.projectedEnd}`)
+}
+
+function testIntervalTimeSpentTotal (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().interval.timeSpent.total === expectedValue, `interval.timeSpent.total should be ${expectedValue} at ${sessionManager.getMetrics().totalMovingTime} seconds, is ${sessionManager.getMetrics().interval.timeSpent.total}`)
+}
+
+function testIntervalTimeSpentMoving (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().interval.timeSpent.moving === expectedValue, `interval.timeSpent.moving should be ${expectedValue} at ${sessionManager.getMetrics().totalMovingTime} seconds, is ${sessionManager.getMetrics().interval.timeSpent.moving}`)
+}
+
+function testIntervalTimeSpentRest (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().interval.timeSpent.rest === expectedValue, `interval.timeSpent.rest should be ${expectedValue} at ${sessionManager.getMetrics().totalMovingTime} seconds, is ${sessionManager.getMetrics().interval.timeSpent.rest}`)
+}
+
+function testIntervalDistance (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().interval.distance.fromStart === expectedValue, `Last interval distance should be ${expectedValue} meters at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().interval.distance.fromStart} meters`)
+}
+
+function testIntervalDistanceToEnd (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().interval.distance.toEnd === expectedValue, `interval.distance.toEnd should be ${expectedValue} at ${sessionManager.getMetrics().totalMovingTime} seconds, is ${sessionManager.getMetrics().interval.distance.toEnd}`)
+}
+
+function testIntervalDistanceTarget (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().interval.distance.target === expectedValue, `interval.distance.target should be ${expectedValue} at ${sessionManager.getMetrics().totalMovingTime} seconds, is ${sessionManager.getMetrics().interval.distance.target}`)
+}
+
+function testIntervalDistanceProjectedEnd (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().interval.distance.projectedEnd === expectedValue, `interval.distance.projectedEnd should be ${expectedValue} at ${sessionManager.getMetrics().totalMovingTime} seconds, is ${sessionManager.getMetrics().interval.distance.projectedEnd}`)
+}
+
+function testIntervalWorkSinceStart (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().interval.work.sinceStart === expectedValue, `interval.work.sinceStart should be ${expectedValue} at ${sessionManager.getMetrics().totalMovingTime} seconds, is ${sessionManager.getMetrics().interval.work.sinceStart}`)
+}
+
+function testIntervalCaloriesSinceStart (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().interval.calories.sinceStart === expectedValue, `interval.calories.sinceStart should be ${expectedValue} at ${sessionManager.getMetrics().totalMovingTime} seconds, is ${sessionManager.getMetrics().interval.calories.sinceStart}`)
+}
+
+function testIntervalCaloriesTarget (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().interval.calories.target === expectedValue, `interval.calories.target should be ${expectedValue} at ${sessionManager.getMetrics().totalMovingTime} seconds, is ${sessionManager.getMetrics().interval.calories.target}`)
+}
+
+function testIntervalCaloriesToEnd (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().interval.calories.toEnd === expectedValue, `interval.calories.toEnd should be ${expectedValue} at ${sessionManager.getMetrics().totalMovingTime} seconds, is ${sessionManager.getMetrics().interval.calories.toEnd}`)
+}
+
+function testIntervalNumberOfStrokes (sessionManager, expectedValue) {
+ // Please note there is a stroke 0
+ assert.ok(sessionManager.getMetrics().interval.numberOfStrokes === expectedValue, `Interval numberOfStrokes should be ${expectedValue} at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().interval.numberOfStrokes}`)
+}
+
+function testIntervalAveragePace (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().interval.pace.average === expectedValue, `Last interval average pace should be ${expectedValue} meters at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().interval.pace.average} sec/500 meters`)
+}
+
+function testIntervalMinimumPace (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().interval.pace.minimum === expectedValue, `Last interval minimum pace should be ${expectedValue} meters at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().interval.pace.minimum} sec/500 meters`)
+}
+
+function testIntervalMaximumPace (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().interval.pace.maximum === expectedValue, `Last interval maximum pace should be ${expectedValue} meters at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().interval.pace.maximum} sec/500 meters`)
+}
+
+function testIntervalMinimumLinearVelocity (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().interval.linearVelocity.minimum === expectedValue, `interval.linearVelocity.minimum should be ${expectedValue} at ${sessionManager.getMetrics().totalMovingTime} seconds, is ${sessionManager.getMetrics().interval.linearVelocity.minimum}`)
+}
+
+function testIntervalAverageLinearVelocity (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().interval.linearVelocity.average === expectedValue, `interval.linearVelocity.average should be ${expectedValue} at ${sessionManager.getMetrics().totalMovingTime} seconds, is ${sessionManager.getMetrics().interval.linearVelocity.average}`)
+}
+
+function testIntervalMaximumLinearVelocity (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().interval.linearVelocity.maximum === expectedValue, `interval.linearVelocity.maximum should be ${expectedValue} at ${sessionManager.getMetrics().totalMovingTime} seconds, is ${sessionManager.getMetrics().interval.linearVelocity.maximum}`)
+}
+
+function testIntervalAveragePower (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().interval.power.average === expectedValue, `Last interval average power should be ${expectedValue} meters at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().interval.power.average} Watt`)
+}
+
+function testIntervalMinimumPower (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().interval.power.minimum === expectedValue, `Last interval minimum power should be ${expectedValue} meters at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().interval.power.minimum} Watt`)
+}
+
+function testIntervalMaximumPower (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().interval.power.maximum === expectedValue, `Last interval maximum power should be ${expectedValue} meters at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().interval.power.maximum} Watt`)
+}
+
+function testIntervalAverageStrokedistance (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().interval.strokeDistance.average === expectedValue, `Last interval average strokeDistance should be ${expectedValue} meters at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().interval.strokeDistance.average} meters`)
+}
+
+function testIntervalMinimumStrokedistance (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().interval.strokeDistance.minimum === expectedValue, `Last interval minimum strokeDistance should be ${expectedValue} meters at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().interval.strokeDistance.minimum} meters`)
+}
+
+function testIntervalMaximumStrokedistance (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().interval.strokeDistance.maximum === expectedValue, `Last interval maximum strokeDistance should be ${expectedValue} meters at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().interval.strokeDistance.maximum} meters`)
+}
+
+function testIntervalAverageStrokerate (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().interval.strokerate.average === expectedValue, `Last interval average strokerate should be ${expectedValue} meters at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().interval.strokerate.average} SPM`)
+}
+
+function testIntervalMinimumStrokerate (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().interval.strokerate.minimum === expectedValue, `Last interval minimum strokerate should be ${expectedValue} meters at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().interval.strokerate.minimum} SPM`)
+}
+
+function testIntervalMaximumStrokerate (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().interval.strokerate.maximum === expectedValue, `Last interval maximum strokerate should be ${expectedValue} meters at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().interval.strokerate.maximum} SPM`)
+}
+
+function testIntervalAverageDragfactor (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().interval.dragfactor.average === expectedValue, `Last interval average dragfactor should be ${expectedValue} meters at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().interval.dragfactor.average}`)
+}
+
+function testIntervalMinimumDragfactor (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().interval.dragfactor.minimum === expectedValue, `Last interval minimum dragfactor should be ${expectedValue} meters at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().interval.dragfactor.minimum}`)
+}
+
+function testIntervalMaximumDragfactor (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().interval.dragfactor.maximum === expectedValue, `Last interval maximum dragfactor should be ${expectedValue} meters at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().interval.dragfactor.maximum}`)
+}
+
+function testSplitNumber (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().split.number === expectedValue, `Last split number should be ${expectedValue} at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().split.number}`)
+}
+
+function testSplitType (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().split.type === expectedValue, `Split type should be ${expectedValue}, is ${sessionManager.getMetrics().split.type}`)
+}
+
+function testSplitTime (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().split.movingTime.sinceStart === expectedValue, `Last split moving time should be ${expectedValue} seconds at ${sessionManager.getMetrics().split.movingTime.sinceStart} seconds`)
+}
+
+function testSplitMovingTimeTarget (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().split.movingTime.target === expectedValue, `split.movingTime.target should be ${expectedValue} at ${sessionManager.getMetrics().totalMovingTime} seconds, is ${sessionManager.getMetrics().split.movingTime.target}`)
+}
+
+function testSplitMovingTimeToEnd (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().split.movingTime.toEnd === expectedValue, `split.movingTime.toEnd should be ${expectedValue} at ${sessionManager.getMetrics().totalMovingTime} seconds, is ${sessionManager.getMetrics().split.movingTime.toEnd}`)
+}
+
+function testSplitMovingTimeProjectedEnd (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().split.movingTime.projectedEnd === expectedValue, `split.movingTime.projectedEnd should be ${expectedValue} at ${sessionManager.getMetrics().totalMovingTime} seconds, is ${sessionManager.getMetrics().split.movingTime.projectedEnd}`)
+}
+
+function testSplitTimeSpentTotal (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().split.timeSpent.total === expectedValue, `split.timeSpent.total should be ${expectedValue} at ${sessionManager.getMetrics().totalMovingTime} seconds, is ${sessionManager.getMetrics().split.timeSpent.total}`)
+}
+
+function testSplitTimeSpentMoving (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().split.timeSpent.moving === expectedValue, `split.timeSpent.moving should be ${expectedValue} at ${sessionManager.getMetrics().totalMovingTime} seconds, is ${sessionManager.getMetrics().split.timeSpent.moving}`)
+}
+
+function testSplitTimeSpentRest (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().split.timeSpent.rest === expectedValue, `split.timeSpent.rest should be ${expectedValue} at ${sessionManager.getMetrics().totalMovingTime} seconds, is ${sessionManager.getMetrics().split.timeSpent.rest}`)
+}
+
+function testSplitDistance (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().split.distance.fromStart === expectedValue, `Last split distance should be ${expectedValue} meters at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().split.distance.fromStart} meters`)
+}
+
+function testSplitDistanceToEnd (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().split.distance.toEnd === expectedValue, `split.distance.toEnd should be ${expectedValue} at ${sessionManager.getMetrics().totalMovingTime} seconds, is ${sessionManager.getMetrics().split.distance.toEnd}`)
+}
+
+function testSplitDistanceTarget (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().split.distance.target === expectedValue, `split.distance.target should be ${expectedValue} at ${sessionManager.getMetrics().totalMovingTime} seconds, is ${sessionManager.getMetrics().split.distance.target}`)
+}
+
+function testSplitDistanceProjectedEnd (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().split.distance.projectedEnd === expectedValue, `split.distance.projectedEnd should be ${expectedValue} at ${sessionManager.getMetrics().totalMovingTime} seconds, is ${sessionManager.getMetrics().split.distance.projectedEnd}`)
+}
+
+function testSplitWorkSinceStart (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().split.work.sinceStart === expectedValue, `split.work.sinceStart should be ${expectedValue} at ${sessionManager.getMetrics().totalMovingTime} seconds, is ${sessionManager.getMetrics().split.work.sinceStart}`)
+}
+
+function testSplitCaloriesSinceStart (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().split.calories.sinceStart === expectedValue, `split.calories.sinceStart should be ${expectedValue} at ${sessionManager.getMetrics().totalMovingTime} seconds, is ${sessionManager.getMetrics().split.calories.sinceStart}`)
+}
+
+function testSplitCaloriesTarget (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().split.calories.target === expectedValue, `split.calories.target should be ${expectedValue} at ${sessionManager.getMetrics().totalMovingTime} seconds, is ${sessionManager.getMetrics().split.calories.target}`)
+}
+
+function testSplitCaloriesToEnd (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().split.calories.toEnd === expectedValue, `split.calories.toEnd should be ${expectedValue} at ${sessionManager.getMetrics().totalMovingTime} seconds, is ${sessionManager.getMetrics().split.calories.toEnd}`)
+}
+
+function testSplitNumberOfStrokes (sessionManager, expectedValue) {
+ // Please note there is a stroke 0
+ assert.ok(sessionManager.getMetrics().split.numberOfStrokes === expectedValue, `Split numberOfStrokes should be ${expectedValue} at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().split.numberOfStrokes}`)
+}
+
+function testSplitAveragePace (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().split.pace.average === expectedValue, `Last split average pace should be ${expectedValue} meters at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().split.pace.average} sec/500 meters`)
+}
+
+function testSplitMinimumPace (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().split.pace.minimum === expectedValue, `Last split minimum pace should be ${expectedValue} meters at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().split.pace.minimum} sec/500 meters`)
+}
+
+function testSplitMaximumPace (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().split.pace.maximum === expectedValue, `Last split maximum pace should be ${expectedValue} meters at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().split.pace.maximum} sec/500 meters`)
+}
+
+function testSplitMinimumLinearVelocity (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().split.linearVelocity.minimum === expectedValue, `split.linearVelocity.minimum should be ${expectedValue} at ${sessionManager.getMetrics().totalMovingTime} seconds, is ${sessionManager.getMetrics().split.linearVelocity.minimum}`)
+}
+
+function testSplitAverageLinearVelocity (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().split.linearVelocity.average === expectedValue, `split.linearVelocity.average should be ${expectedValue} at ${sessionManager.getMetrics().totalMovingTime} seconds, is ${sessionManager.getMetrics().split.linearVelocity.average}`)
+}
+
+function testSplitMaximumLinearVelocity (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().split.linearVelocity.maximum === expectedValue, `split.linearVelocity.maximum should be ${expectedValue} at ${sessionManager.getMetrics().totalMovingTime} seconds, is ${sessionManager.getMetrics().split.linearVelocity.maximum}`)
+}
+
+function testSplitAveragePower (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().split.power.average === expectedValue, `Last split average power should be ${expectedValue} meters at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().split.power.average} Watt`)
+}
+
+function testSplitMinimumPower (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().split.power.minimum === expectedValue, `Last split minimum power should be ${expectedValue} meters at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().split.power.minimum} Watt`)
+}
+
+function testSplitMaximumPower (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().split.power.maximum === expectedValue, `Last split maximum power should be ${expectedValue} meters at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().split.power.maximum} Watt`)
+}
+
+function testSplitAverageStrokedistance (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().split.strokeDistance.average === expectedValue, `Last split average strokeDistance should be ${expectedValue} meters at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().split.strokeDistance.average} meters`)
+}
+
+function testSplitMinimumStrokedistance (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().split.strokeDistance.minimum === expectedValue, `Last split minimum strokeDistance should be ${expectedValue} meters at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().split.strokeDistance.minimum} meters`)
+}
+
+function testSplitMaximumStrokedistance (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().split.strokeDistance.maximum === expectedValue, `Last split maximum strokeDistance should be ${expectedValue} meters at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().split.strokeDistance.maximum} meters`)
+}
+
+function testSplitAverageStrokerate (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().split.strokerate.average === expectedValue, `Last split average strokerate should be ${expectedValue} meters at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().split.strokerate.average} SPM`)
+}
+
+function testSplitMinimumStrokerate (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().split.strokerate.minimum === expectedValue, `Last split minimum strokerate should be ${expectedValue} meters at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().split.strokerate.minimum} SPM`)
+}
+
+function testSplitMaximumStrokerate (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().split.strokerate.maximum === expectedValue, `Last split maximum strokerate should be ${expectedValue} meters at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().split.strokerate.maximum} SPM`)
+}
+
+function testSplitAverageDragfactor (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().split.dragfactor.average === expectedValue, `Last split average dragfactor should be ${expectedValue} meters at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().split.dragfactor.average}`)
+}
+
+function testSplitMinimumDragfactor (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().split.dragfactor.minimum === expectedValue, `Last split minimum dragfactor should be ${expectedValue} meters at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().split.dragfactor.minimum}`)
+}
+
+function testSplitMaximumDragfactor (sessionManager, expectedValue) {
+ assert.ok(sessionManager.getMetrics().split.dragfactor.maximum === expectedValue, `Last split maximum dragfactor should be ${expectedValue} meters at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().split.dragfactor.maximum}`)
+}
+
+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/VO2max.js b/app/engine/VO2max.js
deleted file mode 100644
index 6d15189c2d..0000000000
--- a/app/engine/VO2max.js
+++ /dev/null
@@ -1,165 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- This Module calculates the training specific VO2Max metrics. It is based on formula's found on the web (see function definitions).
-*/
-
-import { createBucketedLinearSeries } from './utils/BucketedLinearSeries.js'
-
-import loglevel from 'loglevel'
-const log = loglevel.getLogger('RowingEngine')
-
-function createVO2max (config) {
- const bucketedLinearSeries = createBucketedLinearSeries(config)
- const minimumValidBrackets = 5.0
- const offset = 90
-
- function calculateVO2max (metrics) {
- let projectedVO2max = 0
- let interpolatedVO2max = 0
-
- if (metrics[0].heartrate !== undefined && metrics[metrics.length - 1].heartrate !== undefined && metrics[metrics.length - 1].heartrate >= config.userSettings.restingHR) {
- projectedVO2max = extrapolatedVO2max(metrics)
- }
-
- interpolatedVO2max = calculateInterpolatedVO2max(metrics)
-
- if (projectedVO2max >= 10 && projectedVO2max <= 60 && interpolatedVO2max >= 10 && interpolatedVO2max <= 60) {
- // Both VO2Max calculations have delivered a valid and credible result
- log.debug(`--- VO2Max calculation delivered two credible results Extrapolated VO2Max: ${projectedVO2max.toFixed(1)} and Interpolated VO2Max: ${interpolatedVO2max.toFixed(1)}`)
- return ((projectedVO2max + interpolatedVO2max) / 2)
- } else {
- // One of the calculations has delivered an invalid result
- if (interpolatedVO2max >= 10 && interpolatedVO2max <= 60) {
- // Interpolation has delivered a credible result
- log.debug(`--- VO2Max calculation delivered one credible result, the Interpolated VO2Max: ${interpolatedVO2max.toFixed(1)}. The Extrapolated VO2Max: ${projectedVO2max.toFixed(1)} was unreliable`)
- return interpolatedVO2max
- } else {
- // Interpolation hasn't delivered a credible result
- if (projectedVO2max >= 10 && projectedVO2max <= 60) {
- // Extrapolation did deliver a credible result
- log.debug(`--- VO2Max calculation delivered one credible result, the Extrapolated VO2Max: ${projectedVO2max.toFixed(1)}. Interpolated VO2Max: ${interpolatedVO2max.toFixed(1)} was unreliable`)
- return projectedVO2max
- } else {
- // No credible results at all!
- log.debug(`--- VO2Max calculation did not deliver any credible results Extrapolated VO2Max: ${projectedVO2max.toFixed(1)}, Interpolated VO2Max: ${interpolatedVO2max.toFixed(1)}`)
- return 0
- }
- }
- }
- }
-
- function extrapolatedVO2max (metrics) {
- // This implements the extrapolation-based VO2Max determination
- // Which is based on the extrapolated maximum power output based on the correlation between heartrate and power,
- // Underlying formula's can be found here: https://sportcoaching.co.nz/how-does-garmin-calculate-vo2-max/
- let ProjectedVO2max
- let i = 0
- while (i < metrics.length && metrics[i].totalMovingTime < offset) {
- // We skip the first timeperiod as it only depicts the change from a resting HR to a working HR
- i++
- }
- while (i < metrics.length) {
- if (metrics[i].heartrate !== undefined && metrics[i].heartrate >= config.userSettings.restingHR && metrics[i].heartrate <= config.userSettings.maxHR && metrics[i].cyclePower !== undefined && metrics[i].cyclePower >= config.userSettings.minPower && metrics[i].cyclePower <= config.userSettings.maxPower) {
- // The data looks credible, lets add it
- bucketedLinearSeries.push(metrics[i].heartrate, metrics[i].cyclePower)
- }
- i++
- }
-
- // All Datapoints have been added, now we determine the projected power
- if (bucketedLinearSeries.numberOfSamples() >= minimumValidBrackets) {
- const projectedPower = bucketedLinearSeries.projectX(config.userSettings.maxHR)
- if (projectedPower <= config.userSettings.maxPower && projectedPower >= bucketedLinearSeries.maxEncounteredY()) {
- ProjectedVO2max = ((14.72 * projectedPower) + 250.39) / config.userSettings.weight
- log.debug(`--- VO2Max Goodness of Fit: ${bucketedLinearSeries.goodnessOfFit().toFixed(6)}, projected power ${projectedPower.toFixed(1)} Watt, extrapolated VO2Max: ${ProjectedVO2max.toFixed(1)}`)
- } else {
- ProjectedVO2max = ((14.72 * bucketedLinearSeries.maxEncounteredY()) + 250.39) / config.userSettings.weight
- log.debug(`--- VO2Max maximum encountered power: ${bucketedLinearSeries.maxEncounteredY().toFixed(1)} Watt, extrapolated VO2Max: ${ProjectedVO2max.toFixed(1)}`)
- }
- } else {
- log.debug(`--- VO2Max extrapolation failed as there were not enough valid brackets: ${bucketedLinearSeries.numberOfSamples()}`)
- ProjectedVO2max = 0
- }
- return ProjectedVO2max
- }
-
- function calculateInterpolatedVO2max (metrics) {
- // This is based on research done by concept2, https://www.concept2.com/indoor-rowers/training/calculators/vo2max-calculator,
- // which determines the VO2Max based on the 2K speed
- const distance = metrics[metrics.length - 1].totalLinearDistance
- const time = metrics[metrics.length - 1].totalMovingTime
- const projectedTwoKPace = interpolatePace(time, distance, 2000)
- const projectedTwoKTimeInMinutes = (4 * projectedTwoKPace) / 60
- let Y = 0
-
- log.debug(`--- VO2Max Interpolated 2K pace: ${Math.floor(projectedTwoKPace / 60)}:${(projectedTwoKPace % 60).toFixed(1)}`)
- // This implements the table with formulas found at https://www.concept2.com/indoor-rowers/training/calculators/vo2max-calculator
- if (config.userSettings.highlyTrained) {
- // Highly trained
- if (config.userSettings.sex === 'male') {
- // Highly trained male
- if (config.userSettings.weight > 75) {
- // Highly trained male, above 75 Kg
- Y = 15.7 - (1.5 * projectedTwoKTimeInMinutes)
- } else {
- // Highly trained male, equal or below 75 Kg
- Y = 15.1 - (1.5 * projectedTwoKTimeInMinutes)
- }
- } else {
- // Highly trained female
- if (config.userSettings.weight > 61.36) {
- // Highly trained female, above 61.36 Kg
- Y = 14.9 - (1.5 * projectedTwoKTimeInMinutes)
- } else {
- // Highly trained female, equal or below 61.36 Kg
- Y = 14.6 - (1.5 * projectedTwoKTimeInMinutes)
- }
- }
- } else {
- // Not highly trained
- if (config.userSettings.sex === 'male') {
- // Not highly trained male
- Y = 10.7 - (0.9 * projectedTwoKTimeInMinutes)
- } else {
- // Not highly trained female
- Y = 10.26 - (0.93 * projectedTwoKTimeInMinutes)
- }
- }
- return (Y * 1000) / config.userSettings.weight
- }
-
- function interpolatePace (origintime, origindistance, targetdistance) {
- // We interpolate the 2K speed based on Paul's Law: https://paulergs.weebly.com/blog/a-quick-explainer-on-pauls-law
- let originpace = 0
-
- if (origintime > 0 && origindistance > 0 && targetdistance > 0) {
- originpace = (500 * origintime) / origindistance
- return (originpace + (config.userSettings.distanceCorrectionFactor * Math.log2(targetdistance / origindistance)))
- } else {
- return 0
- }
- }
-
- function averageObservedHR () {
- bucketedLinearSeries.averageEncounteredX()
- }
-
- function maxObservedHR () {
- bucketedLinearSeries.maxEncounteredX()
- }
-
- function reset () {
- bucketedLinearSeries.reset()
- }
-
- return {
- calculateVO2max,
- averageObservedHR,
- maxObservedHR,
- reset
- }
-}
-
-export { createVO2max }
diff --git a/app/engine/WorkoutRecorder.js b/app/engine/WorkoutRecorder.js
deleted file mode 100644
index 5370a1432f..0000000000
--- a/app/engine/WorkoutRecorder.js
+++ /dev/null
@@ -1,319 +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 { createVO2max } from './VO2max.js'
-import { promisify } from 'util'
-const gzip = promisify(zlib.gzip)
-
-function createWorkoutRecorder () {
- let strokes = []
- let rotationImpulses = []
- let postExerciseHR = []
- 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 createRowingDataFile () {
- 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}_rowingData.csv`
- let currentstroke
- let trackPointTime
- let timestamp
- let i
-
- log.info(`saving session as RowingData file ${filename}...`)
-
- // Required file header, please note this includes a typo and odd spaces as the specification demands it!
- let RowingData = ',index, Stroke Number,TimeStamp (sec), ElapsedTime (sec), HRCur (bpm),DistanceMeters, Cadence (stokes/min), Stroke500mPace (sec/500m), Power (watts), StrokeDistance (meters),' +
- ' DriveTime (ms), DriveLength (meters), StrokeRecoveryTime (ms),Speed, Horizontal (meters), Calories (kCal), DragFactor, PeakDriveForce (N), AverageDriveForce (N),' +
- 'Handle_Force_(N),Handle_Velocity_(m/s),Handle_Power_(W)\n'
-
- // Add the strokes
- i = 0
- while (i < strokes.length) {
- currentstroke = strokes[i]
- trackPointTime = new Date(startTime.getTime() + currentstroke.totalMovingTime * 1000)
- timestamp = trackPointTime.getTime() / 1000
-
- RowingData += `${currentstroke.totalNumberOfStrokes.toFixed(0)},${currentstroke.totalNumberOfStrokes.toFixed(0)},${currentstroke.totalNumberOfStrokes.toFixed(0)},${timestamp.toFixed(0)},` +
- `${currentstroke.totalMovingTime.toFixed(2)},${(currentstroke.heartrate > 30 ? currentstroke.heartrate.toFixed(0) : NaN)},${currentstroke.totalLinearDistance.toFixed(1)},` +
- `${currentstroke.cycleStrokeRate.toFixed(1)},${(currentstroke.totalNumberOfStrokes > 0 ? currentstroke.cyclePace.toFixed(2) : NaN)},${(currentstroke.totalNumberOfStrokes > 0 ? currentstroke.cyclePower.toFixed(0) : NaN)},` +
- `${currentstroke.cycleDistance.toFixed(2)},${(currentstroke.driveDuration * 1000).toFixed(0)},${(currentstroke.totalNumberOfStrokes > 0 ? currentstroke.driveLength.toFixed(2) : NaN)},${(currentstroke.recoveryDuration * 1000).toFixed(0)},` +
- `${(currentstroke.totalNumberOfStrokes > 0 ? currentstroke.cycleLinearVelocity.toFixed(2) : 0)},${currentstroke.totalLinearDistance.toFixed(1)},${currentstroke.totalCalories.toFixed(1)},${currentstroke.dragFactor.toFixed(1)},` +
- `${(currentstroke.totalNumberOfStrokes > 0 ? currentstroke.drivePeakHandleForce.toFixed(1) : NaN)},${(currentstroke.totalNumberOfStrokes > 0 ? currentstroke.driveAverageHandleForce.toFixed(1) : 0)},"${currentstroke.driveHandleForceCurve}",` +
- `"${currentstroke.driveHandleVelocityCurve}","${currentstroke.driveHandlePowerCurve}"\n`
- i++
- }
- await createFile(RowingData, `${filename}`, false)
- }
-
- 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 < 5) 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 drag = workout.strokes.reduce((sum, s) => sum + s.dragFactor, 0) / strokes.length
-
- // VO2Max calculation for the remarks section
- let VO2maxoutput = 'UNDEFINED'
- const VO2max = createVO2max(config)
- const VO2maxResult = VO2max.calculateVO2max(strokes)
- if (VO2maxResult > 10 && VO2maxResult < 60) {
- VO2maxoutput = `${VO2maxResult.toFixed(1)} mL/(kg*min)`
- }
-
- // Addition of HRR data
- let hrrAdittion = ''
- if (postExerciseHR.length > 1 && (postExerciseHR[0] > (0.7 * config.userSettings.maxHR))) {
- // Recovery Heartrate is only defined when the last excercise HR is above 70% of the maximum Heartrate
- if (postExerciseHR.length === 2) {
- hrrAdittion = `, HRR1: ${postExerciseHR[1] - postExerciseHR[0]} (${postExerciseHR[1]} BPM)`
- }
- if (postExerciseHR.length === 3) {
- hrrAdittion = `, HRR1: ${postExerciseHR[1] - postExerciseHR[0]} (${postExerciseHR[1]} BPM), HRR2: ${postExerciseHR[2] - postExerciseHR[1]} (${postExerciseHR[2]} BPM)`
- }
- if (postExerciseHR.length >= 4) {
- hrrAdittion = `, HRR1: ${postExerciseHR[1] - postExerciseHR[0]} (${postExerciseHR[1]} BPM), HRR2: ${postExerciseHR[2] - postExerciseHR[1]} (${postExerciseHR[2]} BPM), HRR3: ${postExerciseHR[3] - postExerciseHR[2]} (${postExerciseHR[3]} BPM)`
- }
- }
-
- 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: lastStroke.totalMovingTime.toFixed(1),
- DistanceMeters: lastStroke.totalLinearDistance.toFixed(1),
- MaximumSpeed: (workout.strokes.map((stroke) => stroke.cycleLinearVelocity).reduce((acc, cycleLinearVelocity) => Math.max(acc, cycleLinearVelocity))).toFixed(2),
- Calories: Math.round(lastStroke.totalCalories),
- /* ToDo Fix issue with IF-statement not being accepted here?
- if (lastStroke.heartrate !== undefined && lastStroke.heartrate > 30) {
- AverageHeartRateBpm: VO2max.averageObservedHR(),
- MaximumHeartRateBpm: VO2max.maxObservedHR,
- //AverageHeartRateBpm: { Value: (workout.strokes.reduce((sum, s) => sum + s.heartrate, 0) / workout.strokes.length).toFixed(2) },
- //MaximumHeartRateBpm: { Value: Math.round(workout.strokes.map((stroke) => stroke.power).reduce((acc, heartrate) => Math.max(acc, heartrate))) },
- }
- */
- Intensity: 'Active',
- Cadence: Math.round(workout.strokes.reduce((sum, s) => sum + s.cycleStrokeRate, 0) / (workout.strokes.length - 1)),
- TriggerMethod: 'Manual',
- Track: {
- Trackpoint: (() => {
- return workout.strokes.map((stroke) => {
- const trackPointTime = new Date(workout.startTime.getTime() + stroke.totalMovingTime * 1000)
- const trackpoint = {
- Time: trackPointTime.toISOString(),
- DistanceMeters: stroke.totalLinearDistance.toFixed(2),
- Cadence: Math.round(stroke.cycleStrokeRate),
- Extensions: {
- 'ns2:TPX': {
- 'ns2:Speed': stroke.cycleLinearVelocity.toFixed(2),
- 'ns2:Watts': Math.round(stroke.cyclePower)
- }
- }
- }
- if (stroke.heartrate !== undefined && stroke.heartrate > 30) {
- trackpoint.HeartRateBpm = { Value: stroke.heartrate }
- }
- return trackpoint
- })
- })()
- },
- Extensions: {
- 'ns2:LX': {
- 'ns2:Steps': lastStroke.totalNumberOfStrokes.toFixed(0),
- // please note, the -1 is needed as we have a stroke 0, with a speed and power of 0. The - 1 corrects this.
- 'ns2:AvgSpeed': (workout.strokes.reduce((sum, s) => sum + s.cycleLinearVelocity, 0) / (workout.strokes.length - 1)).toFixed(2),
- 'ns2:AvgWatts': (workout.strokes.reduce((sum, s) => sum + s.cyclePower, 0) / (workout.strokes.length - 1)).toFixed(0),
- 'ns2:MaxWatts': Math.round(workout.strokes.map((stroke) => stroke.cyclePower).reduce((acc, cyclePower) => Math.max(acc, cyclePower)))
- }
- }
- }
- ],
- Notes: `Indoor Rowing, Drag factor: ${drag.toFixed(1)} 10-6 N*m*s2, Estimated VO2Max: ${VO2maxoutput}${hrrAdittion}`
- }
- },
- 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 = []
- postExerciseHR = []
- startTime = undefined
- }
-
- async function createFile (content, filename, compress = false) {
- if (compress) {
- const gzipContent = await gzip(content)
- try {
- await fs.writeFile(filename, gzipContent)
- } catch (err) {
- log.error(err)
- }
- } else {
- try {
- await fs.writeFile(filename, content)
- } catch (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())
- }
- if (config.createRowingDataFiles) {
- parallelCalls.push(createRowingDataFile())
- }
- await Promise.all(parallelCalls)
- }
-
- async function updateHRRecovery (hrmetrics) {
- postExerciseHR = hrmetrics
- createTcxFile()
- }
-
- function minimumRecordingTimeHasPassed () {
- const minimumRecordingTimeInSeconds = 10
- const rotationImpulseTimeTotal = rotationImpulses.reduce((acc, impulse) => acc + impulse, 0)
- const strokeTimeTotal = strokes[strokes.length - 1].totalMovingTime
- return (Math.max(rotationImpulseTimeTotal, strokeTimeTotal) > minimumRecordingTimeInSeconds)
- }
-
- return {
- recordStroke,
- recordRotationImpulse,
- handlePause,
- activeWorkoutToTcx,
- writeRecordings: createRecordings,
- updateHRRecovery,
- 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/utils/BinarySearchTree.js b/app/engine/utils/BinarySearchTree.js
new file mode 100644
index 0000000000..59e791e9ac
--- /dev/null
+++ b/app/engine/utils/BinarySearchTree.js
@@ -0,0 +1,495 @@
+'use strict'
+/*
+ Open Rowing Monitor, https://github.com/jaapvanekris/openrowingmonitor
+*/
+/**
+ * This creates an ordered series with labels and optional weights
+ * It allows for efficient determining the Weighted Median, Number of Above and Below
+ */
+/* eslint-disable max-lines -- This code has to handle a lot of different situations */
+export function createLabelledBinarySearchTree () {
+ let tree = null
+
+ /**
+ * @param {float} label to use to destroy it later
+ * @param {float} value to store
+ * @param {float} weight attributed to the value (default = 1)
+ */
+ function push (label, value, weight = 1) {
+ if (value === undefined || isNaN(value)) { return }
+ if (tree === null) {
+ tree = newNode(label, value, weight)
+ } else {
+ tree = pushInTree(tree, label, value, weight)
+ }
+ }
+
+ /**
+ * Helper function to actually push value in the current tree
+ * @param {object} the current tree
+ * @param {float} label to use to destroy it later
+ * @param {float} value to store
+ * @param {float} weight attributed to the value
+ */
+ function pushInTree (currentTree, label, value, weight) {
+ if (value <= currentTree.value) {
+ // The value should be on the left side of currentTree
+ if (currentTree.leftNode === null) {
+ currentTree.leftNode = newNode(label, value, weight)
+ } else {
+ currentTree.leftNode = pushInTree(currentTree.leftNode, label, value, weight)
+ }
+ } else {
+ // The value should be on the right side of currentTree
+ if (currentTree.rightNode === null) {
+ currentTree.rightNode = newNode(label, value, weight)
+ } else {
+ currentTree.rightNode = pushInTree(currentTree.rightNode, label, value, weight)
+ }
+ }
+ currentTree.numberOfLeafsAndNodes = currentTree.numberOfLeafsAndNodes + 1
+ currentTree.totalWeight = currentTree.totalWeight + weight
+ return currentTree
+ }
+
+ function newNode (label, value, weight) {
+ return {
+ label,
+ value,
+ weight,
+ leftNode: null,
+ rightNode: null,
+ numberOfLeafsAndNodes: 1,
+ totalWeight: weight
+ }
+ }
+
+ /**
+ * @result {integer} number of values stored in the tree
+ */
+ function size () {
+ if (tree !== null) {
+ return tree.numberOfLeafsAndNodes
+ } else {
+ return 0
+ }
+ }
+
+ /**
+ * @result {float} total weight stored in the tree
+ */
+ function totalWeight () {
+ if (tree !== null) {
+ return tree.totalWeight
+ } 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
+ // We start by releasing 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.weight = null
+ currentTree.numberOfLeafsAndNodes = null
+ currentTree.totalWeight = 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
+ const _closestPredecessor = closestPredecessor(currentTree.leftNode)
+ currentTree.value = _closestPredecessor.value
+ currentTree.label = _closestPredecessor.label
+ currentTree.weight = _closestPredecessor.weight
+ currentTree.leftNode = destroyclosestPredecessor(currentTree.leftNode)
+ } else {
+ // The right sub-tree is smaller then the right one, lets use the closest successor to restore some balance
+ const _closestSuccesor = closestSuccesor(currentTree.rightNode)
+ currentTree.value = _closestSuccesor.value
+ currentTree.label = _closestSuccesor.label
+ currentTree.weight = _closestSuccesor.weight
+ currentTree.rightNode = destroyclosestSuccessor(currentTree.rightNode)
+ }
+ break
+ // no default
+ }
+ }
+
+ // Recalculate the tree size and total weight
+ 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
+ currentTree.totalWeight = currentTree.weight
+ break
+ case (currentTree.leftNode !== null && currentTree.rightNode === null):
+ currentTree.numberOfLeafsAndNodes = currentTree.leftNode.numberOfLeafsAndNodes + 1
+ currentTree.totalWeight = currentTree.leftNode.totalWeight + currentTree.weight
+ break
+ case (currentTree.leftNode === null && currentTree.rightNode !== null):
+ currentTree.numberOfLeafsAndNodes = currentTree.rightNode.numberOfLeafsAndNodes + 1
+ currentTree.totalWeight = currentTree.rightNode.totalWeight + currentTree.weight
+ break
+ case (currentTree.leftNode !== null && currentTree.rightNode !== null):
+ currentTree.numberOfLeafsAndNodes = currentTree.leftNode.numberOfLeafsAndNodes + currentTree.rightNode.numberOfLeafsAndNodes + 1
+ currentTree.totalWeight = currentTree.leftNode.totalWeight + currentTree.rightNode.totalWeight + currentTree.weight
+ break
+ // no default
+ }
+ return currentTree
+ }
+
+ function closestPredecessor (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 closestPredecessor(currentTree.rightNode)
+ } else {
+ // We reached the largest value in the tree
+ return {
+ label: currentTree.label,
+ value: currentTree.value,
+ weight: currentTree.weight
+ }
+ }
+ }
+
+ function destroyclosestPredecessor (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 = destroyclosestPredecessor(currentTree.rightNode)
+ currentTree.numberOfLeafsAndNodes = currentTree.numberOfLeafsAndNodes - 1
+ let totalWeight = currentTree.weight
+ if (currentTree.rightNode !== null && currentTree.rightNode.totalWeight !== undefined) { totalWeight += currentTree.rightNode.totalWeight }
+ if (currentTree.leftNode !== null && currentTree.leftNode.totalWeight !== undefined) { totalWeight += currentTree.leftNode.totalWeight }
+ currentTree.totalWeight = totalWeight
+ 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.weight = null
+ currentTree.numberOfLeafsAndNodes = null
+ currentTree.totalWeight = null
+ return currentTree.leftNode
+ }
+ }
+
+ function closestSuccesor (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 closestSuccesor(currentTree.leftNode)
+ } else {
+ // We reached the smallest value in the tree
+ return {
+ label: currentTree.label,
+ value: currentTree.value,
+ weight: currentTree.weight
+ }
+ }
+ }
+
+ function destroyclosestSuccessor (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 = destroyclosestSuccessor(currentTree.leftNode)
+ currentTree.numberOfLeafsAndNodes = currentTree.numberOfLeafsAndNodes - 1
+ let totalWeight = currentTree.weight
+ if (currentTree.rightNode !== null && currentTree.rightNode.totalWeight !== undefined) { totalWeight += currentTree.rightNode.totalWeight }
+ if (currentTree.leftNode !== null && currentTree.leftNode.totalWeight !== undefined) { totalWeight += currentTree.leftNode.totalWeight }
+ currentTree.totalWeight = totalWeight
+ 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.weight = null
+ currentTree.numberOfLeafsAndNodes = null
+ currentTree.totalWeight = null
+ return currentTree.rightNode
+ }
+ }
+
+ /**
+ * BE AWARE, UNLIKE WITH ARRAYS, THE COUNTING STARTS WITH THE WEIGHT SUM! !!! !!!
+ * THIS LOGIC THUS WORKS DIFFERENT THAN STANDARD MEDIAN! !!!!!!
+ * @returns {float} the median of the tree
+ */
+ function median () {
+ if (tree !== null && tree.numberOfLeafsAndNodes > 0) {
+ // Standard median calculation (weight = 1 for all nodes)
+ 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
+ }
+ }
+
+ /**
+ * @returns {float} the weighed median of the entire tree, with linear interpolation between datapoints if needed
+ */
+ function weightedMedian () {
+ if (!tree || tree.totalWeight === 0) { return undefined }
+
+ const half = tree.totalWeight / 2
+ const underNode = findUndershootingNode(tree, half, 0)
+ const overNode = findOvershootingNode(tree, half, 0)
+
+ switch (true) {
+ case (!underNode && !overNode):
+ return undefined
+ case (!underNode):
+ return overNode.value
+ case (!overNode):
+ return underNode.value
+ case (underNode.cumulativeWeight === overNode.cumulativeWeight || (half === underNode.cumulativeWeight && underNode.value !== overNode.value)):
+ // If at exact boundary or weights are equal, return average
+ return (underNode.value + overNode.value) / 2
+ default:
+ // Interpolate based on where target falls in the weight range
+ // eslint-disable-next-line no-case-declarations -- Code clarity outweighs lint rules
+ const interpolationFactor = (half - underNode.cumulativeWeight) / (overNode.cumulativeWeight - underNode.cumulativeWeight)
+ return underNode.value + (overNode.value - underNode.value) * interpolationFactor
+ }
+ }
+
+ /**
+ * This helper function identifies the node that is closest below the set weight
+ */
+ function findUndershootingNode (node, targetWeight, accWeight = 0) {
+ if (!node) { return null }
+
+ const leftWeight = node.leftNode ? node.leftNode.totalWeight : 0
+ const weightBeforeNode = accWeight + leftWeight
+ const weightUpToNode = weightBeforeNode + node.weight
+
+ switch (true) {
+ case (targetWeight <= weightBeforeNode):
+ return findUndershootingNode(node.leftNode, targetWeight, accWeight)
+ case (targetWeight > weightUpToNode):
+ // eslint-disable-next-line no-case-declarations -- Code clarity outweighs lint rules
+ const rightResult = findUndershootingNode(node.rightNode, targetWeight, weightUpToNode)
+ return rightResult || { value: node.value, cumulativeWeight: weightUpToNode }
+ default:
+ return { value: node.value, cumulativeWeight: weightUpToNode }
+ }
+ }
+
+ /**
+ * This helper function identifies the node that is closest above the set weight
+ */
+ function findOvershootingNode (node, targetWeight, accWeight = 0) {
+ if (!node) { return null }
+
+ const leftWeight = node.leftNode ? node.leftNode.totalWeight : 0
+ const weightBeforeNode = accWeight + leftWeight
+ const weightUpToNode = weightBeforeNode + node.weight
+
+ switch (true) {
+ case (targetWeight < weightBeforeNode):
+ // eslint-disable-next-line no-case-declarations -- Code clarity outweighs lint rules
+ const leftResult = findOvershootingNode(node.leftNode, targetWeight, accWeight)
+ return leftResult || { value: node.value, cumulativeWeight: weightBeforeNode }
+ case (targetWeight >= weightUpToNode):
+ return findOvershootingNode(node.rightNode, targetWeight, weightUpToNode)
+ default:
+ return { value: node.value, cumulativeWeight: weightUpToNode }
+ }
+ }
+
+ /**
+ * @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
+ currentTree.weight = null
+ if (currentTree.leftNode !== null) {
+ resetTree(currentTree.leftNode)
+ currentTree.leftNode = null
+ }
+ if (currentTree.rightNode !== null) {
+ resetTree(currentTree.rightNode)
+ currentTree.rightNode = null
+ }
+ currentTree.numberOfLeafsAndNodes = null
+ currentTree.totalWeight = null
+ }
+ }
+
+ return {
+ push,
+ remove,
+ size,
+ totalWeight,
+ numberOfValuesAbove,
+ numberOfValuesEqualOrBelow,
+ minimum,
+ maximum,
+ median,
+ weightedMedian,
+ 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..2e6c1151ff
--- /dev/null
+++ b/app/engine/utils/BinarySearchTree.test.js
@@ -0,0 +1,244 @@
+'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)
+ testTotalWeight(dataTree, 0)
+ testNumberOfValuesAbove(dataTree, 0, 0)
+ testNumberOfValuesEqualOrBelow(dataTree, 0, 0)
+ testNumberOfValuesAbove(dataTree, 10, 0)
+ testNumberOfValuesEqualOrBelow(dataTree, 10, 0)
+ testMedian(dataTree, 0)
+ testWeightedMedian(dataTree, undefined)
+})
+
+test('Tree behaviour with a single pushed value. Tree = [9]', () => {
+ const dataTree = createLabelledBinarySearchTree()
+ dataTree.push(1, 9, 1)
+ testOrderedSeries(dataTree, [9])
+ testSize(dataTree, 1)
+ testTotalWeight(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)
+ testWeightedMedian(dataTree, 9)
+})
+
+test('Tree behaviour with a second pushed value. Tree = [9, 3]', () => {
+ const dataTree = createLabelledBinarySearchTree()
+ dataTree.push(1, 9, 1)
+ dataTree.push(2, 3, 1)
+ testOrderedSeries(dataTree, [3, 9])
+ testSize(dataTree, 2)
+ testTotalWeight(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)
+ testWeightedMedian(dataTree, 6)
+})
+
+test('Tree behaviour with a third pushed value. Tree = [9, 3, 6]', () => {
+ const dataTree = createLabelledBinarySearchTree()
+ dataTree.push(1, 9, 1)
+ dataTree.push(2, 3, 1)
+ dataTree.push(3, 6, 1)
+ testOrderedSeries(dataTree, [3, 6, 9])
+ testSize(dataTree, 3)
+ testTotalWeight(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)
+ testWeightedMedian(dataTree, 6)
+})
+
+test('Tree behaviour with a fourth pushed value. Tree = [3, 6, 12]', () => {
+ const dataTree = createLabelledBinarySearchTree()
+ dataTree.push(1, 9, 0.5)
+ dataTree.push(2, 3, 0)
+ dataTree.push(3, 6, 1)
+ testSize(dataTree, 3)
+ testTotalWeight(dataTree, 1.5)
+ dataTree.remove(1)
+ testSize(dataTree, 2)
+ testTotalWeight(dataTree, 1)
+ dataTree.push(4, 12, 1)
+ testOrderedSeries(dataTree, [3, 6, 12])
+ testSize(dataTree, 3)
+ testTotalWeight(dataTree, 2)
+ 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)
+ testWeightedMedian(dataTree, 9)
+})
+
+test('Tree behaviour with a fifth pushed value. Series = [6, 12, -3]', () => {
+ const dataTree = createLabelledBinarySearchTree()
+ dataTree.push(1, 9, 0)
+ dataTree.push(2, 3, 0)
+ dataTree.push(3, 6, 0)
+ dataTree.remove(1)
+ dataTree.push(4, 12, 1)
+ dataTree.remove(2)
+ dataTree.push(5, -3, 0)
+ testOrderedSeries(dataTree, [-3, 6, 12])
+ testSize(dataTree, 3)
+ testTotalWeight(dataTree, 1)
+ 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)
+ testWeightedMedian(dataTree, 12)
+})
+
+test('Tree behaviour with complex removals. Series = [9, 6, 5, 8, 7, 9, 12, 10, 11]', () => {
+ const dataTree = createLabelledBinarySearchTree()
+ dataTree.push(1, 9, 0.5)
+ dataTree.push(2, 6, 0.5)
+ dataTree.push(3, 5, 1)
+ dataTree.push(4, 8, 0.5)
+ dataTree.push(5, 7, 1)
+ dataTree.push(6, 9, 1)
+ dataTree.push(7, 12, 1)
+ dataTree.push(8, 10, 1)
+ dataTree.push(9, 11, 1)
+ testOrderedSeries(dataTree, [5, 6, 7, 8, 9, 9, 10, 11, 12])
+ testSize(dataTree, 9)
+ testTotalWeight(dataTree, 7.5)
+ testValueAtInorderPos(dataTree, 5, 9)
+ testMedian(dataTree, 9)
+ testMedian(dataTree, 9)
+ dataTree.remove(1)
+ testOrderedSeries(dataTree, [5, 6, 7, 8, 9, 10, 11, 12])
+ testSize(dataTree, 8)
+ testTotalWeight(dataTree, 7)
+ testValueAtInorderPos(dataTree, 4, 8)
+ testValueAtInorderPos(dataTree, 5, 9)
+ testMedian(dataTree, 8.5)
+ testWeightedMedian(dataTree, 9)
+ dataTree.remove(3)
+ testOrderedSeries(dataTree, [6, 7, 8, 9, 10, 11, 12])
+ testSize(dataTree, 7)
+ testTotalWeight(dataTree, 6)
+ testValueAtInorderPos(dataTree, 4, 9)
+ testMedian(dataTree, 9)
+ testWeightedMedian(dataTree, 9.5)
+})
+
+// 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, 1)
+ dataTree.push(2, 30, 1)
+ dataTree.push(3, 70, 0.5)
+ dataTree.push(4, 20, 1)
+ dataTree.push(5, 40, 1)
+ dataTree.push(6, 60, 1)
+ dataTree.push(7, 80, 0.5)
+ testOrderedSeries(dataTree, [20, 30, 40, 50, 60, 70, 80])
+ testSize(dataTree, 7)
+ testTotalWeight(dataTree, 6)
+ testValueAtInorderPos(dataTree, 4, 50)
+ dataTree.remove(4)
+ testOrderedSeries(dataTree, [30, 40, 50, 60, 70, 80])
+ testSize(dataTree, 6)
+ testTotalWeight(dataTree, 5)
+ testValueAtInorderPos(dataTree, 3, 50)
+ testValueAtInorderPos(dataTree, 4, 60)
+ testMedian(dataTree, 55)
+ testWeightedMedian(dataTree, 50)
+ dataTree.remove(2)
+ testOrderedSeries(dataTree, [40, 50, 60, 70, 80])
+ testSize(dataTree, 5)
+ testValueAtInorderPos(dataTree, 3, 60)
+ testMedian(dataTree, 60)
+ testWeightedMedian(dataTree, 55)
+ dataTree.remove(1)
+ testOrderedSeries(dataTree, [40, 60, 70, 80])
+ testSize(dataTree, 4)
+ testTotalWeight(dataTree, 3)
+ testValueAtInorderPos(dataTree, 2, 60)
+ testValueAtInorderPos(dataTree, 3, 70)
+ testMedian(dataTree, 65)
+ testWeightedMedian(dataTree, 60)
+})
+
+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 testTotalWeight (tree, expectedValue) {
+ assert.ok(tree.totalWeight() === expectedValue, `Expected weight should be ${expectedValue}, encountered ${tree.totalWeight()}`)
+}
+
+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()}`)
+}
+
+function testWeightedMedian (tree, expectedValue) {
+ assert.ok(tree.weightedMedian() === expectedValue, `Expected weighted median to be ${expectedValue}, encountered ${tree.weightedMedian()}`)
+}
+
+test.run()
diff --git a/app/engine/utils/CurveAligner.js b/app/engine/utils/CurveAligner.js
index adff69c346..11109a8aba 100644
--- a/app/engine/utils/CurveAligner.js
+++ b/app/engine/utils/CurveAligner.js
@@ -1,11 +1,11 @@
'use strict'
/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
This keeps an array, for ForceMetrics, and cleans it up
*/
-function createCurveAligner (minimumValue) {
+export function createCurveAligner (minimumValue) {
let _lastCompleteCurve = []
function push (curve) {
@@ -30,7 +30,8 @@ function createCurveAligner (minimumValue) {
}
function reset () {
- _lastCompleteCurve.splice(0, _lastCompleteCurve.length)
+ _lastCompleteCurve = null
+ _lastCompleteCurve = []
}
return {
@@ -39,5 +40,3 @@ function createCurveAligner (minimumValue) {
reset
}
}
-
-export { createCurveAligner }
diff --git a/app/engine/utils/CyclicErrorFilter.js b/app/engine/utils/CyclicErrorFilter.js
new file mode 100644
index 0000000000..3ba97ac126
--- /dev/null
+++ b/app/engine/utils/CyclicErrorFilter.js
@@ -0,0 +1,280 @@
+'use strict'
+/**
+ * @copyright [OpenRowingMonitor]{@link https://github.com/JaapvanEkris/openrowingmonitor}
+ *
+ * @file This implements a cyclic error filter. This is used to create a profile
+ * The filterArray does the calculation, the slope and intercept arrays contain the results for easy retrieval
+ * the slopeCorrection and interceptCorrection ensure preventing time dilation due to excessive corrections
+ * @see {@link https://github.com/JaapvanEkris/openrowingmonitor/blob/main/docs/Mathematical_Foundations.md|for the underlying math description)
+ */
+import loglevel from 'loglevel'
+import { createSeries } from './Series.js'
+import { createWLSLinearSeries } from './WLSLinearSeries.js'
+
+const log = loglevel.getLogger('RowingEngine')
+
+/**
+ * @param {object} rowerSettings - The rower settings configuration object
+ * @param {integer} rowerSettings.numOfImpulsesPerRevolution - Number of impulses per flywheel revolution
+ * @param {integer} rowerSettings.flankLength - Length of the flank used
+ * @param {boolean} rowerSettings.autoAdjustDragFactor - Indicates if the Flywheel.js is allowed to automatically adjust dragfactor (false turns the filter off)
+ * @param {float} rowerSettings.systematicErrorAgressiveness - Agressiveness of the systematic error correction algorithm (0 turns the filter off)
+ * @param {integer} rowerSettings.systematicErrorNumberOfDatapoints - size of the systematic error correction algorithm filter
+ * @param {float} rowerSettings.minimumTimeBetweenImpulses - minimum expected time between impulses (in seconds)
+ * @param {float} rowerSettings.maximumTimeBetweenImpulses - maximum expected time between impulses (in seconds)
+ * @param {function} deltaTime - injection of the linear regression function used for the drag calculation
+ */
+export function createCyclicErrorFilter (rowerSettings, deltaTime) {
+ const CECFilterEnabled = (rowerSettings.autoAdjustDragFactor && rowerSettings.numOfImpulsesPerRevolution > 1 && rowerSettings.systematicErrorNumberOfDatapoints > 0 && rowerSettings.systematicErrorAgressiveness > 0)
+ const _numberOfMagnets = rowerSettings.numOfImpulsesPerRevolution
+ const _flankLength = rowerSettings.flankLength
+ const _agressiveness = Math.min(Math.max(rowerSettings.systematicErrorAgressiveness, 0), 1.5)
+ const _invAgressiveness = Math.min(Math.max(1 - _agressiveness, 0), 1)
+ const _numberOfFilterSamples = Math.max(Math.round((rowerSettings.systematicErrorNumberOfDatapoints / _numberOfMagnets)), 5)
+ const _minimumTimeBetweenImpulses = rowerSettings.minimumTimeBetweenImpulses
+ const _maximumTimeBetweenImpulses = rowerSettings.maximumTimeBetweenImpulses
+ const raw = createSeries(_flankLength)
+ const clean = createSeries(_flankLength)
+ const goodnessOfFit = createSeries(_flankLength)
+ const linearRegressor = deltaTime
+ const domainBorder = (_minimumTimeBetweenImpulses > 0 ? _minimumTimeBetweenImpulses : 0.0001)
+ let recordedRelativePosition = []
+ let recordedAbsolutePosition = []
+ let recordedRawValue = []
+ let filterArray = []
+ let slope = []
+ let intercept = []
+ let startPosition
+ let lowerCursor
+ let upperCursor
+ let slopeSum = _numberOfMagnets
+ let interceptSum = 0
+ let slopeCorrection = 1
+ let interceptCorrection = 0
+ resetFilterConfiguration()
+
+ /**
+ * @param {float} the raw recorded value to be cleaned up
+ * @param {integer} the position of the flywheel
+ * @returns {object} result
+ * @returns {float} result.clean - the resulting clean value
+ * @returns {float} result.goodnessOfFit - The goodness of fit indication for the specific datapoint
+ * @description Applies the filter on the raw value for the given position (i.e. magnet). Please note: this function is NOT stateless, it also fills a hystoric buffer of raw and clean values
+ */
+ function applyFilter (rawValue, position) {
+ if (startPosition === undefined) { startPosition = position + _flankLength }
+ const magnet = position % _numberOfMagnets
+ raw.push(rawValue)
+
+ if (CECFilterEnabled) {
+ const cleanValue = projectX(magnet, rawValue)
+ clean.push(cleanValue)
+ goodnessOfFit.push(filterArray[magnet].goodnessOfFit() * domainFit(rawValue) * domainFit(cleanValue))
+ } else {
+ // In essence, the filter is turned off
+ clean.push(rawValue)
+ goodnessOfFit.push(domainFit(rawValue) * domainFit(rawValue))
+ }
+
+ return {
+ clean: clean.atSeriesEnd(),
+ goodnessOfFit: goodnessOfFit.atSeriesEnd()
+ }
+ }
+
+ /**
+ * @param {integer} magnet - the magnet number
+ * @param {float} rawValue - the raw value to be projected by the function for that magnet
+ * @returns {float} projected result
+ */
+ function projectX (magnet, rawValue) {
+ return (rawValue * slope[magnet] * slopeCorrection) + (intercept[magnet] - interceptCorrection)
+ }
+
+ /**
+ * @param {float} rawValue - the raw value to be mapped onto the domain
+ * @returns {float} an indication of the fit with the domain
+ * @description a very crude approach to downgrade the weight outliers bring to the Weighed TS algorithms. Extremely crude, but it works.
+ */
+ function domainFit (value) {
+ switch (true) {
+ case (value < _minimumTimeBetweenImpulses):
+ // We are below the intended range
+ return Math.min(Math.max(1 - ((_minimumTimeBetweenImpulses - value) / domainBorder), 0.001), 1)
+ case (value > _maximumTimeBetweenImpulses):
+ // We are above the intended range
+ return Math.min(Math.max(1 - ((value - _maximumTimeBetweenImpulses) / domainBorder), 0.001), 1)
+ default:
+ // We are inside the intended range
+ return 1
+ }
+ }
+
+ /**
+ * @returns {object} result - provides the (oldest) object at the head of the FiFo buffer, as once returned as a repsonse to the 'applyFilter()' function
+ * @returns {float} result.clean - the resulting clean value as once returned
+ * @returns {float} result.raw - the initial (raw) datapoint before applying the filter
+ * @returns {float} result.goodnessOfFit - The goodness of fit indication for the specific datapoint
+ */
+ function atSeriesBegin () {
+ if (clean.length() >= _flankLength) {
+ return {
+ clean: clean.atSeriesBegin(),
+ raw: raw.atSeriesBegin(),
+ goodnessOfFit: goodnessOfFit.atSeriesBegin()
+ }
+ } else {
+ return {
+ clean: undefined,
+ raw: undefined,
+ goodnessOfFit: 0
+ }
+ }
+ }
+
+ /**
+ * @param {integer} relativePosition - the position of the recorded datapoint (i.e the sequence number of the datapoint)
+ * @param {float} absolutePosition - the total spinning time of the flywheel
+ * @param {float} rawValue - the raw value
+ */
+ function recordRawDatapoint (relativePosition, absolutePosition, rawValue) {
+ if (CECFilterEnabled && rawValue >= _minimumTimeBetweenImpulses && _maximumTimeBetweenImpulses >= rawValue) {
+ recordedRelativePosition.push(relativePosition)
+ recordedAbsolutePosition.push(absolutePosition)
+ recordedRawValue.push(rawValue)
+ }
+ }
+
+ /**
+ * @description This processes a next two datapoints from the queue
+ */
+ function processNextRawDatapoint () {
+ let perfectCurrentDt
+ let weightCorrectedCorrectedDatapoint
+ let GoF
+
+ // If the Filter isn't active, don't even start processing data, as there is nothing there
+ if (!CECFilterEnabled || recordedRawValue.length < 1) { return }
+
+ if (lowerCursor === undefined || upperCursor === undefined) {
+ lowerCursor = Math.ceil(recordedRelativePosition.length * 0.1)
+ upperCursor = Math.floor(recordedRelativePosition.length * 0.9)
+ }
+
+ if (lowerCursor < upperCursor && recordedRelativePosition[lowerCursor] > startPosition) {
+ perfectCurrentDt = linearRegressor.projectX(recordedAbsolutePosition[lowerCursor])
+ weightCorrectedCorrectedDatapoint = (_invAgressiveness * recordedRawValue[lowerCursor]) + (_agressiveness * perfectCurrentDt)
+ GoF = linearRegressor.goodnessOfFit() * linearRegressor.localGoodnessOfFit(lowerCursor)
+ updateFilter(recordedRelativePosition[lowerCursor] % _numberOfMagnets, recordedRawValue[lowerCursor], weightCorrectedCorrectedDatapoint, GoF)
+ }
+ lowerCursor++
+
+ if (lowerCursor < upperCursor && recordedRelativePosition[upperCursor] > startPosition) {
+ perfectCurrentDt = linearRegressor.projectX(recordedAbsolutePosition[upperCursor])
+ weightCorrectedCorrectedDatapoint = (_invAgressiveness * recordedRawValue[upperCursor]) + (_agressiveness * perfectCurrentDt)
+ GoF = linearRegressor.goodnessOfFit() * linearRegressor.localGoodnessOfFit(upperCursor)
+ updateFilter(recordedRelativePosition[upperCursor] % _numberOfMagnets, recordedRawValue[upperCursor], weightCorrectedCorrectedDatapoint, GoF)
+ }
+ upperCursor--
+ }
+
+ /**
+ * @description Helper function to actually update the filter and calculate all dependent parameters
+ */
+ function updateFilter (magnet, rawDatapoint, correctedDatapoint, goodnessOfFit) {
+ slopeSum -= slope[magnet]
+ interceptSum -= intercept[magnet]
+ filterArray[magnet].push(rawDatapoint, correctedDatapoint, goodnessOfFit)
+ slope[magnet] = filterArray[magnet].slope()
+ slopeSum += slope[magnet]
+ if (slopeSum !== 0) { slopeCorrection = _numberOfMagnets / slopeSum }
+ intercept[magnet] = filterArray[magnet].intercept()
+ interceptSum += intercept[magnet]
+ interceptCorrection = interceptSum / _numberOfMagnets
+ }
+
+ /**
+ * @description This function is used for forcefully clearing the buffers when the buffer is filled with a recovery with too weak GoF
+ */
+ function forceFlushDatapointBuffer () {
+ if (recordedRawValue.length > 1) { log.info('*** Cyclic error filter: cleared datapoint buffer before processing its datapoints has started (recovery GoF was too weak)') }
+ clearDatapointBuffer()
+ }
+
+ /**
+ * @description This function is used for clearing the buffers in order to prepare to record for a new set of datapoints
+ */
+ function clearDatapointBuffer () {
+ // Only clear the buffer if there is something to clear
+ if (CECFilterEnabled && recordedRawValue.length > 0) {
+ recordedRelativePosition = []
+ recordedAbsolutePosition = []
+ recordedRawValue = []
+ lowerCursor = undefined
+ upperCursor = undefined
+ }
+ }
+
+ /**
+ * @description This function is used for clearing the predictive buffers as the flywheel seems to have stopped
+ */
+ function resetFilterConfiguration () {
+ if (slopeSum !== _numberOfMagnets || interceptSum !== 0) { log.debug('*** WARNING: cyclic error filter has configuration forcefully been reset') }
+ const noIncrements = _numberOfFilterSamples
+ const increment = (_maximumTimeBetweenImpulses - _minimumTimeBetweenImpulses) / noIncrements
+
+ lowerCursor = undefined
+ clearDatapointBuffer()
+
+ let i = 0
+ let j = 0
+ let datapoint = 0
+ while (i < _numberOfMagnets) {
+ if (i < filterArray.length) {
+ filterArray[i]?.reset()
+ } else {
+ filterArray[i] = createWLSLinearSeries(_numberOfFilterSamples)
+ }
+ j = 0
+ while (j <= noIncrements) {
+ // This initializes this filter with an identity function (the clean value will be identical to the raw value), to allow a controlled startup of the filter
+ datapoint = _maximumTimeBetweenImpulses - (j * increment)
+ filterArray[i].push(datapoint, datapoint, 0.5)
+ j++
+ }
+ slope[i] = 1
+ intercept[i] = 0
+ i++
+ }
+ slopeSum = _numberOfMagnets
+ interceptSum = 0
+ slopeCorrection = 1
+ interceptCorrection = 0
+ startPosition = undefined
+ }
+
+ /**
+ * @description This function is used for clearing all buffers (i.e. the currentDt's maintained in the flank and the predictive buffers) when the flywheel is completely reset
+ */
+ function reset () {
+ log.debug('*** WARNING: cyclic error filter is reset')
+ slopeSum = _numberOfMagnets
+ interceptSum = 0
+ resetFilterConfiguration()
+ raw.reset()
+ clean.reset()
+ goodnessOfFit.reset()
+ }
+
+ return {
+ applyFilter,
+ recordRawDatapoint,
+ processNextRawDatapoint,
+ updateFilter,
+ atSeriesBegin,
+ forceFlushDatapointBuffer,
+ clearDatapointBuffer,
+ resetFilterConfiguration,
+ reset
+ }
+}
diff --git a/app/engine/utils/CyclicErrorFilter.test.js b/app/engine/utils/CyclicErrorFilter.test.js
new file mode 100644
index 0000000000..a573cfb768
--- /dev/null
+++ b/app/engine/utils/CyclicErrorFilter.test.js
@@ -0,0 +1,1523 @@
+'use strict'
+/**
+ * @copyright [OpenRowingMonitor]{@link https://github.com/JaapvanEkris/openrowingmonitor}
+ *
+ * @file This tests all functions of the CyclicErrorCorrection filter
+ */
+import { test } from 'uvu'
+import * as assert from 'uvu/assert'
+
+import { createCyclicErrorFilter } from './CyclicErrorFilter.js'
+import { createTSLinearSeries } from './TSLinearSeries.js'
+
+test('Correct behaviour of the filter directly after initialisation, withou filter updates, including domain filter behaviour and sync with flank', () => {
+ const baseRowerConfig = {
+ numOfImpulsesPerRevolution: 2,
+ flankLength: 4,
+ autoAdjustDragFactor: true,
+ systematicErrorAgressiveness: 1.0,
+ systematicErrorNumberOfDatapoints: 20,
+ minimumTimeBetweenImpulses: 0.5,
+ maximumTimeBetweenImpulses: 1
+ }
+ const baseRegressionFunction = createTSLinearSeries(20)
+
+ let cleanCurrentDt
+ let currentDtAtSeriesBegin
+ const CECFilter = createCyclicErrorFilter(baseRowerConfig, baseRegressionFunction)
+ // As no changes have been made to the filter profiles, one would expect that clean = raw values, and a Goodness of Fit of 1 inside the domain
+ cleanCurrentDt = CECFilter.applyFilter(1.6, 5)
+ testCleanValueEquals(cleanCurrentDt, 1.6)
+ testGoodnessOfFitEquals(cleanCurrentDt, 0.000001)
+ currentDtAtSeriesBegin = CECFilter.atSeriesBegin()
+ testRawValueAtBeginEquals(currentDtAtSeriesBegin, undefined)
+ testCleanValueAtBeginEquals(currentDtAtSeriesBegin, undefined)
+ testGoodnessOfFitAtBeginEquals(currentDtAtSeriesBegin, 0)
+ cleanCurrentDt = CECFilter.applyFilter(1.5, 6)
+ testCleanValueEquals(cleanCurrentDt, 1.5)
+ testGoodnessOfFitEquals(cleanCurrentDt, 0.000001)
+ currentDtAtSeriesBegin = CECFilter.atSeriesBegin()
+ testRawValueAtBeginEquals(currentDtAtSeriesBegin, undefined)
+ testCleanValueAtBeginEquals(currentDtAtSeriesBegin, undefined)
+ testGoodnessOfFitAtBeginEquals(currentDtAtSeriesBegin, 0)
+ cleanCurrentDt = CECFilter.applyFilter(1.4, 7)
+ testCleanValueEquals(cleanCurrentDt, 1.4)
+ testGoodnessOfFitEquals(cleanCurrentDt, 0.04000000000000007)
+ currentDtAtSeriesBegin = CECFilter.atSeriesBegin()
+ testRawValueAtBeginEquals(currentDtAtSeriesBegin, undefined)
+ testCleanValueAtBeginEquals(currentDtAtSeriesBegin, undefined)
+ testGoodnessOfFitAtBeginEquals(currentDtAtSeriesBegin, 0)
+ cleanCurrentDt = CECFilter.applyFilter(1.3, 8)
+ testCleanValueEquals(cleanCurrentDt, 1.3)
+ testGoodnessOfFitEquals(cleanCurrentDt, 0.15999999999999992)
+ currentDtAtSeriesBegin = CECFilter.atSeriesBegin()
+ testRawValueAtBeginEquals(currentDtAtSeriesBegin, 1.6)
+ testCleanValueAtBeginEquals(currentDtAtSeriesBegin, 1.6)
+ testGoodnessOfFitAtBeginEquals(currentDtAtSeriesBegin, 0.000001)
+ cleanCurrentDt = CECFilter.applyFilter(1.2, 9)
+ testCleanValueEquals(cleanCurrentDt, 1.2)
+ testGoodnessOfFitEquals(cleanCurrentDt, 0.3600000000000001)
+ currentDtAtSeriesBegin = CECFilter.atSeriesBegin()
+ testRawValueAtBeginEquals(currentDtAtSeriesBegin, 1.5)
+ testCleanValueAtBeginEquals(currentDtAtSeriesBegin, 1.5)
+ testGoodnessOfFitAtBeginEquals(currentDtAtSeriesBegin, 0.000001)
+ cleanCurrentDt = CECFilter.applyFilter(1.1, 10)
+ testCleanValueEquals(cleanCurrentDt, 1.1)
+ testGoodnessOfFitEquals(cleanCurrentDt, 0.6399999999999997)
+ currentDtAtSeriesBegin = CECFilter.atSeriesBegin()
+ testRawValueAtBeginEquals(currentDtAtSeriesBegin, 1.4)
+ testCleanValueAtBeginEquals(currentDtAtSeriesBegin, 1.4)
+ testGoodnessOfFitAtBeginEquals(currentDtAtSeriesBegin, 0.04000000000000007)
+ cleanCurrentDt = CECFilter.applyFilter(1.0, 11)
+ testCleanValueEquals(cleanCurrentDt, 1.0)
+ testGoodnessOfFitEquals(cleanCurrentDt, 1.0)
+ currentDtAtSeriesBegin = CECFilter.atSeriesBegin()
+ testRawValueAtBeginEquals(currentDtAtSeriesBegin, 1.3)
+ testCleanValueAtBeginEquals(currentDtAtSeriesBegin, 1.3)
+ testGoodnessOfFitAtBeginEquals(currentDtAtSeriesBegin, 0.15999999999999992)
+ cleanCurrentDt = CECFilter.applyFilter(0.9, 12)
+ testCleanValueEquals(cleanCurrentDt, 0.9)
+ testGoodnessOfFitEquals(cleanCurrentDt, 1.0)
+ currentDtAtSeriesBegin = CECFilter.atSeriesBegin()
+ testRawValueAtBeginEquals(currentDtAtSeriesBegin, 1.2)
+ testCleanValueAtBeginEquals(currentDtAtSeriesBegin, 1.2)
+ testGoodnessOfFitAtBeginEquals(currentDtAtSeriesBegin, 0.3600000000000001)
+ cleanCurrentDt = CECFilter.applyFilter(0.8, 13)
+ testCleanValueEquals(cleanCurrentDt, 0.8)
+ testGoodnessOfFitEquals(cleanCurrentDt, 1.0)
+ currentDtAtSeriesBegin = CECFilter.atSeriesBegin()
+ testRawValueAtBeginEquals(currentDtAtSeriesBegin, 1.1)
+ testCleanValueAtBeginEquals(currentDtAtSeriesBegin, 1.1)
+ testGoodnessOfFitAtBeginEquals(currentDtAtSeriesBegin, 0.6399999999999997)
+ cleanCurrentDt = CECFilter.applyFilter(0.7, 14)
+ testCleanValueEquals(cleanCurrentDt, 0.7)
+ testGoodnessOfFitEquals(cleanCurrentDt, 1.0)
+ currentDtAtSeriesBegin = CECFilter.atSeriesBegin()
+ testRawValueAtBeginEquals(currentDtAtSeriesBegin, 1.0)
+ testCleanValueAtBeginEquals(currentDtAtSeriesBegin, 1.0)
+ testGoodnessOfFitAtBeginEquals(currentDtAtSeriesBegin, 1.0)
+ cleanCurrentDt = CECFilter.applyFilter(0.6, 15)
+ testCleanValueEquals(cleanCurrentDt, 0.6)
+ testGoodnessOfFitEquals(cleanCurrentDt, 1.0)
+ currentDtAtSeriesBegin = CECFilter.atSeriesBegin()
+ testRawValueAtBeginEquals(currentDtAtSeriesBegin, 0.9)
+ testCleanValueAtBeginEquals(currentDtAtSeriesBegin, 0.9)
+ testGoodnessOfFitAtBeginEquals(currentDtAtSeriesBegin, 1.0)
+ cleanCurrentDt = CECFilter.applyFilter(0.5, 16)
+ testCleanValueEquals(cleanCurrentDt, 0.5)
+ testGoodnessOfFitEquals(cleanCurrentDt, 1.0)
+ currentDtAtSeriesBegin = CECFilter.atSeriesBegin()
+ testRawValueAtBeginEquals(currentDtAtSeriesBegin, 0.8)
+ testCleanValueAtBeginEquals(currentDtAtSeriesBegin, 0.8)
+ testGoodnessOfFitAtBeginEquals(currentDtAtSeriesBegin, 1.0)
+ cleanCurrentDt = CECFilter.applyFilter(0.4, 17)
+ testCleanValueEquals(cleanCurrentDt, 0.4)
+ testGoodnessOfFitEquals(cleanCurrentDt, 0.6400000000000001)
+ currentDtAtSeriesBegin = CECFilter.atSeriesBegin()
+ testRawValueAtBeginEquals(currentDtAtSeriesBegin, 0.7)
+ testCleanValueAtBeginEquals(currentDtAtSeriesBegin, 0.7)
+ testGoodnessOfFitAtBeginEquals(currentDtAtSeriesBegin, 1.0)
+ cleanCurrentDt = CECFilter.applyFilter(0.3, 18)
+ testCleanValueEquals(cleanCurrentDt, 0.3)
+ testGoodnessOfFitEquals(cleanCurrentDt, 0.36)
+ currentDtAtSeriesBegin = CECFilter.atSeriesBegin()
+ testRawValueAtBeginEquals(currentDtAtSeriesBegin, 0.6)
+ testCleanValueAtBeginEquals(currentDtAtSeriesBegin, 0.6)
+ testGoodnessOfFitAtBeginEquals(currentDtAtSeriesBegin, 1.0)
+ cleanCurrentDt = CECFilter.applyFilter(0.2, 19)
+ testCleanValueEquals(cleanCurrentDt, 0.2)
+ testGoodnessOfFitEquals(cleanCurrentDt, 0.16000000000000003)
+ currentDtAtSeriesBegin = CECFilter.atSeriesBegin()
+ testRawValueAtBeginEquals(currentDtAtSeriesBegin, 0.5)
+ testCleanValueAtBeginEquals(currentDtAtSeriesBegin, 0.5)
+ testGoodnessOfFitAtBeginEquals(currentDtAtSeriesBegin, 1.0)
+ cleanCurrentDt = CECFilter.applyFilter(0.1, 20)
+ testCleanValueEquals(cleanCurrentDt, 0.1)
+ testGoodnessOfFitEquals(cleanCurrentDt, 0.03999999999999998)
+ currentDtAtSeriesBegin = CECFilter.atSeriesBegin()
+ testRawValueAtBeginEquals(currentDtAtSeriesBegin, 0.4)
+ testCleanValueAtBeginEquals(currentDtAtSeriesBegin, 0.4)
+ testGoodnessOfFitAtBeginEquals(currentDtAtSeriesBegin, 0.6400000000000001)
+ cleanCurrentDt = CECFilter.applyFilter(0.0, 21)
+ testCleanValueEquals(cleanCurrentDt, 0.0)
+ testGoodnessOfFitEquals(cleanCurrentDt, 0.000001)
+ currentDtAtSeriesBegin = CECFilter.atSeriesBegin()
+ testRawValueAtBeginEquals(currentDtAtSeriesBegin, 0.3)
+ testCleanValueAtBeginEquals(currentDtAtSeriesBegin, 0.3)
+ testGoodnessOfFitAtBeginEquals(currentDtAtSeriesBegin, 0.36)
+ cleanCurrentDt = CECFilter.applyFilter(-0.1, 22)
+ testCleanValueEquals(cleanCurrentDt, -0.1)
+ testGoodnessOfFitEquals(cleanCurrentDt, 0.000001)
+ currentDtAtSeriesBegin = CECFilter.atSeriesBegin()
+ testRawValueAtBeginEquals(currentDtAtSeriesBegin, 0.2)
+ testCleanValueAtBeginEquals(currentDtAtSeriesBegin, 0.2)
+ testGoodnessOfFitAtBeginEquals(currentDtAtSeriesBegin, 0.16000000000000003)
+})
+
+test('Correct behaviour of the filter after exposing it to an updated filter, agressiveness 1.0', () => {
+ const baseRowerConfig = {
+ numOfImpulsesPerRevolution: 2,
+ flankLength: 2,
+ autoAdjustDragFactor: true,
+ systematicErrorAgressiveness: 1.0,
+ systematicErrorNumberOfDatapoints: 20,
+ minimumTimeBetweenImpulses: 0,
+ maximumTimeBetweenImpulses: 25
+ }
+
+ const baseRegressionFunction = createTSLinearSeries(24)
+ let cleanCurrentDt = []
+ const CECFilter = createCyclicErrorFilter(baseRowerConfig, baseRegressionFunction)
+
+ // Initialize the Linear regressor to create a function where y = x
+ let i = 0
+ while (i < 24) {
+ baseRegressionFunction.push(i, i)
+ i++
+ }
+
+ testRegressorSlopeEquals(baseRegressionFunction, 1) // Ideal value 1.0
+ testRegressorInterceptEquals(baseRegressionFunction, 0) // Ideal value 0
+ testRegressorGoodnessOfFitEquals(baseRegressionFunction, 1) // Ideal value 1
+
+ // Initialise the starting point
+ cleanCurrentDt[0] = CECFilter.applyFilter(0, 0)
+
+ // Complete dataset, which will be reused for each cycle. In essence y = x, where
+ // - the even datapoints are multipied by 1.1 (so correction factor should become 1/1.1 = 0.9)
+ // - the odd datapoints are multipied by 0.9 (so correction factor should become 1/0.9 = 1.1)
+ let testDataArray = []
+ testDataArray[0] = 0
+ testDataArray[1] = 0.9
+ testDataArray[2] = 2.2
+ testDataArray[3] = 2.7
+ testDataArray[4] = 4.4
+ testDataArray[5] = 4.5
+ testDataArray[6] = 6.6
+ testDataArray[7] = 6.3
+ testDataArray[8] = 8.8
+ testDataArray[9] = 8.1
+ testDataArray[10] = 11.0
+ testDataArray[11] = 9.9
+ testDataArray[12] = 13.2
+ testDataArray[13] = 11.7
+ testDataArray[14] = 15.4
+ testDataArray[15] = 13.5
+ testDataArray[16] = 17.6
+ testDataArray[17] = 15.3
+ testDataArray[18] = 19.8
+ testDataArray[19] = 17.1
+ testDataArray[20] = 22.0
+ testDataArray[21] = 18.9
+ testDataArray[22] = 24.2
+ testDataArray[23] = 20.7
+
+ // Injecting the datapoints.
+ // The even datapoints are multipied by 1.1 (so correction factor should become 1/1.1 = 0.9)
+ // the odd datapoints are multipied by 0.9 (so correction factor is 1/0.9 = 1.1)
+ for (let i = 0; i < testDataArray.length; i++) {
+ CECFilter.recordRawDatapoint(i, i, testDataArray[i])
+ }
+
+ // Process the datapoints, filling the entire array
+ for (let i = 0; i < Math.ceil(testDataArray.length / 2); i++) {
+ CECFilter.processNextRawDatapoint()
+ }
+
+ // Filter the total series of raw datapoints
+ for (let i = 0; i < testDataArray.length; i++) {
+ cleanCurrentDt[i] = CECFilter.applyFilter(testDataArray[i], (30 + i))
+ }
+
+ // Check the results
+ // For even magnets, the correction factor should be 0.9
+ // For odd magnets, the correction factor should be 1.1
+ testCleanValueObject(cleanCurrentDt[0], 0.021405367123935835, 0.9999370176071775) // Insterted value 0.0, ideal value 0.0
+ testCleanValueObject(cleanCurrentDt[1], 0.9698421280169801, 1.0000000000000027) // Insterted value 0.9, ideal value 1.0
+ testCleanValueObject(cleanCurrentDt[2], 1.9983559345572512, 0.9999370176071775) // Insterted value 2.2, ideal value 2.0
+ testCleanValueObject(cleanCurrentDt[3], 2.952337118298812, 1.0000000000000027) // Insterted value 2.7, ideal value 3.0
+ testCleanValueObject(cleanCurrentDt[4], 3.9753065019905667, 0.9999370176071775) // Insterted value 4.4, ideal Value 4.0
+ testCleanValueObject(cleanCurrentDt[5], 4.934832108580643, 1.0000000000000027) // Insterted value 4.5, ideal value 5.0
+ testCleanValueObject(cleanCurrentDt[6], 5.952257069423881, 0.9999370176071775) // Insterted value 6.6, ideal value 6.0
+ testCleanValueObject(cleanCurrentDt[7], 6.917327098862476, 1.0000000000000027) // Insterted value 6.3, ideal value 7.0
+ testCleanValueObject(cleanCurrentDt[8], 7.929207636857198, 0.9999370176071775) // Insterted value 8.8, ideal Value 8.0
+ testCleanValueObject(cleanCurrentDt[9], 8.899822089144307, 1.0000000000000027) // Insterted value 8.1, ideal value 9.0
+ testCleanValueObject(cleanCurrentDt[10], 9.906158204290513, 0.9999370176071775) // Insterted value 11.0, ideal value 10.0
+ testCleanValueObject(cleanCurrentDt[11], 10.882317079426139, 1.0000000000000027) // Insterted value 9.9, ideal value 11.0
+ testCleanValueObject(cleanCurrentDt[12], 11.883108771723826, 0.9999370176071775) // Insterted value 13.2, ideal Value 12.0
+ testCleanValueObject(cleanCurrentDt[13], 12.864812069707972, 1.0000000000000027) // Insterted value 11.7, ideal value 13.0
+ testCleanValueObject(cleanCurrentDt[14], 13.860059339157145, 0.9999370176071775) // Insterted value 15.4, ideal Value 14.0
+ testCleanValueObject(cleanCurrentDt[15], 14.847307059989802, 1.0000000000000027) // Insterted value 13.5, ideal value 15.0
+ testCleanValueObject(cleanCurrentDt[16], 15.83700990659046, 0.9999370176071775) // Insterted value 17.6, ideal value 16.0
+ testCleanValueObject(cleanCurrentDt[17], 16.829802050271635, 1.0000000000000027) /// Insterted value 15.3, ideal value 17.0
+ testCleanValueObject(cleanCurrentDt[18], 17.813960474023773, 0.9999370176071775) // Insterted value 19.8, ideal Value 18.0
+ testCleanValueObject(cleanCurrentDt[19], 18.812297040553467, 1.0000000000000027) // Insterted value 17.1, ideal value 19.0
+ testCleanValueObject(cleanCurrentDt[20], 19.79091104145709, 0.9999370176071775) // Insterted value 22.0, ideal Value 20.0
+ testCleanValueObject(cleanCurrentDt[21], 20.794792030835296, 1.0000000000000027) // Insterted value 18.9, ideal value 21.0
+ testCleanValueObject(cleanCurrentDt[22], 21.767861608890403, 0.9999370176071775) // Insterted value 24.2, ideal Value 22.0
+ testCleanValueObject(cleanCurrentDt[23], 22.77728702111713, 1.0000000000000027) // Insterted value 20.7, ideal value 23.0
+
+ baseRegressionFunction.reset()
+
+ // Here we feed the cleaned data into the regression function to see the result
+ for (let i = 0; i < cleanCurrentDt.length; i++) {
+ baseRegressionFunction.push(i + 30, cleanCurrentDt[i].clean, cleanCurrentDt[i].goodnessOfFit)
+ }
+
+ testRegressorSlopeEquals(baseRegressionFunction, 0.9898437735589946) // Ideal value 1.0
+ testRegressorInterceptEquals(baseRegressionFunction, -29.69566275016495) // Ideal value -30
+ testRegressorGoodnessOfFitEquals(baseRegressionFunction, 0.9999973816413857) // Ideal value 1
+})
+
+test('Correct behaviour of the filter after exposing it to an updated filter, agressiveness 1.1', () => {
+ const baseRowerConfig = {
+ numOfImpulsesPerRevolution: 2,
+ flankLength: 2,
+ autoAdjustDragFactor: true,
+ systematicErrorAgressiveness: 1.1,
+ systematicErrorNumberOfDatapoints: 20,
+ minimumTimeBetweenImpulses: 0,
+ maximumTimeBetweenImpulses: 25
+ }
+
+ const baseRegressionFunction = createTSLinearSeries(20)
+ let cleanCurrentDt = []
+ const CECFilter = createCyclicErrorFilter(baseRowerConfig, baseRegressionFunction)
+
+ // Initialize the Linear regressor to create a function where y = x
+ let i = 0
+ while (i < 20) {
+ baseRegressionFunction.push(i, i)
+ i++
+ }
+
+ // Initialise the starting point
+ cleanCurrentDt[0] = CECFilter.applyFilter(0, 0)
+
+ // Complete dataset, which will be reused for each cycle. In essence y = x, where
+ // - the even datapoints are multipied by 1.1 (so correction factor should become 1/1.1 = 0.9)
+ // - the odd datapoints are multipied by 0.9 (so correction factor should become 1/0.9 = 1.1)
+ let testDataArray = []
+ testDataArray[0] = 0
+ testDataArray[1] = 0.9
+ testDataArray[2] = 2.2
+ testDataArray[3] = 2.7
+ testDataArray[4] = 4.4
+ testDataArray[5] = 4.5
+ testDataArray[6] = 6.6
+ testDataArray[7] = 6.3
+ testDataArray[8] = 8.8
+ testDataArray[9] = 8.1
+ testDataArray[10] = 11.0
+ testDataArray[11] = 9.9
+ testDataArray[12] = 13.2
+ testDataArray[13] = 11.7
+ testDataArray[14] = 15.4
+ testDataArray[15] = 13.5
+ testDataArray[16] = 17.6
+ testDataArray[17] = 15.3
+ testDataArray[18] = 19.8
+ testDataArray[19] = 17.1
+ testDataArray[20] = 22.0
+ testDataArray[21] = 18.9
+ testDataArray[22] = 24.2
+ testDataArray[23] = 20.7
+
+ // Injecting the datapoints.
+ // The even datapoints are multipied by 1.1 (so correction factor should become 1/1.1 = 0.9)
+ // the odd datapoints are multipied by 0.9 (so correction factor is 1/0.9 = 1.1)
+ for (let i = 0; i < testDataArray.length; i++) {
+ baseRegressionFunction.push(i, testDataArray[i], 1)
+ CECFilter.recordRawDatapoint(i, i, testDataArray[i])
+ }
+
+ testRegressorSlopeEquals(baseRegressionFunction, 0.9000000000000004) // Ideal value 1.0
+ testRegressorInterceptEquals(baseRegressionFunction, 0.3999999999999986) // Ideal value 0
+ testRegressorGoodnessOfFitEquals(baseRegressionFunction, 0.9072021143822042) // Ideal value 1
+
+ // Process the datapoints, filling the entire array
+ for (let i = 0; i < Math.ceil(testDataArray.length / 2); i++) {
+ CECFilter.processNextRawDatapoint()
+ }
+
+ // Filter the total series of raw datapoints
+ for (let i = 0; i < testDataArray.length; i++) {
+ cleanCurrentDt[i] = CECFilter.applyFilter(testDataArray[i], (30 + i))
+ }
+
+ // Check the results
+ // For even magnets, the correction factor should be 0.9
+ // For odd magnets, the correction factor should be 1.1
+ testCleanValueObject(cleanCurrentDt[0], -0.07458906182676267, 0.0009997575806960865) // Insterted value 0.0, ideal value 0.0
+ testCleanValueObject(cleanCurrentDt[1], 1.0599883242320103, 1) // Insterted value 0.9, ideal value 1.0
+ testCleanValueObject(cleanCurrentDt[2], 1.9166571856270798, 0.9997575806960866) // Insterted value 2.2, ideal value 2.0
+ testCleanValueObject(cleanCurrentDt[3], 3.0307868490425056, 1) // Insterted value 2.7, ideal value 3.0
+ testCleanValueObject(cleanCurrentDt[4], 3.907903433080922, 0.9997575806960866) // Insterted value 4.4, ideal Value 4.0
+ testCleanValueObject(cleanCurrentDt[5], 5.001585373853001, 1) // Insterted value 4.5, ideal value 5.0
+ testCleanValueObject(cleanCurrentDt[6], 5.899149680534764, 0.9997575806960866) // Insterted value 6.6, ideal value 6.0
+ testCleanValueObject(cleanCurrentDt[7], 6.972383898663496, 1) // Insterted value 6.3, ideal value 7.0
+ testCleanValueObject(cleanCurrentDt[8], 7.890395927988607, 0.9997575806960866) // Insterted value 8.8, ideal Value 8.0
+ testCleanValueObject(cleanCurrentDt[9], 8.94318242347399, 1) // Insterted value 8.1, ideal value 9.0
+ testCleanValueObject(cleanCurrentDt[10], 9.88164217544245, 0.9997575806960866) // Insterted value 11.0, ideal value 10.0
+ testCleanValueObject(cleanCurrentDt[11], 10.913980948284486, 1) // Insterted value 9.9, ideal value 11.0
+ testCleanValueObject(cleanCurrentDt[12], 11.872888422896292, 0.9997575806960866) // Insterted value 13.2, ideal Value 12.0
+ testCleanValueObject(cleanCurrentDt[13], 12.884779473094982, 1) // Insterted value 11.7, ideal value 13.0
+ testCleanValueObject(cleanCurrentDt[14], 13.864134670350134, 0.9997575806960866) // Insterted value 15.4, ideal Value 14.0
+ testCleanValueObject(cleanCurrentDt[15], 14.855577997905476, 1) // Insterted value 13.5, ideal value 15.0
+ testCleanValueObject(cleanCurrentDt[16], 15.855380917803977, 0.9997575806960866) // Insterted value 17.6, ideal value 16.0
+ testCleanValueObject(cleanCurrentDt[17], 16.826376522715975, 1) /// Insterted value 15.3, ideal value 17.0
+ testCleanValueObject(cleanCurrentDt[18], 17.846627165257818, 0.9997575806960866) // Insterted value 19.8, ideal Value 18.0
+ testCleanValueObject(cleanCurrentDt[19], 18.79717504752647, 1) // Insterted value 17.1, ideal value 19.0
+ testCleanValueObject(cleanCurrentDt[20], 19.83787341271166, 0.9997575806960866) // Insterted value 22.0, ideal Value 20.0
+ testCleanValueObject(cleanCurrentDt[21], 20.767973572336963, 1) // Insterted value 18.9, ideal value 21.0
+ testCleanValueObject(cleanCurrentDt[22], 21.8291196601655, 0.9997575806960866) // Insterted value 24.2, ideal Value 22.0
+ testCleanValueObject(cleanCurrentDt[23], 22.738772097147457, 1) // Insterted value 20.7, ideal value 23.0
+
+ baseRegressionFunction.reset()
+
+ // Here we feed the cleaned data into the regression function to see the result
+ for (let i = 0; i < cleanCurrentDt.length; i++) {
+ baseRegressionFunction.push(i + 30, cleanCurrentDt[i].clean, cleanCurrentDt[i].goodnessOfFit)
+ }
+
+ testRegressorSlopeEquals(baseRegressionFunction, 0.9905660245066289) // Ideal value 1.0
+ testRegressorInterceptEquals(baseRegressionFunction, -29.719893104892826) // Ideal value -30
+ testRegressorGoodnessOfFitEquals(baseRegressionFunction, 0.9999726186786235) // Ideal value 1
+})
+
+test('Correct behaviour of the filter after exposing it to an updated filter with noisy linear regressor, agressiveness 1.0', () => {
+ const baseRowerConfig = {
+ numOfImpulsesPerRevolution: 2,
+ flankLength: 2,
+ autoAdjustDragFactor: true,
+ systematicErrorAgressiveness: 1.0,
+ systematicErrorNumberOfDatapoints: 20,
+ minimumTimeBetweenImpulses: 0,
+ maximumTimeBetweenImpulses: 25
+ }
+
+ const baseRegressionFunction = createTSLinearSeries()
+ let cleanCurrentDt = []
+ const CECFilter = createCyclicErrorFilter(baseRowerConfig, baseRegressionFunction)
+
+ // Initialise the starting point
+ cleanCurrentDt[0] = CECFilter.applyFilter(0, 0)
+
+ // Complete dataset, which will be reused for each cycle. In essence y = x, where
+ // - the even datapoints are multipied by 1.1 (so correction factor should become 1/1.1 = 0.9)
+ // - the odd datapoints are multipied by 0.9 (so correction factor should become 1/0.9 = 1.1)
+ let testDataArray = []
+ testDataArray[0] = 0
+ testDataArray[1] = 0.9
+ testDataArray[2] = 2.2
+ testDataArray[3] = 2.7
+ testDataArray[4] = 4.4
+ testDataArray[5] = 4.5
+ testDataArray[6] = 6.6
+ testDataArray[7] = 6.3
+ testDataArray[8] = 8.8
+ testDataArray[9] = 8.1
+ testDataArray[10] = 11.0
+ testDataArray[11] = 9.9
+ testDataArray[12] = 13.2
+ testDataArray[13] = 11.7
+ testDataArray[14] = 15.4
+ testDataArray[15] = 13.5
+ testDataArray[16] = 17.6
+ testDataArray[17] = 15.3
+ testDataArray[18] = 19.8
+ testDataArray[19] = 17.1
+ testDataArray[20] = 22.0
+ testDataArray[21] = 18.9
+ testDataArray[22] = 24.2
+ testDataArray[23] = 20.7
+
+ // Injecting the datapoints.
+ for (let i = 0; i < testDataArray.length; i++) {
+ baseRegressionFunction.push(i, testDataArray[i], 1)
+ CECFilter.recordRawDatapoint(i, i, testDataArray[i])
+ }
+
+ testRegressorSlopeEquals(baseRegressionFunction, 0.9000000000000004) // Ideal value 1.0
+ testRegressorInterceptEquals(baseRegressionFunction, -1.6653345369377348e-16) // Ideal value 0
+ testRegressorGoodnessOfFitEquals(baseRegressionFunction, 0.9305410182055288) // Ideal value 1
+
+ // Process the datapoints, filling the entire array
+ for (let i = 0; i < Math.ceil(testDataArray.length / 2); i++) {
+ CECFilter.processNextRawDatapoint()
+ }
+
+ // Filter the total series of raw datapoints
+ cleanCurrentDt = []
+ for (let i = 0; i < testDataArray.length; i++) {
+ cleanCurrentDt[i] = CECFilter.applyFilter(testDataArray[i], (60 + i))
+ }
+
+ // Check the results
+ // For even magnets, the correction factor should be 0.9
+ // For odd magnets, the correction factor should be 1.1
+ testCleanValueObject(cleanCurrentDt[0], 0.04672198397237463, 0.9996128305851867) // Insterted value 0.0, ideal value 0.0
+ testCleanValueObject(cleanCurrentDt[1], 0.9464106093514552, 0.9999999999999972) // Insterted value 0.9, ideal value 1.0
+ testCleanValueObject(cleanCurrentDt[2], 2.019064533625233, 0.9996128305851867) // Insterted value 2.2, ideal value 2.0
+ testCleanValueObject(cleanCurrentDt[3], 2.9326757959991148, 0.9999999999999972) // Insterted value 2.7, ideal value 3.0
+ testCleanValueObject(cleanCurrentDt[4], 3.9914070832780917, 0.9996128305851867) // Insterted value 4.4, ideal Value 4.0
+ testCleanValueObject(cleanCurrentDt[5], 4.918940982646774, 0.9999999999999972) // Insterted value 4.5, ideal value 5.0
+ testCleanValueObject(cleanCurrentDt[6], 5.96374963293095, 0.9996128305851867) // Insterted value 6.6, ideal value 6.0
+ testCleanValueObject(cleanCurrentDt[7], 6.905206169294433, 0.9999999999999972) // Insterted value 6.3, ideal value 7.0
+ testCleanValueObject(cleanCurrentDt[8], 7.936092182583809, 0.9996128305851867) // Insterted value 8.8, ideal Value 8.0
+ testCleanValueObject(cleanCurrentDt[9], 8.891471355942093, 0.9999999999999972) // Insterted value 8.1, ideal value 9.0
+ testCleanValueObject(cleanCurrentDt[10], 9.90843473223667, 0.9996128305851867) // Insterted value 11.0, ideal value 10.0
+ testCleanValueObject(cleanCurrentDt[11], 10.877736542589753, 0.9999999999999972) // Insterted value 9.9, ideal value 11.0
+ testCleanValueObject(cleanCurrentDt[12], 11.880777281889527, 0.9996128305851867) // Insterted value 13.2, ideal Value 12.0
+ testCleanValueObject(cleanCurrentDt[13], 12.864001729237412, 0.9999999999999972) // Insterted value 11.7, ideal value 13.0
+ testCleanValueObject(cleanCurrentDt[14], 13.853119831542385, 0.9996128305851867) // Insterted value 15.4, ideal Value 14.0
+ testCleanValueObject(cleanCurrentDt[15], 14.850266915885072, 0.9999999999999972) // Insterted value 13.5, ideal value 15.0
+ testCleanValueObject(cleanCurrentDt[16], 15.825462381195244, 0.9996128305851867) // Insterted value 17.6, ideal value 16.0
+ testCleanValueObject(cleanCurrentDt[17], 16.836532102532733, 0.9999999999999972) /// Insterted value 15.3, ideal value 17.0
+ testCleanValueObject(cleanCurrentDt[18], 17.797804930848105, 0.9996128305851867) // Insterted value 19.8, ideal Value 18.0
+ testCleanValueObject(cleanCurrentDt[19], 18.82279728918039, 0.9999999999999972) // Insterted value 17.1, ideal value 19.0
+ testCleanValueObject(cleanCurrentDt[20], 19.770147480500963, 0.9996128305851867) // Insterted value 22.0, ideal Value 20.0
+ testCleanValueObject(cleanCurrentDt[21], 20.80906247582805, 0.9999999999999972) // Insterted value 18.9, ideal value 21.0
+ testCleanValueObject(cleanCurrentDt[22], 21.74249003015382, 0.9996128305851867) // Insterted value 24.2, ideal Value 22.0
+ testCleanValueObject(cleanCurrentDt[23], 22.795327662475707, 0.9999999999999972) // Insterted value 20.7, ideal value 23.0
+
+ baseRegressionFunction.reset()
+ CECFilter.clearDatapointBuffer()
+
+ // Here we feed the cleaned data back in, simulating an active filter for the second round
+ for (let i = 0; i < cleanCurrentDt.length; i++) {
+ baseRegressionFunction.push(i + 30, cleanCurrentDt[i].clean, cleanCurrentDt[i].goodnessOfFit)
+ CECFilter.recordRawDatapoint(i + 30, i + 30, cleanCurrentDt[i].clean)
+ }
+
+ testRegressorSlopeEquals(baseRegressionFunction, 0.9896205322062969) // Ideal value 1.0
+ testRegressorInterceptEquals(baseRegressionFunction, -29.689681155633355) // Ideal value -30
+ testRegressorGoodnessOfFitEquals(baseRegressionFunction, 0.9999865281655795) // Ideal value 1
+
+ // Process the datapoints, filling the entire array
+ for (let i = 0; i < Math.ceil(testDataArray.length / 2); i++) {
+ CECFilter.processNextRawDatapoint()
+ }
+
+ // Filter the total series of raw datapoints
+ cleanCurrentDt = []
+ for (let i = 0; i < testDataArray.length; i++) {
+ cleanCurrentDt[i] = CECFilter.applyFilter(testDataArray[i], (60 + i))
+ }
+
+ // Check the results
+ // For even magnets, the correction factor should be 0.9
+ // For odd magnets, the correction factor should be 1.1
+ testCleanValueObject(cleanCurrentDt[0], -0.11435354528323852, 0.0009676341499669797) // Insterted value 0.0, ideal value 0.0
+ testCleanValueObject(cleanCurrentDt[1], 1.0212581225579935, 1.0000000000000029) // Insterted value 0.9, ideal value 1.0
+ testCleanValueObject(cleanCurrentDt[2], 2.0687685991562463, 0.9676341499669797) // Insterted value 2.2, ideal value 2.0
+ testCleanValueObject(cleanCurrentDt[3], 2.835067277107504, 1.0000000000000029) // Insterted value 2.7, ideal value 3.0
+ testCleanValueObject(cleanCurrentDt[4], 4.251890743595731, 0.9676341499669797) // Insterted value 4.4, ideal Value 4.0
+ testCleanValueObject(cleanCurrentDt[5], 4.648876431657014, 1.0000000000000029) // Insterted value 4.5, ideal value 5.0
+ testCleanValueObject(cleanCurrentDt[6], 6.4350128880352155, 0.9676341499669797) // Insterted value 6.6, ideal value 6.0
+ testCleanValueObject(cleanCurrentDt[7], 6.462685586206524, 1.0000000000000029) // Insterted value 6.3, ideal value 7.0
+ testCleanValueObject(cleanCurrentDt[8], 8.6181350324747, 0.9676341499669797) // Insterted value 8.8, ideal Value 8.0
+ testCleanValueObject(cleanCurrentDt[9], 8.276494740756034, 1.0000000000000029) // Insterted value 8.1, ideal value 9.0
+ testCleanValueObject(cleanCurrentDt[10], 10.801257176914186, 0.9676341499669797) // Insterted value 11.0, ideal value 10.0
+ testCleanValueObject(cleanCurrentDt[11], 10.090303895305546, 1.0000000000000029) // Insterted value 9.9, ideal value 11.0
+ testCleanValueObject(cleanCurrentDt[12], 12.98437932135367, 0.9676341499669797) // Insterted value 13.2, ideal Value 12.0
+ testCleanValueObject(cleanCurrentDt[13], 11.904113049855054, 1.0000000000000029) // Insterted value 11.7, ideal value 13.0
+ testCleanValueObject(cleanCurrentDt[14], 15.167501465793155, 0.9676341499669797) // Insterted value 15.4, ideal Value 14.0
+ testCleanValueObject(cleanCurrentDt[15], 13.717922204404564, 1.0000000000000029) // Insterted value 13.5, ideal value 15.0
+ testCleanValueObject(cleanCurrentDt[16], 17.35062361023264, 0.9676341499669797) // Insterted value 17.6, ideal value 16.0
+ testCleanValueObject(cleanCurrentDt[17], 15.531731358954076, 1.0000000000000029) /// Insterted value 15.3, ideal value 17.0
+ testCleanValueObject(cleanCurrentDt[18], 19.533745754672122, 0.9676341499669797) // Insterted value 19.8, ideal Value 18.0
+ testCleanValueObject(cleanCurrentDt[19], 17.345540513503586, 1.0000000000000029) // Insterted value 17.1, ideal value 19.0
+ testCleanValueObject(cleanCurrentDt[20], 21.71686789911161, 0.9676341499669797) // Insterted value 22.0, ideal Value 20.0
+ testCleanValueObject(cleanCurrentDt[21], 19.159349668053093, 1.0000000000000029) // Insterted value 18.9, ideal value 21.0
+ testCleanValueObject(cleanCurrentDt[22], 23.899990043551092, 0.9676341499669797) // Insterted value 24.2, ideal Value 22.0
+ testCleanValueObject(cleanCurrentDt[23], 20.973158822602603, 1.0000000000000029) // Insterted value 20.7, ideal value 23.0
+
+ baseRegressionFunction.reset()
+ CECFilter.clearDatapointBuffer()
+
+ // Here we feed the cleaned data back in, simulating an active filter for the third round
+ for (let i = 0; i < testDataArray.length; i++) {
+ baseRegressionFunction.push(i + 60, cleanCurrentDt[i].clean, cleanCurrentDt[i].goodnessOfFit)
+ CECFilter.recordRawDatapoint(i + 60, i + 60, cleanCurrentDt[i].clean)
+ }
+
+ testRegressorSlopeEquals(baseRegressionFunction, 0.9069045772747553) // Ideal value 1.0
+ testRegressorInterceptEquals(baseRegressionFunction, -54.29992109120207) // Ideal value -60
+ testRegressorGoodnessOfFitEquals(baseRegressionFunction, 0.9434603025111108) // Ideal value 1
+
+ // Process the datapoints, filling the entire array
+ for (let i = 0; i < Math.ceil(testDataArray.length / 2); i++) {
+ CECFilter.processNextRawDatapoint()
+ }
+
+ // Filter the total series of raw datapoints
+ cleanCurrentDt = []
+ for (let i = 0; i < testDataArray.length; i++) {
+ cleanCurrentDt[i] = CECFilter.applyFilter(testDataArray[i], (90 + i))
+ }
+
+ // Check the results
+ // For even magnets, the correction factor should be 0.9
+ // For odd magnets, the correction factor should be 1.1
+ testCleanValueObject(cleanCurrentDt[0], 0.17647294859759807, 0.9789888647494898) // Insterted value 0.0, ideal value 0.0
+ testCleanValueObject(cleanCurrentDt[1], 0.8033914749345807, 0.999999732411779) // Insterted value 0.9, ideal value 1.0
+ testCleanValueObject(cleanCurrentDt[2], 2.181248802185604, 0.9789888647494898) // Insterted value 2.2, ideal value 2.0
+ testCleanValueObject(cleanCurrentDt[3], 2.7631203219989384, 0.999999732411779) // Insterted value 2.7, ideal value 3.0
+ testCleanValueObject(cleanCurrentDt[4], 4.186024655773609, 0.9789888647494898) // Insterted value 4.4, ideal Value 4.0
+ testCleanValueObject(cleanCurrentDt[5], 4.722849169063296, 0.999999732411779) // Insterted value 4.5, ideal value 5.0
+ testCleanValueObject(cleanCurrentDt[6], 6.1908005093616145, 0.9789888647494898) // Insterted value 6.6, ideal value 6.0
+ testCleanValueObject(cleanCurrentDt[7], 6.6825780161276525, 0.999999732411779) // Insterted value 6.3, ideal value 7.0
+ testCleanValueObject(cleanCurrentDt[8], 8.19557636294962, 0.9789888647494898) // Insterted value 8.8, ideal Value 8.0
+ testCleanValueObject(cleanCurrentDt[9], 8.642306863192012, 0.999999732411779) // Insterted value 8.1, ideal value 9.0
+ testCleanValueObject(cleanCurrentDt[10], 10.200352216537624, 0.9789888647494898) // Insterted value 11.0, ideal value 10.0
+ testCleanValueObject(cleanCurrentDt[11], 10.60203571025637, 0.999999732411779) // Insterted value 9.9, ideal value 11.0
+ testCleanValueObject(cleanCurrentDt[12], 12.20512807012563, 0.9789888647494898) // Insterted value 13.2, ideal Value 12.0
+ testCleanValueObject(cleanCurrentDt[13], 12.561764557320725, 0.999999732411779) // Insterted value 11.7, ideal value 13.0
+ testCleanValueObject(cleanCurrentDt[14], 14.209903923713636, 0.9789888647494898) // Insterted value 15.4, ideal Value 14.0
+ testCleanValueObject(cleanCurrentDt[15], 14.521493404385083, 0.999999732411779) // Insterted value 13.5, ideal value 15.0
+ testCleanValueObject(cleanCurrentDt[16], 16.214679777301644, 0.9789888647494898) // Insterted value 17.6, ideal value 16.0
+ testCleanValueObject(cleanCurrentDt[17], 16.48122225144944, 0.999999732411779) /// Insterted value 15.3, ideal value 17.0
+ testCleanValueObject(cleanCurrentDt[18], 18.219455630889648, 0.9789888647494898) // Insterted value 19.8, ideal Value 18.0
+ testCleanValueObject(cleanCurrentDt[19], 18.440951098513796, 0.999999732411779) // Insterted value 17.1, ideal value 19.0
+ testCleanValueObject(cleanCurrentDt[20], 20.224231484477652, 0.9789888647494898) // Insterted value 22.0, ideal Value 20.0
+ testCleanValueObject(cleanCurrentDt[21], 20.400679945578155, 0.999999732411779) // Insterted value 18.9, ideal value 21.0
+ testCleanValueObject(cleanCurrentDt[22], 22.229007338065657, 0.9789888647494898) // Insterted value 24.2, ideal Value 22.0
+ testCleanValueObject(cleanCurrentDt[23], 22.360408792642513, 0.999999732411779) // Insterted value 20.7, ideal value 23.0
+
+ baseRegressionFunction.reset()
+
+ // Here we feed the cleaned data into the regression function to see the result
+ for (let i = 0; i < cleanCurrentDt.length; i++) {
+ baseRegressionFunction.push(i + 90, cleanCurrentDt[i].clean, cleanCurrentDt[i].goodnessOfFit)
+ }
+
+ testRegressorSlopeEquals(baseRegressionFunction, 0.9798644235321792) // Ideal value 1.0
+ testRegressorInterceptEquals(baseRegressionFunction, -88.36427106649371) // Ideal value -90
+ testRegressorGoodnessOfFitEquals(baseRegressionFunction, 0.9959349205421492) // Ideal value 1
+})
+
+test('Correct behaviour of the filter after exposing it to an updated filter with very noisy linear regressor (2.5%), five rounds, agressiveness 1.0, size two recoveries', () => {
+ const baseRowerConfig = {
+ numOfImpulsesPerRevolution: 2,
+ flankLength: 2,
+ autoAdjustDragFactor: true,
+ systematicErrorAgressiveness: 1.0,
+ systematicErrorNumberOfDatapoints: 44,
+ minimumTimeBetweenImpulses: 0,
+ maximumTimeBetweenImpulses: 25
+ }
+
+ const baseRegressionFunction = createTSLinearSeries()
+
+ // Complete dataset, which will be reused for each cycle. In essence y = x, where
+ // - the even datapoints are multipied by 1.025 (so correction factor should become 1/1.025 = 0.975)
+ // - the odd datapoints are multipied by 0.975 (so correction factor should become 1/0.975 = 1.025)
+ let testDataArray = []
+ testDataArray[0] = 0
+ testDataArray[1] = 0.975
+ testDataArray[2] = 2.05
+ testDataArray[3] = 2.925
+ testDataArray[4] = 4.1
+ testDataArray[5] = 4.875
+ testDataArray[6] = 6.15
+ testDataArray[7] = 6.825
+ testDataArray[8] = 8.2
+ testDataArray[9] = 8.775
+ testDataArray[10] = 10.25
+ testDataArray[11] = 10.725
+ testDataArray[12] = 12.3
+ testDataArray[13] = 12.675
+ testDataArray[14] = 14.35
+ testDataArray[15] = 14.625
+ testDataArray[16] = 16.4
+ testDataArray[17] = 16.575
+ testDataArray[18] = 18.45
+ testDataArray[19] = 18.525
+ testDataArray[20] = 20.5
+ testDataArray[21] = 20.475
+ testDataArray[22] = 22.55
+ testDataArray[23] = 22.425
+
+ let cleanCurrentDt = []
+ const CECFilter = createCyclicErrorFilter(baseRowerConfig, baseRegressionFunction)
+
+ // Initialise the starting point
+ cleanCurrentDt[0] = CECFilter.applyFilter(0, 0)
+
+ // Inecting the datapoints.
+ for (let i = 0; i < testDataArray.length; i++) {
+ baseRegressionFunction.push(i, testDataArray[i], 1)
+ CECFilter.recordRawDatapoint(i, i, testDataArray[i])
+ }
+
+ testRegressorSlopeEquals(baseRegressionFunction, 0.9750000000000002) // Ideal value 1.0
+ testRegressorInterceptEquals(baseRegressionFunction, -1.1102230246251565e-16) // Ideal value 0
+ testRegressorGoodnessOfFitEquals(baseRegressionFunction, 0.9955838667130681) // Ideal value 1
+
+ // Process the datapoints, filling the entire array
+ for (let i = 0; i < Math.ceil(testDataArray.length / 2); i++) {
+ CECFilter.processNextRawDatapoint()
+ }
+
+ // Filter the total series of raw datapoints
+ for (let i = 0; i < testDataArray.length; i++) {
+ cleanCurrentDt[i] = CECFilter.applyFilter(testDataArray[i], (30 + i))
+ }
+
+ // Check the results
+ // For even magnets, the correction factor should be 0.975
+ // For odd magnets, the correction factor should be 1.025
+ testCleanValueObject(cleanCurrentDt[0], 0.07798414227743293, 0.998111300912531) // Insterted value 0.0, ideal value 0.0
+ testCleanValueObject(cleanCurrentDt[1], 0.9207031877969449, 0.9999999999999996) // Insterted value 0.975, ideal value 1.0
+ testCleanValueObject(cleanCurrentDt[2], 2.078180012377459, 0.998111300912531) // Insterted value 2.05, ideal value 2.0
+ testCleanValueObject(cleanCurrentDt[3], 2.918077847945701, 0.9999999999999996) // Insterted value 2.925, ideal Value 3.0
+ testCleanValueObject(cleanCurrentDt[4], 4.078375882477486, 0.998111300912531) // Insterted value 4.1, ideal value 4.0
+ testCleanValueObject(cleanCurrentDt[5], 4.915452508094456, 0.9999999999999996) // Insterted value 4.875, ideal value 5.0
+ testCleanValueObject(cleanCurrentDt[6], 6.0785717525775125, 0.998111300912531) // Insterted value 6.15, ideal value 6.0
+ testCleanValueObject(cleanCurrentDt[7], 6.912827168243212, 0.9999999999999996) // Insterted value 6.825, ideal Value 7.0
+ testCleanValueObject(cleanCurrentDt[8], 8.078767622677539, 0.998111300912531) // Insterted value 8.2, ideal value 8.0
+ testCleanValueObject(cleanCurrentDt[9], 8.910201828391969, 0.9999999999999996) // Insterted value 8.775, ideal value 9.0
+ testCleanValueObject(cleanCurrentDt[10], 10.078963492777564, 0.998111300912531) // Insterted value 10.25, ideal value 10.0
+ testCleanValueObject(cleanCurrentDt[11], 10.907576488540725, 0.9999999999999996) // Insterted value 9.9, ideal Value 11.0
+ testCleanValueObject(cleanCurrentDt[12], 12.079159362877592, 0.998111300912531) // Insterted value 12.3, ideal value 12.0
+ testCleanValueObject(cleanCurrentDt[13], 12.90495114868948, 0.9999999999999996) // Insterted value 12.675, ideal Value 13.0
+ testCleanValueObject(cleanCurrentDt[14], 14.079355232977617, 0.998111300912531) // Insterted value 14.35, ideal value 14.0
+ testCleanValueObject(cleanCurrentDt[15], 14.902325808838237, 0.9999999999999996) // Insterted value 14.625, ideal value 15.0
+ testCleanValueObject(cleanCurrentDt[16], 16.079551103077645, 0.998111300912531) /// Insterted value 16.4, ideal value 16.0
+ testCleanValueObject(cleanCurrentDt[17], 16.899700468986993, 0.9999999999999996) // Insterted value 16.575, ideal Value 17.0
+ testCleanValueObject(cleanCurrentDt[18], 18.07974697317767, 0.998111300912531) // Insterted value 18.45, ideal value 18.0
+ testCleanValueObject(cleanCurrentDt[19], 18.897075129135747, 0.9999999999999996) // Insterted value 18.525, ideal Value 19.0
+ testCleanValueObject(cleanCurrentDt[20], 20.079942843277696, 0.998111300912531) // Insterted value 20.5, ideal value 20.0
+ testCleanValueObject(cleanCurrentDt[21], 20.894449789284508, 0.9999999999999996) // Insterted value 20.475, ideal Value 21.0
+ testCleanValueObject(cleanCurrentDt[22], 22.080138713377725, 0.998111300912531) // Insterted value 22.55, ideal value 22.0
+ testCleanValueObject(cleanCurrentDt[23], 22.891824449433262, 0.9999999999999996) // Insterted value 22.425, ideal Value 23.0
+
+ baseRegressionFunction.reset()
+ CECFilter.clearDatapointBuffer()
+
+ // Here we feed the cleaned data back in, simulating an active filter for the second round
+ for (let i = 0; i < cleanCurrentDt.length; i++) {
+ baseRegressionFunction.push(i + 30, cleanCurrentDt[i].clean, cleanCurrentDt[i].goodnessOfFit)
+ CECFilter.recordRawDatapoint(i + 30, i + 30, cleanCurrentDt[i].clean)
+ }
+
+ testRegressorSlopeEquals(baseRegressionFunction, 0.9986873300743783) // Ideal value 1.0
+ testRegressorInterceptEquals(baseRegressionFunction, -30.03860404450878) // Ideal value -30
+ testRegressorGoodnessOfFitEquals(baseRegressionFunction, 0.9996915637935856) // Ideal value 1
+
+ // Process the datapoints, filling the entire array
+ for (let i = 0; i < Math.ceil(testDataArray.length / 2); i++) {
+ CECFilter.processNextRawDatapoint()
+ }
+
+ // Filter the total series of raw datapoints
+ cleanCurrentDt = []
+ for (let i = 0; i < testDataArray.length; i++) {
+ cleanCurrentDt[i] = CECFilter.applyFilter(testDataArray[i], (60 + i))
+ }
+
+ // Check the results
+ // For even magnets, the correction factor should be 0.975
+ // For odd magnets, the correction factor should be 1.025
+ testCleanValueObject(cleanCurrentDt[0], 0.0004610382453504281, 0.9984930452162009) // Insterted value 0.0, ideal value 0.0
+ testCleanValueObject(cleanCurrentDt[1], 0.9895477238267641, 0.9999999999999988) // Insterted value 0.975, ideal value 1.0
+ testCleanValueObject(cleanCurrentDt[2], 2.0189041538885966, 0.9984930452162009) // Insterted value 2.05, ideal value 2.0
+ testCleanValueObject(cleanCurrentDt[3], 2.9695652479709933, 0.9999999999999988) // Insterted value 2.925, ideal Value 3.0
+ testCleanValueObject(cleanCurrentDt[4], 4.037347269531843, 0.9984930452162009) // Insterted value 4.1, ideal value 4.0
+ testCleanValueObject(cleanCurrentDt[5], 4.949582772115224, 0.9999999999999988) // Insterted value 4.875, ideal value 5.0
+ testCleanValueObject(cleanCurrentDt[6], 6.05579038517509, 0.9984930452162009) // Insterted value 6.15, ideal value 6.0
+ testCleanValueObject(cleanCurrentDt[7], 6.929600296259453, 0.9999999999999988) // Insterted value 6.825, ideal Value 7.0
+ testCleanValueObject(cleanCurrentDt[8], 8.074233500818334, 0.9984930452162009) // Insterted value 8.2, ideal value 8.0
+ testCleanValueObject(cleanCurrentDt[9], 8.909617820403682, 0.9999999999999988) // Insterted value 8.775, ideal value 9.0
+ testCleanValueObject(cleanCurrentDt[10], 10.092676616461583, 0.9984930452162009) // Insterted value 10.25, ideal value 10.0
+ testCleanValueObject(cleanCurrentDt[11], 10.889635344547912, 0.9999999999999988) // Insterted value 9.9, ideal Value 11.0
+ testCleanValueObject(cleanCurrentDt[12], 12.111119732104829, 0.9984930452162009) // Insterted value 12.3, ideal value 12.0
+ testCleanValueObject(cleanCurrentDt[13], 12.86965286869214, 0.9999999999999988) // Insterted value 12.675, ideal Value 13.0
+ testCleanValueObject(cleanCurrentDt[14], 14.129562847748074, 0.9984930452162009) // Insterted value 14.35, ideal value 14.0
+ testCleanValueObject(cleanCurrentDt[15], 14.849670392836371, 0.9999999999999988) // Insterted value 14.625, ideal value 15.0
+ testCleanValueObject(cleanCurrentDt[16], 16.14800596339132, 0.9984930452162009) /// Insterted value 16.4, ideal value 16.0
+ testCleanValueObject(cleanCurrentDt[17], 16.8296879169806, 0.9999999999999988) // Insterted value 16.575, ideal Value 17.0
+ testCleanValueObject(cleanCurrentDt[18], 18.166449079034567, 0.9984930452162009) // Insterted value 18.45, ideal value 18.0
+ testCleanValueObject(cleanCurrentDt[19], 18.809705441124827, 0.9999999999999988) // Insterted value 18.525, ideal Value 19.0
+ testCleanValueObject(cleanCurrentDt[20], 20.18489219467782, 0.9984930452162009) // Insterted value 20.5, ideal value 20.0
+ testCleanValueObject(cleanCurrentDt[21], 20.789722965269057, 0.9999999999999988) // Insterted value 20.475, ideal Value 21.0
+ testCleanValueObject(cleanCurrentDt[22], 22.203335310321062, 0.9984930452162009) // Insterted value 22.55, ideal value 22.0
+ testCleanValueObject(cleanCurrentDt[23], 22.76974048941329, 0.9999999999999988) // Insterted value 22.425, ideal Value 23.0
+
+ baseRegressionFunction.reset()
+ CECFilter.clearDatapointBuffer()
+
+ // Here we feed the cleaned data back in, simulating an active filter for the third round
+ for (let i = 0; i < testDataArray.length; i++) {
+ baseRegressionFunction.push(i + 60, cleanCurrentDt[i].clean, cleanCurrentDt[i].goodnessOfFit)
+ CECFilter.recordRawDatapoint(i + 60, i + 60, cleanCurrentDt[i].clean)
+ }
+
+ testRegressorSlopeEquals(baseRegressionFunction, 0.9900087620721152) // Ideal value 1.0
+ testRegressorInterceptEquals(baseRegressionFunction, -59.40098676257226) // Ideal value -60
+ testRegressorGoodnessOfFitEquals(baseRegressionFunction, 0.9993449445830219) // Ideal value 1
+
+ // Process the datapoints, filling the entire array
+ for (let i = 0; i < Math.ceil(testDataArray.length / 2); i++) {
+ CECFilter.processNextRawDatapoint()
+ }
+
+ // Filter the total series of raw datapoints
+ cleanCurrentDt = []
+ for (let i = 0; i < testDataArray.length; i++) {
+ cleanCurrentDt[i] = CECFilter.applyFilter(testDataArray[i], (90 + i))
+ }
+
+ // Check the results
+ // For even magnets, the correction factor should be 0.975
+ // For odd magnets, the correction factor should be 1.025
+ testCleanValueObject(cleanCurrentDt[0], -0.04374546672531451, 0.000998517840162373) // Insterted value 0.0, ideal value 0.0
+ testCleanValueObject(cleanCurrentDt[1], 1.0277375627805918, 0.9999999999999986) // Insterted value 0.975, ideal value 1.0
+ testCleanValueObject(cleanCurrentDt[2], 1.9873480749020505, 0.9985178401623731) // Insterted value 2.05, ideal value 2.0
+ testCleanValueObject(cleanCurrentDt[3], 2.9957217548911466, 0.9999999999999986) // Insterted value 2.925, ideal Value 3.0
+ testCleanValueObject(cleanCurrentDt[4], 4.018441616529415, 0.9985178401623731) // Insterted value 4.1, ideal value 4.0
+ testCleanValueObject(cleanCurrentDt[5], 4.963705947001702, 0.9999999999999986) // Insterted value 4.875, ideal value 5.0
+ testCleanValueObject(cleanCurrentDt[6], 6.04953515815678, 0.9985178401623731) // Insterted value 6.15, ideal value 6.0
+ testCleanValueObject(cleanCurrentDt[7], 6.931690139112257, 0.9999999999999986) // Insterted value 6.825, ideal Value 7.0
+ testCleanValueObject(cleanCurrentDt[8], 8.080628699784146, 0.9985178401623731) // Insterted value 8.2, ideal value 8.0
+ testCleanValueObject(cleanCurrentDt[9], 8.899674331222812, 0.9999999999999986) // Insterted value 8.775, ideal value 9.0
+ testCleanValueObject(cleanCurrentDt[10], 10.111722241411512, 0.9985178401623731) // Insterted value 10.25, ideal value 10.0
+ testCleanValueObject(cleanCurrentDt[11], 10.867658523333366, 0.9999999999999986) // Insterted value 9.9, ideal Value 11.0
+ testCleanValueObject(cleanCurrentDt[12], 12.142815783038875, 0.9985178401623731) // Insterted value 12.3, ideal value 12.0
+ testCleanValueObject(cleanCurrentDt[13], 12.835642715443923, 0.9999999999999986) // Insterted value 12.675, ideal Value 13.0
+ testCleanValueObject(cleanCurrentDt[14], 14.17390932466624, 0.9985178401623731) // Insterted value 14.35, ideal value 14.0
+ testCleanValueObject(cleanCurrentDt[15], 14.803626907554477, 0.9999999999999986) // Insterted value 14.625, ideal value 15.0
+ testCleanValueObject(cleanCurrentDt[16], 16.205002866293604, 0.9985178401623731) /// Insterted value 16.4, ideal value 16.0
+ testCleanValueObject(cleanCurrentDt[17], 16.771611099665034, 0.9999999999999986) // Insterted value 16.575, ideal Value 17.0
+ testCleanValueObject(cleanCurrentDt[18], 18.236096407920968, 0.9985178401623731) // Insterted value 18.45, ideal value 18.0
+ testCleanValueObject(cleanCurrentDt[19], 18.739595291775586, 0.9999999999999986) // Insterted value 18.525, ideal Value 19.0
+ testCleanValueObject(cleanCurrentDt[20], 20.267189949548335, 0.9985178401623731) // Insterted value 20.5, ideal value 20.0
+ testCleanValueObject(cleanCurrentDt[21], 20.707579483886143, 0.9999999999999986) // Insterted value 20.475, ideal Value 21.0
+ testCleanValueObject(cleanCurrentDt[22], 22.2982834911757, 0.9985178401623731) // Insterted value 22.55, ideal value 22.0
+ testCleanValueObject(cleanCurrentDt[23], 22.675563675996695, 0.9999999999999986) // Insterted value 22.425, ideal Value 23.0
+
+ baseRegressionFunction.reset()
+ CECFilter.clearDatapointBuffer()
+
+ // Here we feed the cleaned data back in, simulating an active filter for the fourth round
+ for (let i = 0; i < testDataArray.length; i++) {
+ baseRegressionFunction.push(i + 90, cleanCurrentDt[i].clean, cleanCurrentDt[i].goodnessOfFit)
+ CECFilter.recordRawDatapoint(i + 90, i + 90, cleanCurrentDt[i].clean)
+ }
+
+ testRegressorSlopeEquals(baseRegressionFunction, 0.9858676025117252) // Ideal value 1.0
+ testRegressorInterceptEquals(baseRegressionFunction, -88.68996527869929) // Ideal value -90
+ testRegressorGoodnessOfFitEquals(baseRegressionFunction, 0.9987903972764073) // Ideal value 1
+
+ // Process the datapoints, filling the entire array
+ for (let i = 0; i < Math.ceil(testDataArray.length / 2); i++) {
+ CECFilter.processNextRawDatapoint()
+ }
+
+ // Filter the total series of raw datapoints
+ cleanCurrentDt = []
+ for (let i = 0; i < testDataArray.length; i++) {
+ cleanCurrentDt[i] = CECFilter.applyFilter(testDataArray[i], (120 + i))
+ }
+
+ // Check the results
+ // For even magnets, the correction factor should be 0.975
+ // For odd magnets, the correction factor should be 1.025
+ testCleanValueObject(cleanCurrentDt[0], 0.011546556256249143, 0.999828493131169) // Insterted value 0.0, ideal value 0.0
+ testCleanValueObject(cleanCurrentDt[1], 0.9740276581705011, 0.9999963158472918) // Insterted value 0.975, ideal value 1.0
+ testCleanValueObject(cleanCurrentDt[2], 2.0393135925897483, 0.999828493131169) // Insterted value 2.05, ideal value 2.0
+ testCleanValueObject(cleanCurrentDt[3], 2.9451760870240014, 0.9999963158472918) // Insterted value 2.925, ideal Value 3.0
+ testCleanValueObject(cleanCurrentDt[4], 4.0670806289232475, 0.999828493131169) // Insterted value 4.1, ideal value 4.0
+ testCleanValueObject(cleanCurrentDt[5], 4.916324515877502, 0.9999963158472918) // Insterted value 4.875, ideal value 5.0
+ testCleanValueObject(cleanCurrentDt[6], 6.094847665256749, 0.999828493131169) // Insterted value 6.15, ideal value 6.0
+ testCleanValueObject(cleanCurrentDt[7], 6.887472944731003, 0.9999963158472918) // Insterted value 6.825, ideal Value 7.0
+ testCleanValueObject(cleanCurrentDt[8], 8.122614701590246, 0.999828493131169) // Insterted value 8.2, ideal value 8.0
+ testCleanValueObject(cleanCurrentDt[9], 8.858621373584505, 0.9999963158472918) // Insterted value 8.775, ideal value 9.0
+ testCleanValueObject(cleanCurrentDt[10], 10.150381737923746, 0.999828493131169) // Insterted value 10.25, ideal value 10.0
+ testCleanValueObject(cleanCurrentDt[11], 10.829769802438003, 0.9999963158472918) // Insterted value 9.9, ideal Value 11.0
+ testCleanValueObject(cleanCurrentDt[12], 12.178148774257249, 0.999828493131169) // Insterted value 12.3, ideal value 12.0
+ testCleanValueObject(cleanCurrentDt[13], 12.800918231291504, 0.9999963158472918) // Insterted value 12.675, ideal Value 13.0
+ testCleanValueObject(cleanCurrentDt[14], 14.205915810590746, 0.999828493131169) // Insterted value 14.35, ideal value 14.0
+ testCleanValueObject(cleanCurrentDt[15], 14.772066660145004, 0.9999963158472918) // Insterted value 14.625, ideal value 15.0
+ testCleanValueObject(cleanCurrentDt[16], 16.233682846924243, 0.999828493131169) /// Insterted value 16.4, ideal value 16.0
+ testCleanValueObject(cleanCurrentDt[17], 16.743215088998504, 0.9999963158472918) // Insterted value 16.575, ideal Value 17.0
+ testCleanValueObject(cleanCurrentDt[18], 18.261449883257747, 0.999828493131169) // Insterted value 18.45, ideal value 18.0
+ testCleanValueObject(cleanCurrentDt[19], 18.714363517852004, 0.9999963158472918) // Insterted value 18.525, ideal Value 19.0
+ testCleanValueObject(cleanCurrentDt[20], 20.289216919591244, 0.999828493131169) // Insterted value 20.5, ideal value 20.0
+ testCleanValueObject(cleanCurrentDt[21], 20.685511946705507, 0.9999963158472918) // Insterted value 20.475, ideal Value 21.0
+ testCleanValueObject(cleanCurrentDt[22], 22.316983955924744, 0.999828493131169) // Insterted value 22.55, ideal value 22.0
+ testCleanValueObject(cleanCurrentDt[23], 22.656660375559007, 0.9999963158472918) // Insterted value 22.425, ideal Value 23.0
+
+ baseRegressionFunction.reset()
+ CECFilter.clearDatapointBuffer()
+
+ // Here we feed the cleaned data back in, simulating an active filter for the fifth round
+ for (let i = 0; i < testDataArray.length; i++) {
+ baseRegressionFunction.push(i + 120, cleanCurrentDt[i].clean, cleanCurrentDt[i].goodnessOfFit)
+ CECFilter.recordRawDatapoint(i + 120, i + 120, cleanCurrentDt[i].clean)
+ }
+
+ testRegressorSlopeEquals(baseRegressionFunction, 0.9855742144267506) // Ideal value 1.0
+ testRegressorInterceptEquals(baseRegressionFunction, -118.28045228746632) // Ideal value -120
+ testRegressorGoodnessOfFitEquals(baseRegressionFunction, 0.9984287342267387) // Ideal value 1
+
+ // Process the datapoints, filling the entire array
+ for (let i = 0; i < Math.ceil(testDataArray.length / 2); i++) {
+ CECFilter.processNextRawDatapoint()
+ }
+
+ // Filter the total series of raw datapoints
+ cleanCurrentDt = []
+ for (let i = 0; i < testDataArray.length; i++) {
+ cleanCurrentDt[i] = CECFilter.applyFilter(testDataArray[i], (150 + i))
+ }
+
+ // Check the results
+ // For even magnets, the correction factor should be 0.975
+ // For odd magnets, the correction factor should be 1.025
+ testCleanValueObject(cleanCurrentDt[0], 0.016961253232687414, 0.9998674965045421) // Insterted value 0.0, ideal value 0.0
+ testCleanValueObject(cleanCurrentDt[1], 0.9716673210063268, 0.9999963182131966) // Insterted value 0.975, ideal value 1.0
+ testCleanValueObject(cleanCurrentDt[2], 2.0383063022686048, 0.9998674965045421) // Insterted value 2.05, ideal value 2.0
+ testCleanValueObject(cleanCurrentDt[3], 2.948924469484355, 0.9999963182131966) // Insterted value 2.925, ideal Value 3.0
+ testCleanValueObject(cleanCurrentDt[4], 4.059651351304522, 0.9998674965045421) // Insterted value 4.1, ideal value 4.0
+ testCleanValueObject(cleanCurrentDt[5], 4.926181617962383, 0.9999963182131966) // Insterted value 4.875, ideal value 5.0
+ testCleanValueObject(cleanCurrentDt[6], 6.08099640034044, 0.9998674965045421) // Insterted value 6.15, ideal value 6.0
+ testCleanValueObject(cleanCurrentDt[7], 6.903438766440412, 0.9999963182131966) // Insterted value 6.825, ideal Value 7.0
+ testCleanValueObject(cleanCurrentDt[8], 8.102341449376356, 0.9998674965045421) // Insterted value 8.2, ideal value 8.0
+ testCleanValueObject(cleanCurrentDt[9], 8.88069591491844, 0.9999963182131966) // Insterted value 8.775, ideal value 9.0
+ testCleanValueObject(cleanCurrentDt[10], 10.123686498412273, 0.9998674965045421) // Insterted value 10.25, ideal value 10.0
+ testCleanValueObject(cleanCurrentDt[11], 10.857953063396469, 0.9999963182131966) // Insterted value 9.9, ideal Value 11.0
+ testCleanValueObject(cleanCurrentDt[12], 12.145031547448191, 0.9998674965045421) // Insterted value 12.3, ideal value 12.0
+ testCleanValueObject(cleanCurrentDt[13], 12.835210211874497, 0.9999963182131966) // Insterted value 12.675, ideal Value 13.0
+ testCleanValueObject(cleanCurrentDt[14], 14.166376596484108, 0.9998674965045421) // Insterted value 14.35, ideal value 14.0
+ testCleanValueObject(cleanCurrentDt[15], 14.812467360352525, 0.9999963182131966) // Insterted value 14.625, ideal value 15.0
+ testCleanValueObject(cleanCurrentDt[16], 16.187721645520025, 0.9998674965045421) /// Insterted value 16.4, ideal value 16.0
+ testCleanValueObject(cleanCurrentDt[17], 16.789724508830556, 0.9999963182131966) // Insterted value 16.575, ideal Value 17.0
+ testCleanValueObject(cleanCurrentDt[18], 18.20906669455594, 0.9998674965045421) // Insterted value 18.45, ideal value 18.0
+ testCleanValueObject(cleanCurrentDt[19], 18.76698165730858, 0.9999963182131966) // Insterted value 18.525, ideal Value 19.0
+ testCleanValueObject(cleanCurrentDt[20], 20.23041174359186, 0.9998674965045421) // Insterted value 20.5, ideal value 20.0
+ testCleanValueObject(cleanCurrentDt[21], 20.744238805786612, 0.9999963182131966) // Insterted value 20.475, ideal Value 21.0
+ testCleanValueObject(cleanCurrentDt[22], 22.251756792627777, 0.9998674965045421) // Insterted value 22.55, ideal value 22.0
+ testCleanValueObject(cleanCurrentDt[23], 22.72149595426464, 0.9999963182131966) // Insterted value 22.425, ideal Value 23.0
+
+ baseRegressionFunction.reset()
+ CECFilter.clearDatapointBuffer()
+
+ // Here we feed the cleaned data back in, simulating an active filter for the fifth round
+ for (let i = 0; i < testDataArray.length; i++) {
+ baseRegressionFunction.push(i + 150, cleanCurrentDt[i].clean, cleanCurrentDt[i].goodnessOfFit)
+ CECFilter.recordRawDatapoint(i + 150, i + 150, cleanCurrentDt[i].clean)
+ }
+
+ testRegressorSlopeEquals(baseRegressionFunction, 0.9886285742390144) // Ideal value 1.0
+ testRegressorInterceptEquals(baseRegressionFunction, -148.31124738908483) // Ideal value -150
+ testRegressorGoodnessOfFitEquals(baseRegressionFunction, 0.9989578675850752) // Ideal value 1
+
+ // Process the datapoints, filling the entire array
+ for (let i = 0; i < Math.ceil(testDataArray.length / 2); i++) {
+ CECFilter.processNextRawDatapoint()
+ }
+
+ // Filter the total series of raw datapoints
+ cleanCurrentDt = []
+ for (let i = 0; i < testDataArray.length; i++) {
+ cleanCurrentDt[i] = CECFilter.applyFilter(testDataArray[i], (180 + i))
+ }
+
+ // Check the results
+ // For even magnets, the correction factor should be 0.975
+ // For odd magnets, the correction factor should be 1.025
+ testCleanValueObject(cleanCurrentDt[0], -0.006379262258864122, 0.0009999378001708077) // Insterted value 0.0, ideal value 0.0
+ testCleanValueObject(cleanCurrentDt[1], 0.9937784051556133, 0.9999989846805964) // Insterted value 0.975, ideal value 1.0
+ testCleanValueObject(cleanCurrentDt[2], 2.0175507449838657, 0.9999378001708076) // Insterted value 2.05, ideal value 2.0
+ testCleanValueObject(cleanCurrentDt[3], 2.9685766909491123, 0.9999989846805964) // Insterted value 2.925, ideal Value 3.0
+ testCleanValueObject(cleanCurrentDt[4], 4.041480752226596, 0.9999378001708076) // Insterted value 4.1, ideal value 4.0
+ testCleanValueObject(cleanCurrentDt[5], 4.943374976742612, 0.9999989846805964) // Insterted value 4.875, ideal value 5.0
+ testCleanValueObject(cleanCurrentDt[6], 6.0654107594693265, 0.9999378001708076) // Insterted value 6.15, ideal value 6.0
+ testCleanValueObject(cleanCurrentDt[7], 6.91817326253611, 0.9999989846805964) // Insterted value 6.825, ideal Value 7.0
+ testCleanValueObject(cleanCurrentDt[8], 8.089340766712056, 0.9999378001708076) // Insterted value 8.2, ideal value 8.0
+ testCleanValueObject(cleanCurrentDt[9], 8.892971548329609, 0.9999989846805964) // Insterted value 8.775, ideal value 9.0
+ testCleanValueObject(cleanCurrentDt[10], 10.113270773954786, 0.9999378001708076) // Insterted value 10.25, ideal value 10.0
+ testCleanValueObject(cleanCurrentDt[11], 10.867769834123107, 0.9999989846805964) // Insterted value 9.9, ideal Value 11.0
+ testCleanValueObject(cleanCurrentDt[12], 12.137200781197517, 0.9999378001708076) // Insterted value 12.3, ideal value 12.0
+ testCleanValueObject(cleanCurrentDt[13], 12.842568119916606, 0.9999989846805964) // Insterted value 12.675, ideal Value 13.0
+ testCleanValueObject(cleanCurrentDt[14], 14.161130788440246, 0.9999378001708076) // Insterted value 14.35, ideal value 14.0
+ testCleanValueObject(cleanCurrentDt[15], 14.817366405710104, 0.9999989846805964) // Insterted value 14.625, ideal value 15.0
+ testCleanValueObject(cleanCurrentDt[16], 16.185060795682976, 0.9999378001708076) /// Insterted value 16.4, ideal value 16.0
+ testCleanValueObject(cleanCurrentDt[17], 16.792164691503604, 0.9999989846805964) // Insterted value 16.575, ideal Value 17.0
+ testCleanValueObject(cleanCurrentDt[18], 18.208990802925705, 0.9999378001708076) // Insterted value 18.45, ideal value 18.0
+ testCleanValueObject(cleanCurrentDt[19], 18.7669629772971, 0.9999989846805964) // Insterted value 18.525, ideal Value 19.0
+ testCleanValueObject(cleanCurrentDt[20], 20.232920810168437, 0.9999378001708076) // Insterted value 20.5, ideal value 20.0
+ testCleanValueObject(cleanCurrentDt[21], 20.741761263090602, 0.9999989846805964) // Insterted value 20.475, ideal Value 21.0
+ testCleanValueObject(cleanCurrentDt[22], 22.256850817411166, 0.9999378001708076) // Insterted value 22.55, ideal value 22.0
+ testCleanValueObject(cleanCurrentDt[23], 22.7165595488841, 0.9999989846805964) // Insterted value 22.425, ideal Value 23.0
+
+ baseRegressionFunction.reset()
+ CECFilter.clearDatapointBuffer()
+
+ // Here we feed the cleaned data back in, simulating an active filter for the fifth round
+ for (let i = 0; i < testDataArray.length; i++) {
+ baseRegressionFunction.push(i + 180, cleanCurrentDt[i].clean, cleanCurrentDt[i].goodnessOfFit)
+ }
+
+ testRegressorSlopeEquals(baseRegressionFunction, 0.9873991428967495) // Ideal value 1.0
+ testRegressorInterceptEquals(baseRegressionFunction, -177.72546645915602) // Ideal value -180
+ testRegressorGoodnessOfFitEquals(baseRegressionFunction, 0.9988695699423202) // Ideal value 1
+})
+
+test('Correct behaviour of the filter after exposing it to an updated filter with an extremely noisy linear regressor (10%), five rounds, agressiveness 1.0, size two recoveries', () => {
+ const baseRowerConfig = {
+ numOfImpulsesPerRevolution: 2,
+ flankLength: 2,
+ autoAdjustDragFactor: true,
+ systematicErrorAgressiveness: 1.0,
+ systematicErrorNumberOfDatapoints: 44,
+ minimumTimeBetweenImpulses: 0,
+ maximumTimeBetweenImpulses: 25
+ }
+
+ const baseRegressionFunction = createTSLinearSeries()
+
+ // Complete dataset, which will be reused for each cycle. In essence y = x, where
+ // - the even datapoints are multipied by 1.1 (so correction factor should become 1/1.1 = 0.9)
+ // - the odd datapoints are multipied by 0.9 (so correction factor should become 1/0.9 = 1.1)
+ let testDataArray = []
+ testDataArray[0] = 0
+ testDataArray[1] = 0.9
+ testDataArray[2] = 2.2
+ testDataArray[3] = 2.7
+ testDataArray[4] = 4.4
+ testDataArray[5] = 4.5
+ testDataArray[6] = 6.6
+ testDataArray[7] = 6.3
+ testDataArray[8] = 8.8
+ testDataArray[9] = 8.1
+ testDataArray[10] = 11.0
+ testDataArray[11] = 9.9
+ testDataArray[12] = 13.2
+ testDataArray[13] = 11.7
+ testDataArray[14] = 15.4
+ testDataArray[15] = 13.5
+ testDataArray[16] = 17.6
+ testDataArray[17] = 15.3
+ testDataArray[18] = 19.8
+ testDataArray[19] = 17.1
+ testDataArray[20] = 22.0
+ testDataArray[21] = 18.9
+ testDataArray[22] = 24.2
+ testDataArray[23] = 20.7
+
+ let cleanCurrentDt = []
+ const CECFilter = createCyclicErrorFilter(baseRowerConfig, baseRegressionFunction)
+
+ // Initialise the starting point
+ cleanCurrentDt[0] = CECFilter.applyFilter(0, 0)
+
+ // Inecting the datapoints.
+ for (let i = 0; i < testDataArray.length; i++) {
+ baseRegressionFunction.push(i, testDataArray[i], 1)
+ CECFilter.recordRawDatapoint(i, i, testDataArray[i])
+ }
+
+ testRegressorSlopeEquals(baseRegressionFunction, 0.9000000000000004) // Ideal value 1.0
+ testRegressorInterceptEquals(baseRegressionFunction, -1.6653345369377348e-16) // Ideal value 0
+ testRegressorGoodnessOfFitEquals(baseRegressionFunction, 0.9305410182055288) // Ideal value 1
+
+ // Process the datapoints, filling the entire array
+ for (let i = 0; i < Math.ceil(testDataArray.length / 2); i++) {
+ CECFilter.processNextRawDatapoint()
+ }
+
+ // Filter the total series of raw datapoints
+ for (let i = 0; i < testDataArray.length; i++) {
+ cleanCurrentDt[i] = CECFilter.applyFilter(testDataArray[i], (30 + i))
+ }
+
+ // Check the results
+ // For even magnets, the correction factor should be 0.9
+ // For odd magnets, the correction factor should be 1.1
+ testCleanValueObject(cleanCurrentDt[0], 0.3077186553798185, 0.9664251040857723) // Insterted value 0.0, ideal value 0.0
+ testCleanValueObject(cleanCurrentDt[1], 0.6774521098766918, 1) // Insterted value 0.9, ideal value 1.0
+ testCleanValueObject(cleanCurrentDt[2], 2.2995234514194602, 0.9664251040857723) // Insterted value 2.2, ideal value 2.0
+ testCleanValueObject(cleanCurrentDt[3], 2.6477936403897124, 1) // Insterted value 2.7, ideal Value 3.0
+ testCleanValueObject(cleanCurrentDt[4], 4.291328247459102, 0.9664251040857723) // Insterted value 4.4, ideal value 4.0
+ testCleanValueObject(cleanCurrentDt[5], 4.6181351709027325, 1) // Insterted value 4.5, ideal value 5.0
+ testCleanValueObject(cleanCurrentDt[6], 6.283133043498744, 0.9664251040857723) // Insterted value 6.6, ideal value 6.0
+ testCleanValueObject(cleanCurrentDt[7], 6.588476701415753, 1) // Insterted value 6.3, ideal Value 7.0
+ testCleanValueObject(cleanCurrentDt[8], 8.274937839538387, 0.9664251040857723) // Insterted value 8.8, ideal value 8.0
+ testCleanValueObject(cleanCurrentDt[9], 8.558818231928772, 1) // Insterted value 8.1, ideal value 9.0
+ testCleanValueObject(cleanCurrentDt[10], 10.266742635578028, 0.9664251040857723) // Insterted value 11.0, ideal value 10.0
+ testCleanValueObject(cleanCurrentDt[11], 10.529159762441793, 1) // Insterted value 9.9, ideal Value 11.0
+ testCleanValueObject(cleanCurrentDt[12], 12.25854743161767, 0.9664251040857723) // Insterted value 13.2, ideal value 12.0
+ testCleanValueObject(cleanCurrentDt[13], 12.499501292954815, 1) // Insterted value 11.7, ideal Value 13.0
+ testCleanValueObject(cleanCurrentDt[14], 14.250352227657311, 0.9664251040857723) // Insterted value 15.4, ideal value 14.0
+ testCleanValueObject(cleanCurrentDt[15], 14.469842823467832, 1) // Insterted value 13.5, ideal value 15.0
+ testCleanValueObject(cleanCurrentDt[16], 16.242157023696954, 0.9664251040857723) /// Insterted value 17.6, ideal value 16.0
+ testCleanValueObject(cleanCurrentDt[17], 16.440184353980854, 1) // Insterted value 15.3, ideal Value 17.0
+ testCleanValueObject(cleanCurrentDt[18], 18.233961819736592, 0.9664251040857723) // Insterted value 19.8, ideal value 18.0
+ testCleanValueObject(cleanCurrentDt[19], 18.410525884493875, 1) // Insterted value 17.1, ideal Value 19.0
+ testCleanValueObject(cleanCurrentDt[20], 20.225766615776237, 0.9664251040857723) // Insterted value 22.0, ideal value 20.0
+ testCleanValueObject(cleanCurrentDt[21], 20.380867415006893, 1) // Insterted value 18.9, ideal Value 21.0
+ testCleanValueObject(cleanCurrentDt[22], 22.217571411815875, 0.9664251040857723) // Insterted value 24.2, ideal value 22.0
+ testCleanValueObject(cleanCurrentDt[23], 22.351208945519915, 1) // Insterted value 20.7, ideal Value 23.0
+
+ baseRegressionFunction.reset()
+ CECFilter.clearDatapointBuffer()
+
+ // Here we feed the cleaned data back in, simulating an active filter for the second round
+ for (let i = 0; i < cleanCurrentDt.length; i++) {
+ baseRegressionFunction.push(i + 30, cleanCurrentDt[i].clean, cleanCurrentDt[i].goodnessOfFit)
+ CECFilter.recordRawDatapoint(i + 30, i + 30, cleanCurrentDt[i].clean)
+ }
+
+ testRegressorSlopeEquals(baseRegressionFunction, 0.9851707652565104) // Ideal value 1.0
+ testRegressorInterceptEquals(baseRegressionFunction, -29.86284161307513) // Ideal value -30
+ testRegressorGoodnessOfFitEquals(baseRegressionFunction, 0.9942898519652195) // Ideal value 1
+
+ // Process the datapoints, filling the entire array
+ for (let i = 0; i < Math.ceil(testDataArray.length / 2); i++) {
+ CECFilter.processNextRawDatapoint()
+ }
+
+ // Filter the total series of raw datapoints
+ cleanCurrentDt = []
+ for (let i = 0; i < testDataArray.length; i++) {
+ cleanCurrentDt[i] = CECFilter.applyFilter(testDataArray[i], (60 + i))
+ }
+
+ // Check the results
+ // For even magnets, the correction factor should be 0.9
+ // For odd magnets, the correction factor should be 1.1
+ testCleanValueObject(cleanCurrentDt[0], 0.011558401819693557, 0.9787788846517882) // Insterted value 0.0, ideal value 0.0
+ testCleanValueObject(cleanCurrentDt[1], 0.9419626485844151, 0.999999999999998) // Insterted value 0.9, ideal value 1.0
+ testCleanValueObject(cleanCurrentDt[2], 2.0807291674985406, 0.9787788846517882) // Insterted value 2.2, ideal value 2.0
+ testCleanValueObject(cleanCurrentDt[3], 2.8490047493926327, 0.999999999999998) // Insterted value 2.7, ideal Value 3.0
+ testCleanValueObject(cleanCurrentDt[4], 4.149899933177388, 0.9787788846517882) // Insterted value 4.4, ideal value 4.0
+ testCleanValueObject(cleanCurrentDt[5], 4.756046850200849, 0.999999999999998) // Insterted value 4.5, ideal value 5.0
+ testCleanValueObject(cleanCurrentDt[6], 6.219070698856234, 0.9787788846517882) // Insterted value 6.6, ideal value 6.0
+ testCleanValueObject(cleanCurrentDt[7], 6.663088951009066, 0.999999999999998) // Insterted value 6.3, ideal Value 7.0
+ testCleanValueObject(cleanCurrentDt[8], 8.288241464535082, 0.9787788846517882) // Insterted value 8.8, ideal value 8.0
+ testCleanValueObject(cleanCurrentDt[9], 8.570131051817283, 0.999999999999998) // Insterted value 8.1, ideal value 9.0
+ testCleanValueObject(cleanCurrentDt[10], 10.357412230213926, 0.9787788846517882) // Insterted value 11.0, ideal value 10.0
+ testCleanValueObject(cleanCurrentDt[11], 10.477173152625502, 0.999999999999998) // Insterted value 9.9, ideal Value 11.0
+ testCleanValueObject(cleanCurrentDt[12], 12.426582995892772, 0.9787788846517882) // Insterted value 13.2, ideal value 12.0
+ testCleanValueObject(cleanCurrentDt[13], 12.384215253433718, 0.999999999999998) // Insterted value 11.7, ideal Value 13.0
+ testCleanValueObject(cleanCurrentDt[14], 14.49575376157162, 0.9787788846517882) // Insterted value 15.4, ideal value 14.0
+ testCleanValueObject(cleanCurrentDt[15], 14.291257354241937, 0.999999999999998) // Insterted value 13.5, ideal value 15.0
+ testCleanValueObject(cleanCurrentDt[16], 16.564924527250472, 0.9787788846517882) /// Insterted value 17.6, ideal value 16.0
+ testCleanValueObject(cleanCurrentDt[17], 16.198299455050154, 0.999999999999998) // Insterted value 15.3, ideal Value 17.0
+ testCleanValueObject(cleanCurrentDt[18], 18.634095292929317, 0.9787788846517882) // Insterted value 19.8, ideal value 18.0
+ testCleanValueObject(cleanCurrentDt[19], 18.10534155585837, 0.999999999999998) // Insterted value 17.1, ideal Value 19.0
+ testCleanValueObject(cleanCurrentDt[20], 20.70326605860816, 0.9787788846517882) // Insterted value 22.0, ideal value 20.0
+ testCleanValueObject(cleanCurrentDt[21], 20.012383656666586, 0.999999999999998) // Insterted value 18.9, ideal Value 21.0
+ testCleanValueObject(cleanCurrentDt[22], 22.77243682428701, 0.9787788846517882) // Insterted value 24.2, ideal value 22.0
+ testCleanValueObject(cleanCurrentDt[23], 21.919425757474805, 0.999999999999998) // Insterted value 20.7, ideal Value 23.0
+
+ baseRegressionFunction.reset()
+ CECFilter.clearDatapointBuffer()
+
+ // Here we feed the cleaned data back in, simulating an active filter for the third round
+ for (let i = 0; i < testDataArray.length; i++) {
+ baseRegressionFunction.push(i + 60, cleanCurrentDt[i].clean, cleanCurrentDt[i].goodnessOfFit)
+ CECFilter.recordRawDatapoint(i + 60, i + 60, cleanCurrentDt[i].clean)
+ }
+
+ testRegressorSlopeEquals(baseRegressionFunction, 0.953521050404109) // Ideal value 1.0
+ testRegressorInterceptEquals(baseRegressionFunction, -57.222821426066226) // Ideal value -60
+ testRegressorGoodnessOfFitEquals(baseRegressionFunction, 0.9879313385536389) // Ideal value 1
+
+ // Process the datapoints, filling the entire array
+ for (let i = 0; i < Math.ceil(testDataArray.length / 2); i++) {
+ CECFilter.processNextRawDatapoint()
+ }
+
+ // Filter the total series of raw datapoints
+ cleanCurrentDt = []
+ for (let i = 0; i < testDataArray.length; i++) {
+ cleanCurrentDt[i] = CECFilter.applyFilter(testDataArray[i], (90 + i))
+ }
+
+ // For even magnets, the correction factor should be 0.9
+ // For odd magnets, the correction factor should be 1.1
+ testCleanValueObject(cleanCurrentDt[0], -0.14308049211288923, 0.000980907789229585) // Insterted value 0.0, ideal value 0.0
+ testCleanValueObject(cleanCurrentDt[1], 1.077866513953529, 1.0000000000000016) // Insterted value 0.9, ideal value 1.0
+ testCleanValueObject(cleanCurrentDt[2], 1.9718870100544368, 0.9809077892295849) // Insterted value 2.2, ideal value 2.0
+ testCleanValueObject(cleanCurrentDt[3], 2.9474385576348094, 1.0000000000000016) // Insterted value 2.7, ideal Value 3.0
+ testCleanValueObject(cleanCurrentDt[4], 4.0868545122217625, 0.9809077892295849) // Insterted value 4.4, ideal value 4.0
+ testCleanValueObject(cleanCurrentDt[5], 4.817010601316088, 1.0000000000000016) // Insterted value 4.5, ideal value 5.0
+ testCleanValueObject(cleanCurrentDt[6], 6.201822014389088, 0.9809077892295849) // Insterted value 6.6, ideal value 6.0
+ testCleanValueObject(cleanCurrentDt[7], 6.686582644997368, 1.0000000000000016) // Insterted value 6.3, ideal Value 7.0
+ testCleanValueObject(cleanCurrentDt[8], 8.316789516556415, 0.9809077892295849) // Insterted value 8.8, ideal value 8.0
+ testCleanValueObject(cleanCurrentDt[9], 8.556154688678646, 1.0000000000000016) // Insterted value 8.1, ideal value 9.0
+ testCleanValueObject(cleanCurrentDt[10], 10.43175701872374, 0.9809077892295849) // Insterted value 11.0, ideal value 10.0
+ testCleanValueObject(cleanCurrentDt[11], 10.425726732359927, 1.0000000000000016) // Insterted value 9.9, ideal Value 11.0
+ testCleanValueObject(cleanCurrentDt[12], 12.546724520891066, 0.9809077892295849) // Insterted value 13.2, ideal value 12.0
+ testCleanValueObject(cleanCurrentDt[13], 12.295298776041207, 1.0000000000000016) // Insterted value 11.7, ideal Value 13.0
+ testCleanValueObject(cleanCurrentDt[14], 14.661692023058393, 0.9809077892295849) // Insterted value 15.4, ideal value 14.0
+ testCleanValueObject(cleanCurrentDt[15], 14.164870819722486, 1.0000000000000016) // Insterted value 13.5, ideal value 15.0
+ testCleanValueObject(cleanCurrentDt[16], 16.776659525225718, 0.9809077892295849) // Insterted value 17.6, ideal value 16.0
+ testCleanValueObject(cleanCurrentDt[17], 16.03444286340377, 1.0000000000000016) // Insterted value 15.3, ideal Value 17.0
+ testCleanValueObject(cleanCurrentDt[18], 18.891627027393042, 0.9809077892295849) // Insterted value 19.8, ideal value 18.0
+ testCleanValueObject(cleanCurrentDt[19], 17.90401490708505, 1.0000000000000016) // Insterted value 17.1, ideal Value 19.0
+ testCleanValueObject(cleanCurrentDt[20], 21.006594529560367, 0.9809077892295849) // Insterted value 22.0, ideal value 20.0
+ testCleanValueObject(cleanCurrentDt[21], 19.773586950766326, 1.0000000000000016) // Insterted value 18.9, ideal Value 21.0
+ testCleanValueObject(cleanCurrentDt[22], 23.12156203172769, 0.9809077892295849) // Insterted value 24.2, ideal value 22.0
+ testCleanValueObject(cleanCurrentDt[23], 21.643158994447607, 1.0000000000000016) // Insterted value 20.7, ideal Value 23.0
+
+ baseRegressionFunction.reset()
+ CECFilter.clearDatapointBuffer()
+
+ // Here we feed the cleaned data back in, simulating an active filter for the fourth round
+ for (let i = 0; i < testDataArray.length; i++) {
+ baseRegressionFunction.push(i + 90, cleanCurrentDt[i].clean, cleanCurrentDt[i].goodnessOfFit)
+ CECFilter.recordRawDatapoint(i + 90, i + 90, cleanCurrentDt[i].clean)
+ }
+
+ testRegressorSlopeEquals(baseRegressionFunction, 0.9375037235566221) // Ideal value 1.0
+ testRegressorInterceptEquals(baseRegressionFunction, -84.24040773313105) // Ideal value -90
+ testRegressorGoodnessOfFitEquals(baseRegressionFunction, 0.9790156172582558) // Ideal value 1
+
+ // Process the datapoints, filling the entire array
+ for (let i = 0; i < Math.ceil(testDataArray.length / 2); i++) {
+ CECFilter.processNextRawDatapoint()
+ }
+
+ // Filter the total series of raw datapoints
+ cleanCurrentDt = []
+ for (let i = 0; i < testDataArray.length; i++) {
+ cleanCurrentDt[i] = CECFilter.applyFilter(testDataArray[i], (120 + i))
+ }
+
+ // For even magnets, the correction factor should be 0.9
+ // For odd magnets, the correction factor should be 1.1
+ testCleanValueObject(cleanCurrentDt[0], 0.03206505953318455, 0.9972877714154669) // Insterted value 0.0, ideal value 0.0
+ testCleanValueObject(cleanCurrentDt[1], 0.9079830315244277, 0.9999916969142686) // Insterted value 0.9, ideal value 1.0
+ testCleanValueObject(cleanCurrentDt[2], 2.1341697258368013, 0.9972877714154669) // Insterted value 2.2, ideal value 2.0
+ testCleanValueObject(cleanCurrentDt[3], 2.7880792136396524, 0.9999916969142686) // Insterted value 2.7, ideal Value 3.0
+ testCleanValueObject(cleanCurrentDt[4], 4.236274392140419, 0.9972877714154669) // Insterted value 4.4, ideal value 4.0
+ testCleanValueObject(cleanCurrentDt[5], 4.668175395754876, 0.9999916969142686) // Insterted value 4.5, ideal value 5.0
+ testCleanValueObject(cleanCurrentDt[6], 6.338379058444035, 0.9972877714154669) // Insterted value 6.6, ideal value 6.0
+ testCleanValueObject(cleanCurrentDt[7], 6.5482715778701, 0.9999916969142686) // Insterted value 6.3, ideal Value 7.0
+ testCleanValueObject(cleanCurrentDt[8], 8.440483724747653, 0.9972877714154669) // Insterted value 8.8, ideal value 8.0
+ testCleanValueObject(cleanCurrentDt[9], 8.428367759985326, 0.9999916969142686) // Insterted value 8.1, ideal value 9.0
+ testCleanValueObject(cleanCurrentDt[10], 10.542588391051268, 0.9972877714154669) // Insterted value 11.0, ideal value 10.0
+ testCleanValueObject(cleanCurrentDt[11], 10.30846394210055, 0.9999916969142686) // Insterted value 9.9, ideal Value 11.0
+ testCleanValueObject(cleanCurrentDt[12], 12.644693057354885, 0.9972877714154669) // Insterted value 13.2, ideal value 12.0
+ testCleanValueObject(cleanCurrentDt[13], 12.188560124215774, 0.9999916969142686) // Insterted value 11.7, ideal Value 13.0
+ testCleanValueObject(cleanCurrentDt[14], 14.746797723658501, 0.9972877714154669) // Insterted value 15.4, ideal value 14.0
+ testCleanValueObject(cleanCurrentDt[15], 14.068656306330999, 0.9999916969142686) // Insterted value 13.5, ideal value 15.0
+ testCleanValueObject(cleanCurrentDt[16], 16.84890238996212, 0.9972877714154669) // Insterted value 17.6, ideal value 16.0
+ testCleanValueObject(cleanCurrentDt[17], 15.948752488446225, 0.9999916969142686) // Insterted value 15.3, ideal Value 17.0
+ testCleanValueObject(cleanCurrentDt[18], 18.951007056265738, 0.9972877714154669) // Insterted value 19.8, ideal value 18.0
+ testCleanValueObject(cleanCurrentDt[19], 17.82884867056145, 0.9999916969142686) // Insterted value 17.1, ideal Value 19.0
+ testCleanValueObject(cleanCurrentDt[20], 21.05311172256935, 0.9972877714154669) // Insterted value 22.0, ideal value 20.0
+ testCleanValueObject(cleanCurrentDt[21], 19.70894485267667, 0.9999916969142686) // Insterted value 18.9, ideal Value 21.0
+ testCleanValueObject(cleanCurrentDt[22], 23.15521638887297, 0.9972877714154669) // Insterted value 24.2, ideal value 22.0
+ testCleanValueObject(cleanCurrentDt[23], 21.589041034791894, 0.9999916969142686) // Insterted value 20.7, ideal Value 23.0
+
+ baseRegressionFunction.reset()
+ CECFilter.clearDatapointBuffer()
+
+ // Here we feed the cleaned data back in, simulating an active filter for the fifth round
+ for (let i = 0; i < testDataArray.length; i++) {
+ baseRegressionFunction.push(i + 120, cleanCurrentDt[i].clean, cleanCurrentDt[i].goodnessOfFit)
+ CECFilter.recordRawDatapoint(i + 120, i + 120, cleanCurrentDt[i].clean)
+ }
+
+ testRegressorSlopeEquals(baseRegressionFunction, 0.9400480910576124) // Ideal value 1.0
+ testRegressorInterceptEquals(baseRegressionFunction, -112.83783598644666) // Ideal value -120
+ testRegressorGoodnessOfFitEquals(baseRegressionFunction, 0.9764199727099291) // Ideal value 1
+
+ // Process the datapoints, filling the entire array
+ for (let i = 0; i < Math.ceil(testDataArray.length / 2); i++) {
+ CECFilter.processNextRawDatapoint()
+ }
+
+ // Filter the total series of raw datapoints
+ cleanCurrentDt = []
+ for (let i = 0; i < testDataArray.length; i++) {
+ cleanCurrentDt[i] = CECFilter.applyFilter(testDataArray[i], (150 + i))
+ }
+
+ // For even magnets, the correction factor should be 0.9
+ // For odd magnets, the correction factor should be 1.1
+ testCleanValueObject(cleanCurrentDt[0], 0.05349387468532712, 0.9987707492830576) // Insterted value 0.0, ideal value 0.0
+ testCleanValueObject(cleanCurrentDt[1], 0.89627054250747, 0.9999916675735381) // Insterted value 0.9, ideal value 1.0
+ testCleanValueObject(cleanCurrentDt[2], 2.1318475215473804, 0.9987707492830576) // Insterted value 2.2, ideal value 2.0
+ testCleanValueObject(cleanCurrentDt[3], 2.795799376893064, 0.9999916675735381) // Insterted value 2.7, ideal Value 3.0
+ testCleanValueObject(cleanCurrentDt[4], 4.210201168409434, 0.9987707492830576) // Insterted value 4.4, ideal value 4.0
+ testCleanValueObject(cleanCurrentDt[5], 4.6953282112786585, 0.9999916675735381) // Insterted value 4.5, ideal value 5.0
+ testCleanValueObject(cleanCurrentDt[6], 6.288554815271485, 0.9987707492830576) // Insterted value 6.6, ideal value 6.0
+ testCleanValueObject(cleanCurrentDt[7], 6.594857045664252, 0.9999916675735381) // Insterted value 6.3, ideal Value 7.0
+ testCleanValueObject(cleanCurrentDt[8], 8.366908462133539, 0.9987707492830576) // Insterted value 8.8, ideal value 8.0
+ testCleanValueObject(cleanCurrentDt[9], 8.494385880049848, 0.9999916675735381) // Insterted value 8.1, ideal value 9.0
+ testCleanValueObject(cleanCurrentDt[10], 10.445262108995593, 0.9987707492830576) // Insterted value 11.0, ideal value 10.0
+ testCleanValueObject(cleanCurrentDt[11], 10.393914714435441, 0.9999916675735381) // Insterted value 9.9, ideal Value 11.0
+ testCleanValueObject(cleanCurrentDt[12], 12.523615755857643, 0.9987707492830576) // Insterted value 13.2, ideal value 12.0
+ testCleanValueObject(cleanCurrentDt[13], 12.293443548821035, 0.9999916675735381) // Insterted value 11.7, ideal Value 13.0
+ testCleanValueObject(cleanCurrentDt[14], 14.601969402719698, 0.9987707492830576) // Insterted value 15.4, ideal value 14.0
+ testCleanValueObject(cleanCurrentDt[15], 14.192972383206628, 0.9999916675735381) // Insterted value 13.5, ideal value 15.0
+ testCleanValueObject(cleanCurrentDt[16], 16.68032304958175, 0.9987707492830576) // Insterted value 17.6, ideal value 16.0
+ testCleanValueObject(cleanCurrentDt[17], 16.092501217592222, 0.9999916675735381) // Insterted value 15.3, ideal Value 17.0
+ testCleanValueObject(cleanCurrentDt[18], 18.758676696443803, 0.9987707492830576) // Insterted value 19.8, ideal value 18.0
+ testCleanValueObject(cleanCurrentDt[19], 17.992030051977817, 0.9999916675735381) // Insterted value 17.1, ideal Value 19.0
+ testCleanValueObject(cleanCurrentDt[20], 20.83703034330586, 0.9987707492830576) // Insterted value 22.0, ideal value 20.0
+ testCleanValueObject(cleanCurrentDt[21], 19.89155888636341, 0.9999916675735381) // Insterted value 18.9, ideal Value 21.0
+ testCleanValueObject(cleanCurrentDt[22], 22.91538399016791, 0.9987707492830576) // Insterted value 24.2, ideal value 22.0
+ testCleanValueObject(cleanCurrentDt[23], 21.791087720749005, 0.9999916675735381) // Insterted value 20.7, ideal Value 23.0
+
+ baseRegressionFunction.reset()
+ CECFilter.clearDatapointBuffer()
+
+ // Here we feed the cleaned data back in, simulating an active filter for the fifth round
+ for (let i = 0; i < testDataArray.length; i++) {
+ baseRegressionFunction.push(i + 150, cleanCurrentDt[i].clean, cleanCurrentDt[i].goodnessOfFit)
+ CECFilter.recordRawDatapoint(i + 150, i + 150, cleanCurrentDt[i].clean)
+ }
+
+ testRegressorSlopeEquals(baseRegressionFunction, 0.9497644171927973) // Ideal value 1.0
+ testRegressorInterceptEquals(baseRegressionFunction, -142.5181564536049) // Ideal value -150
+ testRegressorGoodnessOfFitEquals(baseRegressionFunction, 0.9833890292686881) // Ideal value 1
+})
+
+/* eslint-disable complexity -- This simulates a complex function, not much we can do about it */
+test('Correct behaviour of the filter after exposing it to an updated filter with noisy linear regressor (2.5%), five rounds, agressiveness 1.0, size two recoveries on a RowErg', () => {
+ const baseRowerConfig = {
+ numOfImpulsesPerRevolution: 6,
+ flankLength: 2,
+ autoAdjustDragFactor: true,
+ systematicErrorAgressiveness: 1.0,
+ systematicErrorNumberOfDatapoints: 440,
+ minimumTimeBetweenImpulses: 0,
+ maximumTimeBetweenImpulses: 230
+ }
+
+ const baseRegressionFunction = createTSLinearSeries()
+
+ // Complete dataset, which will be reused for each cycle. In essence y = x, where
+ // - the even datapoints are multipied by 1.1 (so correction factor should become 1/1.1 = 0.9)
+ // - the odd datapoints are multipied by 0.9 (so correction factor should become 1/0.9 = 1.1)
+ let testDataArray = []
+ for (let i = 0; i < 220; i++) {
+ switch (i % 6) {
+ case 0:
+ testDataArray[i] = 1.0125 * i
+ break
+ case 1:
+ testDataArray[i] = 1.025 * i
+ break
+ case 2:
+ testDataArray[i] = 1.0125 * i
+ break
+ case 3:
+ testDataArray[i] = 0.9877 * i
+ break
+ case 4:
+ testDataArray[i] = 0.9756 * i
+ break
+ case 5:
+ testDataArray[i] = 0.9877 * i
+ break
+ // No default
+ }
+ }
+
+ let cleanCurrentDt = []
+ const CECFilter = createCyclicErrorFilter(baseRowerConfig, baseRegressionFunction)
+
+ // Initialise the starting point
+ cleanCurrentDt[0] = CECFilter.applyFilter(0, 0)
+
+ // Inecting the datapoints.
+ for (let i = 0; i < testDataArray.length; i++) {
+ baseRegressionFunction.push(i, testDataArray[i], 1)
+ CECFilter.recordRawDatapoint(i, i, testDataArray[i])
+ }
+
+ testRegressorSlopeEquals(baseRegressionFunction, 1.0008698172472872) // Ideal value 1.0
+ testRegressorInterceptEquals(baseRegressionFunction, 0.01163018275271277) // Ideal value 0
+ testRegressorGoodnessOfFitEquals(baseRegressionFunction, 0.9987940622596934) // Ideal value 1
+
+ // Process the datapoints, filling the entire array
+ for (let i = 0; i < Math.ceil(testDataArray.length / 2); i++) {
+ CECFilter.processNextRawDatapoint()
+ }
+
+ // Filter the total series of raw datapoints
+ for (let i = 0; i < testDataArray.length; i++) {
+ cleanCurrentDt[i] = CECFilter.applyFilter(testDataArray[i], (250 + i))
+ }
+
+ baseRegressionFunction.reset()
+ CECFilter.clearDatapointBuffer()
+
+ // Here we feed the cleaned data back in, simulating an active filter for the second round
+ for (let i = 0; i < cleanCurrentDt.length; i++) {
+ baseRegressionFunction.push(i + 250, cleanCurrentDt[i].clean, cleanCurrentDt[i].goodnessOfFit)
+ CECFilter.recordRawDatapoint(i + 250, i + 250, cleanCurrentDt[i].clean)
+ }
+
+ testRegressorSlopeEquals(baseRegressionFunction, 0.9994796184812487) // Ideal value 1.0
+ testRegressorInterceptEquals(baseRegressionFunction, -249.87103176967827) // Ideal value -250
+ testRegressorGoodnessOfFitEquals(baseRegressionFunction, 0.9969663083924543) // Ideal value 1
+
+ // Process the datapoints, filling the entire array
+ for (let i = 0; i < Math.ceil(testDataArray.length / 2); i++) {
+ CECFilter.processNextRawDatapoint()
+ }
+
+ // Filter the total series of raw datapoints
+ cleanCurrentDt = []
+ for (let i = 0; i < testDataArray.length; i++) {
+ cleanCurrentDt[i] = CECFilter.applyFilter(testDataArray[i], (500 + i))
+ }
+
+ baseRegressionFunction.reset()
+ CECFilter.clearDatapointBuffer()
+
+ // Here we feed the cleaned data back in, simulating an active filter for the third round
+ for (let i = 0; i < testDataArray.length; i++) {
+ baseRegressionFunction.push(i + 500, cleanCurrentDt[i].clean, cleanCurrentDt[i].goodnessOfFit)
+ CECFilter.recordRawDatapoint(i + 500, i + 500, cleanCurrentDt[i].clean)
+ }
+
+ testRegressorSlopeEquals(baseRegressionFunction, 0.9997525129403375) // Ideal value 1.0
+ testRegressorInterceptEquals(baseRegressionFunction, -499.91268495067095) // Ideal value -500
+ testRegressorGoodnessOfFitEquals(baseRegressionFunction, 0.9977841039000378) // Ideal value 1
+
+ // Process the datapoints, filling the entire array
+ for (let i = 0; i < Math.ceil(testDataArray.length / 2); i++) {
+ CECFilter.processNextRawDatapoint()
+ }
+
+ // Filter the total series of raw datapoints
+ cleanCurrentDt = []
+ for (let i = 0; i < testDataArray.length; i++) {
+ cleanCurrentDt[i] = CECFilter.applyFilter(testDataArray[i], (750 + i))
+ }
+
+ baseRegressionFunction.reset()
+ CECFilter.clearDatapointBuffer()
+
+ // Here we feed the cleaned data back in, simulating an active filter for the fourth round
+ for (let i = 0; i < testDataArray.length; i++) {
+ baseRegressionFunction.push(i + 750, cleanCurrentDt[i].clean, cleanCurrentDt[i].goodnessOfFit)
+ CECFilter.recordRawDatapoint(i + 750, i + 750, cleanCurrentDt[i].clean)
+ }
+
+ testRegressorSlopeEquals(baseRegressionFunction, 1.0005226479777438) // Ideal value 1.0
+ testRegressorInterceptEquals(baseRegressionFunction, -750.338863719227) // Ideal value -750
+ testRegressorGoodnessOfFitEquals(baseRegressionFunction, 0.9970610601990529) // Ideal value 1
+
+ // Process the datapoints, filling the entire array
+ for (let i = 0; i < Math.ceil(testDataArray.length / 2); i++) {
+ CECFilter.processNextRawDatapoint()
+ }
+
+ // Filter the total series of raw datapoints
+ cleanCurrentDt = []
+ for (let i = 0; i < testDataArray.length; i++) {
+ cleanCurrentDt[i] = CECFilter.applyFilter(testDataArray[i], (1000 + i))
+ }
+
+ baseRegressionFunction.reset()
+ CECFilter.clearDatapointBuffer()
+
+ // Here we feed the cleaned data back in, simulating an active filter for the fifth round
+ for (let i = 0; i < testDataArray.length; i++) {
+ baseRegressionFunction.push(i + 1000, cleanCurrentDt[i].clean, cleanCurrentDt[i].goodnessOfFit)
+ CECFilter.recordRawDatapoint(i + 1000, i + 1000, cleanCurrentDt[i].clean)
+ }
+
+ testRegressorSlopeEquals(baseRegressionFunction, 0.9993768090584058) // Ideal value 1.0
+ testRegressorInterceptEquals(baseRegressionFunction, -999.3987560436793) // Ideal value -1000
+ testRegressorGoodnessOfFitEquals(baseRegressionFunction, 0.9974086881750074) // Ideal value 1
+
+ // Process the datapoints, filling the entire array
+ for (let i = 0; i < Math.ceil(testDataArray.length / 2); i++) {
+ CECFilter.processNextRawDatapoint()
+ }
+
+ // Filter the total series of raw datapoints
+ cleanCurrentDt = []
+ for (let i = 0; i < testDataArray.length; i++) {
+ cleanCurrentDt[i] = CECFilter.applyFilter(testDataArray[i], (1250 + i))
+ }
+
+ baseRegressionFunction.reset()
+ CECFilter.clearDatapointBuffer()
+
+ // Here we feed the cleaned data back in, simulating an active filter for the fifth round
+ for (let i = 0; i < testDataArray.length; i++) {
+ baseRegressionFunction.push(i + 1250, cleanCurrentDt[i].clean, cleanCurrentDt[i].goodnessOfFit)
+ CECFilter.recordRawDatapoint(i + 1250, i + 1250, cleanCurrentDt[i].clean)
+ }
+
+ testRegressorSlopeEquals(baseRegressionFunction, 1.000640000128456) // Ideal value 1.0
+ testRegressorInterceptEquals(baseRegressionFunction, -1250.8126695381957) // Ideal value -1250
+ testRegressorGoodnessOfFitEquals(baseRegressionFunction, 0.9974581657430908) // Ideal value 1
+
+ // Process the datapoints, filling the entire array
+ for (let i = 0; i < Math.ceil(testDataArray.length / 2); i++) {
+ CECFilter.processNextRawDatapoint()
+ }
+
+ // Filter the total series of raw datapoints
+ cleanCurrentDt = []
+ for (let i = 0; i < testDataArray.length; i++) {
+ cleanCurrentDt[i] = CECFilter.applyFilter(testDataArray[i], (1500 + i))
+ }
+
+ baseRegressionFunction.reset()
+ CECFilter.clearDatapointBuffer()
+
+ // Here we feed the cleaned data back in, simulating an active filter for the fifth round
+ for (let i = 0; i < testDataArray.length; i++) {
+ baseRegressionFunction.push(i + 1500, cleanCurrentDt[i].clean, cleanCurrentDt[i].goodnessOfFit)
+ }
+
+ testRegressorSlopeEquals(baseRegressionFunction, 1.0009288898929578) // Ideal value 1.0
+ testRegressorInterceptEquals(baseRegressionFunction, -1501.3667593727203) // Ideal value -1500
+ testRegressorGoodnessOfFitEquals(baseRegressionFunction, 0.9975644504176134) // Ideal value 1
+})
+/* eslint-enable complexity */
+
+function testCleanValueObject (object, expectedValue, expectedGoodnessOfFit) {
+ assert.ok(object.clean === expectedValue, `Expected cleaned currentDt value to be ${expectedValue}, encountered ${object.clean}`)
+ assert.ok(object.goodnessOfFit === expectedGoodnessOfFit, `Expected goodnessOfFit to be ${expectedGoodnessOfFit}, encountered ${object.goodnessOfFit}`)
+}
+
+function testCleanValueEquals (object, expectedValue) {
+ assert.ok(object.clean === expectedValue, `Expected cleaned currentDt value to be ${expectedValue}, encountered ${object.clean}`)
+}
+
+function testGoodnessOfFitEquals (object, expectedValue) {
+ assert.ok(object.goodnessOfFit === expectedValue, `Expected goodnessOfFit to be ${expectedValue}, encountered ${object.goodnessOfFit}`)
+}
+
+function testRawValueAtBeginEquals (object, expectedValue) {
+ assert.ok(object.raw === expectedValue, `Expected raw value atSeriesBegin() to be ${expectedValue}, encountered ${object.raw}`)
+}
+
+function testCleanValueAtBeginEquals (object, expectedValue) {
+ assert.ok(object.clean === expectedValue, `Expected clean value atSeriesBegin() to be ${expectedValue}, encountered ${object.clean}`)
+}
+
+function testGoodnessOfFitAtBeginEquals (object, expectedValue) {
+ assert.ok(object.goodnessOfFit === expectedValue, `Expected goodnessOfFit atSeriesBegin() to be ${expectedValue}, encountered ${object.goodnessOfFit}`)
+}
+
+function testRegressorSlopeEquals (series, expectedValue) {
+ assert.ok(series.slope() === expectedValue, `Expected regression slope to be ${expectedValue}, encountered a ${series.slope()}`)
+}
+
+function testRegressorInterceptEquals (series, expectedValue) {
+ assert.ok(series.intercept() === expectedValue, `Expected regression intercept to be ${expectedValue}, encountered ${series.intercept()}`)
+}
+
+function testRegressorGoodnessOfFitEquals (series, expectedValue) {
+ assert.ok(series.goodnessOfFit() === expectedValue, `Expected regression goodnessOfFit to be ${expectedValue}, encountered ${series.goodnessOfFit()}`)
+}
+
+test.run()
diff --git a/app/engine/utils/FullTSLinearSeries.js b/app/engine/utils/FullTSLinearSeries.js
deleted file mode 100644
index b0e9c3d4b4..0000000000
--- a/app/engine/utils/FullTSLinearSeries.js
+++ /dev/null
@@ -1,226 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/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.
-
- 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
-
- 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 loglevel from 'loglevel'
-const log = loglevel.getLogger('RowingEngine')
-
-function createTSLinearSeries (maxSeriesLength = 0) {
- const X = createSeries(maxSeriesLength)
- const Y = createSeries(maxSeriesLength)
- const slopes = []
-
- let _A = 0
- let _B = 0
- let _goodnessOfFit = 0
-
- function push (x, y) {
- X.push(x)
- Y.push(y)
-
- if (maxSeriesLength > 0 && slopes.length >= maxSeriesLength) {
- // The maximum of the array has been reached, we have to create room
- // in the 2D array by removing the first row from the table
- removeFirstRow()
- }
-
- // Invariant: the indices of the X and Y array now match up with the
- // row numbers of the slopes array. So, the slope of (X[0],Y[0]) and (X[1],Y[1]
- // will be stored in slopes[0][.].
-
- // Calculate the slopes of this new 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
- let result = 0
- while (i < slopes.length) {
- result = calculateSlope(i, slopes.length)
- slopes[i].push(result)
- i++
- }
- }
- // Add an empty array at the end to store futurs results for the most recent points
- slopes.push([])
-
- // Calculate the median of the slopes
- if (X.length() > 1) {
- _A = median()
- } else {
- _A = 0
- }
- _B = Y.average() - (_A * X.average())
- }
-
- function slope () {
- return _A
- }
-
- function intercept () {
- return _B
- }
-
- function coefficientA () {
- // For testing purposses only!
- return _A
- }
-
- function coefficientB () {
- // For testing purposses only!
- return _B
- }
-
- 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 (_A * x) + _B
- } else {
- return 0
- }
- }
-
- function projectY (y) {
- if (X.length() >= 2 && _A !== 0) {
- return ((y - _B) / _A)
- } else {
- return 0
- }
- }
-
- function numberOfXValuesAbove (testedValue) {
- return X.numberOfValuesAbove(testedValue)
- }
-
- function numberOfXValuesEqualOrBelow (testedValue) {
- return X.numberOfValuesEqualOrBelow(testedValue)
- }
-
- function numberOfYValuesAbove (testedValue) {
- return Y.numberOfValuesAbove(testedValue)
- }
-
- function numberOfYValuesEqualOrBelow (testedValue) {
- return Y.numberOfValuesEqualOrBelow(testedValue)
- }
-
- function xAtSeriesBegin () {
- return X.atSeriesBegin()
- }
-
- function xAtSeriesEnd () {
- return X.atSeriesEnd()
- }
-
- function yAtSeriesBegin () {
- return Y.atSeriesBegin()
- }
-
- function yAtSeriesEnd () {
- return Y.atSeriesEnd()
- }
-
- function xSum () {
- return X.sum()
- }
-
- function ySum () {
- return Y.sum()
- }
-
- function xSeries () {
- return X.series()
- }
-
- function ySeries () {
- return Y.series()
- }
-
- function removeFirstRow () {
- slopes.shift()
- }
-
- 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 median () {
- if (slopes.length > 1) {
- const sortedArray = [...slopes.flat()].sort((a, b) => a - b)
- const mid = Math.floor(sortedArray.length / 2)
- return (sortedArray.length % 2 !== 0 ? sortedArray[mid] : ((sortedArray[mid - 1] + sortedArray[mid]) / 2))
- } else {
- log.eror('TS Linear Regressor, Median calculation on empty dataset attempted!')
- return 0
- }
- }
-
- function reset () {
- X.reset()
- Y.reset()
- slopes.splice(0, slopes.length)
- _A = 0
- _B = 0
- _goodnessOfFit = 0
- }
-
- return {
- push,
- slope,
- intercept,
- coefficientA,
- coefficientB,
- length,
- goodnessOfFit,
- projectX,
- projectY,
- numberOfXValuesAbove,
- numberOfXValuesEqualOrBelow,
- numberOfYValuesAbove,
- numberOfYValuesEqualOrBelow,
- xAtSeriesBegin,
- xAtSeriesEnd,
- yAtSeriesBegin,
- yAtSeriesEnd,
- xSum,
- ySum,
- xSeries,
- ySeries,
- reset
- }
-}
-
-export { createTSLinearSeries }
diff --git a/app/engine/utils/FullTSQuadraticSeries.js b/app/engine/utils/FullTSQuadraticSeries.js
deleted file mode 100644
index a5a9ede858..0000000000
--- a/app/engine/utils/FullTSQuadraticSeries.js
+++ /dev/null
@@ -1,270 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- The TSLinearSeries 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 (due to large
- array based curve fitting), which might be performed on a Pi zero
-
- 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 math 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 loglevel from 'loglevel'
-const log = loglevel.getLogger('RowingEngine')
-
-function createTSQuadraticSeries (maxSeriesLength = 0) {
- const X = createSeries(maxSeriesLength)
- const Y = createSeries(maxSeriesLength)
- const A = []
- let _A = 0
- let _B = 0
- let _C = 0
-
- function push (x, y) {
- const linearResidu = createTSLinearSeries(maxSeriesLength)
-
- X.push(x)
- Y.push(y)
-
- if (maxSeriesLength > 0 && A.length >= maxSeriesLength) {
- // The maximum of the array has been reached, we have to create room
- // in the 2D array by removing the first row from the A-table
- A.shift()
- }
-
- // Invariant: the indices of the X and Y array now match up with the
- // row numbers of the A array. So, the A of (X[0],Y[0]) and (X[1],Y[1]
- // will be stored in A[0][.].
-
- // Add an empty array at the end to store futurs results for the most recent points
- A.push([])
-
- // Calculate the coefficients of this new point
- if (X.length() > 2) {
- // 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() - 2) {
- A[X.length() - 1].push(calculateA(i, X.length() - 1))
- i++
- }
- _A = matrixMedian(A)
-
- i = 0
- linearResidu.reset()
- while (i < X.length() - 1) {
- linearResidu.push(X.get(i), Y.get(i) - (_A * Math.pow(X.get(i), 2)))
- i++
- }
- _B = linearResidu.coefficientA()
- _C = linearResidu.coefficientB()
- } else {
- _A = 0
- _B = 0
- _C = 0
- }
- }
-
- function firstDerivativeAtPosition (position) {
- if (X.length() > 2 && position < X.length()) {
- return ((_A * 2 * X.get(position)) + _B)
- } else {
- return 0
- }
- }
-
- function secondDerivativeAtPosition (position) {
- if (X.length() > 2 && position < X.length()) {
- return (_A * 2)
- } else {
- return 0
- }
- }
-
- function slope (x) {
- if (X.length() > 2) {
- return ((_A * 2 * x) + _B)
- } else {
- return 0
- }
- }
-
- function coefficientA () {
- // For testing purposses only!
- return _A
- }
-
- function coefficientB () {
- // For testing purposses only!
- return _B
- }
-
- function coefficientC () {
- // For testing purposses only!
- return _C
- }
-
- function intercept () {
- return coefficientC()
- }
-
- function length () {
- return X.length()
- }
-
- function goodnessOfFit () {
- // This function returns the R^2 as a goodness of fit indicator
- // ToDo: calculate the goodness of fit when called
- if (X.length() >= 2) {
- // return _goodnessOfFit
- return 1
- } else {
- return 0
- }
- }
-
- function projectX (x) {
- const _C = coefficientC()
- if (X.length() > 2) {
- return ((_A * x * x) + (_B * x) + _C)
- } else {
- return 0
- }
- }
-
- function numberOfXValuesAbove (testedValue) {
- return X.numberOfValuesAbove(testedValue)
- }
-
- function numberOfXValuesEqualOrBelow (testedValue) {
- return X.numberOfValuesEqualOrBelow(testedValue)
- }
-
- function numberOfYValuesAbove (testedValue) {
- return Y.numberOfValuesAbove(testedValue)
- }
-
- function numberOfYValuesEqualOrBelow (testedValue) {
- return Y.numberOfValuesEqualOrBelow(testedValue)
- }
-
- function xAtSeriesBegin () {
- return X.atSeriesBegin()
- }
-
- function xAtSeriesEnd () {
- return X.atSeriesEnd()
- }
-
- function xAtPosition (position) {
- return X.get(position)
- }
-
- function yAtSeriesBegin () {
- return Y.atSeriesBegin()
- }
-
- function yAtSeriesEnd () {
- return Y.atSeriesEnd()
- }
-
- function yAtPosition (position) {
- return Y.get(position)
- }
-
- function xSum () {
- return X.sum()
- }
-
- function ySum () {
- return Y.sum()
- }
-
- function xSeries () {
- return X.series()
- }
-
- function ySeries () {
- return Y.series()
- }
-
- function calculateA (pointOne, pointThree) {
- if ((pointOne + 1) < pointThree && X.get(pointOne) !== X.get(pointThree)) {
- const results = createSeries(maxSeriesLength)
- let pointTwo = pointOne + 1
- while (pointOne < pointTwo && pointTwo < pointThree && X.get(pointOne) !== X.get(pointTwo) && 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
- results.push((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))))
- pointTwo += 1
- }
- return results.median()
- } else {
- log.error('TS Quadratic Regressor, Division by zero prevented in CalculateA!')
- return 0
- }
- }
-
- function matrixMedian (inputMatrix) {
- if (inputMatrix.length > 1) {
- const sortedArray = [...inputMatrix.flat()].sort((a, b) => a - b)
- const mid = Math.floor(sortedArray.length / 2)
- return (sortedArray.length % 2 !== 0 ? sortedArray[mid] : ((sortedArray[mid - 1] + sortedArray[mid]) / 2))
- } else {
- log.error('TS Quadratic Regressor, Median calculation on empty matrix attempted!')
- return 0
- }
- }
-
- function reset () {
- X.reset()
- Y.reset()
- A.splice(0, A.length)
- _A = 0
- _B = 0
- _C = 0
- }
-
- return {
- push,
- firstDerivativeAtPosition,
- secondDerivativeAtPosition,
- slope,
- coefficientA,
- coefficientB,
- coefficientC,
- intercept,
- length,
- goodnessOfFit,
- projectX,
- numberOfXValuesAbove,
- numberOfXValuesEqualOrBelow,
- numberOfYValuesAbove,
- numberOfYValuesEqualOrBelow,
- xAtSeriesBegin,
- xAtSeriesEnd,
- xAtPosition,
- yAtSeriesBegin,
- yAtSeriesEnd,
- yAtPosition,
- xSum,
- ySum,
- xSeries,
- ySeries,
- reset
- }
-}
-
-export { createTSQuadraticSeries }
diff --git a/app/engine/utils/FullTSQuadraticSeries.test.js b/app/engine/utils/FullTSQuadraticSeries.test.js
deleted file mode 100644
index 473b5b0484..0000000000
--- a/app/engine/utils/FullTSQuadraticSeries.test.js
+++ /dev/null
@@ -1,546 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/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)
-})
-
-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)
- 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)
-})
-
-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)
- dataSeries.push(-8, 228)
- testCoefficientA(dataSeries, 4)
- testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 4)
- dataSeries.push(-7, 172)
- testCoefficientA(dataSeries, 4)
- testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 4)
- dataSeries.push(-6, 124)
- testCoefficientA(dataSeries, 4)
- testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 4)
- dataSeries.push(-5, 84)
- testCoefficientA(dataSeries, 4)
- testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 4)
- dataSeries.push(-4, 52)
- testCoefficientA(dataSeries, 4)
- testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 4)
- dataSeries.push(-3, 28)
- testCoefficientA(dataSeries, 4)
- testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 4)
- dataSeries.push(-2, 12)
- testCoefficientA(dataSeries, 4)
- testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 4)
- dataSeries.push(-1, 4)
- testCoefficientA(dataSeries, 4)
- testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 4)
- dataSeries.push(0, 4)
- testCoefficientA(dataSeries, 4)
- testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 4)
- dataSeries.push(1, 12)
- testCoefficientA(dataSeries, 4)
- testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 4)
- dataSeries.push(2, 28)
- testCoefficientA(dataSeries, 4)
- testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 4)
- dataSeries.push(3, 52)
- testCoefficientA(dataSeries, 4)
- testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 4)
- dataSeries.push(4, 84)
- testCoefficientA(dataSeries, 4)
- testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 4)
- dataSeries.push(5, 124)
- testCoefficientA(dataSeries, 4)
- testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 4)
- dataSeries.push(6, 172)
- testCoefficientA(dataSeries, 4)
- testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 4)
- dataSeries.push(7, 228)
- testCoefficientA(dataSeries, 4)
- testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 4)
- dataSeries.push(8, 292)
- testCoefficientA(dataSeries, 4)
- testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 4)
- dataSeries.push(9, 364)
- testCoefficientA(dataSeries, 4)
- testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 4)
- dataSeries.push(10, 444)
- testCoefficientA(dataSeries, 4)
- testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 4)
-})
-
-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)
- dataSeries.push(-8, 229)
- testCoefficientA(dataSeries, 4)
- testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 3.6666666666666643) // This is quite acceptable as ORM ignores the C
- dataSeries.push(-7, 171)
- testCoefficientA(dataSeries, 3.666666666666667)
- testCoefficientB(dataSeries, -1.8333333333333335)
- testCoefficientC(dataSeries, -20.916666666666682)
- dataSeries.push(-6, 125)
- testCoefficientA(dataSeries, 4)
- testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 3.799999999999997) // This is quite acceptable as ORM ignores the C
- dataSeries.push(-5, 83)
- testCoefficientA(dataSeries, 4)
- testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 4)
- dataSeries.push(-4, 53)
- testCoefficientA(dataSeries, 4)
- testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 3.8571428571428577) // This is quite acceptable as ORM ignores the C
- dataSeries.push(-3, 27)
- testCoefficientA(dataSeries, 4)
- testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 4)
- dataSeries.push(-2, 13)
- testCoefficientA(dataSeries, 4)
- testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 3.8888888888888893) // This is quite acceptable as ORM ignores the C
- dataSeries.push(-1, 3)
- testCoefficientA(dataSeries, 4)
- testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 4)
- dataSeries.push(0, 5)
- testCoefficientA(dataSeries, 4)
- testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 4)
- dataSeries.push(1, 11)
- testCoefficientA(dataSeries, 4)
- testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 4)
- dataSeries.push(2, 29)
- testCoefficientA(dataSeries, 4)
- testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 4)
- dataSeries.push(3, 51)
- testCoefficientA(dataSeries, 4)
- testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 4)
- dataSeries.push(4, 85)
- testCoefficientA(dataSeries, 4)
- testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 4)
- dataSeries.push(5, 123)
- testCoefficientA(dataSeries, 4)
- testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 4)
- dataSeries.push(6, 173)
- testCoefficientA(dataSeries, 4)
- testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 4)
- dataSeries.push(7, 227)
- testCoefficientA(dataSeries, 4)
- testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 4)
- dataSeries.push(8, 293)
- testCoefficientA(dataSeries, 4)
- testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 4)
- dataSeries.push(9, 363)
- testCoefficientA(dataSeries, 4)
- testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 4)
- dataSeries.push(10, 444)
- testCoefficientA(dataSeries, 4)
- testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 4)
-})
-
-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, 3.8571428571428577)
- dataSeries.push(-3, 37) // FIRST SPIKE +9
- testCoefficientA(dataSeries, 4)
- testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 4)
- dataSeries.push(-2, 3) // SECOND SPIKE -9
- testCoefficientA(dataSeries, 4)
- testCoefficientB(dataSeries, 4.142857142857142) // Coefficient B seems to take a hit anyway
- testCoefficientC(dataSeries, 5.9999999999999964) // We get a 5.9999999999999964 instead of 4, which is quite acceptable (especially since ORM ignores the C)
- dataSeries.push(-1, 3)
- testCoefficientA(dataSeries, 4)
- testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 4)
- dataSeries.push(0, 5)
- testCoefficientA(dataSeries, 4)
- testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 4)
- dataSeries.push(1, 11)
- testCoefficientA(dataSeries, 4)
- testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 4)
- dataSeries.push(2, 29)
- testCoefficientA(dataSeries, 4)
- testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 4)
- dataSeries.push(3, 51)
- testCoefficientA(dataSeries, 4)
- testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 4)
- dataSeries.push(4, 85)
- testCoefficientA(dataSeries, 4)
- testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 4)
- dataSeries.push(5, 123)
- testCoefficientA(dataSeries, 4)
- testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 4)
- dataSeries.push(6, 173)
- testCoefficientA(dataSeries, 4)
- testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 4)
- dataSeries.push(7, 227)
- testCoefficientA(dataSeries, 4)
- testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 4)
- dataSeries.push(8, 293)
- testCoefficientA(dataSeries, 4)
- testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 3) // This is quite acceptable as ORM ignores the C
- dataSeries.push(9, 363)
- testCoefficientA(dataSeries, 4)
- testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 4) // We get a 3 instead of 4, which is quite acceptable (especially since ORM ignores the C)
- dataSeries.push(10, 444)
- testCoefficientA(dataSeries, 4)
- testCoefficientB(dataSeries, 4)
- testCoefficientC(dataSeries, 4)
-})
-
-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.17785023090944152) // In the example, the TI084 results in -0.1737141137, which we consider acceptably close
- testCoefficientB(dataSeries, 15.115602960635863) // In the example, the TI084 results in 14.52117133, which we consider acceptably close
- testCoefficientC(dataSeries, -35.639987946994665) // In the example, the TI084 results in -21.89774466, which we consider acceptably close
-})
-
-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.1166666666666667) // The example results in 1.1071 for OLS, which we consider acceptably close
- testCoefficientB(dataSeries, 0.966666666666667) // The example results in 1 for OLS, which we consider acceptably close
- testCoefficientC(dataSeries, 0.44722222222222213) // The example results in 0.5714 for OLS, which we consider acceptably close
-})
-
-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.9500000000000005) // The example results in 0.7642857 for OLS, which we consider acceptably close given the small sample size
- testCoefficientB(dataSeries, -7.483333333333338) // The example results in -5.5128571 for OLS, which we consider acceptably close given the small sample size
- testCoefficientC(dataSeries, 17.275000000000006) // The example results in 12.4285714 for OLS, which we consider acceptably close given the small sample size
-})
-
-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, -3.13052236289358)
- testCoefficientB(dataSeries, 1.5907039702198331)
- testCoefficientC(dataSeries, 0.12896850914578195)
-})
-
-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.10466531440162272) // The example results in -0.1012 for R after two rounds, which we consider acceptably close
- testCoefficientB(dataSeries, 6.98670916642519) // The example results in 6.7444 for R after two rounds, which we consider acceptably close
- testCoefficientC(dataSeries, -21.826295759683177) // The example results in 18.2536 for R after two rounds, but for ORM, this factor is irrelevant
-})
-
-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.0004480669511301859) // The example results in -0.00045 through QR decomposition by Givens rotations, which we consider acceptably close
- testCoefficientB(dataSeries, 2.373459636061883) // The example results in 2.39893 for QR decomposition by Givens rotations, which we consider acceptably close
- testCoefficientC(dataSeries, -1178.1630473732216) // The example results in -1216.143887 for QR decomposition by Givens rotations, but for ORM, this factor is irrelevant
-})
-
-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)
- 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)
- 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)
- dataSeries.reset()
- 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 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)
-})
-
-function testCoefficientA (series, expectedValue) {
- assert.ok(series.coefficientA() === expectedValue, `Expected value for coefficientA at X-position ${series.xAtSeriesEnd()} is ${expectedValue}, encountered a ${series.coefficientA()}`)
-}
-
-function testCoefficientB (series, expectedValue) {
- assert.ok(series.coefficientB() === expectedValue, `Expected value for coefficientB at X-position ${series.xAtSeriesEnd()} is ${expectedValue}, encountered a ${series.coefficientB()}`)
-}
-
-function testCoefficientC (series, expectedValue) {
- assert.ok(series.coefficientC() === expectedValue, `Expected value for coefficientC at X-position ${series.xAtSeriesEnd()} is ${expectedValue}, encountered a ${series.coefficientC()}`)
-}
-
-/*
-function testSlope (series, position, expectedValue) {
- assert.ok(series.slope(position) === expectedValue, `Expected value for Slope-${position} at X-position ${series.xAtSeriesEnd()} (slope at X-position ${series.xAtPosition(position)}) is ${expectedValue}, encountered a ${series.slope(position)}`)
-}
-
-function reportAll (series) {
- assert.ok(series.coefficientA() === 99, `time: ${series.xAtSeriesEnd()}, 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/Gaussian.js b/app/engine/utils/Gaussian.js
new file mode 100644
index 0000000000..fbd767f14e
--- /dev/null
+++ b/app/engine/utils/Gaussian.js
@@ -0,0 +1,41 @@
+'use strict'
+/*
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
+*/
+/**
+ * This implements a Gausian weight function, which is used in the moving regression filter
+ * @see {@link https://en.wikipedia.org/wiki/Kernel_(statistics)#Kernel_functions_in_common_use|the description of the various kernels}
+ * Please realize the constant factor 1/Math.Pow(2 * Math.pi(), 0.5) is omitted as it cancels out in the subsequent weight averaging filtering
+ */
+let begin
+let end
+let halfLength
+let middle
+
+export function createGaussianWeightFunction () {
+ begin = 0
+ end = 0
+ halfLength = 0
+ middle = 0
+
+ function setWindowWidth (beginpos, endpos) {
+ begin = beginpos
+ end = endpos
+ halfLength = (end - begin) / 2
+ middle = halfLength + begin
+ }
+
+ function weight (position) {
+ if (position >= begin && end >= position) {
+ const normalizedDistance = Math.abs((middle - position) / halfLength)
+ return Math.exp(-0.5 * Math.pow(normalizedDistance, 2))
+ } else {
+ return 0
+ }
+ }
+
+ return {
+ setWindowWidth,
+ weight
+ }
+}
diff --git a/app/engine/utils/Gaussian.test.js b/app/engine/utils/Gaussian.test.js
new file mode 100644
index 0000000000..139d1592c1
--- /dev/null
+++ b/app/engine/utils/Gaussian.test.js
@@ -0,0 +1,97 @@
+'use strict'
+/*
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
+*/
+/**
+ * This tests the Gaussian Weight Kernel
+ */
+import { test } from 'uvu'
+import * as assert from 'uvu/assert'
+import { createGaussianWeightFunction } from './Gaussian.js'
+
+/**
+ * Test of the weight function in the basic -1 to +1 domain
+ */
+test('Test of weight function for basic -1 to +1 domain', () => {
+ const gaussianWeight = createGaussianWeightFunction()
+ gaussianWeight.setWindowWidth(-1, 1)
+ testWeight(gaussianWeight, -1.125, 0)
+ testWeight(gaussianWeight, -1, 0.6065306597126334)
+ testWeight(gaussianWeight, -0.875, 0.6819407511903481)
+ testWeight(gaussianWeight, -0.75, 0.7548396019890073)
+ testWeight(gaussianWeight, -0.625, 0.8225775623986646)
+ testWeight(gaussianWeight, -0.5, 0.8824969025845955)
+ testWeight(gaussianWeight, -0.375, 0.9321024923595276)
+ testWeight(gaussianWeight, -0.25, 0.9692332344763441)
+ testWeight(gaussianWeight, -0.125, 0.9922179382602435)
+ testWeight(gaussianWeight, 0, 1)
+ testWeight(gaussianWeight, 0.125, 0.9922179382602435)
+ testWeight(gaussianWeight, 0.25, 0.9692332344763441)
+ testWeight(gaussianWeight, 0.375, 0.9321024923595276)
+ testWeight(gaussianWeight, 0.5, 0.8824969025845955)
+ testWeight(gaussianWeight, 0.625, 0.8225775623986646)
+ testWeight(gaussianWeight, 0.75, 0.7548396019890073)
+ testWeight(gaussianWeight, 0.875, 0.6819407511903481)
+ testWeight(gaussianWeight, 1, 0.6065306597126334)
+ testWeight(gaussianWeight, 1.125, 0)
+})
+
+/**
+ * Test of the weight function in the -10 to +10 domain
+ */
+test('Test of weight function for basic -10 to +10 domain', () => {
+ const gaussianWeight = createGaussianWeightFunction()
+ gaussianWeight.setWindowWidth(-10, 10)
+ testWeight(gaussianWeight, -11.25, 0)
+ testWeight(gaussianWeight, -10, 0.6065306597126334)
+ testWeight(gaussianWeight, -8.75, 0.6819407511903481)
+ testWeight(gaussianWeight, -7.5, 0.7548396019890073)
+ testWeight(gaussianWeight, -6.25, 0.8225775623986646)
+ testWeight(gaussianWeight, -5, 0.8824969025845955)
+ testWeight(gaussianWeight, -3.75, 0.9321024923595276)
+ testWeight(gaussianWeight, -2.5, 0.9692332344763441)
+ testWeight(gaussianWeight, -1.25, 0.9922179382602435)
+ testWeight(gaussianWeight, 0, 1)
+ testWeight(gaussianWeight, 1.25, 0.9922179382602435)
+ testWeight(gaussianWeight, 2.5, 0.9692332344763441)
+ testWeight(gaussianWeight, 3.75, 0.9321024923595276)
+ testWeight(gaussianWeight, 5, 0.8824969025845955)
+ testWeight(gaussianWeight, 6.25, 0.8225775623986646)
+ testWeight(gaussianWeight, 7.5, 0.7548396019890073)
+ testWeight(gaussianWeight, 8.75, 0.6819407511903481)
+ testWeight(gaussianWeight, 10, 0.6065306597126334)
+ testWeight(gaussianWeight, 11.25, 0)
+})
+
+/**
+ * Test of the weight function in the 100 to 120 domain
+ */
+test('Test of weight function for basic 100 to +120 domain', () => {
+ const gaussianWeight = createGaussianWeightFunction()
+ gaussianWeight.setWindowWidth(100, 120)
+ testWeight(gaussianWeight, 98.75, 0)
+ testWeight(gaussianWeight, 100, 0.6065306597126334)
+ testWeight(gaussianWeight, 101.25, 0.6819407511903481)
+ testWeight(gaussianWeight, 102.5, 0.7548396019890073)
+ testWeight(gaussianWeight, 103.75, 0.8225775623986646)
+ testWeight(gaussianWeight, 105, 0.8824969025845955)
+ testWeight(gaussianWeight, 106.25, 0.9321024923595276)
+ testWeight(gaussianWeight, 107.5, 0.9692332344763441)
+ testWeight(gaussianWeight, 108.75, 0.9922179382602435)
+ testWeight(gaussianWeight, 110, 1)
+ testWeight(gaussianWeight, 111.25, 0.9922179382602435)
+ testWeight(gaussianWeight, 112.5, 0.9692332344763441)
+ testWeight(gaussianWeight, 113.75, 0.9321024923595276)
+ testWeight(gaussianWeight, 115, 0.8824969025845955)
+ testWeight(gaussianWeight, 116.25, 0.8225775623986646)
+ testWeight(gaussianWeight, 117.5, 0.7548396019890073)
+ testWeight(gaussianWeight, 118.75, 0.6819407511903481)
+ testWeight(gaussianWeight, 120, 0.6065306597126334)
+ testWeight(gaussianWeight, 121.25, 0)
+})
+
+function testWeight (weightFunction, xValue, expectedValue) {
+ assert.ok(weightFunction.weight(xValue) === expectedValue, `Weight should be should be ${expectedValue} at x = ${xValue}, is ${weightFunction.weight(xValue)}`)
+}
+
+test.run()
diff --git a/app/engine/utils/InfiniteSeriesMetrics.js b/app/engine/utils/InfiniteSeriesMetrics.js
new file mode 100644
index 0000000000..dfacd24e93
--- /dev/null
+++ b/app/engine/utils/InfiniteSeriesMetrics.js
@@ -0,0 +1,105 @@
+'use strict'
+/**
+ * @copyright {@link https://github.com/JaapvanEkris/openrowingmonitor|OpenRowingMonitor}
+ *
+ * @file This creates an unlimited series (resetting it is a responsibility of the caller). It allows for determining the Average, Median, Minimum and Maximum
+ */
+/**
+ * @description the creator function, no parameters
+ */
+export function createInfiniteSeriesMetrics () {
+ let min = undefined
+ let max = undefined
+ let seriesSum = 0
+ let seriesCount = 0
+
+ /**
+ * @param {float} value - value to be added to the series
+ */
+ function push (value) {
+ if (value === undefined || isNaN(value)) { return }
+
+ seriesSum += value
+ seriesCount++
+
+ if (min !== undefined) {
+ min = Math.min(min, value)
+ } else {
+ if (!isNaN(value)) { min = value }
+ }
+
+ if (max !== undefined) {
+ max = Math.max(max, value)
+ } else {
+ if (!isNaN(value)) { max = value }
+ }
+ }
+
+ /**
+ * @returns {number} length of the series
+ */
+ function length () {
+ return seriesCount
+ }
+
+ /**
+ * @returns {float} sum of the entire series
+ * @description This determines the total sum of the series. As a running sum becomes unstable after longer running sums, we need to summarise this via a reduce
+ */
+ function sum () {
+ return seriesSum
+ }
+
+ /**
+ * @returns {float} average of the entire series
+ */
+ function average () {
+ if (seriesCount > 0) {
+ return seriesSum / seriesCount
+ } else {
+ return 0
+ }
+ }
+
+ /**
+ * @returns {float} smallest element in the series
+ */
+ function minimum () {
+ if (min !== undefined) {
+ return min
+ } else {
+ return 0
+ }
+ }
+
+ /**
+ * @returns {float} largest value in the series
+ */
+ function maximum () {
+ if (max !== undefined) {
+ return max
+ } else {
+ return 0
+ }
+ }
+
+ /**
+ * Resets the series to its initial state
+ */
+ function reset () {
+ min = undefined
+ max = undefined
+ seriesSum = 0
+ seriesCount = 0
+ }
+
+ return {
+ push,
+ length,
+ sum,
+ average,
+ minimum,
+ maximum,
+ reset
+ }
+}
diff --git a/app/engine/utils/InfiniteSeriesMetrics.test.js b/app/engine/utils/InfiniteSeriesMetrics.test.js
new file mode 100644
index 0000000000..26ed1e938d
--- /dev/null
+++ b/app/engine/utils/InfiniteSeriesMetrics.test.js
@@ -0,0 +1,157 @@
+'use strict'
+/**
+ * @copyright [OpenRowingMonitor]{@link https://github.com/JaapvanEkris/openrowingmonitor}
+ *
+ * @file Tests of the InfiniteSeriesMetrics object.
+ * Please note: this file contains a stress tests of the length(), sum() and average() functions, to detect any issues with numerical stability due to accumulator behaviour
+ */
+import { test } from 'uvu'
+import * as assert from 'uvu/assert'
+
+import { createInfiniteSeriesMetrics } from './InfiniteSeriesMetrics.js'
+
+/**
+ * @description Test behaviour for no datapoints
+ */
+test('Series behaviour with an empty series', () => {
+ const dataSeries = createInfiniteSeriesMetrics()
+ testLength(dataSeries, 0)
+ testSum(dataSeries, 0)
+ testAverage(dataSeries, 0)
+ testMinimum(dataSeries, 0)
+ testMaximum(dataSeries, 0)
+})
+
+/**
+ * @description Test behaviour for a single datapoint
+ */
+test('Series behaviour with a single pushed value. Series = [9]', () => {
+ const dataSeries = createInfiniteSeriesMetrics()
+ dataSeries.push(9)
+ testLength(dataSeries, 1)
+ testSum(dataSeries, 9)
+ testAverage(dataSeries, 9)
+ testMinimum(dataSeries, 9)
+ testMaximum(dataSeries, 9)
+})
+
+/**
+ * @description Test behaviour for two datapoints
+ */
+test('Series behaviour with a second pushed value. Series = [9, 3]', () => {
+ const dataSeries = createInfiniteSeriesMetrics()
+ dataSeries.push(9)
+ dataSeries.push(3)
+ testLength(dataSeries, 2)
+ testSum(dataSeries, 12)
+ testAverage(dataSeries, 6)
+ testMinimum(dataSeries, 3)
+ testMaximum(dataSeries, 9)
+})
+
+/**
+ * @description Test behaviour for three datapoints
+ */
+test('Series behaviour with a third pushed value. Series = [9, 3, 6]', () => {
+ const dataSeries = createInfiniteSeriesMetrics()
+ dataSeries.push(9)
+ dataSeries.push(3)
+ dataSeries.push(6)
+ testLength(dataSeries, 3)
+ testSum(dataSeries, 18)
+ testAverage(dataSeries, 6)
+ testMinimum(dataSeries, 3)
+ testMaximum(dataSeries, 9)
+})
+
+/**
+ * @description Test behaviour for four datapoints
+ */
+test('Series behaviour with a fourth pushed value. Series = [9, 3, 6, 12]', () => {
+ const dataSeries = createInfiniteSeriesMetrics()
+ dataSeries.push(9)
+ dataSeries.push(3)
+ dataSeries.push(6)
+ dataSeries.push(12)
+ testLength(dataSeries, 4)
+ testSum(dataSeries, 30)
+ testAverage(dataSeries, 7.5)
+ testMinimum(dataSeries, 3)
+ testMaximum(dataSeries, 12)
+})
+
+/**
+ * @description Test behaviour for five datapoints
+ */
+test('Series behaviour with a fifth pushed value. Series = [9, 3, 6, 12, -5]', () => {
+ const dataSeries = createInfiniteSeriesMetrics()
+ dataSeries.push(9)
+ dataSeries.push(3)
+ dataSeries.push(6)
+ dataSeries.push(12)
+ dataSeries.push(-5)
+ testSum(dataSeries, 25)
+ testAverage(dataSeries, 5)
+ testMinimum(dataSeries, -5)
+ testMaximum(dataSeries, 12)
+})
+
+/**
+ * @description Test behaviour after a reset()
+ */
+test('Series behaviour with a five pushed values followed by a reset, Series = []', () => {
+ const dataSeries = createInfiniteSeriesMetrics()
+ dataSeries.push(9)
+ dataSeries.push(3)
+ dataSeries.push(6)
+ dataSeries.push(12)
+ dataSeries.push(-5)
+ dataSeries.reset()
+ testLength(dataSeries, 0)
+ testSum(dataSeries, 0)
+ testAverage(dataSeries, 0)
+ testMinimum(dataSeries, 0)
+ testMaximum(dataSeries, 0)
+})
+
+/**
+ * These stress tests test the reliability of the sum(), average() and length() function after a huge number of updates/
+ * Javascript maximum array length is 4294967295, as heap memory is limited, we stay with 2^25 datapoints
+ * This test takes several seconds due to the many large array shifts, so only run them manually when changing the series module
+ */
+test('Stress test of the series object, 67.108.864 datapoints, with a maxLength of 33.554.432 (2^25)', () => {
+ const dataSeries = createInfiniteSeriesMetrics()
+ let j = 0
+ let randomvalue
+ while (j < 33554432) {
+ randomvalue = Math.random()
+ dataSeries.push(randomvalue)
+ dataSeries.push(1 - randomvalue)
+ j++
+ }
+ testLength(dataSeries, 67108864)
+ testSum(dataSeries, 33554432)
+ testAverage(dataSeries, 0.5)
+})
+
+function testLength (series, expectedValue) {
+ assert.ok(series.length() === expectedValue, `Expected length should be ${expectedValue}, encountered ${series.length()}`)
+}
+
+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 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/MovingWindowRegressor.js b/app/engine/utils/MovingWindowRegressor.js
new file mode 100644
index 0000000000..2f5f325088
--- /dev/null
+++ b/app/engine/utils/MovingWindowRegressor.js
@@ -0,0 +1,244 @@
+'use strict'
+/**
+ * @copyright [OpenRowingMonitor]{@link https://github.com/JaapvanEkris/openrowingmonitor}
+ *
+ * @file This implements a Moving Regression Algorithm to obtain a coefficients, first (angular velocity) and
+ * second derivative (angular acceleration) at the front of the flank
+ */
+import { createTSQuadraticSeries } from './TSQuadraticSeries.js'
+import { createWeighedSeries } from './WeighedSeries.js'
+import { createGaussianWeightFunction } from './Gaussian.js'
+
+export function createMovingRegressor (bandwith) {
+ const flankLength = bandwith
+ const quadraticTheilSenRegressor = createTSQuadraticSeries(flankLength)
+ const gaussianWeight = createGaussianWeightFunction()
+ let aMatrix = []
+ let bMatrix = []
+ let cMatrix = []
+
+ /**
+ * @param {float} the x value of the datapoint
+ * @param {float} the y value of the datapoint
+ */
+ function push (x, y, w = 1) {
+ quadraticTheilSenRegressor.push(x, y, w)
+
+ // Let's shift the matrix to make room for a new datapoint
+ if (aMatrix.length >= flankLength) {
+ // The angularVelocityMatrix has reached its maximum length, we need to remove the first element
+ aMatrix[0].reset()
+ aMatrix[0] = null
+ aMatrix.shift()
+ bMatrix[0].reset()
+ bMatrix[0] = null
+ bMatrix.shift()
+ cMatrix[0].reset()
+ cMatrix[0] = null
+ cMatrix.shift()
+ }
+
+ // Let's make room for a new set of values for first and second derivatives
+ // Please note: a weighed median would work here, but results in much less fluid force curves
+ aMatrix[aMatrix.length] = createWeighedSeries(flankLength, 0)
+ bMatrix[bMatrix.length] = createWeighedSeries(flankLength, 0)
+ cMatrix[cMatrix.length] = createWeighedSeries(flankLength, 0)
+
+ let i = 0
+ let weight = 0
+ gaussianWeight.setWindowWidth(quadraticTheilSenRegressor.X.atSeriesBegin(), quadraticTheilSenRegressor.X.atSeriesEnd())
+
+ // Let's calculate the first and second derivatives for each datapoint and store them in their matrices
+ while (i < aMatrix.length && quadraticTheilSenRegressor.reliable()) {
+ weight = quadraticTheilSenRegressor.goodnessOfFit() * quadraticTheilSenRegressor.localGoodnessOfFit(i) * gaussianWeight.weight(quadraticTheilSenRegressor.X.get(i))
+ aMatrix[i].push(quadraticTheilSenRegressor.coefficientA(), weight)
+ bMatrix[i].push(quadraticTheilSenRegressor.coefficientB(), weight)
+ cMatrix[i].push(quadraticTheilSenRegressor.coefficientC(), weight)
+ i++
+ }
+ }
+
+ /**
+ * @param {integer} the position in the flank of the requested value (default = 0)
+ * @returns {float} the coefficient a of the quadratic function y = a x^2 + b x + c
+ */
+ function coefficientA (position = 0) {
+ if (aMatrix.length === flankLength && position < aMatrix.length) {
+ return aMatrix[position].weighedAverage()
+ } else {
+ return undefined
+ }
+ }
+
+ /**
+ * @param {integer} the position in the flank of the requested value (default = 0)
+ * @returns {float} the coefficient b of the quadratic function y = a x^2 + b x + c
+ */
+ function coefficientB (position = 0) {
+ if (bMatrix.length === flankLength && position < aMatrix.length) {
+ return bMatrix[position].weighedAverage()
+ } else {
+ return undefined
+ }
+ }
+
+ /**
+ * @param {integer} the position in the flank of the requested value (default = 0)
+ * @returns {float} the coefficient c of the quadratic function y = a x^2 + b x + c
+ */
+ function coefficientC (position = 0) {
+ if (cMatrix.length === flankLength && position < aMatrix.length) {
+ return cMatrix[position].weighedAverage()
+ } else {
+ return undefined
+ }
+ }
+
+ /**
+ * @param {integer} the position in the flank of the requested value (default = 0)
+ * @returns {float} the firdt derivative of the quadratic function y = a x^2 + b x + c
+ */
+ function firstDerivative (position = 0) {
+ if (aMatrix.length === flankLength && position < aMatrix.length) {
+ return ((aMatrix[position].weighedAverage() * 2 * quadraticTheilSenRegressor.X.get(position)) + bMatrix[position].weighedAverage())
+ } else {
+ return undefined
+ }
+ }
+
+ /**
+ * @param {integer} the position in the flank of the requested value (default = 0)
+ * @returns {float} the second derivative of the quadratic function y = a x^2 + b x + c
+ */
+ function secondDerivative (position = 0) {
+ if (aMatrix.length === flankLength && position < aMatrix.length) {
+ return (aMatrix[position].weighedAverage() * 2)
+ } else {
+ return undefined
+ }
+ }
+
+ /**
+ * @param {integer} the position in the flank of the requested value (default = 0)
+ * @param {float} the x to project onto the function
+ * @returns {float} the resulting y from the projection
+ */
+ function projectX (position, x) {
+ if (aMatrix[position].length() >= 3) {
+ return ((aMatrix[position].weighedAverage() * Math.pow(x, 2)) + (bMatrix[position].weighedAverage() * x) + cMatrix[position].weighedAverage())
+ } else {
+ return 0
+ }
+ }
+
+ /**
+ * @param {integer} the position in the flank of the requested value (default = 0)
+ * @param {float} the y to project onto the function
+ * @returns {array} the resulting x's from the projection
+ */
+ function projectY (position, y) {
+ // Calculate the discriminant
+ const discriminant = Math.pow(bMatrix[position].weighedAverage(), 2) - (4 * aMatrix[position].weighedAverage() * (cMatrix[position].weighedAverage() - y))
+
+ switch (true) {
+ case (aMatrix[position].weighedAverage() === 0 && bMatrix[position].weighedAverage() === 0):
+ // The function is a horizontal flat line, let's return the orignal observation
+ return [quadraticTheilSenRegressor.X.get(position)]
+ case (aMatrix[position].weighedAverage() === 0):
+ // The function is a tilted line, we need to handle this to prevent a division by zero
+ // eslint-disable-next-line no-case-declarations -- Code clarity outweighs lint rules
+ const projection = (y - cMatrix[position].weighedAverage()) / bMatrix[position].weighedAverage()
+ return [projection]
+ case (discriminant > 0):
+ // eslint-disable-next-line no-case-declarations -- Code clarity outweighs lint rules
+ const root1 = (-bMatrix[position].weighedAverage() + Math.sqrt(discriminant)) / (2 * aMatrix[position].weighedAverage())
+ // eslint-disable-next-line no-case-declarations -- Code clarity outweighs lint rules
+ const root2 = (-bMatrix[position].weighedAverage() - Math.sqrt(discriminant)) / (2 * aMatrix[position].weighedAverage())
+ return [root1, root2]
+ case (discriminant === 0):
+ // eslint-disable-next-line no-case-declarations -- Code clarity outweighs lint rules
+ const root = -bMatrix[position].weighedAverage() / (2 * aMatrix[position].weighedAverage())
+ return [root]
+ default:
+ return []
+ }
+ }
+
+ /**
+ * Resets the series to its initial state
+ */
+ function reset () {
+ quadraticTheilSenRegressor.reset()
+ let i = aMatrix.length
+ while (i > 0) {
+ aMatrix[0].reset()
+ aMatrix[0] = null
+ aMatrix.shift()
+ i--
+ }
+ aMatrix = null
+ aMatrix = []
+
+ let j = bMatrix.length
+ while (j > 0) {
+ bMatrix[0].reset()
+ bMatrix[0] = null
+ bMatrix.shift()
+ j--
+ }
+ bMatrix = null
+ bMatrix = []
+
+ let k = cMatrix.length
+ while (k > 0) {
+ cMatrix[0].reset()
+ cMatrix[0] = null
+ cMatrix.shift()
+ k--
+ }
+ cMatrix = null
+ cMatrix = []
+ }
+
+ /**
+ * @param {integer} position - position to be retrieved, starting at 0
+ * @returns {float} X value at that specific postion in the series
+ */
+ function Xget (position = 0) {
+ if (position < quadraticTheilSenRegressor.length()) {
+ return quadraticTheilSenRegressor.X.get(position)
+ } else {
+ return undefined
+ }
+ }
+
+ /**
+ * @param {integer} position - position to be retrieved, starting at 0
+ * @returns {float} Y value at that specific postion in the series
+ */
+ function Yget (position = 0) {
+ if (position < quadraticTheilSenRegressor.length()) {
+ return quadraticTheilSenRegressor.Y.get(position)
+ } else {
+ return undefined
+ }
+ }
+
+ return {
+ push,
+ X: {
+ get: Xget
+ },
+ Y: {
+ get: Yget
+ },
+ coefficientA,
+ coefficientB,
+ coefficientC,
+ firstDerivative,
+ secondDerivative,
+ projectX,
+ projectY,
+ reset
+ }
+}
diff --git a/app/engine/utils/MovingWindowRegressor.test.js b/app/engine/utils/MovingWindowRegressor.test.js
new file mode 100644
index 0000000000..985c7656d4
--- /dev/null
+++ b/app/engine/utils/MovingWindowRegressor.test.js
@@ -0,0 +1,1154 @@
+'use strict'
+/**
+ * @copyright [OpenRowingMonitor]{@link https://github.com/JaapvanEkris/openrowingmonitor}
+ *
+ * @file Tests of the movingRegressor object
+ */
+import { test } from 'uvu'
+import * as assert from 'uvu/assert'
+import { createMovingRegressor } from './MovingWindowRegressor.js'
+
+function flywheelPosition (position) {
+ return ((position * Math.PI) / 3)
+}
+
+/**
+ * @description Test behaviour for no datapoints
+ */
+test('Correct movingRegressor behaviour at initialisation', () => {
+ const flankLength = 12
+ const movingRegressor = createMovingRegressor(flankLength)
+ testFirstDerivative(movingRegressor, undefined)
+ testSecondDerivative(movingRegressor, undefined)
+})
+
+/**
+ * @todo Test behaviour for one datapoint
+ */
+
+/**
+ * @todo Test behaviour for perfect upgoing flank
+ */
+
+/**
+ * @todo Test behaviour for perfect downgoing flank
+ */
+
+/**
+ * @description Test of the integration of the underlying FullTSQuadraticEstimator object
+ * This uses the same data as the function y = 2 x^2 + 4 * x
+ */
+test('Test of correct algorithmic integration of FullTSQuadraticEstimator and movingRegressor object for quadratic function f(x) = 2 * x^2 + 4 * x', () => {
+ const flankLength = 12
+ const movingRegressor = createMovingRegressor(flankLength)
+
+ movingRegressor.push(0, flywheelPosition(0)) // Datapoint 0
+ testFirstDerivative(movingRegressor, undefined)
+ testSecondDerivative(movingRegressor, undefined)
+ movingRegressor.push(0.234341433963188, flywheelPosition(1)) // Datapoint 1
+ testFirstDerivative(movingRegressor, undefined)
+ testSecondDerivative(movingRegressor, undefined)
+ movingRegressor.push(0.430803114057485, flywheelPosition(2)) // Datapoint 2
+ testFirstDerivative(movingRegressor, undefined)
+ testSecondDerivative(movingRegressor, undefined)
+ movingRegressor.push(0.603370302455080, flywheelPosition(3)) // Datapoint 3
+ testFirstDerivative(movingRegressor, undefined)
+ testSecondDerivative(movingRegressor, undefined)
+ movingRegressor.push(0.759089282098323, flywheelPosition(4)) // Datapoint 4
+ testFirstDerivative(movingRegressor, undefined)
+ testSecondDerivative(movingRegressor, undefined)
+ movingRegressor.push(0.902102488824273, flywheelPosition(5)) // Datapoint 5
+ testFirstDerivative(movingRegressor, undefined)
+ testSecondDerivative(movingRegressor, undefined)
+ movingRegressor.push(1.035090330572530, flywheelPosition(6)) // Datapoint 6
+ testFirstDerivative(movingRegressor, undefined)
+ testSecondDerivative(movingRegressor, undefined)
+ movingRegressor.push(1.159905421352540, flywheelPosition(7)) // Datapoint 7
+ testFirstDerivative(movingRegressor, undefined)
+ testSecondDerivative(movingRegressor, undefined)
+ movingRegressor.push(1.27789161392424, flywheelPosition(8)) // Datapoint 8
+ testFirstDerivative(movingRegressor, undefined)
+ testSecondDerivative(movingRegressor, undefined)
+ movingRegressor.push(1.39006045538281, flywheelPosition(9)) // Datapoint 9
+ testFirstDerivative(movingRegressor, undefined)
+ testSecondDerivative(movingRegressor, undefined)
+ movingRegressor.push(1.4971959786895, flywheelPosition(10)) // Datapoint 10
+ testFirstDerivative(movingRegressor, undefined)
+ testSecondDerivative(movingRegressor, undefined)
+ movingRegressor.push(1.59992048562668, flywheelPosition(11)) // Datapoint 11
+ testFirstDerivative(movingRegressor, 4.0000000000000115) // Values from Datapoint 0 are now passsing through
+ testSecondDerivative(movingRegressor, 3.9999999999999933)
+ movingRegressor.push(1.69873772478535, flywheelPosition(12)) // Datapoint 12
+ testFirstDerivative(movingRegressor, 4.937365735852762) // Values from Datapoint 1 are now passsing through
+ testSecondDerivative(movingRegressor, 3.9999999999999925)
+ movingRegressor.push(1.79406229042552, flywheelPosition(13)) // Datapoint 13
+ testFirstDerivative(movingRegressor, 5.723212456229947) // Values from Datapoint 2 are now passsing through
+ testSecondDerivative(movingRegressor, 3.999999999999984)
+ movingRegressor.push(1.88624026345282, flywheelPosition(14)) // Datapoint 14
+ testFirstDerivative(movingRegressor, 6.413481209820322) // Values from Datapoint 3 are now passsing through
+ testSecondDerivative(movingRegressor, 3.9999999999999742)
+ movingRegressor.push(1.97556408668583, flywheelPosition(15)) // Datapoint 15
+ testFirstDerivative(movingRegressor, 7.036357128393286) // Values from Datapoint 4 are now passsing through
+ testSecondDerivative(movingRegressor, 3.9999999999999747)
+ movingRegressor.push(2.06228352860619, flywheelPosition(16)) // Datapoint 16
+ testFirstDerivative(movingRegressor, 7.608409955297076) // Values from Datapoint 5 are now passsing through
+ testSecondDerivative(movingRegressor, 3.999999999999983)
+ movingRegressor.push(2.14661392375536, flywheelPosition(17)) // Datapoint 17
+ testFirstDerivative(movingRegressor, 8.140361322290104) // Values from Datapoint 6 are now passsing through
+ testSecondDerivative(movingRegressor, 3.9999999999999916)
+ movingRegressor.push(2.22874247359082, flywheelPosition(18)) // Datapoint 18
+ testFirstDerivative(movingRegressor, 8.639621685410132) // Values from Datapoint 7 are now passsing through
+ testSecondDerivative(movingRegressor, 4.000000000000013)
+ movingRegressor.push(2.30883313818749, flywheelPosition(19)) // Datapoint 19
+ testFirstDerivative(movingRegressor, 9.111566455696927) // Values from Datapoint 8 are now passsing through
+ testSecondDerivative(movingRegressor, 4.000000000000042)
+ movingRegressor.push(2.38703048583357, flywheelPosition(20)) // Datapoint 20
+ testFirstDerivative(movingRegressor, 9.560241821531205) // Values from Datapoint 9 are now passsing through
+ testSecondDerivative(movingRegressor, 4.000000000000061)
+ movingRegressor.push(2.46346275966182, flywheelPosition(21)) // Datapoint 21
+ testFirstDerivative(movingRegressor, 9.988783914757967) // Values from Datapoint 10 are now passsing through
+ testSecondDerivative(movingRegressor, 4.00000000000007)
+ movingRegressor.push(2.53824434757728, flywheelPosition(22)) // Datapoint 22
+ testFirstDerivative(movingRegressor, 10.399681942506692) // Values from Datapoint 11 are now passsing through
+ testSecondDerivative(movingRegressor, 4.0000000000000835)
+ movingRegressor.push(2.61147779153643, flywheelPosition(23)) // Datapoint 23
+ testFirstDerivative(movingRegressor, 10.794950899141389) // Values from Datapoint 12 are now passsing through
+ testSecondDerivative(movingRegressor, 4.0000000000000915)
+ movingRegressor.push(2.68325543702296, flywheelPosition(24)) // Datapoint 24
+ testFirstDerivative(movingRegressor, 11.176249161702088) // Values from Datapoint 13 are now passsing through
+ testSecondDerivative(movingRegressor, 4.000000000000075)
+ movingRegressor.push(2.75366079846827, flywheelPosition(25)) // Datapoint 25
+ testFirstDerivative(movingRegressor, 11.544961053811306) // Values from Datapoint 14 are now passsing through
+ testSecondDerivative(movingRegressor, 4.000000000000067)
+ movingRegressor.push(2.82276969821042, flywheelPosition(26)) // Datapoint 26
+ testFirstDerivative(movingRegressor, 11.902256346743357) // Values from Datapoint 15 are now passsing through
+ testSecondDerivative(movingRegressor, 4.000000000000058)
+ movingRegressor.push(2.89065122327279, flywheelPosition(27)) // Datapoint 27
+ testFirstDerivative(movingRegressor, 12.249134114424805) // Values from Datapoint 16 are now passsing through
+ testSecondDerivative(movingRegressor, 4.000000000000049)
+ movingRegressor.push(2.95736853436123, flywheelPosition(28)) // Datapoint 28
+ testFirstDerivative(movingRegressor, 12.586455695021487) // Values from Datapoint 17 are now passsing through
+ testSecondDerivative(movingRegressor, 4.000000000000033)
+ movingRegressor.push(3.02297955405576, flywheelPosition(29)) // Datapoint 29
+ testFirstDerivative(movingRegressor, 12.91496989436332) // Values from Datapoint 18 are now passsing through
+ testSecondDerivative(movingRegressor, 4.000000000000027)
+ movingRegressor.push(3.08753755553988, flywheelPosition(30)) // Datapoint 30
+ testFirstDerivative(movingRegressor, 13.23533255274999) // Values from Datapoint 19 are now passsing through
+ testSecondDerivative(movingRegressor, 4.000000000000016)
+ movingRegressor.push(3.15109166889232, flywheelPosition(31)) // Datapoint 31
+ testFirstDerivative(movingRegressor, 13.548121943334301) // Values from Datapoint 20 are now passsing through
+ testSecondDerivative(movingRegressor, 4.000000000000006)
+})
+
+/**
+ * @description Test of the integration of the underlying FullTSQuadraticEstimator object
+ * The data follows the function y = X^3 + 2 * x^2 + 4 * x
+ * To test if multiple quadratic regressions can decently approximate a cubic function
+ */
+test('Test of correct algorithmic integration of FullTSQuadraticEstimator and movingRegressor object for cubic function f(x) = X^3 + 2 * x^2 + 4 * x', () => {
+ const flankLength = 12
+ const movingRegressor = createMovingRegressor(flankLength)
+
+ movingRegressor.push(0, flywheelPosition(0)) // Datapoint 0
+ testFirstDerivative(movingRegressor, undefined)
+ testSecondDerivative(movingRegressor, undefined)
+ movingRegressor.push(0.231815755285445, flywheelPosition(1)) // Datapoint 1
+ testFirstDerivative(movingRegressor, undefined)
+ testSecondDerivative(movingRegressor, undefined)
+ movingRegressor.push(0.41798587349477, flywheelPosition(2)) // Datapoint 2
+ testFirstDerivative(movingRegressor, undefined)
+ testSecondDerivative(movingRegressor, undefined)
+ movingRegressor.push(0.573659684819169, flywheelPosition(3)) // Datapoint 3
+ testFirstDerivative(movingRegressor, undefined)
+ testSecondDerivative(movingRegressor, undefined)
+ movingRegressor.push(0.707924094678216, flywheelPosition(4)) // Datapoint 4
+ testFirstDerivative(movingRegressor, undefined)
+ testSecondDerivative(movingRegressor, undefined)
+ movingRegressor.push(0.826414402971124, flywheelPosition(5)) // Datapoint 5
+ testFirstDerivative(movingRegressor, undefined)
+ testSecondDerivative(movingRegressor, undefined)
+ movingRegressor.push(0.932810595231392, flywheelPosition(6)) // Datapoint 6
+ testFirstDerivative(movingRegressor, undefined)
+ testSecondDerivative(movingRegressor, undefined)
+ movingRegressor.push(1.02963328885463, flywheelPosition(7)) // Datapoint 7
+ testFirstDerivative(movingRegressor, undefined)
+ testSecondDerivative(movingRegressor, undefined)
+ movingRegressor.push(1.11868033498976, flywheelPosition(8)) // Datapoint 8
+ testFirstDerivative(movingRegressor, undefined)
+ testSecondDerivative(movingRegressor, undefined)
+ movingRegressor.push(1.20127811057228, flywheelPosition(9)) // Datapoint 9
+ testFirstDerivative(movingRegressor, undefined)
+ testSecondDerivative(movingRegressor, undefined)
+ movingRegressor.push(1.27843316652448, flywheelPosition(10)) // Datapoint 10
+ testFirstDerivative(movingRegressor, undefined)
+ testSecondDerivative(movingRegressor, undefined)
+ movingRegressor.push(1.35092771853781, flywheelPosition(11)) // Datapoint 11
+ testFirstDerivative(movingRegressor, 3.1619218560690983) // Datapoint 0, Theoretical value: 4
+ testSecondDerivative(movingRegressor, 7.251023549310305) // Datapoint 0, Theoretical value: 4
+ movingRegressor.push(1.41938205529707, flywheelPosition(12)) // Datapoint 12
+ testFirstDerivative(movingRegressor, 4.795017407170356) // Datapoint 1, Theoretical value: 5.088478654, error: -6,64%
+ testSecondDerivative(movingRegressor, 7.324931550092253) // Datapoint 1, Theoretical value: 5.390894532, error: 38,46%
+ movingRegressor.push(1.48429666701973, flywheelPosition(13)) // Datapoint 13
+ testFirstDerivative(movingRegressor, 6.098616558470358) // Datapoint 2, Theoretical value: 6.196080065, error: -2,14%
+ testSecondDerivative(movingRegressor, 7.656104494382835) // Datapoint 2, Theoretical value: 6.507915241, error: 18,21%
+ movingRegressor.push(1.54608149753959, flywheelPosition(14)) // Datapoint 14
+ testFirstDerivative(movingRegressor, 7.261045146876182) // Datapoint 3, Theoretical value: 7.281895041, error: -0,79%
+ testSecondDerivative(movingRegressor, 8.125127482274081) // Datapoint 3, Theoretical value: 7.441958109, error: 9,49%
+ movingRegressor.push(1.60507676311623, flywheelPosition(15)) // Datapoint 15
+ testFirstDerivative(movingRegressor, 8.3354523167128) // Datapoint 4, Theoretical value: 8.33516595, error: -0,42%
+ testSecondDerivative(movingRegressor, 8.591085532405375) // Datapoint 4, Theoretical value: 8.247544568, error: 4,32%
+ movingRegressor.push(1.66156809465495, flywheelPosition(16)) // Datapoint 16
+ testFirstDerivative(movingRegressor, 9.346198019520195) // Datapoint 5, Theoretical value: 9.354539908, error: -0,44%
+ testSecondDerivative(movingRegressor, 9.05816287785614) // Datapoint 5, Theoretical value: 8.958486418, error: 1,06%
+ movingRegressor.push(1.71579776502858, flywheelPosition(17)) // Datapoint 17
+ testFirstDerivative(movingRegressor, 10.314972131734764) // Datapoint 6, Theoretical value: 10.3416492, error: -0,56%
+ testSecondDerivative(movingRegressor, 9.531782371110388) // Datapoint 6, Theoretical value: 9.596863571, error: -0,95%
+ movingRegressor.push(1.76797315746226, flywheelPosition(18)) // Datapoint 18
+ testFirstDerivative(movingRegressor, 11.253026452431916) // Datapoint 7, Theoretical value: 11.29896728, error: -0,68%
+ testSecondDerivative(movingRegressor, 10.006689891934682) // Datapoint 7, Theoretical value: 10.17779973, error: -2,15%
+ movingRegressor.push(1.81827325164023, flywheelPosition(19)) // Datapoint 19
+ testFirstDerivative(movingRegressor, 12.167114512288997) // Datapoint 8, Theoretical value: 12.22905842, error: -0,76%
+ testSecondDerivative(movingRegressor, 10.479926499860278) // Datapoint 8, Theoretical value: 10.71208201, error: -2,78%
+ movingRegressor.push(1.86685366056842, flywheelPosition(20)) // Datapoint 20
+ testFirstDerivative(movingRegressor, 13.06228935387478) // Datapoint 9, Theoretical value: 13.13431974, error: -0,79%
+ testSecondDerivative(movingRegressor, 10.94574190420843) // Datapoint 9, Theoretical value: 11.20766866, error: -3,03%
+ movingRegressor.push(1.91385059111525, flywheelPosition(21)) // Datapoint 21
+ testFirstDerivative(movingRegressor, 13.940750925066355) // Datapoint 10, Theoretical value: 14.01690675, error: -0,78%
+ testSecondDerivative(movingRegressor, 11.403650671998214) // Datapoint 10, Theoretical value: 11.670599, error: -2,98%
+ movingRegressor.push(1.95938399371638, flywheelPosition(22)) // Datapoint 22
+ testFirstDerivative(movingRegressor, 14.80669498176647) // Datapoint 11, Theoretical value: 14.87872798, error: -0,69%
+ testSecondDerivative(movingRegressor, 11.856689681955745) // Datapoint 11, Theoretical value: 12.10556631, error: -2,69%
+ movingRegressor.push(2.00356009326199, flywheelPosition(23)) // Datapoint 23
+ testFirstDerivative(movingRegressor, 15.659331443649235) // Datapoint 12, Theoretical value: 15.72146448, error: -0,57%
+ testSecondDerivative(movingRegressor, 12.3033090600004) // Datapoint 12, Theoretical value: 12.51629233, error: -2,22%
+ movingRegressor.push(2.04647344207189, flywheelPosition(24)) // Datapoint 24
+ testFirstDerivative(movingRegressor, 16.492736768968758) // Datapoint 13, Theoretical value: 16.54659646, error: -0,47%
+ testSecondDerivative(movingRegressor, 12.721354618620541) // Datapoint 13, Theoretical value: 12.90578, error: -1,86%
+ movingRegressor.push(2.08820859973702, flywheelPosition(25)) // Datapoint 25
+ testFirstDerivative(movingRegressor, 17.307691210719657) // Datapoint 14, Theoretical value: 17.35542998, error: -0,40%
+ testSecondDerivative(movingRegressor, 13.11397255097589) // Datapoint 14, Theoretical value: 13.27648899, error: -1,59%
+ movingRegressor.push(2.12884151869732, flywheelPosition(26)) // Datapoint 26
+ testFirstDerivative(movingRegressor, 18.106493986724217) // Datapoint 15, Theoretical value: 18.1491213, error: -0,34%
+ testSecondDerivative(movingRegressor, 13.486098587072668) // Datapoint 15, Theoretical value: 13.63046058, error: -1,38%
+ movingRegressor.push(2.1684406955958, flywheelPosition(27)) // Datapoint 27
+ testFirstDerivative(movingRegressor, 18.890426542395396) // Datapoint 16, Theoretical value: 18.92869798, error: -0,29%
+ testSecondDerivative(movingRegressor, 13.840428977173227) // Datapoint 16, Theoretical value: 13.96940857, error: -1,20%
+ movingRegressor.push(2.20706813459232, flywheelPosition(28)) // Datapoint 28
+ testFirstDerivative(movingRegressor, 19.660398675998493) // Datapoint 17, Theoretical value: 19.69507697, error: -0,26%
+ testSecondDerivative(movingRegressor, 14.178743620220295) // Datapoint 17, Theoretical value: 14.29478659, error: -1,06%
+ movingRegressor.push(2.24478015850658, flywheelPosition(29)) // Datapoint 29
+ testFirstDerivative(movingRegressor, 20.41744737019293) // Datapoint 18, Theoretical value: 20.44907989, error: -0,23%
+ testSecondDerivative(movingRegressor, 14.502790132819) // Datapoint 18, Theoretical value: 14.60783894, error: -0,94%
+ movingRegressor.push(2.28162809590139, flywheelPosition(30)) // Datapoint 30
+ testFirstDerivative(movingRegressor, 21.1623762673629) // Datapoint 19, Theoretical value: 21.19144586, error: -0,20%
+ testSecondDerivative(movingRegressor, 14.813903373334561) // Datapoint 19, Theoretical value: 14.90963951, error: -0,83%
+ movingRegressor.push(2.31765886632097, flywheelPosition(31)) // Datapoint 31
+ testFirstDerivative(movingRegressor, 21.89597076848041) // Datapoint 20, Theoretical value: , error: %
+ testSecondDerivative(movingRegressor, 15.113402988997308) // Datapoint 20, Theoretical value: , error: %
+})
+
+/**
+ * @description Test of the integration of the underlying FullTSQuadraticEstimator object
+ * The data follows the function y = X^3 + 2 * x^2 + 4 * x with a +/-0.0001 sec injected noise in currentDt
+ * To test if multiple quadratic regressions can decently approximate a cubic function with noise
+ * Please note: theoretical values are based on the perfect function (i.e. without noise)
+ */
+test('Test of correct algorithmic integration of FullTSQuadraticEstimator and movingRegressor object for cubic function f(x) = X^3 + 2 * x^2 + 4 * x with +/- 0.0001 error', () => {
+ const flankLength = 12
+ const movingRegressor = createMovingRegressor(flankLength)
+
+ movingRegressor.push(0, flywheelPosition(0)) // Datapoint 0
+ testFirstDerivative(movingRegressor, undefined)
+ testSecondDerivative(movingRegressor, undefined)
+ movingRegressor.push(0.231815755285445, flywheelPosition(1)) // Datapoint 1
+ testFirstDerivative(movingRegressor, undefined)
+ testSecondDerivative(movingRegressor, undefined)
+ movingRegressor.push(0.41798587349477, flywheelPosition(2)) // Datapoint 2
+ testFirstDerivative(movingRegressor, undefined)
+ testSecondDerivative(movingRegressor, undefined)
+ movingRegressor.push(0.573659684819169, flywheelPosition(3)) // Datapoint 3
+ testFirstDerivative(movingRegressor, undefined)
+ testSecondDerivative(movingRegressor, undefined)
+ movingRegressor.push(0.707924094678216, flywheelPosition(4)) // Datapoint 4
+ testFirstDerivative(movingRegressor, undefined)
+ testSecondDerivative(movingRegressor, undefined)
+ movingRegressor.push(0.826414402971124, flywheelPosition(5)) // Datapoint 5
+ testFirstDerivative(movingRegressor, undefined)
+ testSecondDerivative(movingRegressor, undefined)
+ movingRegressor.push(0.932810595231392, flywheelPosition(6)) // Datapoint 6
+ testFirstDerivative(movingRegressor, undefined)
+ testSecondDerivative(movingRegressor, undefined)
+ movingRegressor.push(1.02963328885463, flywheelPosition(7)) // Datapoint 7
+ testFirstDerivative(movingRegressor, undefined)
+ testSecondDerivative(movingRegressor, undefined)
+ movingRegressor.push(1.11868033498976, flywheelPosition(8)) // Datapoint 8
+ testFirstDerivative(movingRegressor, undefined)
+ testSecondDerivative(movingRegressor, undefined)
+ movingRegressor.push(1.20127811057228, flywheelPosition(9)) // Datapoint 9
+ testFirstDerivative(movingRegressor, undefined)
+ testSecondDerivative(movingRegressor, undefined)
+ movingRegressor.push(1.27843316652448, flywheelPosition(10)) // Datapoint 10
+ testFirstDerivative(movingRegressor, undefined)
+ testSecondDerivative(movingRegressor, undefined)
+ movingRegressor.push(1.35092771853781, flywheelPosition(11)) // Datapoint 11
+ testFirstDerivative(movingRegressor, 3.1619218560690983) // Datapoint 0, Theoretical value: 4
+ testSecondDerivative(movingRegressor, 7.251023549310305) // Datapoint 0, Theoretical value: 4
+ movingRegressor.push(1.41938205529707, flywheelPosition(12)) // Datapoint 12
+ testFirstDerivative(movingRegressor, 4.795017407170356) // Datapoint 1, Theoretical value: 5.088478654, error: -6.58%
+ testSecondDerivative(movingRegressor, 7.324931550092253) // Datapoint 1, Theoretical value: 5.390894532, error: 38.38%
+ movingRegressor.push(1.48429666701973, flywheelPosition(13)) // Datapoint 13
+ testFirstDerivative(movingRegressor, 6.098616558470358) // Datapoint 2, Theoretical value: 6.196080065, error: -2.11%
+ testSecondDerivative(movingRegressor, 7.656104494382835) // Datapoint 2, Theoretical value: 6.507915241, error: 18.14%
+ movingRegressor.push(1.54608149753959, flywheelPosition(14)) // Datapoint 14
+ testFirstDerivative(movingRegressor, 7.261045146876182) // Datapoint 3, Theoretical value: 7.281895041, error: -0.77%
+ testSecondDerivative(movingRegressor, 8.125127482274081) // Datapoint 3, Theoretical value: 7.441958109, error: 9.40%
+ movingRegressor.push(1.60507676311623, flywheelPosition(15)) // Datapoint 15
+ testFirstDerivative(movingRegressor, 8.3354523167128) // Datapoint 4, Theoretical value: 8.33516595, error: -0.42%
+ testSecondDerivative(movingRegressor, 8.591085532405375) // Datapoint 4, Theoretical value: 8.247544568, error: 4.24%
+ movingRegressor.push(1.66156809465495, flywheelPosition(16)) // Datapoint 16
+ testFirstDerivative(movingRegressor, 9.346198019520195) // Datapoint 5, Theoretical value: 9.354539908, error: -0.44%
+ testSecondDerivative(movingRegressor, 9.05816287785614) // Datapoint 5, Theoretical value: 8.958486418, error: 1.00%
+ movingRegressor.push(1.71579776502858, flywheelPosition(17)) // Datapoint 17
+ testFirstDerivative(movingRegressor, 10.314972131734764) // Datapoint 6, Theoretical value: 10.3416492, error: -0.56%
+ testSecondDerivative(movingRegressor, 9.531782371110388) // Datapoint 6, Theoretical value: 9.596863571, error: -1.00%
+ movingRegressor.push(1.76797315746226, flywheelPosition(18)) // Datapoint 18
+ testFirstDerivative(movingRegressor, 11.253026452431916) // Datapoint 7, Theoretical value: 11.29896728, error: -0.67%
+ testSecondDerivative(movingRegressor, 10.006689891934682) // Datapoint 7, Theoretical value: 10.17779973, error: -2.21%
+ movingRegressor.push(1.81827325164023, flywheelPosition(19)) // Datapoint 19
+ testFirstDerivative(movingRegressor, 12.167114512288997) // Datapoint 8, Theoretical value: 12.22905842, error: -0.76%
+ testSecondDerivative(movingRegressor, 10.479926499860278) // Datapoint 8, Theoretical value: 10.71208201, error: -2.84%
+ movingRegressor.push(1.86685366056842, flywheelPosition(20)) // Datapoint 20
+ testFirstDerivative(movingRegressor, 13.06228935387478) // Datapoint 9, Theoretical value: 13.13431974, error: -0.79%
+ testSecondDerivative(movingRegressor, 10.94574190420843) // Datapoint 9, Theoretical value: 11.20766866, error: -3.08%
+ movingRegressor.push(1.91385059111525, flywheelPosition(21)) // Datapoint 21
+ testFirstDerivative(movingRegressor, 13.940750925066355) // Datapoint 10, Theoretical value: 14.01690675, error: -0.78%
+ testSecondDerivative(movingRegressor, 11.403650671998214) // Datapoint 10, Theoretical value: 11.670599, error: -3.04%
+ movingRegressor.push(1.95938399371638, flywheelPosition(22)) // Datapoint 22
+ testFirstDerivative(movingRegressor, 14.80669498176647) // Datapoint 11, Theoretical value: 14.87872798, error: -0.68%
+ testSecondDerivative(movingRegressor, 11.856689681955745) // Datapoint 11, Theoretical value: 12.10556631, error: -2.76%
+ movingRegressor.push(2.00356009326199, flywheelPosition(23)) // Datapoint 23
+ testFirstDerivative(movingRegressor, 15.659331443649235) // Datapoint 12, Theoretical value: 15.72146448, error: -0.57%
+ testSecondDerivative(movingRegressor, 12.3033090600004) // Datapoint 12, Theoretical value: 12.51629233, error: -2.30%
+ movingRegressor.push(2.04647344207189, flywheelPosition(24)) // Datapoint 24
+ testFirstDerivative(movingRegressor, 16.492736768968758) // Datapoint 13, Theoretical value: 16.54659646, error: -0.46%
+ testSecondDerivative(movingRegressor, 12.721354618620541) // Datapoint 13, Theoretical value: 12.90578, error: -1.95%
+ movingRegressor.push(2.08820859973702, flywheelPosition(25)) // Datapoint 25
+ testFirstDerivative(movingRegressor, 17.307691210719657) // Datapoint 14, Theoretical value: 17.35542998, error: -0.39%
+ testSecondDerivative(movingRegressor, 13.11397255097589) // Datapoint 14, Theoretical value: 13.27648899, error: -1.70%
+ movingRegressor.push(2.12884151869732, flywheelPosition(26)) // Datapoint 26
+ testFirstDerivative(movingRegressor, 18.106493986724217) // Datapoint 15, Theoretical value: 18.1491213, error: -0.32%
+ testSecondDerivative(movingRegressor, 13.486098587072668) // Datapoint 15, Theoretical value: 13.63046058, error: -1.51%
+ movingRegressor.push(2.1684406955958, flywheelPosition(27)) // Datapoint 27
+ testFirstDerivative(movingRegressor, 18.890426542395396) // Datapoint 16, Theoretical value: 18.92869798, error: -0.28%
+ testSecondDerivative(movingRegressor, 13.840428977173227) // Datapoint 16, Theoretical value: 13.96940857, error: -1.35%
+ movingRegressor.push(2.20706813459232, flywheelPosition(28)) // Datapoint 28
+ testFirstDerivative(movingRegressor, 19.660398675998493) // Datapoint 17, Theoretical value: 19.69507697, error: -0.24%
+ testSecondDerivative(movingRegressor, 14.178743620220295) // Datapoint 17, Theoretical value: 14.29478659, error: -1.23%
+ movingRegressor.push(2.24478015850658, flywheelPosition(29)) // Datapoint 29
+ testFirstDerivative(movingRegressor, 20.41744737019293) // Datapoint 18, Theoretical value: 20.44907989, error: -0.21%
+ testSecondDerivative(movingRegressor, 14.502790132819) // Datapoint 18, Theoretical value: 14.60783894, error: -1.13%
+ movingRegressor.push(2.28162809590139, flywheelPosition(30)) // Datapoint 30
+ testFirstDerivative(movingRegressor, 21.1623762673629) // Datapoint 19, Theoretical value: 21.19144586, error: -0.18%
+ testSecondDerivative(movingRegressor, 14.813903373334561) // Datapoint 19, Theoretical value: 14.90963951, error: -1.05%
+ movingRegressor.push(2.31765886632097, flywheelPosition(31)) // Datapoint 31
+ testFirstDerivative(movingRegressor, 21.89597076848041) // Datapoint 20, Theoretical value: 21.19144586, error: -0.18%
+ testSecondDerivative(movingRegressor, 15.113402988997308) // Datapoint 20, Theoretical value: 14.90963951, error: -1.05%
+})
+
+/**
+ * @description Test of the integration of the underlying FullTSQuadraticEstimator object
+ * The data follows the function y = (x + 3,22398390803294)^3 + 33,5103216382911
+ * To test if multiple quadratic regressions can decently approximate a cubic function
+ */
+test('Test of correct algorithmic behaviourof FullTSQuadraticEstimator in movingRegressor object for function f(x) = (x + 3,22398390803294)^3 + 33,5103216382911', () => {
+ const flankLength = 11
+ const movingRegressor = createMovingRegressor(flankLength)
+
+ movingRegressor.push(0, flywheelPosition(0)) // Datapoint 0
+ movingRegressor.push(0.0339391931958861, flywheelPosition(1)) // Datapoint 1
+ movingRegressor.push(0.0686163387311174, flywheelPosition(2)) // Datapoint 2
+ movingRegressor.push(0.104072908191785, flywheelPosition(3)) // Datapoint 3
+ movingRegressor.push(0.140354232816639, flywheelPosition(4)) // Datapoint 4
+ movingRegressor.push(0.177510015343162, flywheelPosition(5)) // Datapoint 5
+ movingRegressor.push(0.215594931499885, flywheelPosition(6)) // Datapoint 6
+ movingRegressor.push(0.254669340957169, flywheelPosition(7)) // Datapoint 7
+ movingRegressor.push(0.294800132909893, flywheelPosition(8)) // Datapoint 8
+ movingRegressor.push(0.336061738566166, flywheelPosition(9)) // Datapoint 9
+ movingRegressor.push(0.378537352322414, flywheelPosition(10)) // Datapoint 10
+ testFirstDerivative(movingRegressor, 31.15175433824249) // Datapoint: 0, Theoretical value: 31.182216717766, Error: -0.0977%
+ testSecondDerivative(movingRegressor, -18.70425512401731) // Datapoint: 0, Theoretical value: -19.3439034481976, Error: -3.3067%
+ movingRegressor.push(0.422320416281029, flywheelPosition(11)) // Datapoint 11
+ testFirstDerivative(movingRegressor, 30.513098889123356) // Datapoint: 1, Theoretical value: 30.5291558479794, Error: -0.0526%
+ testSecondDerivative(movingRegressor, -18.681649183708288) // Datapoint: 1, Theoretical value: -19.1402682890223, Error: -2.3961%
+ movingRegressor.push(0.467516440428408, flywheelPosition(12)) // Datapoint 12
+ testFirstDerivative(movingRegressor, 29.85986155955904) // Datapoint: 2, Theoretical value: 29.8690334922051, Error: -0.0307%
+ testSecondDerivative(movingRegressor, -18.589545542159925) // Datapoint: 2, Theoretical value: -18.9322054158109, Error: -1.8099%
+ movingRegressor.push(0.514245255352352, flywheelPosition(13)) // Datapoint 13
+ testFirstDerivative(movingRegressor, 29.195872708833107) // Datapoint: 3, Theoretical value: 29.2015339407895, Error: -0.0194%
+ testSecondDerivative(movingRegressor, -18.4460337009964) // Datapoint: 3, Theoretical value: -18.7194659990469, Error: -1.4607%
+ movingRegressor.push(0.562643829050002, flywheelPosition(14)) // Datapoint 14
+ testFirstDerivative(movingRegressor, 28.52198052325988) // Datapoint: 4, Theoretical value: 28.5263159216238, Error: -0.0152%
+ testSecondDerivative(movingRegressor, -18.289734522926175) // Datapoint: 4, Theoretical value: -18.5017780512978, Error: -1.1461%
+ movingRegressor.push(0.612869829134886, flywheelPosition(15)) // Datapoint 15
+ testFirstDerivative(movingRegressor, 27.838071498993234) // Datapoint: 5, Theoretical value: 27.8430095365212, Error: -0.0177%
+ testSecondDerivative(movingRegressor, -18.118042271601063) // Datapoint: 5, Theoretical value: -18.2788433561387, Error: -0.8797%
+ movingRegressor.push(0.665106184462922, flywheelPosition(16)) // Datapoint 16
+ testFirstDerivative(movingRegressor, 27.144243702454105) // Datapoint: 6, Theoretical value: 27.1512127023768, Error: -0.0257%
+ testSecondDerivative(movingRegressor, -17.929615535082775) // Datapoint: 6, Theoretical value: -18.0503338591983, Error: -0.6688%
+ movingRegressor.push(0.719567008604913, flywheelPosition(17)) // Datapoint 17
+ testFirstDerivative(movingRegressor, 26.44064030984685) // Datapoint: 7, Theoretical value: 26.4504869947451, Error: -0.0372%
+ testSecondDerivative(movingRegressor, -17.724298552595286) // Datapoint: 7, Theoretical value: -17.8158874024546, Error: -0.5141%
+ movingRegressor.push(0.776505412873583, flywheelPosition(18)) // Datapoint 18
+ testFirstDerivative(movingRegressor, 25.727388210906753) // Datapoint: 8, Theoretical value: 25.7403527653323, Error: -0.0504%
+ testSecondDerivative(movingRegressor, -17.502608958090796) // Datapoint: 8, Theoretical value: -17.5751026507383, Error: -0.4125%
+ movingRegressor.push(0.836223994993886, flywheelPosition(19)) // Datapoint 19
+ testFirstDerivative(movingRegressor, 25.004447900335343) // Datapoint: 9, Theoretical value: 25.020283370693, Error: -0.0633%
+ testSecondDerivative(movingRegressor, -17.265411481197397) // Datapoint: 9, Theoretical value: -17.3275330168006, Error: -0.3585%
+ movingRegressor.push(0.899089205013686, flywheelPosition(20)) // Datapoint 20
+ testFirstDerivative(movingRegressor, 24.271609293004744) // Datapoint: 10, Theoretical value: 24.2896983042147, Error: -0.0745%
+ testSecondDerivative(movingRegressor, -17.01327023725214) // Datapoint: 10, Theoretical value: -17.0726793342632, Error: -0.348%
+ movingRegressor.push(0.96555148010585, flywheelPosition(21)) // Datapoint 21
+ testFirstDerivative(movingRegressor, 23.528633993819774) // Datapoint: 11, Theoretical value: 23.5479549630465, Error: -0.082%
+ testSecondDerivative(movingRegressor, -16.74548308493857) // Datapoint: 11, Theoretical value: -16.8099809505115, Error: -0.3837%
+ movingRegressor.push(1.03617422913716, flywheelPosition(22)) // Datapoint 22
+ testFirstDerivative(movingRegressor, 22.773623149365505) // Datapoint: 12, Theoretical value: 22.7943386998864, Error: -0.0909%
+ testSecondDerivative(movingRegressor, -16.468465526497997) // Datapoint: 12, Theoretical value: -16.5388048056272, Error: -0.4253%
+ movingRegressor.push(1.11167688752162, flywheelPosition(23)) // Datapoint 23
+ testFirstDerivative(movingRegressor, 22.005733141869783) // Datapoint: 13, Theoretical value: 22.0280506974936, Error: -0.1013%
+ testSecondDerivative(movingRegressor, -16.18133180794129) // Datapoint: 13, Theoretical value: -16.2584319160835, Error: -0.4742%
+ movingRegressor.push(1.19300131290642, flywheelPosition(24)) // Datapoint 24
+ testFirstDerivative(movingRegressor, 21.22397651669105) // Datapoint: 14, Theoretical value: 21.2481930480027, Error: -0.114%
+ testSecondDerivative(movingRegressor, -15.883041508980552) // Datapoint: 14, Theoretical value: -15.9680404738976, Error: -0.5323%
+ movingRegressor.push(1.28141893909019, flywheelPosition(25)) // Datapoint 25
+ testFirstDerivative(movingRegressor, 20.427255162207537) // Datapoint: 15, Theoretical value: 20.4537501990589, Error: -0.1295%
+ testSecondDerivative(movingRegressor, -15.572356144457256) // Datapoint: 15, Theoretical value: -15.6666844733883, Error: -0.6021%
+ movingRegressor.push(1.37871375938891, flywheelPosition(26)) // Datapoint 26
+ testFirstDerivative(movingRegressor, 19.614341957383857) // Datapoint: 16, Theoretical value: 19.6435656125486, Error: -0.1488%
+ testSecondDerivative(movingRegressor, -15.247777333845995) // Datapoint: 16, Theoretical value: -15.3532663414201, Error: -0.6871%
+ movingRegressor.push(1.48751821514026, flywheelPosition(27)) // Datapoint 27
+ testFirstDerivative(movingRegressor, 18.783685853718048) // Datapoint: 17, Theoretical value: 18.8163120184221, Error: -0.1734%
+ testSecondDerivative(movingRegressor, -14.907455157933619) // Datapoint: 17, Theoretical value: -15.0265013965682, Error: -0.7922%
+ movingRegressor.push(1.61199195401647, flywheelPosition(28)) // Datapoint 28
+ testFirstDerivative(movingRegressor, 17.933422588953594) // Datapoint: 18, Theoretical value: 17.9704529528025, Error: -0.2061%
+ testSecondDerivative(movingRegressor, -14.54879094209201) // Datapoint: 18, Theoretical value: -14.6848709709561, Error: -0.9267%
+ movingRegressor.push(1.75939202047142, flywheelPosition(29)) // Datapoint 29
+ testFirstDerivative(movingRegressor, 17.061479737474052) // Datapoint: 19, Theoretical value: 17.1041922069488, Error: -0.2497%
+ testSecondDerivative(movingRegressor, -14.168437758362021) // Datapoint: 19, Theoretical value: -14.3265594782343, Error: -1.1037%
+ movingRegressor.push(1.94454504624793, flywheelPosition(30)) // Datapoint 30
+ testFirstDerivative(movingRegressor, 16.16525797986565) // Datapoint: 20, Theoretical value: 16.2154061403809, Error: -0.3093%
+ testSecondDerivative(movingRegressor, -13.763237669973963) // Datapoint: 20, Theoretical value: -13.9493682181155, Error: -1.3343%
+ movingRegressor.push(2.20849261046968, flywheelPosition(31)) // Datapoint 31
+ testFirstDerivative(movingRegressor, 15.240931059835175) // Datapoint: 21, Theoretical value: 15.3015510945379, Error: -0.3962%
+ testSecondDerivative(movingRegressor, -13.326034947566537) // Datapoint: 21, Theoretical value: -13.5505945675625, Error: -1.6572%
+ movingRegressor.push(3.22398390803294, flywheelPosition(32)) // Datapoint 32
+ testFirstDerivative(movingRegressor, 14.287862296451902) // Datapoint: 22, Theoretical value: 14.3595335732101, Error: -0.4991%
+ testSecondDerivative(movingRegressor, -12.86939888687925) // Datapoint: 22, Theoretical value: -13.1268580733747, Error: -1.9613%
+ movingRegressor.push(4.2394752055962, flywheelPosition(33)) // Datapoint 33
+ testFirstDerivative(movingRegressor, 13.343446992890124) // Datapoint: 23, Theoretical value: 13.3855228467043, Error: -0.3097%
+ testSecondDerivative(movingRegressor, -12.593658621633756) // Datapoint: 23, Theoretical value: -12.6738421230679, Error: -0.6068%
+ movingRegressor.push(4.50342276981795, flywheelPosition(34)) // Datapoint 34
+ testFirstDerivative(movingRegressor, 12.272424425339942) // Datapoint: 24, Theoretical value: 12.3746709051205, Error: -0.8234%
+ testSecondDerivative(movingRegressor, -11.943049564910012) // Datapoint: 24, Theoretical value: -12.1858955707591, Error: -1.9677%
+ movingRegressor.push(4.68857579559446, flywheelPosition(35)) // Datapoint 35
+ testFirstDerivative(movingRegressor, 11.111275577176187) // Datapoint: 25, Theoretical value: 11.3206759756907, Error: -1.8498%
+ testSecondDerivative(movingRegressor, -11.10165547134061) // Datapoint: 25, Theoretical value: -11.6553898136565, Error: -4.7286%
+ movingRegressor.push(4.83597586204941, flywheelPosition(36)) // Datapoint 36
+ testFirstDerivative(movingRegressor, 9.85670546231101) // Datapoint: 26, Theoretical value: 10.2150657644303, Error: -3.5125%
+ testSecondDerivative(movingRegressor, -10.131638260894363) // Datapoint: 26, Theoretical value: -11.0716208918642, Error: -8.4727%
+ movingRegressor.push(4.96044960092562, flywheelPosition(37)) // Datapoint 37
+ testFirstDerivative(movingRegressor, 8.33150256290571) // Datapoint: 27, Theoretical value: 9.04593930777978, Error: -7.9107%
+ testSecondDerivative(movingRegressor, -8.90260010296994) // Datapoint: 27, Theoretical value: -10.4187941573561, Error: -14.5446%
+ movingRegressor.push(5.06925405667697, flywheelPosition(38)) // Datapoint 38
+ testFirstDerivative(movingRegressor, 6.616103614905059) // Datapoint: 28, Theoretical value: 7.79555417944151, Error: -15.1565%
+ testSecondDerivative(movingRegressor, -7.485916659905859) // Datapoint: 28, Theoretical value: -9.67195172409882, Error: -22.6092%
+ movingRegressor.push(5.16654887697569, flywheelPosition(39)) // Datapoint 39
+ testFirstDerivative(movingRegressor, 4.888940778715439) // Datapoint: 29, Theoretical value: 6.43508819133308, Error: -24.0732%
+ testSecondDerivative(movingRegressor, -5.956802316997369) // Datapoint: 29, Theoretical value: -8.78755132536914, Error: -32.2443%
+ movingRegressor.push(5.25496650315946, flywheelPosition(40)) // Datapoint 40
+ testFirstDerivative(movingRegressor, 3.6316979581923494) // Datapoint: 30, Theoretical value: 4.91089140313715, Error: -26.1114%
+ testSecondDerivative(movingRegressor, -4.771588801465927) // Datapoint: 30, Theoretical value: -7.67663317071005, Error: -37.9068%
+ movingRegressor.push(5.33629092854426, flywheelPosition(41)) // Datapoint 41
+ testFirstDerivative(movingRegressor, 2.3077423939611448) // Datapoint: 31, Theoretical value: 3.09366772628014, Error: -25.4724%
+ testSecondDerivative(movingRegressor, -3.5593152612469012) // Datapoint: 31, Theoretical value: -6.09294778537956, Error: -41.7127%
+ movingRegressor.push(5.41179358692871, flywheelPosition(42)) // Datapoint 42
+ testFirstDerivative(movingRegressor, 1.5335044322403928) // Datapoint: 32, Theoretical value: 0
+ testSecondDerivative(movingRegressor, 3.8379764035844055e-14) // Datapoint: 32, Theoretical value: 0
+ movingRegressor.push(5.48241633596003, flywheelPosition(43)) // Datapoint 43
+ testFirstDerivative(movingRegressor, 2.3077423939611457) // Datapoint: 33, Theoretical value: 3.09366772628014, Error: -25.4724%
+ testSecondDerivative(movingRegressor, 3.5593152612468977) // Datapoint: 33, Theoretical value: 6.09294778537956, Error: -41.7127%
+ movingRegressor.push(5.54887861105219, flywheelPosition(44)) // Datapoint 44
+ testFirstDerivative(movingRegressor, 3.6316979581922624) // Datapoint: 34, Theoretical value: 4.91089140313715, Error: -26.1114%
+ testSecondDerivative(movingRegressor, 4.771588801466153) // Datapoint: 34, Theoretical value: 7.67663317071005, Error: -37.9068%
+ movingRegressor.push(5.61174382107199, flywheelPosition(45)) // Datapoint 45
+ testFirstDerivative(movingRegressor, 4.888940778715508) // Datapoint: 35, Theoretical value: 6.43508819133308, Error: -24.0732%
+ testSecondDerivative(movingRegressor, 5.956802316997485) // Datapoint: 35, Theoretical value: 8.78755132536914, Error: -32.2443%
+ movingRegressor.push(5.6714624031923, flywheelPosition(46)) // Datapoint 46
+ testFirstDerivative(movingRegressor, 6.616103614905288) // Datapoint: 36, Theoretical value: 7.79555417944151, Error: -15.1565%
+ testSecondDerivative(movingRegressor, 7.485916659905754) // Datapoint: 36, Theoretical value: 9.67195172409882, Error: -22.6092%
+ movingRegressor.push(5.72840080746097, flywheelPosition(47)) // Datapoint 47
+ testFirstDerivative(movingRegressor, 8.331502562905932) // Datapoint: 37, Theoretical value: 9.04593930777979, Error: -7.9107%
+ testSecondDerivative(movingRegressor, 8.902600102969847) // Datapoint: 37, Theoretical value: 10.4187941573561, Error: -14.5446%
+ movingRegressor.push(5.78286163160296, flywheelPosition(48)) // Datapoint 48
+ testFirstDerivative(movingRegressor, 9.856705462311233) // Datapoint: 38, Theoretical value: 10.2150657644303, Error: -3.5125%
+ testSecondDerivative(movingRegressor, 10.131638260894228) // Datapoint: 38, Theoretical value: 11.0716208918642, Error: -8.4727%
+ movingRegressor.push(5.83509798693099, flywheelPosition(49)) // Datapoint 49
+ testFirstDerivative(movingRegressor, 11.111275577176677) // Datapoint: 39, Theoretical value: 11.3206759756907, Error: -1.8498%
+ testSecondDerivative(movingRegressor, 11.101655471339662) // Datapoint: 39, Theoretical value: 11.6553898136565, Error: -4.7286%
+ movingRegressor.push(5.88532398701588, flywheelPosition(50)) // Datapoint 50
+ testFirstDerivative(movingRegressor, 12.272424425340205) // Datapoint: 40, Theoretical value: 12.3746709051205, Error: -0.8234%
+ testSecondDerivative(movingRegressor, 11.943049564909279) // Datapoint: 40, Theoretical value: 12.1858955707591, Error: -1.9677%
+ movingRegressor.push(5.93372256071353, flywheelPosition(51)) // Datapoint 51
+ testFirstDerivative(movingRegressor, 13.343446992889987) // Datapoint: 41, Theoretical value: 13.3855228467043, Error: -0.3097%
+ testSecondDerivative(movingRegressor, 12.593658621633706) // Datapoint: 41, Theoretical value: 12.6738421230679, Error: -0.6068%
+ movingRegressor.push(5.98045137563747, flywheelPosition(52)) // Datapoint 52
+ testFirstDerivative(movingRegressor, 14.287862296451628) // Datapoint: 42, Theoretical value: 14.3595335732101, Error: -0.4991%
+ testSecondDerivative(movingRegressor, 12.869398886878852) // Datapoint: 42, Theoretical value: 13.1268580733747, Error: -1.9613%
+ movingRegressor.push(6.02564739978485, flywheelPosition(53)) // Datapoint 53
+ testFirstDerivative(movingRegressor, 15.240931059835063) // Datapoint: 43, Theoretical value: 15.3015510945379, Error: -0.3962%
+ testSecondDerivative(movingRegressor, 13.326034947565466) // Datapoint: 43, Theoretical value: 13.5505945675625, Error: -1.6572%
+ movingRegressor.push(6.06943046374346, flywheelPosition(54)) // Datapoint 54
+ testFirstDerivative(movingRegressor, 16.165257979865466) // Datapoint: 44, Theoretical value: 16.2154061403809, Error: -0.3093%
+ testSecondDerivative(movingRegressor, 13.763237669972586) // Datapoint: 44, Theoretical value: 13.9493682181155, Error: -1.3343%
+ movingRegressor.push(6.11190607749971, flywheelPosition(55)) // Datapoint 55
+ testFirstDerivative(movingRegressor, 17.061479737473583) // Datapoint: 45, Theoretical value: 17.1041922069488, Error: -0.2497%
+ testSecondDerivative(movingRegressor, 14.16843775836098) // Datapoint: 45, Theoretical value: 14.3265594782343, Error: -1.1037%
+ movingRegressor.push(6.15316768315598, flywheelPosition(56)) // Datapoint 56
+ testFirstDerivative(movingRegressor, 17.933422588953036) // Datapoint: 46, Theoretical value: 17.9704529528025, Error: -0.2061%
+ testSecondDerivative(movingRegressor, 14.548790942092099) // Datapoint: 46, Theoretical value: 14.6848709709561, Error: -0.9267%
+ movingRegressor.push(6.19329847510871, flywheelPosition(57)) // Datapoint 57
+ testFirstDerivative(movingRegressor, 18.783685853717557) // Datapoint: 47, Theoretical value: 18.8163120184221, Error: -0.1734%
+ testSecondDerivative(movingRegressor, 14.907455157934207) // Datapoint: 47, Theoretical value: 15.0265013965682, Error: -0.7922%
+ movingRegressor.push(6.23237288456599, flywheelPosition(58)) // Datapoint 58
+ testFirstDerivative(movingRegressor, 19.614341957383544) // Datapoint: 48, Theoretical value: 19.6435656125486, Error: -0.1488%
+ testSecondDerivative(movingRegressor, 15.247777333846214) // Datapoint: 48, Theoretical value: 15.3532663414201, Error: -0.6871%
+ movingRegressor.push(6.27045780072272, flywheelPosition(59)) // Datapoint 59
+ testFirstDerivative(movingRegressor, 20.427255162207402) // Datapoint: 49, Theoretical value: 20.4537501990589, Error: -0.1295%
+ testSecondDerivative(movingRegressor, 15.572356144456794) // Datapoint: 49, Theoretical value: 15.6666844733883, Error: -0.6021%
+ movingRegressor.push(6.30761358324924, flywheelPosition(60)) // Datapoint 60
+ testFirstDerivative(movingRegressor, 21.223976516691252) // Datapoint: 50, Theoretical value: 21.2481930480027, Error: -0.114%
+ testSecondDerivative(movingRegressor, 15.883041508981133) // Datapoint: 50, Theoretical value: 15.9680404738976, Error: -0.5323%
+ movingRegressor.push(6.34389490787409, flywheelPosition(61)) // Datapoint 61
+ testFirstDerivative(movingRegressor, 22.005733141870095) // Datapoint: 51, Theoretical value: 22.0280506974936, Error: -0.1013%
+ testSecondDerivative(movingRegressor, 16.18133180794123) // Datapoint: 51, Theoretical value: 16.2584319160835, Error: -0.4742%
+ movingRegressor.push(6.37935147733476, flywheelPosition(62)) // Datapoint 62
+ testFirstDerivative(movingRegressor, 22.77362314936576) // Datapoint: 52, Theoretical value: 22.7943386998864, Error: -2.3204%
+ testSecondDerivative(movingRegressor, 16.46846552649644) // Datapoint: 52, Theoretical value: 16.5388048056272, Error: -0.4253%
+ movingRegressor.push(6.41402862286999, flywheelPosition(63)) // Datapoint 63
+ testFirstDerivative(movingRegressor, 23.528633993819923) // Datapoint: 53, Theoretical value: 23.5479549630465, Error: 0.2272%
+ testSecondDerivative(movingRegressor, 16.745483084939668) // Datapoint: 53, Theoretical value: 16.8099809505115, Error: -0.3837%
+ movingRegressor.push(6.41402862286999, flywheelPosition(64)) // Datapoint 64
+ testFirstDerivative(movingRegressor, 24.271654626903214) // Datapoint: 54, Theoretical value: 24.2896983042147, Error: 1.9476%
+ testSecondDerivative(movingRegressor, 17.012520012731436) // Datapoint: 54, Theoretical value: 17.0726793342632, Error: -0.3524%
+ movingRegressor.push(6.44796781606588, flywheelPosition(65)) // Datapoint 65
+ testFirstDerivative(movingRegressor, 25.003717545806197) // Datapoint: 55, Theoretical value: 25.020283370693, Error: 2.9728%
+ testSecondDerivative(movingRegressor, 17.27251336444372) // Datapoint: 55, Theoretical value: 17.3275330168006, Error: -0.3175%
+ movingRegressor.push(6.48264496160111, flywheelPosition(66)) // Datapoint 66
+ testFirstDerivative(movingRegressor, 25.72670308190149) // Datapoint: 56, Theoretical value: 25.7403527653323, Error: 3.4113%
+ testSecondDerivative(movingRegressor, 17.524882364022666) // Datapoint: 56, Theoretical value: 17.5751026507383, Error: -0.2857%
+ movingRegressor.push(6.51810153106178, flywheelPosition(67)) // Datapoint 67
+ testFirstDerivative(movingRegressor, 26.624349154334766) // Datapoint: 57, Theoretical value: 26.4504869947451, Error: 3.3525%
+ testSecondDerivative(movingRegressor, 17.75730877420827) // Datapoint: 57, Theoretical value: 17.8158874024546, Error: -7.2011%
+ movingRegressor.push(6.55438285568663, flywheelPosition(68)) // Datapoint 68
+ testFirstDerivative(movingRegressor, 27.579555943496985) // Datapoint: 58, Theoretical value: 27.1512127023768, Error: 2.871%
+ testSecondDerivative(movingRegressor, 17.955629118247334) // Datapoint: 58, Theoretical value: 18.0503338591983, Error: -23.3038%
+ movingRegressor.push(6.59153863821315, flywheelPosition(69)) // Datapoint 69
+ testFirstDerivative(movingRegressor, 28.89052571737747) // Datapoint: 59, Theoretical value: 27.8430095365212, Error: 2.0292%
+ testSecondDerivative(movingRegressor, 15.665483074845106) // Datapoint: 59, Theoretical value: 18.2788433561387, Error: -38.625%
+ movingRegressor.push(6.62962355436988, flywheelPosition(70)) // Datapoint 70
+ testFirstDerivative(movingRegressor, 29.951538371892738) // Datapoint: 60, Theoretical value: 28.5263159216238, Error: 0.8795%
+ testSecondDerivative(movingRegressor, 12.901462381724917) // Datapoint: 60, Theoretical value: 18.5017780512978, Error: -53.23%
+ movingRegressor.push(6.66869796382716, flywheelPosition(71)) // Datapoint 71
+ testFirstDerivative(movingRegressor, 30.806657246584255) // Datapoint: 61, Theoretical value: 29.2015339407895, Error: -0.5338%
+ testSecondDerivative(movingRegressor, 9.67140666836943) // Datapoint: 61, Theoretical value: 18.7194659990469, Error: -67.1765%
+ movingRegressor.push(6.70882875577989, flywheelPosition(72)) // Datapoint 72
+ testFirstDerivative(movingRegressor, 31.405396142822518) // Datapoint: 62, Theoretical value: 29.8690334922051, Error: -2.173%
+ testSecondDerivative(movingRegressor, 6.000343686171773) // Datapoint: 62, Theoretical value: 18.9322054158109, Error: -80.516%
+ movingRegressor.push(6.75009036143616, flywheelPosition(73)) // Datapoint 73
+ testFirstDerivative(movingRegressor, 31.70459727805595) // Datapoint: 63, Theoretical value: 30.5291558479794, Error: -4.0058%
+ testSecondDerivative(movingRegressor, 1.9206519801849915) // Datapoint: 63, Theoretical value: 19.1402682890223, Error: -93.2941%
+ movingRegressor.push(6.79256597519241, flywheelPosition(74)) // Datapoint 74
+ testFirstDerivative(movingRegressor, 31.765903811444034) // Datapoint: 64, Theoretical value: 31.182216717766, Error: -6.0163%
+ testSecondDerivative(movingRegressor, -1.9932629732984233) // Datapoint: 64, Theoretical value: -19.3439034481976, Error: -106.6353%, This is expected as it is a welding point between two graphs
+ movingRegressor.push(6.83634903915102, flywheelPosition(75)) // Datapoint 75
+ testFirstDerivative(movingRegressor, 31.56636248448924) // Datapoint: 65, Theoretical value: 30.5291558479794, Error: -3.9942%
+ testSecondDerivative(movingRegressor, -6.041241293208837) // Datapoint: 65, Theoretical value: -19.1402682890223, Error: -94.3891%
+ movingRegressor.push(6.8815450632984, flywheelPosition(76)) // Datapoint 76
+ testFirstDerivative(movingRegressor, 31.068488732359405) // Datapoint: 66, Theoretical value: 29.8690334922051, Error: -2.1371%
+ testSecondDerivative(movingRegressor, -9.708056537795159) // Datapoint: 66, Theoretical value: -18.9322054158109, Error: -81.5859%
+ movingRegressor.push(6.92827387822234, flywheelPosition(77)) // Datapoint 77
+ testFirstDerivative(movingRegressor, 30.315160610570246) // Datapoint: 67, Theoretical value: 29.2015339407895, Error: -0.4733%
+ testSecondDerivative(movingRegressor, -12.951751177840897) // Datapoint: 67, Theoretical value: -18.7194659990469, Error: -68.1811%
+ movingRegressor.push(6.97667245191999, flywheelPosition(78)) // Datapoint 78
+ testFirstDerivative(movingRegressor, 29.199797883477018) // Datapoint: 68, Theoretical value: 28.5263159216238, Error: 0.964%
+ testSecondDerivative(movingRegressor, -15.873257856339913) // Datapoint: 68, Theoretical value: -18.5017780512978, Error: -54.1246%
+ movingRegressor.push(7.02689845200488, flywheelPosition(79)) // Datapoint 79
+ testFirstDerivative(movingRegressor, 27.971567095364264) // Datapoint: 69, Theoretical value: 27.8430095365212, Error: 2.1359%
+ testSecondDerivative(movingRegressor, -18.17668918703874) // Datapoint: 69, Theoretical value: -18.2788433561387, Error: -39.3603%
+ movingRegressor.push(7.07913480733291, flywheelPosition(80)) // Datapoint 80
+ testFirstDerivative(movingRegressor, 27.14327299326837) // Datapoint: 70, Theoretical value: 27.1512127023768, Error: 2.9966%
+ testSecondDerivative(movingRegressor, -17.984173147864997) // Datapoint: 70, Theoretical value: -18.0503338591983, Error: -23.8249%
+ movingRegressor.push(7.1335956314749, flywheelPosition(81)) // Datapoint 81
+ testFirstDerivative(movingRegressor, 26.437672893260455) // Datapoint: 71, Theoretical value: 26.4504869947451, Error: 3.4917%
+ testSecondDerivative(movingRegressor, -17.759972771852997) // Datapoint: 71, Theoretical value: -17.8158874024546, Error: -7.4468%
+ movingRegressor.push(7.19053403574357, flywheelPosition(82)) // Datapoint 82
+ testFirstDerivative(movingRegressor, 25.72556390928743) // Datapoint: 72, Theoretical value: 25.7403527653323, Error: 3.5565%
+ testSecondDerivative(movingRegressor, -17.51562527634176) // Datapoint: 72, Theoretical value: -17.5751026507383, Error: -0.3384%
+ movingRegressor.push(7.25025261786388, flywheelPosition(83)) // Datapoint 83
+ testFirstDerivative(movingRegressor, 25.00442855876365) // Datapoint: 73, Theoretical value: 25.020283370693, Error: 3.1135%
+ testSecondDerivative(movingRegressor, -17.264560797969754) // Datapoint: 73, Theoretical value: -17.3275330168006, Error: -0.3634%
+ movingRegressor.push(7.31311782788368, flywheelPosition(84)) // Datapoint 84
+ testFirstDerivative(movingRegressor, 24.27277785941115) // Datapoint: 74, Theoretical value: 24.2896983042147, Error: 2.0695%
+ testSecondDerivative(movingRegressor, -17.007291771200208) // Datapoint: 74, Theoretical value: -17.0726793342632, Error: -0.383%
+ movingRegressor.push(7.37958010297584, flywheelPosition(85)) // Datapoint 85
+ testFirstDerivative(movingRegressor, 23.52863399382072) // Datapoint: 75, Theoretical value: 23.5479549630465, Error: 0.3114%
+ testSecondDerivative(movingRegressor, -16.745483084942506) // Datapoint: 75, Theoretical value: -16.8099809505115, Error: -0.3837%
+ movingRegressor.push(7.45020285200716, flywheelPosition(86)) // Datapoint 86
+ testFirstDerivative(movingRegressor, 22.7736231493666) // Datapoint: 76, Theoretical value: 22.7943386998864, Error: -2.2989%
+ testSecondDerivative(movingRegressor, -16.468465526502353) // Datapoint: 76, Theoretical value: -16.5388048056272, Error: -0.4253%
+ movingRegressor.push(7.52570551039161, flywheelPosition(87)) // Datapoint 87
+ testFirstDerivative(movingRegressor, 22.00573314187072) // Datapoint: 77, Theoretical value: 22.0280506974936, Error: -0.1013%
+ testSecondDerivative(movingRegressor, -16.18133180794765) // Datapoint: 77, Theoretical value: -16.2584319160835, Error: -0.4742%
+ movingRegressor.push(7.60702993577641, flywheelPosition(88)) // Datapoint 88
+ testFirstDerivative(movingRegressor, 21.22397651669148) // Datapoint: 78, Theoretical value: 21.2481930480027, Error: -0.114%
+ testSecondDerivative(movingRegressor, -15.883041508986818) // Datapoint: 78, Theoretical value: -15.9680404738976, Error: -0.5323%
+ movingRegressor.push(7.69544756196018, flywheelPosition(89)) // Datapoint 89
+ testFirstDerivative(movingRegressor, 20.427255162207473) // Datapoint: 79, Theoretical value: 20.4537501990589, Error: -0.1295%
+ testSecondDerivative(movingRegressor, -15.572356144461647) // Datapoint: 79, Theoretical value: -15.6666844733883, Error: -0.6021%
+ movingRegressor.push(7.7927423822589, flywheelPosition(90)) // Datapoint 90
+ testFirstDerivative(movingRegressor, 19.614341957383232) // Datapoint: 80, Theoretical value: 19.6435656125486, Error: -0.1488%
+ testSecondDerivative(movingRegressor, -15.247777333850475) // Datapoint: 80, Theoretical value: -15.3532663414201, Error: -0.6871%
+ movingRegressor.push(7.90154683801025, flywheelPosition(91)) // Datapoint 91
+ testFirstDerivative(movingRegressor, 18.78368585371763) // Datapoint: 81, Theoretical value: 18.8163120184221, Error: -0.1734%
+ testSecondDerivative(movingRegressor, -14.907455157935825) // Datapoint: 81, Theoretical value: -15.0265013965682, Error: -0.7922%
+ movingRegressor.push(8.02602057688646, flywheelPosition(92)) // Datapoint 92
+ testFirstDerivative(movingRegressor, 17.933422588953093) // Datapoint: 82, Theoretical value: 17.9704529528025, Error: -0.2061%
+ testSecondDerivative(movingRegressor, -14.548790942093078) // Datapoint: 82, Theoretical value: -14.6848709709561, Error: -0.9267%
+ movingRegressor.push(8.17342064334141, flywheelPosition(93)) // Datapoint 93
+ testFirstDerivative(movingRegressor, 17.061479737473306) // Datapoint: 83, Theoretical value: 17.1041922069488, Error: -0.2497%
+ testSecondDerivative(movingRegressor, -14.168437758363037) // Datapoint: 83, Theoretical value: -14.3265594782343, Error: -1.1037%
+ movingRegressor.push(8.35857366911792, flywheelPosition(94)) // Datapoint 94
+ testFirstDerivative(movingRegressor, 16.165257979865217) // Datapoint: 84, Theoretical value: 16.2154061403809, Error: -0.3093%
+ testSecondDerivative(movingRegressor, -13.76323766997378) // Datapoint: 84, Theoretical value: -13.9493682181155, Error: -1.3343%
+ movingRegressor.push(8.62252123333967, flywheelPosition(95)) // Datapoint 95
+ testFirstDerivative(movingRegressor, 15.24093105983495) // Datapoint: 85, Theoretical value: 15.3015510945379, Error: -0.3962%
+ testSecondDerivative(movingRegressor, -13.326034947565844) // Datapoint: 85, Theoretical value: -13.5505945675625, Error: -1.6572%
+ movingRegressor.push(9.63801253090293, flywheelPosition(96)) // Datapoint 96
+ testFirstDerivative(movingRegressor, 14.287862296451593) // Datapoint: 86, Theoretical value: 14.3595335732101, Error: -0.4991%
+ testSecondDerivative(movingRegressor, -12.86939888687885) // Datapoint: 86, Theoretical value: -13.1268580733747, Error: -1.9613%
+ movingRegressor.push(10.6535038284662, flywheelPosition(97)) // Datapoint 97
+ testFirstDerivative(movingRegressor, 13.343446992890279) // Datapoint: 87, Theoretical value: 13.3855228467043, Error: -0.3097%
+ testSecondDerivative(movingRegressor, -12.5936586216325) // Datapoint: 87, Theoretical value: -12.6738421230679, Error: -0.6068%
+ movingRegressor.push(10.9174513926879, flywheelPosition(98)) // Datapoint 98
+ testFirstDerivative(movingRegressor, 12.272424425340773) // Datapoint: 88, Theoretical value: 12.3746709051205, Error: -0.8234%
+ testSecondDerivative(movingRegressor, -11.943049564907804) // Datapoint: 88, Theoretical value: -12.1858955707591, Error: -1.9677%
+ movingRegressor.push(11.1026044184645, flywheelPosition(99)) // Datapoint 99
+ testFirstDerivative(movingRegressor, 11.111275577176826) // Datapoint: 89, Theoretical value: 11.3206759756907, Error: -1.8498%
+ testSecondDerivative(movingRegressor, -11.10165547133927) // Datapoint: 89, Theoretical value: -11.6553898136565, Error: -4.7286%
+ movingRegressor.push(11.2500044849194, flywheelPosition(100)) // Datapoint 100
+ testFirstDerivative(movingRegressor, 9.856705462311382) // Datapoint: 90, Theoretical value: 10.2150657644303, Error: -3.5125%
+ testSecondDerivative(movingRegressor, -10.131638260893967) // Datapoint: 90, Theoretical value: -11.0716208918642, Error: -8.4727%
+ movingRegressor.push(11.3744782237956, flywheelPosition(101)) // Datapoint 101
+ testFirstDerivative(movingRegressor, 8.331502562905783) // Datapoint: 91, Theoretical value: 9.04593930777978, Error: -7.9107%
+ testSecondDerivative(movingRegressor, -8.902600102970158) // Datapoint: 91, Theoretical value: -10.4187941573561, Error: -14.5446%
+ movingRegressor.push(11.483282679547, flywheelPosition(102)) // Datapoint 102
+ testFirstDerivative(movingRegressor, 6.616103614905029) // Datapoint: 92, Theoretical value: 7.79555417944151, Error: -15.1565%
+ testSecondDerivative(movingRegressor, -7.485916659905551) // Datapoint: 92, Theoretical value: -9.67195172409882, Error: -22.6092%
+ movingRegressor.push(11.5805774998457, flywheelPosition(103)) // Datapoint 103
+ testFirstDerivative(movingRegressor, 4.888940778715536) // Datapoint: 93, Theoretical value: 6.43508819133308, Error: -24.0732%
+ testSecondDerivative(movingRegressor, -5.956802316997673) // Datapoint: 93, Theoretical value: -8.78755132536914, Error: -32.2443%
+ movingRegressor.push(11.6689951260294, flywheelPosition(104)) // Datapoint 104
+ testFirstDerivative(movingRegressor, 3.6316979581926674) // Datapoint: 94, Theoretical value: 4.91089140313715, Error: -26.1114%
+ testSecondDerivative(movingRegressor, -4.771588801466752) // Datapoint: 94, Theoretical value: -7.67663317071005, Error: -37.9068%
+ movingRegressor.push(11.7503195514143, flywheelPosition(105)) // Datapoint 105
+ testFirstDerivative(movingRegressor, 2.3077423939613055) // Datapoint: 95, Theoretical value: 3.09366772628014, Error: -25.4724%
+ testSecondDerivative(movingRegressor, -3.559315261247479) // Datapoint: 95, Theoretical value: -6.09294778537956, Error: -41.7127%
+ movingRegressor.push(11.8258222097987, flywheelPosition(106)) // Datapoint 1066
+ testFirstDerivative(movingRegressor, 1.5335044322408324) // Datapoint: 96, Theoretical value: 0
+ testSecondDerivative(movingRegressor, -2.0596270771553654e-12) // Datapoint: 96, Theoretical value: 0
+ movingRegressor.push(11.89644495883, flywheelPosition(107)) // Datapoint 107
+ testFirstDerivative(movingRegressor, 2.307742393960204) // Datapoint: 97, Theoretical value: 3.09366772628014, Error: -25.4724%
+ testSecondDerivative(movingRegressor, 3.559315261247989) // Datapoint: 97, Theoretical value: 6.09294778537956, Error: -41.7127%
+ movingRegressor.push(11.9629072339222, flywheelPosition(108)) // Datapoint 108
+ testFirstDerivative(movingRegressor, 3.6316979581925963) // Datapoint: 98, Theoretical value: 4.91089140313715, Error: -26.1114%
+ testSecondDerivative(movingRegressor, 4.771588801465188) // Datapoint: 98, Theoretical value: 7.67663317071005, Error: -37.9068%
+ movingRegressor.push(12.025772443942, flywheelPosition(109)) // Datapoint 109
+ testFirstDerivative(movingRegressor, 4.888940778716552) // Datapoint: 99, Theoretical value: 6.43508819133308, Error: -24.0732%
+ testSecondDerivative(movingRegressor, 5.956802316995809) // Datapoint: 99, Theoretical value: 8.78755132536914, Error: -32.2443%
+ movingRegressor.push(12.0854910260623, flywheelPosition(110)) // Datapoint 110
+ testFirstDerivative(movingRegressor, 6.616103614905953) // Datapoint: 100, Theoretical value: 7.79555417944151, Error: -15.1565%
+ testSecondDerivative(movingRegressor, 7.485916659903175) // Datapoint: 100, Theoretical value: 9.67195172409882, Error: -22.6092%
+ movingRegressor.push(12.142429430331, flywheelPosition(111)) // Datapoint 111
+ testFirstDerivative(movingRegressor, 8.331502562907502) // Datapoint: 101, Theoretical value: 9.04593930777979, Error: -7.9107%
+ testSecondDerivative(movingRegressor, 8.902600102964639) // Datapoint: 101, Theoretical value: 10.4187941573561, Error: -14.5446%
+ movingRegressor.push(12.1968902544729, flywheelPosition(112)) // Datapoint 112
+ testFirstDerivative(movingRegressor, 9.856705462312902) // Datapoint: 102, Theoretical value: 10.2150657644303, Error: -3.5125%
+ testSecondDerivative(movingRegressor, 10.131638260888828) // Datapoint: 102, Theoretical value: 11.0716208918642, Error: -8.4727%
+ movingRegressor.push(12.249126609801, flywheelPosition(113)) // Datapoint 113
+ testFirstDerivative(movingRegressor, 11.111275577177864) // Datapoint: 103, Theoretical value: 11.3206759756907, Error: -1.8498%
+ testSecondDerivative(movingRegressor, 11.101655471333814) // Datapoint: 103, Theoretical value: 11.6553898136565, Error: -4.7286%
+ movingRegressor.push(12.2993526098859, flywheelPosition(114)) // Datapoint 114
+ testFirstDerivative(movingRegressor, 12.272424425338542) // Datapoint: 104, Theoretical value: 12.3746709051205, Error: -0.8234%
+ testSecondDerivative(movingRegressor, 11.943049564906497) // Datapoint: 104, Theoretical value: 12.1858955707591, Error: -1.9677%
+ movingRegressor.push(12.3477511835835, flywheelPosition(115)) // Datapoint 115
+ testFirstDerivative(movingRegressor, 13.343446992888772) // Datapoint: 105, Theoretical value: 13.3855228467043, Error: -0.3097%
+ testSecondDerivative(movingRegressor, 12.59365862163293) // Datapoint: 105, Theoretical value: 12.6738421230679, Error: -0.6068%
+ movingRegressor.push(12.3944799985075, flywheelPosition(116)) // Datapoint 116
+ testFirstDerivative(movingRegressor, 14.28786229645084) // Datapoint: 106, Theoretical value: 14.3595335732101, Error: -0.4991%
+ testSecondDerivative(movingRegressor, 12.86939888687176) // Datapoint: 106, Theoretical value: 13.1268580733747, Error: -1.9613%
+ movingRegressor.push(12.4396760226548, flywheelPosition(117)) // Datapoint 117
+ testFirstDerivative(movingRegressor, 15.240931059833144) // Datapoint: 107, Theoretical value: 15.3015510945379, Error: -0.3962%
+ testSecondDerivative(movingRegressor, 13.32603494755819) // Datapoint: 107, Theoretical value: 13.5505945675625, Error: -1.6572%
+ movingRegressor.push(12.4834590866135, flywheelPosition(118)) // Datapoint 118
+ testFirstDerivative(movingRegressor, 16.165257979863895) // Datapoint: 108, Theoretical value: 16.2154061403809, Error: -0.3093%
+ testSecondDerivative(movingRegressor, 13.763237669965005) // Datapoint: 108, Theoretical value: 13.9493682181155, Error: -1.3343%
+ movingRegressor.push(12.5259347003697, flywheelPosition(119)) // Datapoint 119
+ testFirstDerivative(movingRegressor, 17.061479737472382) // Datapoint: 109, Theoretical value: 17.1041922069488, Error: -0.2497%
+ testSecondDerivative(movingRegressor, 14.16843775835476) // Datapoint: 109, Theoretical value: 14.3265594782343, Error: -1.1037%
+ movingRegressor.push(12.567196306026, flywheelPosition(120)) // Datapoint 120
+ testFirstDerivative(movingRegressor, 17.933422588952254) // Datapoint: 110, Theoretical value: 17.9704529528025, Error: -0.2061%
+ testSecondDerivative(movingRegressor, 14.548790942084981) // Datapoint: 110, Theoretical value: 14.6848709709561, Error: -0.9267%
+ movingRegressor.push(12.6073270979787, flywheelPosition(121)) // Datapoint 121
+ testFirstDerivative(movingRegressor, 18.783685853716804) // Datapoint: 111, Theoretical value: 18.8163120184221, Error: -0.1734%
+ testSecondDerivative(movingRegressor, 14.907455157924247) // Datapoint: 111, Theoretical value: 15.0265013965682, Error: -0.7922%
+ movingRegressor.push(12.646401507436, flywheelPosition(122)) // Datapoint 122
+ testFirstDerivative(movingRegressor, 19.61434195738164) // Datapoint: 112, Theoretical value: 19.6435656125486, Error: -0.1488%
+ testSecondDerivative(movingRegressor, 15.24777733383992) // Datapoint: 112, Theoretical value: 15.3532663414201, Error: -0.6871%
+ movingRegressor.push(12.6844864235927, flywheelPosition(123)) // Datapoint 123
+ testFirstDerivative(movingRegressor, 20.427255162205995) // Datapoint: 113, Theoretical value: 20.4537501990589, Error: -0.1295%
+ testSecondDerivative(movingRegressor, 15.572356144449973) // Datapoint: 113, Theoretical value: 15.6666844733883, Error: -0.6021%
+ movingRegressor.push(12.7216422061192, flywheelPosition(124)) // Datapoint 124
+ testFirstDerivative(movingRegressor, 21.223976516688566) // Datapoint: 114, Theoretical value: 21.2481930480027, Error: -0.114%
+ testSecondDerivative(movingRegressor, 15.883041508982846) // Datapoint: 114, Theoretical value: 15.9680404738976, Error: -0.5323%
+ movingRegressor.push(12.7579235307441, flywheelPosition(125)) // Datapoint 125
+ testFirstDerivative(movingRegressor, 22.005733141866813) // Datapoint: 115, Theoretical value: 22.0280506974936, Error: -0.1013%
+ testSecondDerivative(movingRegressor, 16.181331807938957) // Datapoint: 115, Theoretical value: 16.2584319160835, Error: -0.4742%
+ movingRegressor.push(12.7933801002048, flywheelPosition(126)) // Datapoint 126
+ testFirstDerivative(movingRegressor, 22.77362314936127) // Datapoint: 116, Theoretical value: 22.7943386998864, Error: -0.0909%
+ testSecondDerivative(movingRegressor, 16.468465526509394) // Datapoint: 116, Theoretical value: 16.5388048056272, Error: -0.4253%
+ movingRegressor.push(12.82805724574, flywheelPosition(127)) // Datapoint 127
+ testFirstDerivative(movingRegressor, 23.528633993818517) // Datapoint: 117, Theoretical value: 23.5479549630465, Error: -0.082%
+ testSecondDerivative(movingRegressor, 16.745483084958252) // Datapoint: 117, Theoretical value: 16.8099809505115, Error: -0.3837%
+ movingRegressor.push(12.8619964389359, flywheelPosition(128)) // Datapoint 128
+ testFirstDerivative(movingRegressor, 24.271609293007685) // Datapoint: 118, Theoretical value: 24.2896983042147, Error: -0.0745%
+ testSecondDerivative(movingRegressor, 17.01327023725146) // Datapoint: 118, Theoretical value: 17.0726793342632, Error: -0.348%
+ movingRegressor.push(12.8952357296491, flywheelPosition(129)) // Datapoint 129
+ testFirstDerivative(movingRegressor, 25.00329039949736) // Datapoint: 119, Theoretical value: 25.020283370693, Error: -0.0679%
+ testSecondDerivative(movingRegressor, 17.2725894495434) // Datapoint: 119, Theoretical value: 17.3275330168006, Error: -0.3171%
+ movingRegressor.push(12.9278101225835, flywheelPosition(130)) // Datapoint 130
+ testFirstDerivative(movingRegressor, 25.724341026843803) // Datapoint: 120, Theoretical value: 25.7403527653323, Error: -0.0622%
+ testSecondDerivative(movingRegressor, 17.524103281563516) // Datapoint: 120, Theoretical value: 17.5751026507383, Error: -0.2902%
+ movingRegressor.push(12.9597519026518, flywheelPosition(131)) // Datapoint 131
+ testFirstDerivative(movingRegressor, 26.43535860745402) // Datapoint: 121, Theoretical value: 26.4504869947451, Error: -0.0572%
+ testSecondDerivative(movingRegressor, 17.76839220151102) // Datapoint: 121, Theoretical value: 17.8158874024546, Error: -0.2666%
+ movingRegressor.push(12.9910909173424, flywheelPosition(132)) // Datapoint 132
+ testFirstDerivative(movingRegressor, 27.13688352453582) // Datapoint: 122, Theoretical value: 27.1512127023768, Error: -0.0528%
+ testSecondDerivative(movingRegressor, 18.005968411087238) // Datapoint: 122, Theoretical value: 18.0503338591983, Error: -0.2458%
+ movingRegressor.push(13.0218548227995, flywheelPosition(133)) // Datapoint 133
+ testFirstDerivative(movingRegressor, 27.829406702835342) // Datapoint: 123, Theoretical value: 27.8430095365212, Error: -0.0489%
+ testSecondDerivative(movingRegressor, 18.237286747595334) // Datapoint: 123, Theoretical value: 18.2788433561387, Error: -0.2273%
+ movingRegressor.push(13.052069299129, flywheelPosition(134)) // Datapoint 134
+ testFirstDerivative(movingRegressor, 28.51337591257476) // Datapoint: 124, Theoretical value: 28.5263159216238, Error: -0.0454%
+ testSecondDerivative(movingRegressor, 18.46275340124088) // Datapoint: 124, Theoretical value: 18.5017780512978, Error: -0.2109%
+})
+
+// Test behaviour for no datapoints
+test('Test of correct algorithmic behaviourof FullTSQuadraticEstimator in movingRegressor object for function f(x) = (x + 2,01853237434599)^5 + 33,5103216382911', () => {
+ const flankLength = 11
+ const movingRegressor = createMovingRegressor(flankLength)
+
+ movingRegressor.push(0, flywheelPosition(0)) // Datapoint 0
+ movingRegressor.push(0.0127765482722895, flywheelPosition(1)) // Datapoint 1
+ movingRegressor.push(0.0258871873643309, flywheelPosition(2)) // Datapoint 2
+ movingRegressor.push(0.0393522399171293, flywheelPosition(3)) // Datapoint 3
+ movingRegressor.push(0.0531940190835751, flywheelPosition(4)) // Datapoint 4
+ movingRegressor.push(0.067437102746416, flywheelPosition(5)) // Datapoint 5
+ movingRegressor.push(0.0821086572565166, flywheelPosition(6)) // Datapoint 6
+ movingRegressor.push(0.0972388219213607, flywheelPosition(7)) // Datapoint 7
+ movingRegressor.push(0.112861168581494, flywheelPosition(8)) // Datapoint 8
+ movingRegressor.push(0.129013254748914, flywheelPosition(9)) // Datapoint 9
+ movingRegressor.push(0.14573729434739, flywheelPosition(10)) // Datapoint 10
+ testFirstDerivative(movingRegressor, 82.83414815073293) // Datapoint: 0, Theoretical value: 83.0066489499545, Error: -0.2078%
+ testSecondDerivative(movingRegressor, -154.84617912466788) // Datapoint: 0, Theoretical value: -164.489111009377, Error: -5.8624%
+ movingRegressor.push(0.163080977673881, flywheelPosition(11)) // Datapoint 11
+ testFirstDerivative(movingRegressor, 80.83430671887758) // Datapoint: 1, Theoretical value: 80.924915348848, Error: -0.112%
+ testSecondDerivative(movingRegressor, -154.5212766669226) // Datapoint: 1, Theoretical value: -161.385377615499, Error: -4.2532%
+ movingRegressor.push(0.181098482654215, flywheelPosition(12)) // Datapoint 12
+ testFirstDerivative(movingRegressor, 78.77829854479528) // Datapoint: 2, Theoretical value: 78.8297052108031, Error: -0.0652%
+ testSecondDerivative(movingRegressor, -153.1647345450032) // Datapoint: 2, Theoretical value: -158.241328111624, Error: -3.2081%
+ movingRegressor.push(0.199851734088548, flywheelPosition(13)) // Datapoint 13
+ testFirstDerivative(movingRegressor, 76.68917854978797) // Datapoint: 3, Theoretical value: 76.7204774755507, Error: -0.0408%
+ testSecondDerivative(movingRegressor, -151.05377106974638) // Datapoint: 3, Theoretical value: -155.055067784803, Error: -2.5806%
+ movingRegressor.push(0.219411988349432, flywheelPosition(14)) // Datapoint 14
+ testFirstDerivative(movingRegressor, 74.5731479614624) // Datapoint: 4, Theoretical value: 74.5966498720006, Error: -0.0315%
+ testSecondDerivative(movingRegressor, -148.77399328985268) // Datapoint: 4, Theoretical value: -151.824543946359, Error: -2.0093%
+ movingRegressor.push(0.239861850988137, flywheelPosition(15)) // Datapoint 15
+ testFirstDerivative(movingRegressor, 72.43104307362053) // Datapoint: 5, Theoretical value: 72.4575941879248, Error: -0.0366%
+ testSecondDerivative(movingRegressor, -146.291108515136) // Datapoint: 5, Theoretical value: -148.547526597246, Error: -1.519%
+ movingRegressor.push(0.261297878827625, flywheelPosition(16)) // Datapoint 16
+ testFirstDerivative(movingRegressor, 70.26499879303667) // Datapoint: 6, Theoretical value: 70.3026308003372, Error: -0.0535%
+ testSecondDerivative(movingRegressor, -143.58937061930774) // Datapoint: 6, Theoretical value: -145.221585916134, Error: -1.1239%
+ movingRegressor.push(0.283833984369796, flywheelPosition(17)) // Datapoint 17
+ testFirstDerivative(movingRegressor, 68.07758327384184) // Datapoint: 7, Theoretical value: 68.1310223179495, Error: -0.0784%
+ testSecondDerivative(movingRegressor, -140.67035054010483) // Datapoint: 7, Theoretical value: -141.84406590439, Error: -0.8275%
+ movingRegressor.push(0.307605962104817, flywheelPosition(18)) // Datapoint 18
+ testFirstDerivative(movingRegressor, 65.87145764731441) // Datapoint: 8, Theoretical value: 65.9419661500209, Error: -0.1069%
+ testSecondDerivative(movingRegressor, -137.54570192933917) // Datapoint: 8, Theoretical value: -138.412053350131, Error: -0.6259%
+ movingRegressor.push(0.332777616653774, flywheelPosition(19)) // Datapoint 19
+ testFirstDerivative(movingRegressor, 63.648554184124606) // Datapoint: 9, Theoretical value: 63.7345857676683, Error: -0.135%
+ testSecondDerivative(movingRegressor, -134.23327757538945) // Datapoint: 9, Theoretical value: -134.922341047831, Error: -0.5107%
+ movingRegressor.push(0.359549232710504, flywheelPosition(20)) // Datapoint 20
+ testFirstDerivative(movingRegressor, 61.41000388054417) // Datapoint: 10, Theoretical value: 61.5079203602521, Error: -0.1592%
+ testSecondDerivative(movingRegressor, -130.7477684604862) // Datapoint: 10, Theoretical value: -131.371383910936, Error: -0.4747%
+ movingRegressor.push(0.388169562514999, flywheelPosition(21)) // Datapoint 21
+ testFirstDerivative(movingRegressor, 59.1570731910292) // Datapoint: 11, Theoretical value: 59.2609125050954, Error: -0.1752%
+ testSecondDerivative(movingRegressor, -127.08642259785582) // Datapoint: 11, Theoretical value: -127.75524621423, Error: -0.5235%
+ movingRegressor.push(0.418953264914326, flywheelPosition(22)) // Datapoint 22
+ testFirstDerivative(movingRegressor, 56.88180606989519) // Datapoint: 12, Theoretical value: 56.9923933553014, Error: -0.194%
+ testSecondDerivative(movingRegressor, -123.34941561724611) // Datapoint: 12, Theoretical value: -124.069537659016, Error: -0.5804%
+ movingRegressor.push(0.452307108879146, flywheelPosition(23)) // Datapoint 23
+ testFirstDerivative(movingRegressor, 54.5826015331356) // Datapoint: 13, Theoretical value: 54.7010646957755, Error: -0.2166%
+ testSecondDerivative(movingRegressor, -119.53055207765563) // Datapoint: 13, Theoretical value: -120.309335206939, Error: -0.6473%
+ movingRegressor.push(0.488770894429097, flywheelPosition(24)) // Datapoint 24
+ testFirstDerivative(movingRegressor, 52.25778033833686) // Datapoint: 14, Theoretical value: 52.3854770038019, Error: -0.2438%
+ testSecondDerivative(movingRegressor, -115.6226233758796) // Datapoint: 14, Theoretical value: -116.469086585965, Error: -0.7268%
+ movingRegressor.push(0.52908442241122, flywheelPosition(25)) // Datapoint 25
+ testFirstDerivative(movingRegressor, 49.905473188025454) // Datapoint: 15, Theoretical value: 50.0440023505143, Error: -0.2768%
+ testSecondDerivative(movingRegressor, -111.61712925853197) // Datapoint: 15, Theoretical value: -112.542489895293, Error: -0.8222%
+ movingRegressor.push(0.574303665896444, flywheelPosition(26)) // Datapoint 26
+ testFirstDerivative(movingRegressor, 47.52311022187186) // Datapoint: 16, Theoretical value: 47.6748005513145, Error: -0.3182%
+ testSecondDerivative(movingRegressor, -107.50388680814983) // Datapoint: 16, Theoretical value: -108.522341606437, Error: -0.9385%
+ movingRegressor.push(0.626017879593542, flywheelPosition(27)) // Datapoint 27
+ testFirstDerivative(movingRegressor, 45.107793331419145) // Datapoint: 17, Theoretical value: 45.2757763502753, Error: -0.371%
+ testSecondDerivative(movingRegressor, -103.27042724443308) // Datapoint: 17, Theoretical value: -104.400342127248, Error: -1.0823%
+ movingRegressor.push(0.686797656295626, flywheelPosition(28)) // Datapoint 28
+ testFirstDerivative(movingRegressor, 42.656217000836094) // Datapoint: 18, Theoretical value: 42.8445244981284, Error: -0.4395%
+ testSecondDerivative(movingRegressor, -98.8976072195803) // Datapoint: 18, Theoretical value: -100.166843393353, Error: -1.2671%
+ movingRegressor.push(0.761258258676804, flywheelPosition(29)) // Datapoint 29
+ testFirstDerivative(movingRegressor, 40.16444538212078) // Datapoint: 19, Theoretical value: 40.3782581761556, Error: -0.5295%
+ testSecondDerivative(movingRegressor, -94.36804851908964) // Datapoint: 19, Theoretical value: -95.8105157156623, Error: -1.5055%
+ movingRegressor.push(0.85918996538624, flywheelPosition(30)) // Datapoint 30
+ testFirstDerivative(movingRegressor, 37.625913993605074) // Datapoint: 20, Theoretical value: 37.8737140208996, Error: -0.6543%
+ testSecondDerivative(movingRegressor, -89.65680699527465) // Datapoint: 20, Theoretical value: -91.3178996709088, Error: -1.819%
+ movingRegressor.push(1.00926618717299, flywheelPosition(31)) // Datapoint 31
+ testFirstDerivative(movingRegressor, 35.03463828636044) // Datapoint: 21, Theoretical value: 35.3270234685551, Error: -0.8277%
+ testSecondDerivative(movingRegressor, -84.72131405455713) // Datapoint: 21, Theoretical value: -86.6727901598317, Error: -2.2515%
+ movingRegressor.push(2.01853237434599, flywheelPosition(32)) // Datapoint 32
+ testFirstDerivative(movingRegressor, 32.554704278009275) // Datapoint: 22, Theoretical value: 32.7335342472893, Error: -0.5404%
+ testSecondDerivative(movingRegressor, -81.7211071442608) // Datapoint: 22, Theoretical value: -81.8553682135038, Error: -0.1341%
+ movingRegressor.push(3.02779856151898, flywheelPosition(33)) // Datapoint 33
+ testFirstDerivative(movingRegressor, 30.000816353086208) // Datapoint: 23, Theoretical value: 30.0875556300011, Error: -0.2733%
+ testSecondDerivative(movingRegressor, -78.74803078952486) // Datapoint: 23, Theoretical value: -76.8409405553369, Error: 2.563%
+ movingRegressor.push(3.17787478330574, flywheelPosition(34)) // Datapoint 34
+ testFirstDerivative(movingRegressor, 27.35085176667772) // Datapoint: 24, Theoretical value: 27.3819824840534, Error: -0.0837%
+ testSecondDerivative(movingRegressor, -75.79177233150754) // Datapoint: 24, Theoretical value: -71.5980441226457, Error: 6.0231%
+ movingRegressor.push(3.27580649001517, flywheelPosition(35)) // Datapoint 35
+ testFirstDerivative(movingRegressor, 24.26071071895941) // Datapoint: 25, Theoretical value: 24.6077174058151, Error: -1.395%
+ testSecondDerivative(movingRegressor, -70.11339732080359) // Datapoint: 25, Theoretical value: -66.0854711273398, Error: 6.2702%
+ movingRegressor.push(3.35026709239635, flywheelPosition(36)) // Datapoint 36
+ testFirstDerivative(movingRegressor, 20.837518238274917) // Datapoint: 26, Theoretical value: 21.7527364967177, Error: -4.2193%
+ testSecondDerivative(movingRegressor, -63.09857048895648) // Datapoint: 26, Theoretical value: -60.2473455054648, Error: 4.9079%
+ movingRegressor.push(3.41104686909844, flywheelPosition(37)) // Datapoint 37
+ testFirstDerivative(movingRegressor, 16.553286547446575) // Datapoint: 27, Theoretical value: 18.8004784715502, Error: -12.0216%
+ testSecondDerivative(movingRegressor, -53.82083440126937) // Datapoint: 27, Theoretical value: -54.0044029484732, Error: -0.1884%
+ movingRegressor.push(3.46276108279553, flywheelPosition(38)) // Datapoint 38
+ testFirstDerivative(movingRegressor, 12.04945082759771) // Datapoint: 28, Theoretical value: 15.7268191179949, Error: -23.5492%
+ testSecondDerivative(movingRegressor, -44.46299143724117) // Datapoint: 28, Theoretical value: -47.2370928078489, Error: -5.7614%
+ movingRegressor.push(3.50798032628076, flywheelPosition(39)) // Datapoint 39
+ testFirstDerivative(movingRegressor, 7.995979006137617) // Datapoint: 29, Theoretical value: 12.4936663152318, Error: -36.3269%
+ testSecondDerivative(movingRegressor, -35.67688197212385) // Datapoint: 29, Theoretical value: -39.7484244987643, Error: -10.203%
+ movingRegressor.push(3.54829385426288, flywheelPosition(40)) // Datapoint 40
+ testFirstDerivative(movingRegressor, 4.545499777144109) // Datapoint: 30, Theoretical value: 9.03268562508831, Error: -50.2852%
+ testSecondDerivative(movingRegressor, -26.644166155256052) // Datapoint: 30, Theoretical value: -31.164858820935, Error: -14.6531%
+ movingRegressor.push(3.58475763981283, flywheelPosition(41)) // Datapoint 41
+ testFirstDerivative(movingRegressor, 3.8462783014954773) // Datapoint: 31, Theoretical value: 5.18791555937216, Error: -26.4463%
+ testSecondDerivative(movingRegressor, -19.207586578866348) // Datapoint: 31, Theoretical value: -20.5611388761721, Error: -7.4435%
+ movingRegressor.push(3.61811148377765, flywheelPosition(42)) // Datapoint 42
+ testFirstDerivative(movingRegressor, 3.1383576841321967) // Datapoint: 32, Theoretical value: 0
+ testSecondDerivative(movingRegressor, 4.409564582673597e-15) // Datapoint: 32, Theoretical value: 0
+ movingRegressor.push(3.64889518617698, flywheelPosition(43)) // Datapoint 43
+ testFirstDerivative(movingRegressor, 3.8462783014949977) // Datapoint: 33, Theoretical value: 5.18791555937215, Error: -26.4463%
+ testSecondDerivative(movingRegressor, 19.2075865788684) // Datapoint: 33, Theoretical value: 20.5611388761721, Error: -7.4435%
+ movingRegressor.push(3.67751551598147, flywheelPosition(44)) // Datapoint 44
+ testFirstDerivative(movingRegressor, 4.545499777143718) // Datapoint: 34, Theoretical value: 9.03268562508831, Error: -50.2852%
+ testSecondDerivative(movingRegressor, 26.64416615525877) // Datapoint: 34, Theoretical value: 31.164858820935, Error: -14.6531%
+ movingRegressor.push(3.7042871320382, flywheelPosition(45)) // Datapoint 45
+ testFirstDerivative(movingRegressor, 7.995979006135855) // Datapoint: 35, Theoretical value: 12.4936663152318, Error: -36.3269%
+ testSecondDerivative(movingRegressor, 35.67688197213815) // Datapoint: 35, Theoretical value: 39.7484244987643, Error: -10.203%
+ movingRegressor.push(3.72945878658716, flywheelPosition(46)) // Datapoint 46
+ testFirstDerivative(movingRegressor, 12.049450827592068) // Datapoint: 36, Theoretical value: 15.7268191179949, Error: -23.5492%
+ testSecondDerivative(movingRegressor, 44.46299143727433) // Datapoint: 36, Theoretical value: 47.2370928078489, Error: -5.7614%
+ movingRegressor.push(3.75323076432218, flywheelPosition(47)) // Datapoint 47
+ testFirstDerivative(movingRegressor, 16.55328654744136) // Datapoint: 37, Theoretical value: 18.8004784715502, Error: -12.0216%
+ testSecondDerivative(movingRegressor, 53.820834401312524) // Datapoint: 37, Theoretical value: 54.0044029484732, Error: -0.1884%
+ movingRegressor.push(3.77576686986435, flywheelPosition(48)) // Datapoint 48
+ testFirstDerivative(movingRegressor, 20.837518238271798) // Datapoint: 38, Theoretical value: 21.7527364967177, Error: -4.2193%
+ testSecondDerivative(movingRegressor, 63.0985704889981) // Datapoint: 38, Theoretical value: 60.2473455054648, Error: 4.9079%
+ movingRegressor.push(3.79720289770384, flywheelPosition(49)) // Datapoint 49
+ testFirstDerivative(movingRegressor, 24.260710718959217) // Datapoint: 39, Theoretical value: 24.6077174058151, Error: -1.395%
+ testSecondDerivative(movingRegressor, 70.11339732083867) // Datapoint: 39, Theoretical value: 66.0854711273397, Error: 6.2702%
+ movingRegressor.push(3.81765276034255, flywheelPosition(50)) // Datapoint 50
+ testFirstDerivative(movingRegressor, 27.35085176667826) // Datapoint: 40, Theoretical value: 27.3819824840534, Error: -0.0837%
+ testSecondDerivative(movingRegressor, 75.79177233154196) // Datapoint: 40, Theoretical value: 71.5980441226458, Error: 6.0231%
+ movingRegressor.push(3.83721301460343, flywheelPosition(51)) // Datapoint 51
+ testFirstDerivative(movingRegressor, 30.000816353087885) // Datapoint: 41, Theoretical value: 30.0875556300011, Error: -0.2733%
+ testSecondDerivative(movingRegressor, 78.74803078955205) // Datapoint: 41, Theoretical value: 76.8409405553369, Error: 2.563%
+ movingRegressor.push(3.85596626603776, flywheelPosition(52)) // Datapoint 52
+ testFirstDerivative(movingRegressor, 32.5547042780135) // Datapoint: 42, Theoretical value: 32.7335342472893, Error: -0.5404%
+ testSecondDerivative(movingRegressor, 81.72110714427114) // Datapoint: 42, Theoretical value: 81.8553682135038, Error: -0.1341%
+ movingRegressor.push(3.8739837710181, flywheelPosition(53)) // Datapoint 53
+ testFirstDerivative(movingRegressor, 35.03463828636535) // Datapoint: 43, Theoretical value: 35.3270234685551, Error: -0.8277%
+ testSecondDerivative(movingRegressor, 84.72131405455926) // Datapoint: 43, Theoretical value: 86.6727901598316, Error: -2.2515%
+ movingRegressor.push(3.89132745434459, flywheelPosition(54)) // Datapoint 54
+ testFirstDerivative(movingRegressor, 37.625913993608265) // Datapoint: 44, Theoretical value: 37.8737140208995, Error: -0.6543%
+ testSecondDerivative(movingRegressor, 89.65680699528338) // Datapoint: 44, Theoretical value: 91.3178996709088, Error: -1.819%
+ movingRegressor.push(3.90805149394306, flywheelPosition(55)) // Datapoint 55
+ testFirstDerivative(movingRegressor, 40.16444538212363) // Datapoint: 45, Theoretical value: 40.3782581761556, Error: -0.5295%
+ testSecondDerivative(movingRegressor, 94.36804851909604) // Datapoint: 45, Theoretical value: 95.8105157156622, Error: -1.5055%
+ movingRegressor.push(3.92420358011048, flywheelPosition(56)) // Datapoint 56
+ testFirstDerivative(movingRegressor, 42.656217000838126) // Datapoint: 46, Theoretical value: 42.8445244981284, Error: -0.4395%
+ testSecondDerivative(movingRegressor, 98.89760721956821) // Datapoint: 46, Theoretical value: 100.166843393353, Error: -1.2671%
+ movingRegressor.push(3.93982592677062, flywheelPosition(57)) // Datapoint 57
+ testFirstDerivative(movingRegressor, 45.10779333141892) // Datapoint: 47, Theoretical value: 45.2757763502753, Error: -0.371%
+ testSecondDerivative(movingRegressor, 103.27042724442408) // Datapoint: 47, Theoretical value: 104.400342127248, Error: -1.0823%
+ movingRegressor.push(3.95495609143546, flywheelPosition(58)) // Datapoint 58
+ testFirstDerivative(movingRegressor, 47.5231102218691) // Datapoint: 48, Theoretical value: 47.6748005513145, Error: -0.3182%
+ testSecondDerivative(movingRegressor, 107.50388680816235) // Datapoint: 48, Theoretical value: 108.522341606437, Error: -0.9385%
+ movingRegressor.push(3.96962764594556, flywheelPosition(59)) // Datapoint 59
+ testFirstDerivative(movingRegressor, 49.905473188024416) // Datapoint: 49, Theoretical value: 50.0440023505143, Error: -0.2768%
+ testSecondDerivative(movingRegressor, 111.6171292585368) // Datapoint: 49, Theoretical value: 112.542489895293, Error: -0.8222%
+ movingRegressor.push(3.9838707296084, flywheelPosition(60)) // Datapoint 60
+ testFirstDerivative(movingRegressor, 52.25778033833785) // Datapoint: 50, Theoretical value: 52.3854770038019, Error: -0.2438%
+ testSecondDerivative(movingRegressor, 115.62262337587573) // Datapoint: 50, Theoretical value: 116.469086585965, Error: -0.7268%
+ movingRegressor.push(3.99771250877485, flywheelPosition(61)) // Datapoint 61
+ testFirstDerivative(movingRegressor, 54.582601533135005) // Datapoint: 51, Theoretical value: 54.7010646957755, Error: -0.2166%
+ testSecondDerivative(movingRegressor, 119.53055207766285) // Datapoint: 51, Theoretical value: 120.309335206939, Error: -0.6473%
+ movingRegressor.push(4.01117756132765, flywheelPosition(62)) // Datapoint 62
+ testFirstDerivative(movingRegressor, 56.881806069895106) // Datapoint: 52, Theoretical value: 56.9923933553014, Error: -0.194%
+ testSecondDerivative(movingRegressor, 123.34941561724908) // Datapoint: 52, Theoretical value: 124.069537659016, Error: -0.5804%
+ movingRegressor.push(4.02428820041969, flywheelPosition(63)) // Datapoint 63
+ testFirstDerivative(movingRegressor, 59.15707319103194) // Datapoint: 53, Theoretical value: 59.2609125050953, Error: -0.1752%
+ testSecondDerivative(movingRegressor, 127.08642259784997) // Datapoint: 53, Theoretical value: 127.75524621423, Error: -0.5235%
+ movingRegressor.push(4.02428820041969, flywheelPosition(64)) // Datapoint 64
+ testFirstDerivative(movingRegressor, 61.41026002082708) // Datapoint: 54, Theoretical value: 61.5079203602521, Error: -0.1588%
+ testSecondDerivative(movingRegressor, 130.7372321804723) // Datapoint: 54, Theoretical value: 131.371383910936, Error: -0.4827%
+ movingRegressor.push(4.03706474869198, flywheelPosition(65)) // Datapoint 65
+ testFirstDerivative(movingRegressor, 63.644342254392484) // Datapoint: 55, Theoretical value: 63.7345857676683, Error: -0.1416%
+ testSecondDerivative(movingRegressor, 134.33869512072937) // Datapoint: 55, Theoretical value: 134.922341047831, Error: -0.4326%
+ movingRegressor.push(4.05017538778402, flywheelPosition(66)) // Datapoint 66
+ testFirstDerivative(movingRegressor, 65.86741453755053) // Datapoint: 56, Theoretical value: 65.9419661500209, Error: -0.1131%
+ testSecondDerivative(movingRegressor, 137.8780141797187) // Datapoint: 56, Theoretical value: 138.412053350131, Error: -0.3858%
+ movingRegressor.push(4.06364044033682, flywheelPosition(67)) // Datapoint 67
+ testFirstDerivative(movingRegressor, 68.54444889883757) // Datapoint: 57, Theoretical value: 68.1310223179495, Error: 0.6068%
+ testSecondDerivative(movingRegressor, 141.16457001710992) // Datapoint: 57, Theoretical value: 141.84406590439, Error: -0.479%
+ movingRegressor.push(4.07748221950326, flywheelPosition(68)) // Datapoint 68
+ testFirstDerivative(movingRegressor, 71.3654378949409) // Datapoint: 58, Theoretical value: 70.3026308003372, Error: 1.5118%
+ testSecondDerivative(movingRegressor, 143.98144239891764) // Datapoint: 58, Theoretical value: 145.221585916134, Error: -0.854%
+ movingRegressor.push(4.0917253031661, flywheelPosition(69)) // Datapoint 69
+ testFirstDerivative(movingRegressor, 75.30164018219989) // Datapoint: 59, Theoretical value: 72.4575941879248, Error: 3.9251%
+ testSecondDerivative(movingRegressor, 126.07905869782206) // Datapoint: 59, Theoretical value: 148.547526597246, Error: -15.1254%
+ movingRegressor.push(4.1063968576762, flywheelPosition(70)) // Datapoint 70
+ testFirstDerivative(movingRegressor, 78.57819540283134) // Datapoint: 60, Theoretical value: 74.5966498720006, Error: 5.3374%
+ testSecondDerivative(movingRegressor, 103.79601069533149) // Datapoint: 60, Theoretical value: 151.824543946359, Error: -31.6342%
+ movingRegressor.push(4.12152702234105, flywheelPosition(71)) // Datapoint 71
+ testFirstDerivative(movingRegressor, 81.20994360799062) // Datapoint: 61, Theoretical value: 76.7204774755507, Error: 5.8517%
+ testSecondDerivative(movingRegressor, 77.65096540872354) // Datapoint: 61, Theoretical value: 155.055067784803, Error: -49.9204%
+ movingRegressor.push(4.13714936900118, flywheelPosition(72)) // Datapoint 72
+ testFirstDerivative(movingRegressor, 83.04710562163946) // Datapoint: 62, Theoretical value: 78.829705210803, Error: 5.35%
+ testSecondDerivative(movingRegressor, 47.96831078789868) // Datapoint: 62, Theoretical value: 158.241328111624, Error: -69.6866%
+ movingRegressor.push(4.1533014551686, flywheelPosition(73)) // Datapoint 73
+ testFirstDerivative(movingRegressor, 83.97100662917377) // Datapoint: 63, Theoretical value: 80.924915348848, Error: 3.7641%
+ testSecondDerivative(movingRegressor, 14.954334770920239) // Datapoint: 63, Theoretical value: 161.385377615499, Error: -90.7338%
+ movingRegressor.push(4.17002549476708, flywheelPosition(74)) // Datapoint 74
+ testFirstDerivative(movingRegressor, 84.16754477550508) // Datapoint: 64, Theoretical value: 83.0066489499545, Error: 1.3986%
+ testSecondDerivative(movingRegressor, -17.092112574980536) // Datapoint: 64, Theoretical value: -164.489111009377, Error: -89.609%
+ movingRegressor.push(4.18736917809357, flywheelPosition(75)) // Datapoint 75
+ testFirstDerivative(movingRegressor, 83.5716461621889) // Datapoint: 65, Theoretical value: 80.924915348848, Error: 3.2706%
+ testSecondDerivative(movingRegressor, -49.900050401294735) // Datapoint: 65, Theoretical value: -161.385377615499, Error: -69.0802%
+ movingRegressor.push(4.2053866830739, flywheelPosition(76)) // Datapoint 76
+ testFirstDerivative(movingRegressor, 82.06385360415482) // Datapoint: 66, Theoretical value: 78.8297052108031, Error: 4.1027%
+ testSecondDerivative(movingRegressor, -79.55362430561435) // Datapoint: 66, Theoretical value: -158.241328111624, Error: -49.7264%
+ movingRegressor.push(4.22413993450824, flywheelPosition(77)) // Datapoint 77
+ testFirstDerivative(movingRegressor, 79.76241462500502) // Datapoint: 67, Theoretical value: 76.7204774755507, Error: 3.965%
+ testSecondDerivative(movingRegressor, -105.7692377776201) // Datapoint: 67, Theoretical value: -155.055067784803, Error: -31.786%
+ movingRegressor.push(4.24370018876912, flywheelPosition(78)) // Datapoint 78
+ testFirstDerivative(movingRegressor, 76.41914684790277) // Datapoint: 68, Theoretical value: 74.5966498720006, Error: 2.4431%
+ testSecondDerivative(movingRegressor, -129.14752813375634) // Datapoint: 68, Theoretical value: -151.824543946359, Error: -14.9363%
+ movingRegressor.push(4.26415005140783, flywheelPosition(79)) // Datapoint 79
+ testFirstDerivative(movingRegressor, 72.72961089976661) // Datapoint: 69, Theoretical value: 72.4575941879248, Error: 0.3754%
+ testSecondDerivative(movingRegressor, -147.17724519180067) // Datapoint: 69, Theoretical value: -148.547526597246, Error: -0.9225%
+ movingRegressor.push(4.28558607924731, flywheelPosition(80)) // Datapoint 80
+ testFirstDerivative(movingRegressor, 70.25958290426024) // Datapoint: 70, Theoretical value: 70.3026308003372, Error: -0.0612%
+ testSecondDerivative(movingRegressor, -144.4135816996841) // Datapoint: 70, Theoretical value: -145.221585916134, Error: -0.5564%
+ movingRegressor.push(4.30812218478948, flywheelPosition(81)) // Datapoint 81
+ testFirstDerivative(movingRegressor, 68.06067353487629) // Datapoint: 71, Theoretical value: 68.1310223179495, Error: -0.1033%
+ testSecondDerivative(movingRegressor, -141.20668760476255) // Datapoint: 71, Theoretical value: -141.84406590439, Error: -0.4494%
+ movingRegressor.push(4.33189416252451, flywheelPosition(82)) // Datapoint 82
+ testFirstDerivative(movingRegressor, 65.86108760287186) // Datapoint: 72, Theoretical value: 65.9419661500209, Error: -0.1227%
+ testSecondDerivative(movingRegressor, -137.74011344154923) // Datapoint: 72, Theoretical value: -138.412053350131, Error: -0.4855%
+ movingRegressor.push(4.35706581707346, flywheelPosition(83)) // Datapoint 83
+ testFirstDerivative(movingRegressor, 63.648449832698134) // Datapoint: 73, Theoretical value: 63.7345857676683, Error: -0.1351%
+ testSecondDerivative(movingRegressor, -134.22069550066365) // Datapoint: 73, Theoretical value: -134.922341047831, Error: -0.52%
+ movingRegressor.push(4.38383743313019, flywheelPosition(84)) // Datapoint 84
+ testFirstDerivative(movingRegressor, 61.41665794554547) // Datapoint: 74, Theoretical value: 61.5079203602521, Error: -0.1484%
+ testSecondDerivative(movingRegressor, -130.65983476435815) // Datapoint: 74, Theoretical value: -131.371383910936, Error: -0.5416%
+ movingRegressor.push(4.41245776293469, flywheelPosition(85)) // Datapoint 85
+ testFirstDerivative(movingRegressor, 59.15707319102739) // Datapoint: 75, Theoretical value: 59.2609125050954, Error: -0.1752%
+ testSecondDerivative(movingRegressor, -127.08642259788174) // Datapoint: 75, Theoretical value: -127.75524621423, Error: -0.5235%
+ movingRegressor.push(4.44324146533401, flywheelPosition(86)) // Datapoint 86
+ testFirstDerivative(movingRegressor, 56.881806069896356) // Datapoint: 76, Theoretical value: 56.9923933553014, Error: -0.194%
+ testSecondDerivative(movingRegressor, -123.34941561724712) // Datapoint: 76, Theoretical value: -124.069537659016, Error: -0.5804%
+ movingRegressor.push(4.47659530929884, flywheelPosition(87)) // Datapoint 87
+ testFirstDerivative(movingRegressor, 54.58260153313722) // Datapoint: 77, Theoretical value: 54.7010646957755, Error: -0.2166%
+ testSecondDerivative(movingRegressor, -119.53055207765404) // Datapoint: 77, Theoretical value: -120.309335206939, Error: -0.6473%
+ movingRegressor.push(4.51305909484878, flywheelPosition(88)) // Datapoint 88
+ testFirstDerivative(movingRegressor, 52.257780338337625) // Datapoint: 78, Theoretical value: 52.3854770038019, Error: -0.2438%
+ testSecondDerivative(movingRegressor, -115.62262337589141) // Datapoint: 78, Theoretical value: -116.469086585965, Error: -0.7268%
+ movingRegressor.push(4.55337262283091, flywheelPosition(89)) // Datapoint 89
+ testFirstDerivative(movingRegressor, 49.905473188025326) // Datapoint: 79, Theoretical value: 50.0440023505143, Error: -0.2768%
+ testSecondDerivative(movingRegressor, -111.61712925855326) // Datapoint: 79, Theoretical value: -112.542489895293, Error: -0.8222%
+ movingRegressor.push(4.59859186631613, flywheelPosition(90)) // Datapoint 90
+ testFirstDerivative(movingRegressor, 47.52311022187348) // Datapoint: 80, Theoretical value: 47.6748005513145, Error: -0.3182%
+ testSecondDerivative(movingRegressor, -107.50388680816754) // Datapoint: 80, Theoretical value: -108.522341606437, Error: -0.9385%
+ movingRegressor.push(4.65030608001323, flywheelPosition(91)) // Datapoint 91
+ testFirstDerivative(movingRegressor, 45.10779333141943) // Datapoint: 81, Theoretical value: 45.2757763502753, Error: -0.371%
+ testSecondDerivative(movingRegressor, -103.27042724445896) // Datapoint: 81, Theoretical value: -104.400342127248, Error: -1.0823%
+ movingRegressor.push(4.71108585671531, flywheelPosition(92)) // Datapoint 92
+ testFirstDerivative(movingRegressor, 42.656217000835056) // Datapoint: 82, Theoretical value: 42.8445244981284, Error: -0.4395%
+ testSecondDerivative(movingRegressor, -98.89760721960394) // Datapoint: 82, Theoretical value: -100.166843393353, Error: -1.2671%
+ movingRegressor.push(4.78554645909649, flywheelPosition(93)) // Datapoint 93
+ testFirstDerivative(movingRegressor, 40.164445382119595) // Datapoint: 83, Theoretical value: 40.3782581761556, Error: -0.5295%
+ testSecondDerivative(movingRegressor, -94.36804851911774) // Datapoint: 83, Theoretical value: -95.8105157156623, Error: -1.5055%
+ movingRegressor.push(4.88347816580593, flywheelPosition(94)) // Datapoint 94
+ testFirstDerivative(movingRegressor, 37.62591399360383) // Datapoint: 84, Theoretical value: 37.8737140208996, Error: -0.6543%
+ testSecondDerivative(movingRegressor, -89.65680699529587) // Datapoint: 84, Theoretical value: -91.3178996709088, Error: -1.819%
+ movingRegressor.push(5.03355438759268, flywheelPosition(95)) // Datapoint 95
+ testFirstDerivative(movingRegressor, 35.034638286358415) // Datapoint: 85, Theoretical value: 35.3270234685551, Error: -0.8277%
+ testSecondDerivative(movingRegressor, -84.7213140545765) // Datapoint: 85, Theoretical value: -86.6727901598317, Error: -2.2515%
+ movingRegressor.push(6.04282057476568, flywheelPosition(96)) // Datapoint 96
+ testFirstDerivative(movingRegressor, 32.55470427800782) // Datapoint: 86, Theoretical value: 32.7335342472893, Error: -0.5404%
+ testSecondDerivative(movingRegressor, -81.72110714427964) // Datapoint: 86, Theoretical value: -81.8553682135038, Error: -0.1341%
+ movingRegressor.push(7.05208676193867, flywheelPosition(97)) // Datapoint 97
+ testFirstDerivative(movingRegressor, 30.0008163530847) // Datapoint: 87, Theoretical value: 30.0875556300011, Error: -0.2733%
+ testSecondDerivative(movingRegressor, -78.74803078953659) // Datapoint: 87, Theoretical value: -76.8409405553369, Error: 2.563%
+ movingRegressor.push(7.20216298372543, flywheelPosition(98)) // Datapoint 98
+ testFirstDerivative(movingRegressor, 27.350851766678318) // Datapoint: 88, Theoretical value: 27.3819824840534, Error: -0.0837%
+ testSecondDerivative(movingRegressor, -75.7917723315129) // Datapoint: 88, Theoretical value: -71.5980441226457, Error: 6.0231%
+ movingRegressor.push(7.30009469043486, flywheelPosition(99)) // Datapoint 99
+ testFirstDerivative(movingRegressor, 24.260710718958364) // Datapoint: 89, Theoretical value: 24.6077174058151, Error: -1.395%
+ testSecondDerivative(movingRegressor, -70.11339732081393) // Datapoint: 89, Theoretical value: -66.0854711273398, Error: 6.2702%
+ movingRegressor.push(7.37455529281604, flywheelPosition(100)) // Datapoint 100
+ testFirstDerivative(movingRegressor, 20.837518238273844) // Datapoint: 90, Theoretical value: 21.7527364967177, Error: -4.2193%
+ testSecondDerivative(movingRegressor, -63.09857048896637) // Datapoint: 90, Theoretical value: -60.2473455054648, Error: 4.9079%
+ movingRegressor.push(7.43533506951812, flywheelPosition(101)) // Datapoint 101
+ testFirstDerivative(movingRegressor, 16.55328654744659) // Datapoint: 91, Theoretical value: 18.8004784715502, Error: -12.0216%
+ testSecondDerivative(movingRegressor, -53.82083440127297) // Datapoint: 91, Theoretical value: -54.0044029484732, Error: -0.1884%
+ movingRegressor.push(7.48704928321522, flywheelPosition(102)) // Datapoint 102
+ testFirstDerivative(movingRegressor, 12.04945082759599) // Datapoint: 92, Theoretical value: 15.7268191179949, Error: -23.5492%
+ testSecondDerivative(movingRegressor, -44.46299143725113) // Datapoint: 92, Theoretical value: -47.2370928078489, Error: -5.7614%
+ movingRegressor.push(7.53226852670045, flywheelPosition(103)) // Datapoint 103
+ testFirstDerivative(movingRegressor, 7.995979006137162) // Datapoint: 93, Theoretical value: 12.4936663152318, Error: -36.3269%
+ testSecondDerivative(movingRegressor, -35.676881972128726) // Datapoint: 93, Theoretical value: -39.7484244987643, Error: -10.203%
+ movingRegressor.push(7.57258205468257, flywheelPosition(104)) // Datapoint 104
+ testFirstDerivative(movingRegressor, 4.5454997771441015) // Datapoint: 94, Theoretical value: 9.03268562508831, Error: -50.2852%
+ testSecondDerivative(movingRegressor, -26.64416615525746) // Datapoint: 94, Theoretical value: -31.164858820935, Error: -14.6531%
+ movingRegressor.push(7.60904584023252, flywheelPosition(105)) // Datapoint 105
+ testFirstDerivative(movingRegressor, 3.846278301494735) // Datapoint: 95, Theoretical value: 5.18791555937216, Error: -26.4463%
+ testSecondDerivative(movingRegressor, -19.20758657886873) // Datapoint: 95, Theoretical value: -20.5611388761721, Error: -7.4435%
+ movingRegressor.push(7.64239968419734, flywheelPosition(106)) // Datapoint 106
+ testFirstDerivative(movingRegressor, 3.1383576841322074) // Datapoint: 96, Theoretical value: 0
+ testSecondDerivative(movingRegressor, 1.3705117896314261e-14) // Datapoint: 96, Theoretical value: 0
+ movingRegressor.push(7.67318338659667, flywheelPosition(107)) // Datapoint 107
+ testFirstDerivative(movingRegressor, 3.846278301494692) // Datapoint: 97, Theoretical value: 5.18791555937215, Error: -26.4463%
+ testSecondDerivative(movingRegressor, 19.20758657887071) // Datapoint: 97, Theoretical value: 20.5611388761721, Error: -7.4435%
+ movingRegressor.push(7.70180371640116, flywheelPosition(108)) // Datapoint 108
+ testFirstDerivative(movingRegressor, 4.545499777143846) // Datapoint: 98, Theoretical value: 9.03268562508831, Error: -50.2852%
+ testSecondDerivative(movingRegressor, 26.644166155259857) // Datapoint: 98, Theoretical value: 31.164858820935, Error: -14.6531%
+ movingRegressor.push(7.72857533245789, flywheelPosition(109)) // Datapoint 109
+ testFirstDerivative(movingRegressor, 7.995979006133808) // Datapoint: 99, Theoretical value: 12.4936663152318, Error: -36.3269%
+ testSecondDerivative(movingRegressor, 35.67688197214775) // Datapoint: 99, Theoretical value: 39.7484244987643, Error: -10.203%
+ movingRegressor.push(7.75374698700685, flywheelPosition(110)) // Datapoint 110
+ testFirstDerivative(movingRegressor, 12.049450827602357) // Datapoint: 100, Theoretical value: 15.7268191179949, Error: -23.5492%
+ testSecondDerivative(movingRegressor, 44.46299143724677) // Datapoint: 100, Theoretical value: 47.2370928078489, Error: -5.7614%
+ movingRegressor.push(7.77751896474187, flywheelPosition(111)) // Datapoint 111
+ testFirstDerivative(movingRegressor, 16.55328654745199) // Datapoint: 101, Theoretical value: 18.8004784715502, Error: -12.0216%
+ testSecondDerivative(movingRegressor, 53.82083440127187) // Datapoint: 101, Theoretical value: 54.0044029484732, Error: -0.1884%
+ movingRegressor.push(7.80005507028404, flywheelPosition(112)) // Datapoint 112
+ testFirstDerivative(movingRegressor, 20.837518238281234) // Datapoint: 102, Theoretical value: 21.7527364967177, Error: -4.2193%
+ testSecondDerivative(movingRegressor, 63.09857048895572) // Datapoint: 102, Theoretical value: 60.2473455054648, Error: 4.9079%
+ movingRegressor.push(7.82149109812353, flywheelPosition(113)) // Datapoint 113
+ testFirstDerivative(movingRegressor, 24.260710718966607) // Datapoint: 103, Theoretical value: 24.6077174058151, Error: -1.395%
+ testSecondDerivative(movingRegressor, 70.11339732079524) // Datapoint: 103, Theoretical value: 66.0854711273397, Error: 6.2702%
+ movingRegressor.push(7.84194096076223, flywheelPosition(114)) // Datapoint 114
+ testFirstDerivative(movingRegressor, 27.350851766683718) // Datapoint: 104, Theoretical value: 27.3819824840534, Error: -0.0837%
+ testSecondDerivative(movingRegressor, 75.79177233149834) // Datapoint: 104, Theoretical value: 71.5980441226458, Error: 6.0231%
+ movingRegressor.push(7.86150121502312, flywheelPosition(115)) // Datapoint 115
+ testFirstDerivative(movingRegressor, 30.000816353090613) // Datapoint: 105, Theoretical value: 30.0875556300011, Error: -0.2733%
+ testSecondDerivative(movingRegressor, 78.7480307895183) // Datapoint: 105, Theoretical value: 76.8409405553369, Error: 2.563%
+ movingRegressor.push(7.88025446645745, flywheelPosition(116)) // Datapoint 116
+ testFirstDerivative(movingRegressor, 32.55470427801254) // Datapoint: 106, Theoretical value: 32.7335342472893, Error: -0.5404%
+ testSecondDerivative(movingRegressor, 81.72110714425453) // Datapoint: 106, Theoretical value: 81.8553682135038, Error: -0.1341%
+ movingRegressor.push(7.89827197143778, flywheelPosition(117)) // Datapoint 117
+ testFirstDerivative(movingRegressor, 35.034638286363815) // Datapoint: 107, Theoretical value: 35.3270234685551, Error: -0.8277%
+ testSecondDerivative(movingRegressor, 84.72131405454387) // Datapoint: 107, Theoretical value: 86.6727901598316, Error: -2.2515%
+ movingRegressor.push(7.91561565476428, flywheelPosition(118)) // Datapoint 118
+ testFirstDerivative(movingRegressor, 37.62591399360758) // Datapoint: 108, Theoretical value: 37.8737140208995, Error: -0.6543%
+ testSecondDerivative(movingRegressor, 89.65680699524638) // Datapoint: 108, Theoretical value: 91.3178996709088, Error: -1.819%
+ movingRegressor.push(7.93233969436275, flywheelPosition(119)) // Datapoint 119
+ testFirstDerivative(movingRegressor, 40.16444538212181) // Datapoint: 109, Theoretical value: 40.3782581761556, Error: -0.5295%
+ testSecondDerivative(movingRegressor, 94.36804851906145) // Datapoint: 109, Theoretical value: 95.8105157156622, Error: -1.5055%
+ movingRegressor.push(7.94849178053017, flywheelPosition(120)) // Datapoint 120
+ testFirstDerivative(movingRegressor, 42.656217000836136) // Datapoint: 110, Theoretical value: 42.8445244981284, Error: -0.4395%
+ testSecondDerivative(movingRegressor, 98.89760721951238) // Datapoint: 110, Theoretical value: 100.166843393353, Error: -1.2671%
+ movingRegressor.push(7.9641141271903, flywheelPosition(121)) // Datapoint 121
+ testFirstDerivative(movingRegressor, 45.10779333141909) // Datapoint: 111, Theoretical value: 45.2757763502753, Error: -0.371%
+ testSecondDerivative(movingRegressor, 103.27042724435985) // Datapoint: 111, Theoretical value: 104.400342127248, Error: -1.0823%
+ movingRegressor.push(7.97924429185515, flywheelPosition(122)) // Datapoint 122
+ testFirstDerivative(movingRegressor, 47.52311022187121) // Datapoint: 112, Theoretical value: 47.6748005513145, Error: -0.3182%
+ testSecondDerivative(movingRegressor, 107.50388680806326) // Datapoint: 112, Theoretical value: 108.522341606437, Error: -0.9385%
+ movingRegressor.push(7.99391584636525, flywheelPosition(123)) // Datapoint 123
+ testFirstDerivative(movingRegressor, 49.90547318802419) // Datapoint: 113, Theoretical value: 50.0440023505143, Error: -0.2768%
+ testSecondDerivative(movingRegressor, 111.61712925841942) // Datapoint: 113, Theoretical value: 112.542489895293, Error: -0.8222%
+ movingRegressor.push(8.00815893002809, flywheelPosition(124)) // Datapoint 124
+ testFirstDerivative(movingRegressor, 52.25778033833399) // Datapoint: 114, Theoretical value: 52.3854770038019, Error: -0.2438%
+ testSecondDerivative(movingRegressor, 115.62262337575498) // Datapoint: 114, Theoretical value: 116.469086585965, Error: -0.7268%
+ movingRegressor.push(8.02200070919454, flywheelPosition(125)) // Datapoint 125
+ testFirstDerivative(movingRegressor, 54.582601533131424) // Datapoint: 115, Theoretical value: 54.7010646957755, Error: -0.2166%
+ testSecondDerivative(movingRegressor, 119.530552077508) // Datapoint: 115, Theoretical value: 120.309335206939, Error: -0.6473%
+ movingRegressor.push(8.03546576174734, flywheelPosition(126)) // Datapoint 126
+ testFirstDerivative(movingRegressor, 56.88180606988715) // Datapoint: 116, Theoretical value: 56.9923933553014, Error: -0.194%
+ testSecondDerivative(movingRegressor, 123.34941561712556) // Datapoint: 116, Theoretical value: 124.069537659016, Error: -0.5804%
+ movingRegressor.push(8.04857640083938, flywheelPosition(127)) // Datapoint 127
+ testFirstDerivative(movingRegressor, 59.15707319101773) // Datapoint: 117, Theoretical value: 59.2609125050953, Error: -0.1752%
+ testSecondDerivative(movingRegressor, 127.08642259777825) // Datapoint: 117, Theoretical value: 127.75524621423, Error: -0.5235%
+ movingRegressor.push(8.06135294911167, flywheelPosition(128)) // Datapoint 128
+ testFirstDerivative(movingRegressor, 61.41000388053635) // Datapoint: 118, Theoretical value: 61.5079203602521, Error: -0.1592%
+ testSecondDerivative(movingRegressor, 130.74776846040675) // Datapoint: 118, Theoretical value: 131.371383910936, Error: -0.4747%
+ movingRegressor.push(8.07381397226209, flywheelPosition(129)) // Datapoint 129
+ testFirstDerivative(movingRegressor, 63.64197850554342) // Datapoint: 119, Theoretical value: 63.7345857676683, Error: -0.1453%
+ testSecondDerivative(movingRegressor, 134.3388391346595) // Datapoint: 119, Theoretical value: 134.922341047831, Error: -0.4325%
+ movingRegressor.push(8.08597647902856, flywheelPosition(130)) // Datapoint 130
+ testFirstDerivative(movingRegressor, 65.85414584244677) // Datapoint: 120, Theoretical value: 65.9419661500209, Error: -0.1332%
+ testSecondDerivative(movingRegressor, 137.86436162982568) // Datapoint: 120, Theoretical value: 138.412053350131, Error: -0.3957%
+ movingRegressor.push(8.09785609325274, flywheelPosition(131)) // Datapoint 131
+ testFirstDerivative(movingRegressor, 68.0475404855556) // Datapoint: 121, Theoretical value: 68.1310223179495, Error: -0.1225%
+ testSecondDerivative(movingRegressor, 141.32851756189933) // Datapoint: 121, Theoretical value: 141.84406590439, Error: -0.3635%
+ movingRegressor.push(8.10946720260155, flywheelPosition(132)) // Datapoint 132
+ testFirstDerivative(movingRegressor, 70.22309927953825) // Datapoint: 122, Theoretical value: 70.3026308003372, Error: -0.1131%
+ testSecondDerivative(movingRegressor, 144.73503205195712) // Datapoint: 122, Theoretical value: 145.221585916134, Error: -0.335%
+ movingRegressor.push(8.1208230876678, flywheelPosition(133)) // Datapoint 133
+ testFirstDerivative(movingRegressor, 72.38167464942285) // Datapoint: 123, Theoretical value: 72.4575941879248, Error: -0.1048%
+ testSecondDerivative(movingRegressor, 148.0872440238788) // Datapoint: 123, Theoretical value: 148.547526597246, Error: -0.3099%
+ movingRegressor.push(8.13193603449435, flywheelPosition(134)) // Datapoint 134
+ testFirstDerivative(movingRegressor, 74.52404555335283) // Datapoint: 124, Theoretical value: 74.5966498720006, Error: -0.0973%
+ testSecondDerivative(movingRegressor, 151.38816260883686) // Datapoint: 124, Theoretical value: 151.824543946359, Error: -0.2874%
+})
+
+function testFirstDerivative (regressor, expectedValue) {
+ assert.ok(regressor.firstDerivative(0) === expectedValue, `First derivative should be ${expectedValue} Radians/sec at ${regressor.X.get(0)} sec, is ${regressor.firstDerivative(0)}`)
+}
+
+function testSecondDerivative (regressor, expectedValue) {
+ assert.ok(regressor.secondDerivative(0) === expectedValue, `Second derivative should be ${expectedValue} Radians/sec^2 at ${regressor.X.get(0)} sec, is ${regressor.secondDerivative(0)}`)
+}
+
+test.run()
diff --git a/app/engine/utils/OLSLinearSeries.js b/app/engine/utils/OLSLinearSeries.js
deleted file mode 100644
index cc382f6234..0000000000
--- a/app/engine/utils/OLSLinearSeries.js
+++ /dev/null
@@ -1,203 +0,0 @@
-'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/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')
-
-function createOLSLinearSeries (maxSeriesLength = 0) {
- const X = createSeries(maxSeriesLength)
- const XX = createSeries(maxSeriesLength)
- const Y = createSeries(maxSeriesLength)
- const YY = createSeries(maxSeriesLength)
- const XY = createSeries(maxSeriesLength)
- const trend = createSeries(maxSeriesLength)
- let _slope = 0
- let _intercept = 0
- let _goodnessOfFit = 0
-
- function push (x, y) {
- 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)
- trend.push(determineTrend(X.length() - 2, X.length() - 1))
- } 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 {
- return 0
- }
- }
-
- function numberOfXValuesAbove (testedValue) {
- return X.numberOfValuesAbove(testedValue)
- }
-
- function numberOfXValuesEqualOrBelow (testedValue) {
- return X.numberOfValuesEqualOrBelow(testedValue)
- }
-
- function numberOfYValuesAbove (testedValue) {
- return Y.numberOfValuesAbove(testedValue)
- }
-
- function numberOfYValuesEqualOrBelow (testedValue) {
- return Y.numberOfValuesEqualOrBelow(testedValue)
- }
-
- function numberOfUpwardTrend () {
- return trend.numberOfValuesAbove(0)
- }
-
- function numberOfFlatOrDownwardTrend () {
- return trend.numberOfValuesEqualOrBelow(0)
- }
-
- function xAtSeriesBegin () {
- return X.atSeriesBegin()
- }
-
- function xAtSeriesEnd () {
- return X.atSeriesEnd()
- }
-
- function yAtSeriesBegin () {
- return Y.atSeriesBegin()
- }
-
- function yAtSeriesEnd () {
- return Y.atSeriesEnd()
- }
-
- function xSum () {
- return X.sum()
- }
-
- function ySum () {
- return Y.sum()
- }
-
- function xSeries () {
- return X.series()
- }
-
- function ySeries () {
- return Y.series()
- }
-
- function determineTrend (pointOne, pointTwo) {
- if (pointOne !== pointTwo) {
- return (Y.get(pointTwo) - Y.get(pointOne))
- } else {
- log.error('OLS Linear Regressor, trend determination, trend can not be applied to one point!')
- return 0
- }
- }
-
- function reset () {
- X.reset()
- XX.reset()
- Y.reset()
- YY.reset()
- XY.reset()
- _slope = 0
- _intercept = 0
- _goodnessOfFit = 0
- }
-
- return {
- push,
- slope,
- intercept,
- length,
- goodnessOfFit,
- projectX,
- projectY,
- numberOfXValuesAbove,
- numberOfXValuesEqualOrBelow,
- numberOfYValuesAbove,
- numberOfYValuesEqualOrBelow,
- numberOfUpwardTrend,
- numberOfFlatOrDownwardTrend,
- xAtSeriesBegin,
- xAtSeriesEnd,
- yAtSeriesBegin,
- yAtSeriesEnd,
- xSum,
- ySum,
- xSeries,
- ySeries,
- reset
- }
-}
-
-export { createOLSLinearSeries }
diff --git a/app/engine/utils/Series.js b/app/engine/utils/Series.js
index 2bc189578c..ecc128e4e6 100644
--- a/app/engine/utils/Series.js
+++ b/app/engine/utils/Series.js
@@ -1,31 +1,65 @@
'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- This creates a series with a maximum number of values
- It allows for determining the Average, Median, Number of Positive, number of Negative
-*/
-
-function createSeries (maxSeriesLength) {
- const seriesArray = []
- let seriesSum = 0
+/**
+ * @copyright {@link https://github.com/JaapvanEkris/openrowingmonitor|OpenRowingMonitor}
+ *
+ * @file This creates a series with a maximum number of values. It allows for determining the Average, Median, Number of Positive, number of Negative
+ * BE AWARE: The median function is extremely CPU intensive for larger series. Use the BinarySearchTree for that situation instead!
+ * BE AWARE: Accumulators (seriesSum especially) are vulnerable to floating point rounding errors causing drift.
+ */
+/**
+ * @param {number} maxSeriesLength - The maximum length of the series (0 for unlimited)
+ */
+export function createSeries (maxSeriesLength = 0) {
+ /**
+ * @type {Array}
+ * 'updateCountCeiling' is added as a future provision. It currently set to 1, forcing a sum recalc every push. Setting it higher reduces CPU load, but also reduces accuracy
+ * due to accumulator rounding issues. Special tests are present in the corresponding unit-tests, but testing of dependent modules show small deviations
+ */
+ const updateCountCeiling = maxSeriesLength > 0 ? Math.min(1, maxSeriesLength) : 1
+ let seriesArray = []
let numPos = 0
let numNeg = 0
-
+ let min = undefined
+ let max = undefined
+ let seriesSum = null
+ let updatecount = 0
+
+ /**
+ * @param {float} value - 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]
+ // The maximum of the array has been reached, we have to create room by removing the first value from the array
if (seriesArray[0] > 0) {
numPos--
} else {
numNeg--
}
+ if (min === seriesArray[0]) {
+ min = undefined
+ }
+ if (max === seriesArray[0]) {
+ max = undefined
+ }
+ if (seriesSum !== null) { seriesSum -= seriesArray[0] }
seriesArray.shift()
}
seriesArray.push(value)
- seriesSum += value
+
+ updatecount++
+
+ if (updatecount < updateCountCeiling && seriesSum !== null) {
+ seriesSum += value
+ } else {
+ updatecount = 0
+ seriesSum = null
+ }
+
if (value > 0) {
numPos++
} else {
@@ -33,10 +67,16 @@ function createSeries (maxSeriesLength) {
}
}
+ /**
+ * @returns {number} length of the series
+ */
function length () {
return seriesArray.length
}
+ /**
+ * @returns {float} the oldest value of the series (i.e. the one first added)
+ */
function atSeriesBegin () {
if (seriesArray.length > 0) {
return seriesArray[0]
@@ -45,6 +85,9 @@ function createSeries (maxSeriesLength) {
}
}
+ /**
+ * @returns {float} the youngest value of the series (i.e. the one last added)
+ */
function atSeriesEnd () {
if (seriesArray.length > 0) {
return seriesArray[seriesArray.length - 1]
@@ -53,6 +96,10 @@ function createSeries (maxSeriesLength) {
}
}
+ /**
+ * @param {integer} position - position to be retrieved, starting at 0
+ * @returns {float} value at that specific postion in the series
+ */
function get (position) {
if (position >= 0 && position < seriesArray.length) {
return seriesArray[position]
@@ -61,6 +108,10 @@ function createSeries (maxSeriesLength) {
}
}
+ /**
+ * @param {float} testedValue - tested value
+ * @returns {integer} count of values in the series above the tested value
+ */
function numberOfValuesAbove (testedValue) {
if (testedValue === 0) {
return numPos
@@ -77,6 +128,10 @@ function createSeries (maxSeriesLength) {
}
}
+ /**
+ * @param {float} testedValue - tested value
+ * @returns {integer} number of values in the series below or equal to the tested value
+ */
function numberOfValuesEqualOrBelow (testedValue) {
if (testedValue === 0) {
return numNeg
@@ -93,18 +148,56 @@ function createSeries (maxSeriesLength) {
}
}
+ /**
+ * @returns {float} sum of the entire series
+ * @description This determines the total sum of the series. As a running sum becomes unstable after longer running sums, we need to summarise this via a reduce
+ */
function sum () {
+ if (seriesSum === null) {
+ seriesSum = (seriesArray.length > 0 ? seriesArray.reduce((total, item) => total + item) : 0)
+ }
return seriesSum
}
+ /**
+ * @returns {float} average of the entire series
+ */
function average () {
if (seriesArray.length > 0) {
- return seriesSum / seriesArray.length
+ return sum() / seriesArray.length
} else {
return 0
}
}
+ /**
+ * @returns {float} smallest element in the series
+ */
+ function minimum () {
+ if (seriesArray.length > 0) {
+ if (isNaN(min)) { min = Math.min(...seriesArray) }
+ return min
+ } else {
+ return 0
+ }
+ }
+
+ /**
+ * @returns {float} largest value in the series
+ */
+ function maximum () {
+ if (seriesArray.length > 0) {
+ if (isNaN(max)) { max = Math.max(...seriesArray) }
+ return max
+ } else {
+ return 0
+ }
+ }
+
+ /**
+ * @returns {float} median of the series
+ * @description returns the median of the series. As this is a CPU intensive approach, DO NOT USE FOR LARGE SERIES!. For larger series, use the BinarySearchTree.js instead
+ */
function median () {
if (seriesArray.length > 0) {
const mid = Math.floor(seriesArray.length / 2)
@@ -115,6 +208,9 @@ function createSeries (maxSeriesLength) {
}
}
+ /**
+ * @returns {array} returns the entire series
+ */
function series () {
if (seriesArray.length > 0) {
return seriesArray
@@ -123,11 +219,17 @@ function createSeries (maxSeriesLength) {
}
}
+ /**
+ * Resets the series to its initial state
+ */
function reset () {
- seriesArray.splice(0, seriesArray.length)
- seriesSum = 0
+ seriesArray = /** @type {Array} */(/** @type {unknown} */(null))
+ seriesArray = []
numPos = 0
numNeg = 0
+ min = undefined
+ max = undefined
+ seriesSum = null
}
return {
@@ -140,10 +242,10 @@ function createSeries (maxSeriesLength) {
numberOfValuesEqualOrBelow,
sum,
average,
+ minimum,
+ maximum,
median,
series,
reset
}
}
-
-export { createSeries }
diff --git a/app/engine/utils/Series.test.js b/app/engine/utils/Series.test.js
index 8df93334a7..94d63d45fb 100644
--- a/app/engine/utils/Series.test.js
+++ b/app/engine/utils/Series.test.js
@@ -1,14 +1,19 @@
'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- As this object is fundamental for most other utility objects, we must test its behaviour quite thoroughly
-*/
+/**
+ * @copyright [OpenRowingMonitor]{@link https://github.com/JaapvanEkris/openrowingmonitor}
+ *
+ * @file Tests of the Series object. As this object is fundamental for most other utility objects, we must test its behaviour quite thoroughly
+ * Please note: this file contains commented out stress tests of the length(), sum(), average() functions, to detect any issues with numerical stability
+ * As these tests tend to run in the dozens of minutes, we do not run them systematically, but they should be run when the series object is changed.
+ */
import { test } from 'uvu'
import * as assert from 'uvu/assert'
import { createSeries } from './Series.js'
+/**
+ * @description Test behaviour for no datapoints
+ */
test('Series behaviour with an empty series', () => {
const dataSeries = createSeries(3)
testLength(dataSeries, 0)
@@ -21,8 +26,13 @@ test('Series behaviour with an empty series', () => {
testSum(dataSeries, 0)
testAverage(dataSeries, 0)
testMedian(dataSeries, 0)
+ testMinimum(dataSeries, 0)
+ testMaximum(dataSeries, 0)
})
+/**
+ * @description Test behaviour for a single datapoint
+ */
test('Series behaviour with a single pushed value. Series = [9]', () => {
const dataSeries = createSeries(3)
dataSeries.push(9)
@@ -36,8 +46,13 @@ test('Series behaviour with a single pushed value. Series = [9]', () => {
testSum(dataSeries, 9)
testAverage(dataSeries, 9)
testMedian(dataSeries, 9)
+ testMinimum(dataSeries, 9)
+ testMaximum(dataSeries, 9)
})
+/**
+ * @description Test behaviour for two datapoints
+ */
test('Series behaviour with a second pushed value. Series = [9, 3]', () => {
const dataSeries = createSeries(3)
dataSeries.push(9)
@@ -52,8 +67,13 @@ test('Series behaviour with a second pushed value. Series = [9, 3]', () => {
testSum(dataSeries, 12)
testAverage(dataSeries, 6)
testMedian(dataSeries, 6)
+ testMinimum(dataSeries, 3)
+ testMaximum(dataSeries, 9)
})
+/**
+ * @description Test behaviour for three datapoints
+ */
test('Series behaviour with a third pushed value. Series = [9, 3, 6]', () => {
const dataSeries = createSeries(3)
dataSeries.push(9)
@@ -69,8 +89,13 @@ test('Series behaviour with a third pushed value. Series = [9, 3, 6]', () => {
testSum(dataSeries, 18)
testAverage(dataSeries, 6)
testMedian(dataSeries, 6)
+ testMinimum(dataSeries, 3)
+ testMaximum(dataSeries, 9)
})
+/**
+ * @description Test behaviour for four datapoints
+ */
test('Series behaviour with a fourth pushed value. Series = [3, 6, 12]', () => {
const dataSeries = createSeries(3)
dataSeries.push(9)
@@ -87,8 +112,13 @@ test('Series behaviour with a fourth pushed value. Series = [3, 6, 12]', () => {
testSum(dataSeries, 21)
testAverage(dataSeries, 7)
testMedian(dataSeries, 6)
+ testMinimum(dataSeries, 3)
+ testMaximum(dataSeries, 12)
})
+/**
+ * @description Test behaviour for five datapoints
+ */
test('Series behaviour with a fifth pushed value. Series = [6, 12, -3]', () => {
const dataSeries = createSeries(3)
dataSeries.push(9)
@@ -106,8 +136,49 @@ test('Series behaviour with a fifth pushed value. Series = [6, 12, -3]', () => {
testSum(dataSeries, 15)
testAverage(dataSeries, 5)
testMedian(dataSeries, 6)
+ testMinimum(dataSeries, -3)
+ testMaximum(dataSeries, 12)
+})
+
+/**
+ * @description Test behaviour for recalculations of the min/max values
+ */
+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)
})
+/**
+ * @description Test behaviour for recalculations of the min/max values
+ */
+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)
+})
+
+/**
+ * @description Test behaviour after a reset()
+ */
test('Series behaviour with a five pushed values followed by a reset, Series = []', () => {
const dataSeries = createSeries(3)
dataSeries.push(9)
@@ -128,6 +199,43 @@ test('Series behaviour with a five pushed values followed by a reset, Series = [
testMedian(dataSeries, 0)
})
+/* These stress tests test the reliability of the sum(), average() and length() function after a huge number of updates
+// This specific test takes a long time (over 10 minutes), so only run them manually when changing the series module
+// Javascript maximum array length is 4294967295, as heap memory is limited, we stay with 2^25 datapoints
+test('Stress test of the series object, 33.554.432 (2^25) datapoints', () => {
+ const dataSeries = createSeries()
+ let j = 0
+ let randomvalue
+ while (j < 16777216) {
+ randomvalue = Math.random()
+ dataSeries.push(randomvalue)
+ dataSeries.push(1 - randomvalue)
+ j++
+ }
+ testLength(dataSeries, 33554432)
+ testSum(dataSeries, 16777216)
+ testAverage(dataSeries, 0.5)
+ testMedian(dataSeries, 0.5)
+})
+
+// Javascript maximum array length is 4294967295, as heap memory is limited, we stay with 2^25 datapoints
+// This test takes several hours (!) due to the many large array shifts, so only run them manually when changing the series module
+test('Stress test of the series object, 67.108.864 datapoints, with a maxLength of 33.554.432 (2^25)', () => {
+ const dataSeries = createSeries(33554432)
+ let j = 0
+ let randomvalue
+ while (j < 33554432) {
+ randomvalue = Math.random()
+ dataSeries.push(randomvalue)
+ dataSeries.push(1 - randomvalue)
+ j++
+ }
+ testLength(dataSeries, 33554432)
+ testSum(dataSeries, 16777216)
+ testAverage(dataSeries, 0.5)
+ testMedian(dataSeries, 0.5)
+}) */
+
function testLength (series, expectedValue) {
assert.ok(series.length() === expectedValue, `Expected length should be ${expectedValue}, encountered ${series.length()}`)
}
@@ -160,4 +268,12 @@ 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
index f22aea991b..9be00afa09 100644
--- a/app/engine/utils/StreamFilter.js
+++ b/app/engine/utils/StreamFilter.js
@@ -1,23 +1,31 @@
'use strict'
/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- This keeps an array, which we can ask for an moving average
-
- Please note: The array contains maxLength values
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
*/
+/**
+ * This keeps a series of specified length, which we can ask for an moving median
+ * This is used by RowingStatistics.js to aggregate over multiple stroke phasee
+ */
+import { createLabelledBinarySearchTree } from './BinarySearchTree.js'
-import { createSeries } from './Series.js'
-
-function createStreamFilter (maxLength, defaultValue) {
- const dataPoints = createSeries(maxLength)
+export function createStreamFilter (maxLength, defaultValue) {
let lastRawDatapoint = defaultValue
let cleanDatapoint = defaultValue
+ let position = 0
+ let bst = createLabelledBinarySearchTree()
function push (dataPoint) {
- lastRawDatapoint = dataPoint
- dataPoints.push(dataPoint)
- cleanDatapoint = dataPoints.median()
+ if (dataPoint !== undefined && !isNaN(dataPoint)) {
+ lastRawDatapoint = dataPoint
+ if (maxLength > 0) {
+ position = (position + 1) % maxLength
+ bst.remove(position)
+ bst.push(position, dataPoint, 1)
+ } else {
+ bst.push(position, dataPoint, 1)
+ }
+ cleanDatapoint = bst.median()
+ }
}
function raw () {
@@ -25,7 +33,7 @@ function createStreamFilter (maxLength, defaultValue) {
}
function clean () {
- if (dataPoints.length() > 0) {
+ if (bst.size() > 0) {
// The series contains sufficient values to be valid
return cleanDatapoint
} else {
@@ -35,11 +43,11 @@ function createStreamFilter (maxLength, defaultValue) {
}
function reliable () {
- return dataPoints.length() > 0
+ return bst.size() > 0
}
function reset () {
- dataPoints.reset()
+ bst.reset()
lastRawDatapoint = defaultValue
cleanDatapoint = defaultValue
}
@@ -52,5 +60,3 @@ function createStreamFilter (maxLength, defaultValue) {
reset
}
}
-
-export { createStreamFilter }
diff --git a/app/engine/utils/StreamFilter.test.js b/app/engine/utils/StreamFilter.test.js
index 05c0d4fea4..a5dbc9c8e9 100644
--- a/app/engine/utils/StreamFilter.test.js
+++ b/app/engine/utils/StreamFilter.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/engine/utils/TSLinearSeries.js b/app/engine/utils/TSLinearSeries.js
new file mode 100644
index 0000000000..17b8f8099e
--- /dev/null
+++ b/app/engine/utils/TSLinearSeries.js
@@ -0,0 +1,316 @@
+'use strict'
+/**
+ * @copyright [OpenRowingMonitor]{@link https://github.com/JaapvanEkris/openrowingmonitor}
+ *
+ * @file The TSLinearSeries is a datatype that represents a Weighted Linear Series. It allows
+ * values to be retrieved (like a FiFo buffer, or Queue) but it also includes
+ * a Weighted 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')
+
+/**
+ * @param {integer} maxSeriesLength - the maximum length of the quadratic series, default = 0 for unlimited
+ */
+export function createTSLinearSeries (maxSeriesLength = 0) {
+ const X = createSeries(maxSeriesLength)
+ const Y = createSeries(maxSeriesLength)
+ const weight = createSeries(maxSeriesLength)
+ const WY = createSeries(maxSeriesLength)
+ const A = createLabelledBinarySearchTree()
+
+ let _A = 0
+ let _B = 0
+ let _sst = 0
+ let _goodnessOfFit = 0
+
+ /**
+ * @param {float} x - the x value of the datapoint
+ * @param {float} y - the y value of the datapoint
+ * @param {float} w - the weight of the datapoint (optional, defaults to 1 for unweighted regression)
+ * Invariant: BinarySearchTree A contains all calculated a's (as in the general formula y = a * x + b),
+ * where the a's are labeled in the BinarySearchTree with their Xi when they BEGIN in the point (Xi, Yi)
+ */
+ function push (x, y, w = 1) {
+ 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 X[0] as well before this value is trashed
+ A.remove(X.get(0))
+ }
+
+ X.push(x)
+ Y.push(y)
+ weight.push(w)
+ WY.push(w * 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
+ let slope
+ let combinedweight
+ while (i < X.length() - 1) {
+ // Calculate the slope with all preceeding datapoints and X.length() - 1'th datapoint (as the array starts at zero)
+ slope = calculateSlope(i, X.length() - 1)
+ combinedweight = weight.get(i) * w
+ A.push(X.get(i), slope, combinedweight)
+ i++
+ }
+ }
+
+ // Calculate the median of the slopes
+ if (X.length() > 1) {
+ _A = A.weightedMedian()
+ } else {
+ _A = 0
+ }
+
+ // Invalidate the previously calculated intercept and goodnessOfFit. We'll only calculate them if we need them
+ _B = null
+ _sst = null
+ _goodnessOfFit = null
+ }
+
+ /**
+ * @returns {float} the slope of the linear function
+ */
+ function slope () {
+ return _A
+ }
+
+ /**
+ * @returns {float} the intercept of the linear function
+ */
+ function intercept () {
+ calculateIntercept()
+ return _B
+ }
+
+ /**
+ * @returns {float} the coefficient a of the linear function y = a * x + b
+ */
+ function coefficientA () {
+ return _A
+ }
+
+ /**
+ * @returns {float} the coefficient b of the linear function y = a * x + b
+ */
+ function coefficientB () {
+ calculateIntercept()
+ return _B
+ }
+
+ /**
+ * @returns {integer} the lenght of the stored series
+ */
+ function length () {
+ return X.length()
+ }
+
+ /**
+ * @returns {float} the R^2 as a global goodness of fit indicator
+ * It will automatically recalculate the _goodnessOfFit when it isn't defined
+ * This lazy approach is intended to prevent unneccesary calculations, especially when there is a batch of datapoints
+ * pushes from the TSQuadratic regressor processing its linear residu
+ * @see [Goodness-of-Fit Statistics] {@link https://web.maths.unsw.edu.au/~adelle/Garvan/Assays/GoodnessOfFit.html}
+ */
+ function goodnessOfFit () {
+ let i = 0
+ let sse = 0
+ calculateIntercept()
+ if (_goodnessOfFit === null) {
+ if (X.length() >= 2) {
+ _sst = 0
+
+ // Calculate weighted R^2
+ const weightedAverageY = WY.sum() / weight.sum()
+
+ while (i < X.length()) {
+ sse += weight.get(i) * Math.pow(Y.get(i) - projectX(X.get(i)), 2)
+ _sst += weight.get(i) * Math.pow(Y.get(i) - weightedAverageY, 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.01
+ break
+ case (_sst !== 0):
+ _goodnessOfFit = 1 - (sse / _sst)
+ break
+ default:
+ // When SST = 0, R2 isn't defined
+ _goodnessOfFit = 0.01
+ }
+ } else {
+ _goodnessOfFit = 0
+ }
+ }
+ return _goodnessOfFit
+ }
+
+ /**
+ * @param {integer} position - The position in the series for which the Local Goodness Of Fit has to be calcuated
+ * @returns {float} the local R^2 as a local goodness of fit indicator
+ */
+ function localGoodnessOfFit (position) {
+ if (_sst === null) {
+ // Force the recalculation of the _sst
+ goodnessOfFit()
+ }
+ if (X.length() >= 2 && position < X.length()) {
+ const weightedSquaredError = weight.get(position) * Math.pow((Y.get(position) - projectX(X.get(position))), 2)
+ /* eslint-disable no-unreachable -- rather be systematic and add a break in all case statements */
+ switch (true) {
+ case (weightedSquaredError === 0):
+ return 1
+ break
+ case (weightedSquaredError > _sst):
+ // This is a pretty bad fit as the error is bigger than just using the line for the average y as intercept
+ return 0.01
+ break
+ case (_sst !== 0):
+ return Math.min(Math.max(1 - ((weightedSquaredError * X.length()) / _sst), 0), 1)
+ break
+ default:
+ // When _SST = 0, localGoodnessOfFit isn't defined
+ return 0.01
+ }
+ /* eslint-enable no-unreachable */
+ } else {
+ return 0
+ }
+ }
+
+ /**
+ * @param {float} x - the x value to be projected
+ * @returns {float} the resulting y value when projected via the linear function
+ */
+ function projectX (x) {
+ if (X.length() >= 2) {
+ calculateIntercept()
+ return (_A * x) + _B
+ } else {
+ return 0
+ }
+ }
+
+ /**
+ * @param {float} y - the y value to be solved
+ * @returns {float} the resulting x value when solved via the linear function
+ */
+ 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
+ }
+ }
+
+ /**
+ * @param {integer} pointOne - The position in the series of the first datapoint used for the slope calculation
+ * @param {integer} pointTwo - The position in the series of the second datapoint used for the slope calculation
+ * @returns {float} the slope of the linear function
+ */
+ 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
+ }
+ }
+
+ /**
+ * @description This helper function calculates the intercept and stores it in _B
+ */
+ 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, we recreate the B-tree for each newly added datapoint anyway, so the label i isn't relevant
+ B.push(i, (Y.get(i) - (_A * X.get(i))), weight.get(i))
+ i++
+ }
+ _B = B.weightedMedian()
+ } else {
+ _B = 0
+ }
+ }
+ B.reset()
+ }
+
+ /**
+ * @returns {boolean} whether the linear regression should be considered reliable to produce results
+ */
+ function reliable () {
+ return (X.length() >= 2)
+ }
+
+ /**
+ * @description This function is used for clearing data and state, bringing it back to its original state
+ */
+ function reset () {
+ if (X.length() > 0) {
+ // There is something to reset
+ X.reset()
+ Y.reset()
+ weight.reset()
+ WY.reset()
+ A.reset()
+ _A = 0
+ _B = 0
+ _goodnessOfFit = 0
+ }
+ }
+
+ return {
+ push,
+ X,
+ Y,
+ slope,
+ intercept,
+ coefficientA,
+ coefficientB,
+ length,
+ goodnessOfFit,
+ localGoodnessOfFit,
+ projectX,
+ projectY,
+ reliable,
+ reset
+ }
+}
diff --git a/app/engine/utils/OLSLinearSeries.test.js b/app/engine/utils/TSLinearSeries.test.js
similarity index 52%
rename from app/engine/utils/OLSLinearSeries.test.js
rename to app/engine/utils/TSLinearSeries.test.js
index 92e6445eb0..e6aec4f722 100644
--- a/app/engine/utils/OLSLinearSeries.test.js
+++ b/app/engine/utils/TSLinearSeries.test.js
@@ -1,14 +1,14 @@
'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 { createOLSLinearSeries } from './OLSLinearSeries.js'
+import { createTSLinearSeries } from './TSLinearSeries.js'
test('Correct behaviour of a series after initialisation', () => {
- const dataSeries = createOLSLinearSeries(3)
+ const dataSeries = createTSLinearSeries(3)
testLength(dataSeries, 0)
testXAtSeriesBegin(dataSeries, 0)
testYAtSeriesBegin(dataSeries, 0)
@@ -27,10 +27,11 @@ test('Correct behaviour of a series after initialisation', () => {
testSlopeEquals(dataSeries, 0)
testInterceptEquals(dataSeries, 0)
testGoodnessOfFitEquals(dataSeries, 0)
+ testLocalGoodnessOfFitEquals(dataSeries, 0, 0)
})
-test('Correct behaviour of a series after several puhed values, function y = 3x + 6, noisefree, 1 datapoint', () => {
- const dataSeries = createOLSLinearSeries(3)
+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)
@@ -51,10 +52,11 @@ test('Correct behaviour of a series after several puhed values, function y = 3x
testSlopeEquals(dataSeries, 0)
testInterceptEquals(dataSeries, 0)
testGoodnessOfFitEquals(dataSeries, 0)
+ testLocalGoodnessOfFitEquals(dataSeries, 0, 0)
})
-test('Correct behaviour of a series after several puhed values, function y = 3x + 6, noisefree, 2 datapoints', () => {
- const dataSeries = createOLSLinearSeries(3)
+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)
@@ -75,10 +77,12 @@ test('Correct behaviour of a series after several puhed values, function y = 3x
testSlopeEquals(dataSeries, 3)
testInterceptEquals(dataSeries, -6)
testGoodnessOfFitEquals(dataSeries, 1)
+ testLocalGoodnessOfFitEquals(dataSeries, 0, 1)
+ testLocalGoodnessOfFitEquals(dataSeries, 1, 1)
})
-test('Correct behaviour of a series after several puhed values, function y = 3x + 6, noisefree, 3 datapoints', () => {
- const dataSeries = createOLSLinearSeries(3)
+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)
@@ -100,10 +104,42 @@ test('Correct behaviour of a series after several puhed values, function y = 3x
testSlopeEquals(dataSeries, 3)
testInterceptEquals(dataSeries, -6)
testGoodnessOfFitEquals(dataSeries, 1)
+ testLocalGoodnessOfFitEquals(dataSeries, 0, 1)
+ testLocalGoodnessOfFitEquals(dataSeries, 1, 1)
+ testLocalGoodnessOfFitEquals(dataSeries, 2, 1)
+ testLocalGoodnessOfFitEquals(dataSeries, 3, 0) // Overshooting the length of the series
})
-test('Correct behaviour of a series after several puhed values, function y = 3x + 6, noisefree, 4 datapoints', () => {
- const dataSeries = createOLSLinearSeries(3)
+test('Correct behaviour of a series after several puhed values, function y = 3x - 6, noisefree, 3 datapoints, uniform (halved) weights', () => {
+ const dataSeries = createTSLinearSeries(3)
+ dataSeries.push(5, 9, 0.5)
+ dataSeries.push(3, 3, 0.5)
+ dataSeries.push(4, 6, 0.5)
+ 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)
+ testLocalGoodnessOfFitEquals(dataSeries, 0, 1)
+ testLocalGoodnessOfFitEquals(dataSeries, 1, 1)
+ testLocalGoodnessOfFitEquals(dataSeries, 2, 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)
@@ -126,10 +162,13 @@ test('Correct behaviour of a series after several puhed values, function y = 3x
testSlopeEquals(dataSeries, 3)
testInterceptEquals(dataSeries, -6)
testGoodnessOfFitEquals(dataSeries, 1)
+ testLocalGoodnessOfFitEquals(dataSeries, 0, 1)
+ testLocalGoodnessOfFitEquals(dataSeries, 1, 1)
+ testLocalGoodnessOfFitEquals(dataSeries, 2, 1)
})
-test('Correct behaviour of a series after several puhed values, function y = 3x + 6, noisefree, 5 datapoints', () => {
- const dataSeries = createOLSLinearSeries(3)
+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)
@@ -153,10 +192,57 @@ test('Correct behaviour of a series after several puhed values, function y = 3x
testSlopeEquals(dataSeries, 3)
testInterceptEquals(dataSeries, -6)
testGoodnessOfFitEquals(dataSeries, 1)
+ testLocalGoodnessOfFitEquals(dataSeries, 0, 1)
+ testLocalGoodnessOfFitEquals(dataSeries, 1, 1)
+ testLocalGoodnessOfFitEquals(dataSeries, 2, 1)
+})
+
+test('Series with 5 elements, with 2 noisy datapoints, ideal function y = 3x - 6, uniform weights', () => {
+ const dataSeries = createTSLinearSeries(5)
+ dataSeries.push(5, 9)
+ dataSeries.push(3, 2)
+ dataSeries.push(4, 7)
+ dataSeries.push(6, 12)
+ dataSeries.push(1, -3)
+ testSlopeEquals(dataSeries, 3) // Theoretical noisefree value 3
+ testInterceptEquals(dataSeries, -6) // Theoretical noisefree value -6
+ testGoodnessOfFitEquals(dataSeries, 0.9858356940509915) // Ideal value 1
+ testLocalGoodnessOfFitEquals(dataSeries, 0, 1)
+ testXProjectionEquals(dataSeries, 1, -3) // Theoretical noisefree value -3
+ testLocalGoodnessOfFitEquals(dataSeries, 1, 0.9645892351274787)
+ testXProjectionEquals(dataSeries, 3, 3) // Theoretical noisefree value 3
+ testLocalGoodnessOfFitEquals(dataSeries, 2, 0.9645892351274787)
+ testXProjectionEquals(dataSeries, 4, 6) // Theoretical noisefree value 6
+ testLocalGoodnessOfFitEquals(dataSeries, 3, 1)
+ testXProjectionEquals(dataSeries, 5, 9) // Theoretical noisefree value 9
+ testLocalGoodnessOfFitEquals(dataSeries, 4, 1)
+ testXProjectionEquals(dataSeries, 6, 12) // Theoretical noisefree value 12
})
-test('Correct behaviour of a series after several puhed values, function y = 3x + 6, noisefree, 4 datapoints and a reset', () => {
- const dataSeries = createOLSLinearSeries(3)
+test('Series with 5 elements, with 2 noisy datapoints, ideal function y = 3x - 6, non-uniform weights', () => {
+ const dataSeries = createTSLinearSeries(5)
+ dataSeries.push(5, 9, 1)
+ dataSeries.push(3, 2, 0.5)
+ dataSeries.push(4, 7, 0.5)
+ dataSeries.push(6, 12, 1)
+ dataSeries.push(1, -3, 1)
+ testSlopeEquals(dataSeries, 3) // Theoretical noisefree value 3
+ testInterceptEquals(dataSeries, -6) // Theoretical noisefree value -6
+ testGoodnessOfFitEquals(dataSeries, 0.9925338310779281) // Ideal value 1
+ testLocalGoodnessOfFitEquals(dataSeries, 0, 1)
+ testXProjectionEquals(dataSeries, 1, -3) // Theoretical noisefree value -3
+ testLocalGoodnessOfFitEquals(dataSeries, 1, 0.9813345776948204)
+ testXProjectionEquals(dataSeries, 3, 3) // Theoretical noisefree value 3
+ testLocalGoodnessOfFitEquals(dataSeries, 2, 0.9813345776948204)
+ testXProjectionEquals(dataSeries, 4, 6) // Theoretical noisefree value 6
+ testLocalGoodnessOfFitEquals(dataSeries, 3, 1)
+ testXProjectionEquals(dataSeries, 5, 9) // Theoretical noisefree value 9
+ testLocalGoodnessOfFitEquals(dataSeries, 4, 1)
+ testXProjectionEquals(dataSeries, 6, 12) // Theoretical noisefree value 12
+})
+
+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)
@@ -180,18 +266,7 @@ test('Correct behaviour of a series after several puhed values, function y = 3x
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)
+ testLocalGoodnessOfFitEquals(dataSeries, 0, 0)
})
function testLength (series, expectedValue) {
@@ -199,70 +274,63 @@ function testLength (series, expectedValue) {
}
function testXAtSeriesBegin (series, expectedValue) {
- assert.ok(series.xAtSeriesBegin() === expectedValue, `Expected xAtSeriesBegin to be ${expectedValue}, encountered a ${series.xAtSeriesBegin()}`)
+ assert.ok(series.X.atSeriesBegin() === expectedValue, `Expected X.atSeriesBegin to be ${expectedValue}, encountered a ${series.X.atSeriesBegin()}`)
}
function testYAtSeriesBegin (series, expectedValue) {
- assert.ok(series.yAtSeriesBegin() === expectedValue, `Expected yAtSeriesBegin to be ${expectedValue}, encountered a ${series.yAtSeriesBegin()}`)
+ assert.ok(series.Y.atSeriesBegin() === expectedValue, `Expected Y.atSeriesBegin to be ${expectedValue}, encountered a ${series.Y.atSeriesBegin()}`)
}
function testXAtSeriesEnd (series, expectedValue) {
- assert.ok(series.xAtSeriesEnd() === expectedValue, `Expected xAtSeriesEnd to be ${expectedValue}, encountered a ${series.xAtSeriesEnd()}`)
+ assert.ok(series.X.atSeriesEnd() === expectedValue, `Expected X.atSeriesEnd to be ${expectedValue}, encountered a ${series.X.atSeriesEnd()}`)
}
function testYAtSeriesEnd (series, expectedValue) {
- assert.ok(series.yAtSeriesEnd() === expectedValue, `Expected yAtSeriesEnd to be ${expectedValue}, encountered a ${series.yAtSeriesEnd()}`)
+ 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.numberOfXValuesAbove(cutoff) === expectedValue, `Expected numberOfXValuesAbove(${cutoff}) to be ${expectedValue}, encountered a ${series.numberOfXValuesAbove(cutoff)}`)
+ 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.numberOfYValuesAbove(cutoff) === expectedValue, `Expected numberOfYValuesAbove(${cutoff}) to be ${expectedValue}, encountered a ${series.numberOfYValuesAbove(cutoff)}`)
+ 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.numberOfXValuesEqualOrBelow(cutoff) === expectedValue, `Expected numberOfXValuesEqualOrBelow(${cutoff}) to be ${expectedValue}, encountered a ${series.numberOfXValuesEqualOrBelow(cutoff)}`)
+ 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.numberOfYValuesEqualOrBelow(cutoff) === expectedValue, `Expected numberOfYValuesEqualOrBelow(${cutoff}) to be ${expectedValue}, encountered a ${series.numberOfYValuesEqualOrBelow(cutoff)}`)
+ 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.xSum() === expectedValue, `Expected xSum to be ${expectedValue}, encountered a ${series.xSum()}`)
+ assert.ok(series.X.sum() === expectedValue, `Expected X.sum to be ${expectedValue}, encountered a ${series.X.sum()}`)
}
function testYSum (series, expectedValue) {
- assert.ok(series.ySum() === expectedValue, `Expected ySum to be ${expectedValue}, encountered a ${series.ySum()}`)
+ 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()}`)
+function testLocalGoodnessOfFitEquals (series, position, expectedValue) {
+ assert.ok(series.localGoodnessOfFit(position) === expectedValue, `Expected localGoodnessOfFit at position ${position} to be ${expectedValue}, encountered ${series.localGoodnessOfFit(position)}`)
+}
+
+function testXProjectionEquals (series, value, expectedValue) {
+ assert.ok(series.projectX(value) === expectedValue, `Expected projectX at value ${value} to be ${expectedValue}, encountered ${series.projectX(value)}`)
}
test.run()
diff --git a/app/engine/utils/TSQuadraticSeries.js b/app/engine/utils/TSQuadraticSeries.js
new file mode 100644
index 0000000000..c7d3ca07e0
--- /dev/null
+++ b/app/engine/utils/TSQuadraticSeries.js
@@ -0,0 +1,380 @@
+'use strict'
+/**
+ * @copyright [OpenRowingMonitor]{@link https://github.com/JaapvanEkris/openrowingmonitor}
+ *
+ * @file 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 maximum length is set. After the buffer is filled, the oldest will be pushed
+ * out of the buffer 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 './TSLinearSeries.js'
+import { createLabelledBinarySearchTree } from './BinarySearchTree.js'
+
+import loglevel from 'loglevel'
+const log = loglevel.getLogger('RowingEngine')
+
+/**
+ * @param {integer} maxSeriesLength - the maximum length of the quadratic series, 0 for unlimited
+ */
+export function createTSQuadraticSeries (maxSeriesLength = 0) {
+ const X = createSeries(maxSeriesLength)
+ const Y = createSeries(maxSeriesLength)
+ const weight = createSeries(maxSeriesLength)
+ const WY = createSeries(maxSeriesLength)
+ const A = createLabelledBinarySearchTree()
+ const linearResidu = createTSLinearSeries(maxSeriesLength)
+ let _A = 0
+ let _B = 0
+ let _C = 0
+ let _sst = 0
+ let _goodnessOfFit = 0
+
+ /**
+ * @param {float} x - the x value of the datapoint
+ * @param {float} y - the y value of the datapoint
+ * @param {float} w - the weight of the datapoint (defaults to 1)
+ * Invariant: BinrySearchTree A contains all calculated a's (as in the general formula y = a * x^2 + b * x + c),
+ * where the a's are labeled in the BinarySearchTree with their Xi when they BEGIN in the point (Xi, Yi)
+ */
+ /* eslint-disable max-statements -- A lot of variables have to be set */
+ function push (x, y, w = 1) {
+ 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)
+ weight.push(w)
+ WY.push(w * y)
+ _A = 0
+ _B = 0
+ _C = 0
+ _sst = 0
+ _goodnessOfFit = 0
+
+ if (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
+ let i = 0
+ let j = 0
+
+ // First we calculate the A for the formula
+ let combinedweight = 0
+ let coeffA = 1
+ while (i < X.length() - 2) {
+ j = i + 1
+ while (j < X.length() - 1) {
+ combinedweight = weight.get(i) * weight.get(j) * w
+ coeffA = calculateA(i, j, X.length() - 1)
+ A.push(X.get(i), coeffA, combinedweight)
+ j++
+ }
+ i++
+ }
+ _A = A.weightedMedian()
+
+ // We invalidate the linearResidu, B, C, and goodnessOfFit, as this will trigger a recalculate when they are needed
+ linearResidu.reset()
+ _B = null
+ _C = null
+ _sst = null
+ _goodnessOfFit = null
+ }
+ }
+ /* eslint-enable max-statements */
+
+ /**
+ * @param {integer} position - the position in the flank of the requested value (default = 0)
+ * @returns {float} the firdt derivative of the quadratic function y = a x^2 + b x + c
+ */
+ function firstDerivativeAtPosition (position = 0) {
+ if (X.length() >= 3 && position < X.length()) {
+ calculateB()
+ return ((_A * 2 * X.get(position)) + _B)
+ } else {
+ return 0
+ }
+ }
+
+ /**
+ * @param {integer} position - the position in the flank of the requested value (default = 0)
+ * @returns {float} the second derivative of the quadratic function y = a x^2 + b x + c
+ */
+ function secondDerivativeAtPosition (position = 0) {
+ if (X.length() >= 3 && position < X.length()) {
+ return (_A * 2)
+ } else {
+ return 0
+ }
+ }
+
+ /**
+ * @param {float} x - the x value of the requested value
+ * @returns {float} the slope of the linear function
+ */
+ function slope (x) {
+ if (X.length() >= 3) {
+ calculateB()
+ return ((_A * 2 * x) + _B)
+ } else {
+ return 0
+ }
+ }
+
+ /**
+ * @returns {float} the (quadratic) coefficient a of the quadratic function y = a x^2 + b x + c
+ */
+ function coefficientA () {
+ return _A
+ }
+
+ /**
+ * @returns {float} the (linear) coefficient b of the quadratic function y = a x^2 + b x + c
+ */
+ function coefficientB () {
+ calculateB()
+ return _B
+ }
+
+ /**
+ * @returns {float} the (intercept) coefficient c of the quadratic function y = a x^2 + b x + c
+ */
+ function coefficientC () {
+ calculateB()
+ calculateC()
+ return _C
+ }
+
+ /**
+ * @returns {float} the intercept of the quadratic function
+ */
+ function intercept () {
+ calculateB()
+ calculateC()
+ return _C
+ }
+
+ /**
+ * @returns {integer} the lenght of the stored series
+ */
+ function length () {
+ return X.length()
+ }
+
+ /**
+ * @returns {float} the R^2 as a global goodness of fit indicator
+ */
+ function goodnessOfFit () {
+ let i = 0
+ let sse = 0
+ if (_goodnessOfFit === null) {
+ calculateB()
+ calculateC()
+ if (X.length() >= 3) {
+ _sst = 0
+ const weightedAverageY = WY.sum() / weight.sum()
+
+ while (i < X.length()) {
+ sse += weight.get(i) * Math.pow(Y.get(i) - projectX(X.get(i)), 2)
+ _sst += weight.get(i) * Math.pow(Y.get(i) - weightedAverageY, 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.01
+ break
+ case (_sst !== 0):
+ _goodnessOfFit = 1 - (sse / _sst)
+ break
+ default:
+ // When _SST = 0, R2 isn't defined
+ _goodnessOfFit = 0.01
+ }
+ } else {
+ _goodnessOfFit = 0
+ }
+ }
+ return _goodnessOfFit
+ }
+
+ /**
+ * @returns {float} the local R^2 as a local goodness of fit indicator
+ */
+ function localGoodnessOfFit (position) {
+ if (_sst === null) {
+ // Force the recalculation of the _sst
+ goodnessOfFit()
+ }
+ if (X.length() >= 3 && position < X.length()) {
+ const squaredError = Math.pow((Y.get(position) - projectX(X.get(position))), 2)
+ /* eslint-disable no-unreachable -- rather be systematic and add a break in all case statements */
+ switch (true) {
+ case (squaredError === 0):
+ return 1
+ break
+ case (squaredError > _sst):
+ // This is a pretty bad fit as the error is bigger than just using the line for the average y as intercept
+ return 0.01
+ break
+ case (_sst !== 0):
+ return Math.min(Math.max(1 - ((squaredError * X.length()) / _sst), 0), 1)
+ break
+ default:
+ // When _SST = 0, localGoodnessOfFit isn't defined
+ return 0.01
+ }
+ /* eslint-enable no-unreachable */
+ } else {
+ return 0
+ }
+ }
+
+ /**
+ * @param {float} x - the x value to be projected
+ * @returns {float} the resulting y value when projected via the linear function
+ */
+ function projectX (x) {
+ if (X.length() >= 3) {
+ calculateB()
+ calculateC()
+ return ((_A * x * x) + (_B * x) + _C)
+ } else {
+ return 0
+ }
+ }
+
+ /**
+ * @param {integer} pointOne - The position in the series of the first datapoint used for the quadratic coefficient calculation
+ * @param {integer} pointTwo - The position in the series of the second datapoint used for the quadratic coefficient calculation
+ * @param {integer} pointThree - The position in the series of the third datapoint used for the quadratic coefficient calculation
+ * @returns {float} the coefficient A of the linear function
+ */
+ 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
+ }
+ }
+
+ /**
+ * @description This helper function calculates the slope of the linear residu and stores it in _B
+ */
+ 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
+ }
+ }
+ }
+
+ /**
+ * @description This helper function calculates the intercept of the linear residu and stores it in _C
+ */
+ 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
+ }
+ }
+ }
+
+ /**
+ * @description This helper function fills the linear residu
+ */
+ 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)), weight.get(i))
+ i++
+ }
+ }
+ }
+
+ /**
+ * @returns {boolean} whether the quadratic regression should be considered reliable to produce results
+ */
+ function reliable () {
+ return (X.length() >= 3)
+ }
+
+ /**
+ * @description This function is used for clearing data and state
+ */
+ function reset () {
+ if (X.length() > 0) {
+ // There is something to reset
+ X.reset()
+ Y.reset()
+ weight.reset()
+ WY.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,
+ localGoodnessOfFit,
+ projectX,
+ reliable,
+ reset
+ }
+}
diff --git a/app/engine/utils/TSQuadraticSeries.test.js b/app/engine/utils/TSQuadraticSeries.test.js
new file mode 100644
index 0000000000..5ab31e5724
--- /dev/null
+++ b/app/engine/utils/TSQuadraticSeries.test.js
@@ -0,0 +1,1831 @@
+'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 './TSQuadraticSeries.js'
+
+/**
+ * This series of tests focusses on testing the reliability of the quadratic estimator algorithm
+ */
+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)
+})
+
+/**
+ * This group tests the results of the functions actually used. Please note: these exact same tests are also used in Flywheel.test.js
+ */
+/**
+ * The data of the underlying test is based on y = pow(x, 2) + 4 x, where we have a spacing in y of 1/3th pi (i.e. a 6 magnet flywheel)
+ * and a flankLength of 12 (2 * magnets), as this is what Flywheel.test.js will use. CurrentDt's are mentioned in the comment behind the line
+ * So the first derivative should follow y' = 4x + 4, and the second derivative y'' = 4,
+ */
+test('Quadratic Approximation on a perfect noisefree function y = 2 * Math.pow(x, 2) + 4 * x + 2, 32 datapoints', () => {
+ const dataSeries = createTSQuadraticSeries(12)
+ testLength(dataSeries, 0)
+ testIsReliable(dataSeries, false)
+ testGoodnessOfFitEquals(dataSeries, 0)
+ testLocalGoodnessOfFit (dataSeries, 0, 0)
+ dataSeries.push(0.000000000000000, 0.000000000000000) // Datapoint 0, no currentDt
+ testLength(dataSeries, 1)
+ testIsReliable(dataSeries, false)
+ testGoodnessOfFitEquals(dataSeries, 0)
+ testLocalGoodnessOfFit (dataSeries, 0, 0)
+ dataSeries.push(0.234341433963188, 1.047197551196600) // Datapoint 1, currentDt = 0,234341433963188
+ testLength(dataSeries, 2)
+ testIsReliable(dataSeries, false)
+ testGoodnessOfFitEquals(dataSeries, 0)
+ testLocalGoodnessOfFit(dataSeries, 0, 0)
+ testLocalGoodnessOfFit(dataSeries, 1, 0)
+ dataSeries.push(0.430803114057485, 2.094395102393200) // Datapoint 2, currentDt = 0,196461680094298
+ testLength(dataSeries, 3)
+ testIsReliable(dataSeries, true)
+ testCoefficientA(dataSeries, 2.000000000000049)
+ testCoefficientB(dataSeries, 3.999999999999996)
+ testCoefficientC(dataSeries, 0)
+ testGoodnessOfFitEquals(dataSeries, 1)
+ testFirstDerivativeAtPosition(dataSeries, 0, 3.999999999999996) // datapoint 0
+ testSecondDerivativeAtPosition(dataSeries, 0, 4.000000000000098)
+ testLocalGoodnessOfFit(dataSeries, 0, 1)
+ testFirstDerivativeAtPosition(dataSeries, 1, 4.9373657358527705) // datapoint 1
+ testSecondDerivativeAtPosition(dataSeries, 1, 4.000000000000098)
+ testLocalGoodnessOfFit(dataSeries, 1, 1)
+ testFirstDerivativeAtPosition(dataSeries, 2, 5.723212456229978) // datapoint 2
+ testSecondDerivativeAtPosition(dataSeries, 2, 4.000000000000098)
+ testLocalGoodnessOfFit(dataSeries, 2, 1)
+ dataSeries.push(0.603370302455080, 3.141592653589790) // Datapoint 3, currentDt = 0,172567188397595
+ testLength(dataSeries, 4)
+ testIsReliable(dataSeries, true)
+ testCoefficientA(dataSeries, 1.9999999999999454)
+ testCoefficientB(dataSeries, 4.000000000000037)
+ testCoefficientC(dataSeries, -1.3322676295501878e-15)
+ testGoodnessOfFitEquals(dataSeries, 1)
+ testFirstDerivativeAtPosition(dataSeries, 0, 4.000000000000037) // datapoint 0
+ testSecondDerivativeAtPosition(dataSeries, 0, 3.9999999999998908)
+ testLocalGoodnessOfFit(dataSeries, 0, 1)
+ testFirstDerivativeAtPosition(dataSeries, 1, 4.937365735852763) // datapoint 1
+ testSecondDerivativeAtPosition(dataSeries, 1, 3.9999999999998908)
+ testLocalGoodnessOfFit(dataSeries, 1, 1)
+ testFirstDerivativeAtPosition(dataSeries, 2, 5.7232124562299305) // datapoint 2
+ testSecondDerivativeAtPosition(dataSeries, 2, 3.9999999999998908)
+ testLocalGoodnessOfFit(dataSeries, 2, 1)
+ testFirstDerivativeAtPosition(dataSeries, 3, 6.413481209820292) // datapoint 3
+ testSecondDerivativeAtPosition(dataSeries, 3, 3.9999999999998908)
+ testLocalGoodnessOfFit(dataSeries, 3, 1)
+ dataSeries.push(0.759089282098323, 4.188790204786390) // Datapoint 4, currentDt = 0,155718979643243
+ testLength(dataSeries, 5)
+ testIsReliable(dataSeries, true)
+ testCoefficientA(dataSeries, 1.9999999999999871)
+ testCoefficientB(dataSeries, 4.0000000000000115)
+ testCoefficientC(dataSeries, 0)
+ testGoodnessOfFitEquals(dataSeries, 1)
+ testFirstDerivativeAtPosition(dataSeries, 0, 4.0000000000000115) // datapoint 0
+ testSecondDerivativeAtPosition(dataSeries, 0, 3.9999999999999742)
+ testLocalGoodnessOfFit(dataSeries, 0, 1)
+ testFirstDerivativeAtPosition(dataSeries, 1, 4.937365735852757) // datapoint 1
+ testSecondDerivativeAtPosition(dataSeries, 1, 3.9999999999999742)
+ testLocalGoodnessOfFit(dataSeries, 1, 1)
+ testFirstDerivativeAtPosition(dataSeries, 2, 5.72321245622994) // datapoint 2
+ testSecondDerivativeAtPosition(dataSeries, 2, 3.9999999999999742)
+ testLocalGoodnessOfFit(dataSeries, 2, 1)
+ testFirstDerivativeAtPosition(dataSeries, 3, 6.413481209820317) // datapoint 3
+ testSecondDerivativeAtPosition(dataSeries, 3, 3.9999999999999742)
+ testLocalGoodnessOfFit(dataSeries, 3, 1)
+ testFirstDerivativeAtPosition(dataSeries, 4, 7.036357128393284) // datapoint 4
+ testSecondDerivativeAtPosition(dataSeries, 4, 3.9999999999999742)
+ testLocalGoodnessOfFit(dataSeries, 4, 1)
+ dataSeries.push(0.902102488824273, 5.235987755982990) // Datapoint 5, currentDt = 0,143013206725950
+ testLength(dataSeries, 6)
+ testIsReliable(dataSeries, true)
+ testCoefficientA(dataSeries, 2)
+ testCoefficientB(dataSeries, 4.000000000000002)
+ testCoefficientC(dataSeries, 1.0547118733938987e-15)
+ testGoodnessOfFitEquals(dataSeries, 1)
+ testFirstDerivativeAtPosition(dataSeries, 0, 4.000000000000002) // datapoint 0
+ testSecondDerivativeAtPosition(dataSeries, 0, 4)
+ testLocalGoodnessOfFit(dataSeries, 0, 1)
+ testFirstDerivativeAtPosition(dataSeries, 1, 4.937365735852754) // datapoint 1
+ testSecondDerivativeAtPosition(dataSeries, 1, 4)
+ testLocalGoodnessOfFit(dataSeries, 1, 1)
+ testFirstDerivativeAtPosition(dataSeries, 2, 5.723212456229942) // datapoint 2
+ testSecondDerivativeAtPosition(dataSeries, 2, 4)
+ testLocalGoodnessOfFit(dataSeries, 2, 1)
+ testFirstDerivativeAtPosition(dataSeries, 3, 6.413481209820322) // datapoint 3
+ testSecondDerivativeAtPosition(dataSeries, 3, 4)
+ testLocalGoodnessOfFit(dataSeries, 3, 1)
+ testFirstDerivativeAtPosition(dataSeries, 4, 7.036357128393294) // datapoint 4
+ testSecondDerivativeAtPosition(dataSeries, 4, 4)
+ testLocalGoodnessOfFit(dataSeries, 4, 1)
+ testFirstDerivativeAtPosition(dataSeries, 5, 7.608409955297094) // datapoint 5
+ testSecondDerivativeAtPosition(dataSeries, 5, 4)
+ testLocalGoodnessOfFit(dataSeries, 5, 1)
+ dataSeries.push(1.035090330572530, 6.283185307179590) // Datapoint 6, currentDt = 0,132987841748253
+ testLength(dataSeries, 7)
+ testIsReliable(dataSeries, true)
+ testCoefficientA(dataSeries, 1.9999999999999525)
+ testCoefficientB(dataSeries, 4.000000000000037)
+ testCoefficientC(dataSeries, 0)
+ testGoodnessOfFitEquals(dataSeries, 1)
+ testFirstDerivativeAtPosition(dataSeries, 0, 4.000000000000037) // datapoint 0
+ testSecondDerivativeAtPosition(dataSeries, 0, 3.999999999999905)
+ testLocalGoodnessOfFit(dataSeries, 0, 1)
+ testFirstDerivativeAtPosition(dataSeries, 1, 4.937365735852767) // datapoint 1
+ testSecondDerivativeAtPosition(dataSeries, 1, 3.999999999999905)
+ testLocalGoodnessOfFit(dataSeries, 1, 1)
+ testFirstDerivativeAtPosition(dataSeries, 2, 5.723212456229937) // datapoint 2
+ testSecondDerivativeAtPosition(dataSeries, 2, 3.999999999999905)
+ testLocalGoodnessOfFit(dataSeries, 2, 1)
+ testFirstDerivativeAtPosition(dataSeries, 3, 6.413481209820301) // datapoint 3
+ testSecondDerivativeAtPosition(dataSeries, 3, 3.999999999999905)
+ testLocalGoodnessOfFit(dataSeries, 3, 1)
+ testFirstDerivativeAtPosition(dataSeries, 4, 7.0363571283932576) // datapoint 4
+ testSecondDerivativeAtPosition(dataSeries, 4, 3.999999999999905)
+ testLocalGoodnessOfFit(dataSeries, 4, 1)
+ testFirstDerivativeAtPosition(dataSeries, 5, 7.608409955297043) // datapoint 5
+ testSecondDerivativeAtPosition(dataSeries, 5, 3.999999999999905)
+ testLocalGoodnessOfFit(dataSeries, 5, 1)
+ testFirstDerivativeAtPosition(dataSeries, 6, 8.140361322290058) // datapoint 6
+ testSecondDerivativeAtPosition(dataSeries, 6, 3.999999999999905)
+ testLocalGoodnessOfFit(dataSeries, 6, 1)
+ dataSeries.push(1.159905421352540, 7.330382858376180) // Datapoint 7, currentDt = 0,124815090780014
+ testLength(dataSeries, 8)
+ testIsReliable(dataSeries, true)
+ testCoefficientA(dataSeries, 1.9999999999999734)
+ testCoefficientB(dataSeries, 4.000000000000025)
+ testCoefficientC(dataSeries, -6.661338147750939e-16)
+ testGoodnessOfFitEquals(dataSeries, 1)
+ testFirstDerivativeAtPosition(dataSeries, 0, 4.000000000000025) // datapoint 0
+ testSecondDerivativeAtPosition(dataSeries, 0, 3.9999999999999467)
+ testLocalGoodnessOfFit(dataSeries, 0, 1)
+ testFirstDerivativeAtPosition(dataSeries, 1, 4.937365735852764) // datapoint 1
+ testSecondDerivativeAtPosition(dataSeries, 1, 3.9999999999999467)
+ testLocalGoodnessOfFit(dataSeries, 1, 1)
+ testFirstDerivativeAtPosition(dataSeries, 2, 5.723212456229942) // datapoint 2
+ testSecondDerivativeAtPosition(dataSeries, 2, 3.9999999999999467)
+ testLocalGoodnessOfFit(dataSeries, 2, 1)
+ testFirstDerivativeAtPosition(dataSeries, 3, 6.413481209820313) // datapoint 3
+ testSecondDerivativeAtPosition(dataSeries, 3, 3.9999999999999467)
+ testLocalGoodnessOfFit(dataSeries, 3, 1)
+ testFirstDerivativeAtPosition(dataSeries, 4, 7.036357128393277) // datapoint 4
+ testSecondDerivativeAtPosition(dataSeries, 4, 3.9999999999999467)
+ testLocalGoodnessOfFit(dataSeries, 4, 1)
+ testFirstDerivativeAtPosition(dataSeries, 5, 7.608409955297069) // datapoint 5
+ testSecondDerivativeAtPosition(dataSeries, 5, 3.9999999999999467)
+ testLocalGoodnessOfFit(dataSeries, 5, 1)
+ testFirstDerivativeAtPosition(dataSeries, 6, 8.14036132229009) // datapoint 6
+ testSecondDerivativeAtPosition(dataSeries, 6, 3.9999999999999467)
+ testLocalGoodnessOfFit(dataSeries, 6, 1)
+ testFirstDerivativeAtPosition(dataSeries, 7, 8.639621685410123) // datapoint 7
+ testSecondDerivativeAtPosition(dataSeries, 7, 3.9999999999999467)
+ testLocalGoodnessOfFit(dataSeries, 7, 1)
+ dataSeries.push(1.27789161392424, 8.37758040957278) // Datapoint 8, currentDt = 0,117986192571703
+ testLength(dataSeries, 9)
+ testIsReliable(dataSeries, true)
+ testCoefficientA(dataSeries, 2.000000000000002)
+ testCoefficientB(dataSeries, 3.999999999999999)
+ testCoefficientC(dataSeries, 1.7763568394002505e-15)
+ testGoodnessOfFitEquals(dataSeries, 1)
+ testFirstDerivativeAtPosition(dataSeries, 0, 3.999999999999999) // datapoint 0
+ testSecondDerivativeAtPosition(dataSeries, 0, 4.000000000000004)
+ testLocalGoodnessOfFit(dataSeries, 0, 1)
+ testFirstDerivativeAtPosition(dataSeries, 1, 4.937365735852752) // datapoint 1
+ testSecondDerivativeAtPosition(dataSeries, 1, 4.000000000000004)
+ testLocalGoodnessOfFit(dataSeries, 1, 1)
+ testFirstDerivativeAtPosition(dataSeries, 2, 5.723212456229941) // datapoint 2
+ testSecondDerivativeAtPosition(dataSeries, 2, 4.000000000000004)
+ testLocalGoodnessOfFit(dataSeries, 2, 1)
+ testFirstDerivativeAtPosition(dataSeries, 3, 6.413481209820322) // datapoint 3
+ testSecondDerivativeAtPosition(dataSeries, 3, 4.000000000000004)
+ testLocalGoodnessOfFit(dataSeries, 3, 1)
+ testFirstDerivativeAtPosition(dataSeries, 4, 7.036357128393295) // datapoint 4
+ testSecondDerivativeAtPosition(dataSeries, 4, 4.000000000000004)
+ testLocalGoodnessOfFit(dataSeries, 4, 1)
+ testFirstDerivativeAtPosition(dataSeries, 5, 7.608409955297095) // datapoint 5
+ testSecondDerivativeAtPosition(dataSeries, 5, 4.000000000000004)
+ testLocalGoodnessOfFit(dataSeries, 5, 1)
+ testFirstDerivativeAtPosition(dataSeries, 6, 8.140361322290124) // datapoint 6
+ testSecondDerivativeAtPosition(dataSeries, 6, 4.000000000000004)
+ testLocalGoodnessOfFit(dataSeries, 6, 1)
+ testFirstDerivativeAtPosition(dataSeries, 7, 8.639621685410164) // datapoint 7
+ testSecondDerivativeAtPosition(dataSeries, 7, 4.000000000000004)
+ testLocalGoodnessOfFit(dataSeries, 7, 1)
+ testFirstDerivativeAtPosition(dataSeries, 8, 9.111566455696964) // datapoint 8
+ testSecondDerivativeAtPosition(dataSeries, 8, 4.000000000000004)
+ testLocalGoodnessOfFit(dataSeries, 8, 1)
+ dataSeries.push(1.39006045538281, 9.42477796076938) // Datapoint 9, currentDt = 0,112168841458569
+ testLength(dataSeries, 10)
+ testIsReliable(dataSeries, true)
+ testCoefficientA(dataSeries, 2.000000000000008)
+ testCoefficientB(dataSeries, 3.999999999999994)
+ testCoefficientC(dataSeries, 2.220446049250313e-15)
+ testGoodnessOfFitEquals(dataSeries, 1)
+ testFirstDerivativeAtPosition(dataSeries, 0, 3.999999999999994) // datapoint 0
+ testSecondDerivativeAtPosition(dataSeries, 0, 4.000000000000016)
+ testLocalGoodnessOfFit(dataSeries, 0, 1)
+ testFirstDerivativeAtPosition(dataSeries, 1, 4.937365735852749) // datapoint 1
+ testSecondDerivativeAtPosition(dataSeries, 1, 4.000000000000016)
+ testLocalGoodnessOfFit(dataSeries, 1, 1)
+ testFirstDerivativeAtPosition(dataSeries, 2, 5.723212456229941) // datapoint 2
+ testSecondDerivativeAtPosition(dataSeries, 2, 4.000000000000016)
+ testLocalGoodnessOfFit(dataSeries, 2, 1)
+ testFirstDerivativeAtPosition(dataSeries, 3, 6.413481209820324) // datapoint 3
+ testSecondDerivativeAtPosition(dataSeries, 3, 4.000000000000016)
+ testLocalGoodnessOfFit(dataSeries, 3, 1)
+ testFirstDerivativeAtPosition(dataSeries, 4, 7.036357128393298) // datapoint 4
+ testSecondDerivativeAtPosition(dataSeries, 4, 4.000000000000016)
+ testLocalGoodnessOfFit(dataSeries, 4, 1)
+ testFirstDerivativeAtPosition(dataSeries, 5, 7.6084099552971) // datapoint 5
+ testSecondDerivativeAtPosition(dataSeries, 5, 4.000000000000016)
+ testLocalGoodnessOfFit(dataSeries, 5, 1)
+ testFirstDerivativeAtPosition(dataSeries, 6, 8.140361322290131) // datapoint 6
+ testSecondDerivativeAtPosition(dataSeries, 6, 4.000000000000016)
+ testLocalGoodnessOfFit(dataSeries, 6, 1)
+ testFirstDerivativeAtPosition(dataSeries, 7, 8.639621685410173) // datapoint 7
+ testSecondDerivativeAtPosition(dataSeries, 7, 4.000000000000016)
+ testLocalGoodnessOfFit(dataSeries, 7, 1)
+ testFirstDerivativeAtPosition(dataSeries, 8, 9.111566455696973) // datapoint 8
+ testSecondDerivativeAtPosition(dataSeries, 8, 4.000000000000016)
+ testLocalGoodnessOfFit(dataSeries, 8, 1)
+ testFirstDerivativeAtPosition(dataSeries, 9, 9.560241821531257) // datapoint 9
+ testSecondDerivativeAtPosition(dataSeries, 9, 4.000000000000016)
+ testLocalGoodnessOfFit(dataSeries, 9, 1)
+ dataSeries.push(1.4971959786895, 10.471975511966) // Datapoint 10, currentDt = 0,107135523306685
+ testLength(dataSeries, 11)
+ testIsReliable(dataSeries, true)
+ testCoefficientA(dataSeries, 1.9999999999999885)
+ testCoefficientB(dataSeries, 4.00000000000001)
+ testCoefficientC(dataSeries, 0)
+ testGoodnessOfFitEquals(dataSeries, 1)
+ testFirstDerivativeAtPosition(dataSeries, 0, 4.00000000000001) // datapoint 0
+ testSecondDerivativeAtPosition(dataSeries, 0, 3.999999999999977)
+ testLocalGoodnessOfFit(dataSeries, 0, 1)
+ testFirstDerivativeAtPosition(dataSeries, 1, 4.937365735852756) // datapoint 1
+ testSecondDerivativeAtPosition(dataSeries, 1, 3.999999999999977)
+ testLocalGoodnessOfFit(dataSeries, 1, 1)
+ testFirstDerivativeAtPosition(dataSeries, 2, 5.723212456229939) // datapoint 2
+ testSecondDerivativeAtPosition(dataSeries, 2, 3.999999999999977)
+ testLocalGoodnessOfFit(dataSeries, 2, 1)
+ testFirstDerivativeAtPosition(dataSeries, 3, 6.413481209820317) // datapoint 3
+ testSecondDerivativeAtPosition(dataSeries, 3, 3.999999999999977)
+ testLocalGoodnessOfFit(dataSeries, 3, 1)
+ testFirstDerivativeAtPosition(dataSeries, 4, 7.036357128393284) // datapoint 4
+ testSecondDerivativeAtPosition(dataSeries, 4, 3.999999999999977)
+ testLocalGoodnessOfFit(dataSeries, 4, 1)
+ testFirstDerivativeAtPosition(dataSeries, 5, 7.6084099552970805) // datapoint 5
+ testSecondDerivativeAtPosition(dataSeries, 5, 3.999999999999977)
+ testLocalGoodnessOfFit(dataSeries, 5, 1)
+ testFirstDerivativeAtPosition(dataSeries, 6, 8.140361322290106) // datapoint 6
+ testSecondDerivativeAtPosition(dataSeries, 6, 3.999999999999977)
+ testLocalGoodnessOfFit(dataSeries, 6, 1)
+ testFirstDerivativeAtPosition(dataSeries, 7, 8.639621685410143) // datapoint 7
+ testSecondDerivativeAtPosition(dataSeries, 7, 3.999999999999977)
+ testLocalGoodnessOfFit(dataSeries, 7, 1)
+ testFirstDerivativeAtPosition(dataSeries, 8, 9.111566455696941) // datapoint 8
+ testSecondDerivativeAtPosition(dataSeries, 8, 3.999999999999977)
+ testLocalGoodnessOfFit(dataSeries, 8, 1)
+ testFirstDerivativeAtPosition(dataSeries, 9, 9.560241821531218) // datapoint 9
+ testSecondDerivativeAtPosition(dataSeries, 9, 3.999999999999977)
+ testLocalGoodnessOfFit(dataSeries, 9, 1)
+ testFirstDerivativeAtPosition(dataSeries, 10, 9.988783914757974) // datapoint 10
+ testSecondDerivativeAtPosition(dataSeries, 10, 3.999999999999977)
+ testLocalGoodnessOfFit(dataSeries, 10, 1)
+ dataSeries.push(1.59992048562668, 11.5191730631626) // Datapoint 11, currentDt = 0,102724506937187
+ testLength(dataSeries, 12)
+ testIsReliable(dataSeries, true)
+ testCoefficientA(dataSeries, 2.000000000000018)
+ testCoefficientB(dataSeries, 3.9999999999999805)
+ testCoefficientC(dataSeries, 5.329070518200751e-15)
+ testGoodnessOfFitEquals(dataSeries, 1)
+ testFirstDerivativeAtPosition(dataSeries, 0, 3.9999999999999805) // datapoint 0
+ testSecondDerivativeAtPosition(dataSeries, 0, 4.000000000000036)
+ testLocalGoodnessOfFit(dataSeries, 0, 1)
+ testFirstDerivativeAtPosition(dataSeries, 1, 4.937365735852741) // datapoint 1
+ testSecondDerivativeAtPosition(dataSeries, 1, 4.000000000000036)
+ testLocalGoodnessOfFit(dataSeries, 1, 1)
+ testFirstDerivativeAtPosition(dataSeries, 2, 5.723212456229936) // datapoint 2
+ testSecondDerivativeAtPosition(dataSeries, 2, 4.000000000000036)
+ testLocalGoodnessOfFit(dataSeries, 2, 1)
+ testFirstDerivativeAtPosition(dataSeries, 3, 6.413481209820322) // datapoint 3
+ testSecondDerivativeAtPosition(dataSeries, 3, 4.000000000000036)
+ testLocalGoodnessOfFit(dataSeries, 3, 1)
+ testFirstDerivativeAtPosition(dataSeries, 4, 7.0363571283933) // datapoint 4
+ testSecondDerivativeAtPosition(dataSeries, 4, 4.000000000000036)
+ testLocalGoodnessOfFit(dataSeries, 4, 1)
+ testFirstDerivativeAtPosition(dataSeries, 5, 7.608409955297105) // datapoint 5
+ testSecondDerivativeAtPosition(dataSeries, 5, 4.000000000000036)
+ testLocalGoodnessOfFit(dataSeries, 5, 1)
+ testFirstDerivativeAtPosition(dataSeries, 6, 8.140361322290138) // datapoint 6
+ testSecondDerivativeAtPosition(dataSeries, 6, 4.000000000000036)
+ testLocalGoodnessOfFit(dataSeries, 6, 1)
+ testFirstDerivativeAtPosition(dataSeries, 7, 8.639621685410184) // datapoint 7
+ testSecondDerivativeAtPosition(dataSeries, 7, 4.000000000000036)
+ testLocalGoodnessOfFit(dataSeries, 7, 1)
+ testFirstDerivativeAtPosition(dataSeries, 8, 9.111566455696988) // datapoint 8
+ testSecondDerivativeAtPosition(dataSeries, 8, 4.000000000000036)
+ testLocalGoodnessOfFit(dataSeries, 8, 1)
+ testFirstDerivativeAtPosition(dataSeries, 9, 9.560241821531271) // datapoint 9
+ testSecondDerivativeAtPosition(dataSeries, 9, 4.000000000000036)
+ testLocalGoodnessOfFit(dataSeries, 9, 1)
+ testFirstDerivativeAtPosition(dataSeries, 10, 9.988783914758034) // datapoint 10
+ testSecondDerivativeAtPosition(dataSeries, 10, 4.000000000000036)
+ testLocalGoodnessOfFit(dataSeries, 10, 1)
+ testFirstDerivativeAtPosition(dataSeries, 11, 10.39968194250676) // datapoint 11
+ testSecondDerivativeAtPosition(dataSeries, 11, 4.000000000000036)
+ testLocalGoodnessOfFit(dataSeries, 11, 1)
+ dataSeries.push(1.69873772478535, 12.5663706143592) // Datapoint 12, currentDt = 0,098817239158663
+ testLength(dataSeries, 12)
+ testIsReliable(dataSeries, true)
+ testCoefficientA(dataSeries, 2.0000000000000124)
+ testCoefficientB(dataSeries, 3.999999999999981)
+ testCoefficientC(dataSeries, 8.215650382226158e-15)
+ testGoodnessOfFitEquals(dataSeries, 1)
+ testFirstDerivativeAtPosition(dataSeries, 0, 4.9373657358527385) // datapoint 1, the series had shifted and pushed out datapoint 0
+ testSecondDerivativeAtPosition(dataSeries, 0, 4.000000000000025)
+ testLocalGoodnessOfFit(dataSeries, 0, 1)
+ testFirstDerivativeAtPosition(dataSeries, 1, 5.723212456229931) // datapoint 2
+ testSecondDerivativeAtPosition(dataSeries, 1, 4.000000000000025)
+ testLocalGoodnessOfFit(dataSeries, 1, 1)
+ testFirstDerivativeAtPosition(dataSeries, 2, 6.413481209820317) // datapoint 3
+ testSecondDerivativeAtPosition(dataSeries, 2, 4.000000000000025)
+ testLocalGoodnessOfFit(dataSeries, 2, 1)
+ testFirstDerivativeAtPosition(dataSeries, 3, 7.036357128393292) // datapoint 4
+ testSecondDerivativeAtPosition(dataSeries, 3, 4.000000000000025)
+ testLocalGoodnessOfFit(dataSeries, 3, 1)
+ testFirstDerivativeAtPosition(dataSeries, 4, 7.608409955297096) // datapoint 5
+ testSecondDerivativeAtPosition(dataSeries, 4, 4.000000000000025)
+ testLocalGoodnessOfFit(dataSeries, 4, 1)
+ testFirstDerivativeAtPosition(dataSeries, 5, 8.140361322290127) // datapoint 6
+ testSecondDerivativeAtPosition(dataSeries, 5, 4.000000000000025)
+ testLocalGoodnessOfFit(dataSeries, 5, 1)
+ testFirstDerivativeAtPosition(dataSeries, 6, 8.63962168541017) // datapoint 7
+ testSecondDerivativeAtPosition(dataSeries, 6, 4.000000000000025)
+ testLocalGoodnessOfFit(dataSeries, 6, 1)
+ testFirstDerivativeAtPosition(dataSeries, 7, 9.111566455696973) // datapoint 8
+ testSecondDerivativeAtPosition(dataSeries, 7, 4.000000000000025)
+ testLocalGoodnessOfFit(dataSeries, 7, 1)
+ testFirstDerivativeAtPosition(dataSeries, 8, 9.560241821531255) // datapoint 9
+ testSecondDerivativeAtPosition(dataSeries, 8, 4.000000000000025)
+ testLocalGoodnessOfFit(dataSeries, 8, 1)
+ testFirstDerivativeAtPosition(dataSeries, 9, 9.988783914758018) // datapoint 10
+ testSecondDerivativeAtPosition(dataSeries, 9, 4.000000000000025)
+ testLocalGoodnessOfFit(dataSeries, 9, 1)
+ testFirstDerivativeAtPosition(dataSeries, 10, 10.39968194250674) // datapoint 11
+ testSecondDerivativeAtPosition(dataSeries, 10, 4.000000000000025)
+ testLocalGoodnessOfFit(dataSeries, 10, 1)
+ testFirstDerivativeAtPosition(dataSeries, 11, 10.794950899141423) // datapoint 12
+ testSecondDerivativeAtPosition(dataSeries, 11, 4.000000000000025)
+ testLocalGoodnessOfFit(dataSeries, 11, 1)
+ dataSeries.push(1.79406229042552, 13.6135681655558) // Datapoint 13, currentDt = 0,095324565640171
+ testLength(dataSeries, 12)
+ testIsReliable(dataSeries, true)
+ testCoefficientA(dataSeries, 2.0000000000000204)
+ testCoefficientB(dataSeries, 3.9999999999999565)
+ testCoefficientC(dataSeries, 2.0650148258027912e-14)
+ testGoodnessOfFitEquals(dataSeries, 1)
+ testFirstDerivativeAtPosition(dataSeries, 0, 5.7232124562299145) // datapoint 2, as datapoint 1 was pushed out
+ testSecondDerivativeAtPosition(dataSeries, 0, 4.000000000000041)
+ testLocalGoodnessOfFit(dataSeries, 0, 1)
+ testFirstDerivativeAtPosition(dataSeries, 1, 6.413481209820302) // datapoint 3
+ testSecondDerivativeAtPosition(dataSeries, 1, 4.000000000000041)
+ testLocalGoodnessOfFit(dataSeries, 1, 1)
+ testFirstDerivativeAtPosition(dataSeries, 2, 7.03635712839328) // datapoint 4
+ testSecondDerivativeAtPosition(dataSeries, 2, 4.000000000000041)
+ testLocalGoodnessOfFit(dataSeries, 2, 1)
+ testFirstDerivativeAtPosition(dataSeries, 3, 7.608409955297086) // datapoint 5
+ testSecondDerivativeAtPosition(dataSeries, 3, 4.000000000000041)
+ testLocalGoodnessOfFit(dataSeries, 3, 1)
+ testFirstDerivativeAtPosition(dataSeries, 4, 8.140361322290119) // datapoint 6
+ testSecondDerivativeAtPosition(dataSeries, 4, 4.000000000000041)
+ testLocalGoodnessOfFit(dataSeries, 4, 1)
+ testFirstDerivativeAtPosition(dataSeries, 5, 8.639621685410162) // datapoint 7
+ testSecondDerivativeAtPosition(dataSeries, 5, 4.000000000000041)
+ testLocalGoodnessOfFit(dataSeries, 5, 1)
+ testFirstDerivativeAtPosition(dataSeries, 6, 9.11156645569697) // datapoint 8
+ testSecondDerivativeAtPosition(dataSeries, 6, 4.000000000000041)
+ testLocalGoodnessOfFit(dataSeries, 6, 1)
+ testFirstDerivativeAtPosition(dataSeries, 7, 9.560241821531253) // datapoint 9
+ testSecondDerivativeAtPosition(dataSeries, 7, 4.000000000000041)
+ testLocalGoodnessOfFit(dataSeries, 7, 1)
+ testFirstDerivativeAtPosition(dataSeries, 8, 9.988783914758017) // datapoint 10
+ testSecondDerivativeAtPosition(dataSeries, 8, 4.000000000000041)
+ testLocalGoodnessOfFit(dataSeries, 8, 1)
+ testFirstDerivativeAtPosition(dataSeries, 9, 10.399681942506742) // datapoint 11
+ testSecondDerivativeAtPosition(dataSeries, 9, 4.000000000000041)
+ testLocalGoodnessOfFit(dataSeries, 9, 1)
+ testFirstDerivativeAtPosition(dataSeries, 10, 10.794950899141426) // datapoint 12
+ testSecondDerivativeAtPosition(dataSeries, 10, 4.000000000000041)
+ testLocalGoodnessOfFit(dataSeries, 10, 1)
+ testFirstDerivativeAtPosition(dataSeries, 11, 11.17624916170211) // datapoint 13
+ testSecondDerivativeAtPosition(dataSeries, 11, 4.000000000000041)
+ testLocalGoodnessOfFit(dataSeries, 11, 1)
+ dataSeries.push(1.88624026345282, 14.6607657167524) // Datapoint 14, currentDt = 0,092177973027300
+ testLength(dataSeries, 12)
+ testIsReliable(dataSeries, true)
+ testCoefficientA(dataSeries, 1.999999999999996)
+ testCoefficientB(dataSeries, 4.000000000000007)
+ testCoefficientC(dataSeries, -3.9968028886505635e-15)
+ testGoodnessOfFitEquals(dataSeries, 1)
+ testFirstDerivativeAtPosition(dataSeries, 0, 6.413481209820322) // datapoint 3, as datapoint 2 was pushed out
+ testSecondDerivativeAtPosition(dataSeries, 0, 3.999999999999992)
+ testLocalGoodnessOfFit(dataSeries, 0, 1)
+ testFirstDerivativeAtPosition(dataSeries, 1, 7.036357128393293) // datapoint 4
+ testSecondDerivativeAtPosition(dataSeries, 1, 3.999999999999992)
+ testLocalGoodnessOfFit(dataSeries, 1, 1)
+ testFirstDerivativeAtPosition(dataSeries, 2, 7.608409955297092) // datapoint 5
+ testSecondDerivativeAtPosition(dataSeries, 2, 3.999999999999992)
+ testLocalGoodnessOfFit(dataSeries, 2, 1)
+ testFirstDerivativeAtPosition(dataSeries, 3, 8.140361322290119) // datapoint 6
+ testSecondDerivativeAtPosition(dataSeries, 3, 3.999999999999992)
+ testLocalGoodnessOfFit(dataSeries, 3, 1)
+ testFirstDerivativeAtPosition(dataSeries, 4, 8.639621685410159) // datapoint 7
+ testSecondDerivativeAtPosition(dataSeries, 4, 3.999999999999992)
+ testLocalGoodnessOfFit(dataSeries, 4, 1)
+ testFirstDerivativeAtPosition(dataSeries, 5, 9.111566455696956) // datapoint 8
+ testSecondDerivativeAtPosition(dataSeries, 5, 3.999999999999992)
+ testLocalGoodnessOfFit(dataSeries, 5, 1)
+ testFirstDerivativeAtPosition(dataSeries, 6, 9.560241821531235) // datapoint 9
+ testSecondDerivativeAtPosition(dataSeries, 6, 3.999999999999992)
+ testLocalGoodnessOfFit(dataSeries, 6, 1)
+ testFirstDerivativeAtPosition(dataSeries, 7, 9.988783914757995) // datapoint 10
+ testSecondDerivativeAtPosition(dataSeries, 7, 3.999999999999992)
+ testLocalGoodnessOfFit(dataSeries, 7, 1)
+ testFirstDerivativeAtPosition(dataSeries, 8, 10.399681942506714) // datapoint 11
+ testSecondDerivativeAtPosition(dataSeries, 8, 3.999999999999992)
+ testLocalGoodnessOfFit(dataSeries, 8, 1)
+ testFirstDerivativeAtPosition(dataSeries, 9, 10.794950899141394) // datapoint 12
+ testSecondDerivativeAtPosition(dataSeries, 9, 3.999999999999992)
+ testLocalGoodnessOfFit(dataSeries, 9, 1)
+ testFirstDerivativeAtPosition(dataSeries, 10, 11.176249161702072) // datapoint 13
+ testSecondDerivativeAtPosition(dataSeries, 10, 3.999999999999992)
+ testLocalGoodnessOfFit(dataSeries, 10, 1)
+ testFirstDerivativeAtPosition(dataSeries, 11, 11.54496105381127) // datapoint 14
+ testSecondDerivativeAtPosition(dataSeries, 11, 3.999999999999992)
+ testLocalGoodnessOfFit(dataSeries, 11, 1)
+ dataSeries.push(1.97556408668583, 15.707963267949) // Datapoint 15, currentDt = 0,089323823233014
+ testLength(dataSeries, 12)
+ testIsReliable(dataSeries, true)
+ testCoefficientA(dataSeries, 2.000000000000044)
+ testCoefficientB(dataSeries, 3.999999999999893)
+ testCoefficientC(dataSeries, 5.88418203051333e-14)
+ testGoodnessOfFitEquals(dataSeries, 1)
+ testFirstDerivativeAtPosition(dataSeries, 0, 7.036357128393252) // datapoint 4, as datapoint 3 was pushed out
+ testSecondDerivativeAtPosition(dataSeries, 0, 4.000000000000088)
+ testLocalGoodnessOfFit(dataSeries, 0, 1)
+ testFirstDerivativeAtPosition(dataSeries, 1, 7.6084099552970645) // datapoint 5
+ testSecondDerivativeAtPosition(dataSeries, 1, 4.000000000000088)
+ testLocalGoodnessOfFit(dataSeries, 1, 1)
+ testFirstDerivativeAtPosition(dataSeries, 2, 8.140361322290104) // datapoint 6
+ testSecondDerivativeAtPosition(dataSeries, 2, 4.000000000000088)
+ testLocalGoodnessOfFit(dataSeries, 2, 1)
+ testFirstDerivativeAtPosition(dataSeries, 3, 8.639621685410155) // datapoint 7
+ testSecondDerivativeAtPosition(dataSeries, 3, 4.000000000000088)
+ testLocalGoodnessOfFit(dataSeries, 3, 1)
+ testFirstDerivativeAtPosition(dataSeries, 4, 9.111566455696966) // datapoint 8
+ testSecondDerivativeAtPosition(dataSeries, 4, 4.000000000000088)
+ testLocalGoodnessOfFit(dataSeries, 4, 1)
+ testFirstDerivativeAtPosition(dataSeries, 5, 9.560241821531255) // datapoint 9
+ testSecondDerivativeAtPosition(dataSeries, 5, 4.000000000000088)
+ testLocalGoodnessOfFit(dataSeries, 5, 1)
+ testFirstDerivativeAtPosition(dataSeries, 6, 9.988783914758024) // datapoint 10
+ testSecondDerivativeAtPosition(dataSeries, 6, 4.000000000000088)
+ testLocalGoodnessOfFit(dataSeries, 6, 1)
+ testFirstDerivativeAtPosition(dataSeries, 7, 10.399681942506753) // datapoint 11
+ testSecondDerivativeAtPosition(dataSeries, 7, 4.000000000000088)
+ testLocalGoodnessOfFit(dataSeries, 7, 1)
+ testFirstDerivativeAtPosition(dataSeries, 8, 10.794950899141442) // datapoint 12
+ testSecondDerivativeAtPosition(dataSeries, 8, 4.000000000000088)
+ testLocalGoodnessOfFit(dataSeries, 8, 1)
+ testFirstDerivativeAtPosition(dataSeries, 9, 11.176249161702131) // datapoint 13
+ testSecondDerivativeAtPosition(dataSeries, 9, 4.000000000000088)
+ testLocalGoodnessOfFit(dataSeries, 9, 1)
+ testFirstDerivativeAtPosition(dataSeries, 10, 11.544961053811338) // datapoint 14
+ testSecondDerivativeAtPosition(dataSeries, 10, 4.000000000000088)
+ testLocalGoodnessOfFit(dataSeries, 10, 1)
+ testFirstDerivativeAtPosition(dataSeries, 11, 11.902256346743387) // datapoint 15
+ testSecondDerivativeAtPosition(dataSeries, 11, 4.000000000000088)
+ testLocalGoodnessOfFit(dataSeries, 11, 1)
+ dataSeries.push(2.06228352860619, 16.7551608191456) // Datapoint 16, currentDt = 0,086719441920360
+ testLength(dataSeries, 12)
+ testIsReliable(dataSeries, true)
+ testCoefficientA(dataSeries, 2.000000000000065)
+ testCoefficientB(dataSeries, 3.9999999999998357)
+ testCoefficientC(dataSeries, 9.769962616701378e-14)
+ testGoodnessOfFitEquals(dataSeries, 1)
+ testFirstDerivativeAtPosition(dataSeries, 0, 7.608409955297045) // datapoint 5
+ testSecondDerivativeAtPosition(dataSeries, 0, 4.00000000000013)
+ testLocalGoodnessOfFit(dataSeries, 0, 1)
+ testFirstDerivativeAtPosition(dataSeries, 1, 8.14036132229009) // datapoint 6
+ testSecondDerivativeAtPosition(dataSeries, 1, 4.00000000000013)
+ testLocalGoodnessOfFit(dataSeries, 1, 1)
+ testFirstDerivativeAtPosition(dataSeries, 2, 8.639621685410145) // datapoint 7
+ testSecondDerivativeAtPosition(dataSeries, 2, 4.00000000000013)
+ testLocalGoodnessOfFit(dataSeries, 2, 1)
+ testFirstDerivativeAtPosition(dataSeries, 3, 9.111566455696963) // datapoint 8
+ testSecondDerivativeAtPosition(dataSeries, 3, 4.00000000000013)
+ testLocalGoodnessOfFit(dataSeries, 3, 1)
+ testFirstDerivativeAtPosition(dataSeries, 4, 9.560241821531257) // datapoint 9
+ testSecondDerivativeAtPosition(dataSeries, 4, 4.00000000000013)
+ testLocalGoodnessOfFit(dataSeries, 4, 1)
+ testFirstDerivativeAtPosition(dataSeries, 5, 9.98878391475803) // datapoint 10
+ testSecondDerivativeAtPosition(dataSeries, 5, 4.00000000000013)
+ testLocalGoodnessOfFit(dataSeries, 5, 1)
+ testFirstDerivativeAtPosition(dataSeries, 6, 10.399681942506763) // datapoint 11
+ testSecondDerivativeAtPosition(dataSeries, 6, 4.00000000000013)
+ testLocalGoodnessOfFit(dataSeries, 6, 1)
+ testFirstDerivativeAtPosition(dataSeries, 7, 10.794950899141455) // datapoint 12
+ testSecondDerivativeAtPosition(dataSeries, 7, 4.00000000000013)
+ testLocalGoodnessOfFit(dataSeries, 7, 1)
+ testFirstDerivativeAtPosition(dataSeries, 8, 11.176249161702149) // datapoint 13
+ testSecondDerivativeAtPosition(dataSeries, 8, 4.00000000000013)
+ testLocalGoodnessOfFit(dataSeries, 8, 1)
+ testFirstDerivativeAtPosition(dataSeries, 9, 11.54496105381136) // datapoint 14
+ testSecondDerivativeAtPosition(dataSeries, 9, 4.00000000000013)
+ testLocalGoodnessOfFit(dataSeries, 9, 1)
+ testFirstDerivativeAtPosition(dataSeries, 10, 11.90225634674341) // datapoint 15
+ testSecondDerivativeAtPosition(dataSeries, 10, 4.00000000000013)
+ testLocalGoodnessOfFit(dataSeries, 10, 1)
+ testFirstDerivativeAtPosition(dataSeries, 11, 12.249134114424862) // datapoint 16
+ testSecondDerivativeAtPosition(dataSeries, 11, 4.00000000000013)
+ testLocalGoodnessOfFit(dataSeries, 11, 1)
+ dataSeries.push(2.14661392375536, 17.8023583703422) // Datapoint 17, currentDt = 0,084330395149166
+ testLength(dataSeries, 12)
+ testIsReliable(dataSeries, true)
+ testCoefficientA(dataSeries, 1.999999999999992)
+ testCoefficientB(dataSeries, 4.00000000000005)
+ testCoefficientC(dataSeries, -6.084022174945858e-14)
+ testGoodnessOfFitEquals(dataSeries, 1)
+ testFirstDerivativeAtPosition(dataSeries, 0, 8.140361322290154) // datapoint 6
+ testSecondDerivativeAtPosition(dataSeries, 0, 3.999999999999984)
+ testLocalGoodnessOfFit(dataSeries, 0, 1)
+ testFirstDerivativeAtPosition(dataSeries, 1, 8.63962168541019) // datapoint 7
+ testSecondDerivativeAtPosition(dataSeries, 1, 3.999999999999984)
+ testLocalGoodnessOfFit(dataSeries, 1, 1)
+ testFirstDerivativeAtPosition(dataSeries, 2, 9.11156645569699) // datapoint 8
+ testSecondDerivativeAtPosition(dataSeries, 2, 3.999999999999984)
+ testLocalGoodnessOfFit(dataSeries, 2, 1)
+ testFirstDerivativeAtPosition(dataSeries, 3, 9.560241821531267) // datapoint 9
+ testSecondDerivativeAtPosition(dataSeries, 3, 3.999999999999984)
+ testLocalGoodnessOfFit(dataSeries, 3, 1)
+ testFirstDerivativeAtPosition(dataSeries, 4, 9.988783914758026) // datapoint 10
+ testSecondDerivativeAtPosition(dataSeries, 4, 3.999999999999984)
+ testLocalGoodnessOfFit(dataSeries, 4, 1)
+ testFirstDerivativeAtPosition(dataSeries, 5, 10.399681942506744) // datapoint 11
+ testSecondDerivativeAtPosition(dataSeries, 5, 3.999999999999984)
+ testLocalGoodnessOfFit(dataSeries, 5, 1)
+ testFirstDerivativeAtPosition(dataSeries, 6, 10.794950899141423) // datapoint 12
+ testSecondDerivativeAtPosition(dataSeries, 6, 3.999999999999984)
+ testLocalGoodnessOfFit(dataSeries, 6, 1)
+ testFirstDerivativeAtPosition(dataSeries, 7, 11.176249161702101) // datapoint 13
+ testSecondDerivativeAtPosition(dataSeries, 7, 3.999999999999984)
+ testLocalGoodnessOfFit(dataSeries, 7, 1)
+ testFirstDerivativeAtPosition(dataSeries, 8, 11.5449610538113) // datapoint 14
+ testSecondDerivativeAtPosition(dataSeries, 8, 3.999999999999984)
+ testLocalGoodnessOfFit(dataSeries, 8, 1)
+ testFirstDerivativeAtPosition(dataSeries, 9, 11.902256346743338) // datapoint 15
+ testSecondDerivativeAtPosition(dataSeries, 9, 3.999999999999984)
+ testLocalGoodnessOfFit(dataSeries, 9, 1)
+ testFirstDerivativeAtPosition(dataSeries, 10, 12.249134114424775) // datapoint 16
+ testSecondDerivativeAtPosition(dataSeries, 10, 3.999999999999984)
+ testLocalGoodnessOfFit(dataSeries, 10, 1)
+ testFirstDerivativeAtPosition(dataSeries, 11, 12.586455695021456) // datapoint 17
+ testSecondDerivativeAtPosition(dataSeries, 11, 3.999999999999984)
+ testLocalGoodnessOfFit(dataSeries, 11, 1)
+ dataSeries.push(2.22874247359082, 18.8495559215388) // Datapoint 18, currentDt = 0,082128549835466
+ testLength(dataSeries, 12)
+ testIsReliable(dataSeries, true)
+ testCoefficientA(dataSeries, 2.0000000000001164)
+ testCoefficientB(dataSeries, 3.999999999999644)
+ testCoefficientC(dataSeries, 2.686739719592879e-13)
+ testGoodnessOfFitEquals(dataSeries, 1)
+ testFirstDerivativeAtPosition(dataSeries, 0, 8.639621685410074) // datapoint 7
+ testSecondDerivativeAtPosition(dataSeries, 0, 4.000000000000233)
+ testLocalGoodnessOfFit(dataSeries, 0, 1)
+ testFirstDerivativeAtPosition(dataSeries, 1, 9.111566455696902) // datapoint 8
+ testSecondDerivativeAtPosition(dataSeries, 1, 4.000000000000233)
+ testLocalGoodnessOfFit(dataSeries, 1, 1)
+ testFirstDerivativeAtPosition(dataSeries, 2, 9.560241821531207) // datapoint 9
+ testSecondDerivativeAtPosition(dataSeries, 2, 4.000000000000233)
+ testLocalGoodnessOfFit(dataSeries, 2, 1)
+ testFirstDerivativeAtPosition(dataSeries, 3, 9.988783914757992) // datapoint 10
+ testSecondDerivativeAtPosition(dataSeries, 3, 4.000000000000233)
+ testLocalGoodnessOfFit(dataSeries, 3, 1)
+ testFirstDerivativeAtPosition(dataSeries, 4, 10.399681942506735) // datapoint 11
+ testSecondDerivativeAtPosition(dataSeries, 4, 4.000000000000233)
+ testLocalGoodnessOfFit(dataSeries, 4, 1)
+ testFirstDerivativeAtPosition(dataSeries, 5, 10.794950899141439) // datapoint 12
+ testSecondDerivativeAtPosition(dataSeries, 5, 4.000000000000233)
+ testLocalGoodnessOfFit(dataSeries, 5, 1)
+ testFirstDerivativeAtPosition(dataSeries, 6, 11.176249161702142) // datapoint 13
+ testSecondDerivativeAtPosition(dataSeries, 6, 4.000000000000233)
+ testLocalGoodnessOfFit(dataSeries, 6, 1)
+ testFirstDerivativeAtPosition(dataSeries, 7, 11.544961053811363) // datapoint 14
+ testSecondDerivativeAtPosition(dataSeries, 7, 4.000000000000233)
+ testLocalGoodnessOfFit(dataSeries, 7, 1)
+ testFirstDerivativeAtPosition(dataSeries, 8, 11.902256346743425) // datapoint 15
+ testSecondDerivativeAtPosition(dataSeries, 8, 4.000000000000233)
+ testLocalGoodnessOfFit(dataSeries, 8, 1)
+ testFirstDerivativeAtPosition(dataSeries, 9, 12.249134114424884) // datapoint 16
+ testSecondDerivativeAtPosition(dataSeries, 9, 4.000000000000233)
+ testLocalGoodnessOfFit(dataSeries, 9, 1)
+ testFirstDerivativeAtPosition(dataSeries, 10, 12.586455695021584) // datapoint 17
+ testSecondDerivativeAtPosition(dataSeries, 10, 4.000000000000233)
+ testLocalGoodnessOfFit(dataSeries, 10, 1)
+ testFirstDerivativeAtPosition(dataSeries, 11, 12.914969894363441) // datapoint 18
+ testSecondDerivativeAtPosition(dataSeries, 11, 4.000000000000233)
+ testLocalGoodnessOfFit(dataSeries, 11, 1)
+ dataSeries.push(2.30883313818749, 19.8967534727354) // Datapoint 19, currentDt = 0,080090664596669
+ testLength(dataSeries, 12)
+ testIsReliable(dataSeries, true)
+ testCoefficientA(dataSeries, 2.0000000000001563)
+ testCoefficientB(dataSeries, 3.999999999999474)
+ testCoefficientC(dataSeries, 4.3787196091216174e-13)
+ testGoodnessOfFitEquals(dataSeries, 1)
+ testFirstDerivativeAtPosition(dataSeries, 0, 9.111566455696835) // datapoint 8
+ testSecondDerivativeAtPosition(dataSeries, 0, 4.000000000000313)
+ testLocalGoodnessOfFit(dataSeries, 0, 1)
+ testFirstDerivativeAtPosition(dataSeries, 1, 9.560241821531148) // datapoint 9
+ testSecondDerivativeAtPosition(dataSeries, 1, 4.000000000000313)
+ testLocalGoodnessOfFit(dataSeries, 1, 1)
+ testFirstDerivativeAtPosition(dataSeries, 2, 9.988783914757942) // datapoint 10
+ testSecondDerivativeAtPosition(dataSeries, 2, 4.000000000000313)
+ testLocalGoodnessOfFit(dataSeries, 2, 1)
+ testFirstDerivativeAtPosition(dataSeries, 3, 10.399681942506694) // datapoint 11
+ testSecondDerivativeAtPosition(dataSeries, 3, 4.000000000000313)
+ testLocalGoodnessOfFit(dataSeries, 3, 1)
+ testFirstDerivativeAtPosition(dataSeries, 4, 10.794950899141405) // datapoint 12
+ testSecondDerivativeAtPosition(dataSeries, 4, 4.000000000000313)
+ testLocalGoodnessOfFit(dataSeries, 4, 1)
+ testFirstDerivativeAtPosition(dataSeries, 5, 11.176249161702115) // datapoint 13
+ testSecondDerivativeAtPosition(dataSeries, 5, 4.000000000000313)
+ testLocalGoodnessOfFit(dataSeries, 5, 1)
+ testFirstDerivativeAtPosition(dataSeries, 6, 11.544961053811344) // datapoint 14
+ testSecondDerivativeAtPosition(dataSeries, 6, 4.000000000000313)
+ testLocalGoodnessOfFit(dataSeries, 6, 1)
+ testFirstDerivativeAtPosition(dataSeries, 7, 11.90225634674341) // datapoint 15
+ testSecondDerivativeAtPosition(dataSeries, 7, 4.000000000000313)
+ testLocalGoodnessOfFit(dataSeries, 7, 1)
+ testFirstDerivativeAtPosition(dataSeries, 8, 12.249134114424878) // datapoint 16
+ testSecondDerivativeAtPosition(dataSeries, 8, 4.000000000000313)
+ testLocalGoodnessOfFit(dataSeries, 8, 1)
+ testFirstDerivativeAtPosition(dataSeries, 9, 12.586455695021586) // datapoint 17
+ testSecondDerivativeAtPosition(dataSeries, 9, 4.000000000000313)
+ testLocalGoodnessOfFit(dataSeries, 9, 1)
+ testFirstDerivativeAtPosition(dataSeries, 10, 12.91496989436345) // datapoint 18
+ testSecondDerivativeAtPosition(dataSeries, 10, 4.000000000000313)
+ testLocalGoodnessOfFit(dataSeries, 10, 1)
+ testFirstDerivativeAtPosition(dataSeries, 11, 13.235332552750155) // datapoint 19
+ testSecondDerivativeAtPosition(dataSeries, 11, 4.000000000000313)
+ testLocalGoodnessOfFit(dataSeries, 11, 1)
+ dataSeries.push(2.38703048583357, 20.943951023932) // Datapoint 20, currentDt = 0,078197347646078
+ testLength(dataSeries, 12)
+ testIsReliable(dataSeries, true)
+ testCoefficientA(dataSeries, 2.000000000000047)
+ testCoefficientB(dataSeries, 3.9999999999998614)
+ testCoefficientC(dataSeries, 1.0125233984581428e-13)
+ testGoodnessOfFitEquals(dataSeries, 1)
+ testFirstDerivativeAtPosition(dataSeries, 0, 9.560241821531232) // datapoint 9
+ testSecondDerivativeAtPosition(dataSeries, 0, 4.000000000000094)
+ testLocalGoodnessOfFit(dataSeries, 0, 1)
+ testFirstDerivativeAtPosition(dataSeries, 1, 9.988783914758002) // datapoint 10
+ testSecondDerivativeAtPosition(dataSeries, 1, 4.000000000000094)
+ testLocalGoodnessOfFit(dataSeries, 1, 1)
+ testFirstDerivativeAtPosition(dataSeries, 2, 10.399681942506732) // datapoint 11
+ testSecondDerivativeAtPosition(dataSeries, 2, 4.000000000000094)
+ testLocalGoodnessOfFit(dataSeries, 2, 1)
+ testFirstDerivativeAtPosition(dataSeries, 3, 10.794950899141421) // datapoint 12
+ testSecondDerivativeAtPosition(dataSeries, 3, 4.000000000000094)
+ testLocalGoodnessOfFit(dataSeries, 3, 1)
+ testFirstDerivativeAtPosition(dataSeries, 4, 11.17624916170211) // datapoint 13
+ testSecondDerivativeAtPosition(dataSeries, 4, 4.000000000000094)
+ testLocalGoodnessOfFit(dataSeries, 4, 1)
+ testFirstDerivativeAtPosition(dataSeries, 5, 11.544961053811319) // datapoint 14
+ testSecondDerivativeAtPosition(dataSeries, 5, 4.000000000000094)
+ testLocalGoodnessOfFit(dataSeries, 5, 1)
+ testFirstDerivativeAtPosition(dataSeries, 6, 11.902256346743368) // datapoint 15
+ testSecondDerivativeAtPosition(dataSeries, 6, 4.000000000000094)
+ testLocalGoodnessOfFit(dataSeries, 6, 1)
+ testFirstDerivativeAtPosition(dataSeries, 7, 12.249134114424814) // datapoint 16
+ testSecondDerivativeAtPosition(dataSeries, 7, 4.000000000000094)
+ testLocalGoodnessOfFit(dataSeries, 7, 1)
+ testFirstDerivativeAtPosition(dataSeries, 8, 12.586455695021504) // datapoint 17
+ testSecondDerivativeAtPosition(dataSeries, 8, 4.000000000000094)
+ testLocalGoodnessOfFit(dataSeries, 8, 1)
+ testFirstDerivativeAtPosition(dataSeries, 9, 12.91496989436335) // datapoint 18
+ testSecondDerivativeAtPosition(dataSeries, 9, 4.000000000000094)
+ testLocalGoodnessOfFit(dataSeries, 9, 1)
+ testFirstDerivativeAtPosition(dataSeries, 10, 13.235332552750037) // datapoint 19
+ testSecondDerivativeAtPosition(dataSeries, 10, 4.000000000000094)
+ testLocalGoodnessOfFit(dataSeries, 10, 1)
+ testFirstDerivativeAtPosition(dataSeries, 11, 13.548121943334367) // datapoint 20
+ testSecondDerivativeAtPosition(dataSeries, 11, 4.000000000000094)
+ testLocalGoodnessOfFit(dataSeries, 11, 1)
+ dataSeries.push(2.46346275966182, 21.9911485751286) // Datapoint 21, currentDt = 0,076432273828253
+ testLength(dataSeries, 12)
+ testIsReliable(dataSeries, true)
+ testCoefficientA(dataSeries, 2.000000000000006)
+ testCoefficientB(dataSeries, 4.000000000000021)
+ testCoefficientC(dataSeries, -4.618527782440651e-14)
+ testGoodnessOfFitEquals(dataSeries, 1)
+ testFirstDerivativeAtPosition(dataSeries, 0, 9.98878391475804) // datapoint 10
+ testSecondDerivativeAtPosition(dataSeries, 0, 4.000000000000012)
+ testLocalGoodnessOfFit(dataSeries, 0, 1)
+ testFirstDerivativeAtPosition(dataSeries, 1, 10.39968194250676) // datapoint 11
+ testSecondDerivativeAtPosition(dataSeries, 1, 4.000000000000012)
+ testLocalGoodnessOfFit(dataSeries, 1, 1)
+ testFirstDerivativeAtPosition(dataSeries, 2, 10.794950899141442) // datapoint 12
+ testSecondDerivativeAtPosition(dataSeries, 2, 4.000000000000012)
+ testLocalGoodnessOfFit(dataSeries, 2, 1)
+ testFirstDerivativeAtPosition(dataSeries, 3, 11.176249161702124) // datapoint 13
+ testSecondDerivativeAtPosition(dataSeries, 3, 4.000000000000012)
+ testLocalGoodnessOfFit(dataSeries, 3, 1)
+ testFirstDerivativeAtPosition(dataSeries, 4, 11.544961053811324) // datapoint 14
+ testSecondDerivativeAtPosition(dataSeries, 4, 4.000000000000012)
+ testLocalGoodnessOfFit(dataSeries, 4, 1)
+ testFirstDerivativeAtPosition(dataSeries, 5, 11.902256346743366) // datapoint 15
+ testSecondDerivativeAtPosition(dataSeries, 5, 4.000000000000012)
+ testLocalGoodnessOfFit(dataSeries, 5, 1)
+ testFirstDerivativeAtPosition(dataSeries, 6, 12.249134114424805) // datapoint 16
+ testSecondDerivativeAtPosition(dataSeries, 6, 4.000000000000012)
+ testLocalGoodnessOfFit(dataSeries, 6, 1)
+ testFirstDerivativeAtPosition(dataSeries, 7, 12.586455695021488) // datapoint 17
+ testSecondDerivativeAtPosition(dataSeries, 7, 4.000000000000012)
+ testLocalGoodnessOfFit(dataSeries, 7, 1)
+ testFirstDerivativeAtPosition(dataSeries, 8, 12.91496989436333) // datapoint 18
+ testSecondDerivativeAtPosition(dataSeries, 8, 4.000000000000012)
+ testLocalGoodnessOfFit(dataSeries, 8, 1)
+ testFirstDerivativeAtPosition(dataSeries, 9, 13.235332552750009) // datapoint 19
+ testSecondDerivativeAtPosition(dataSeries, 9, 4.000000000000012)
+ testLocalGoodnessOfFit(dataSeries, 9, 1)
+ testFirstDerivativeAtPosition(dataSeries, 10, 13.548121943334332) // datapoint 20
+ testSecondDerivativeAtPosition(dataSeries, 10, 4.000000000000012)
+ testLocalGoodnessOfFit(dataSeries, 10, 1)
+ testFirstDerivativeAtPosition(dataSeries, 11, 13.853851038647331) // datapoint 21
+ testSecondDerivativeAtPosition(dataSeries, 11, 4.000000000000012)
+ testLocalGoodnessOfFit(dataSeries, 11, 1)
+ dataSeries.push(2.53824434757728, 23.0383461263251) // Datapoint 22, currentDt = 0,074781587915460
+ testLength(dataSeries, 12)
+ testIsReliable(dataSeries, true)
+ testCoefficientA(dataSeries, 1.9999999999998974)
+ testCoefficientB(dataSeries, 4.000000000000433)
+ testCoefficientC(dataSeries, -4.1877612488860905e-13)
+ testGoodnessOfFitEquals(dataSeries, 1)
+ testFirstDerivativeAtPosition(dataSeries, 0, 10.399681942506824) // datapoint 11
+ testSecondDerivativeAtPosition(dataSeries, 0, 3.999999999999795)
+ testLocalGoodnessOfFit(dataSeries, 0, 1)
+ testFirstDerivativeAtPosition(dataSeries, 1, 10.794950899141485) // datapoint 12
+ testSecondDerivativeAtPosition(dataSeries, 1, 3.999999999999795)
+ testLocalGoodnessOfFit(dataSeries, 1, 1)
+ testFirstDerivativeAtPosition(dataSeries, 2, 11.176249161702145) // datapoint 13
+ testSecondDerivativeAtPosition(dataSeries, 2, 3.999999999999795)
+ testLocalGoodnessOfFit(dataSeries, 2, 1)
+ testFirstDerivativeAtPosition(dataSeries, 3, 11.544961053811326) // datapoint 14
+ testSecondDerivativeAtPosition(dataSeries, 3, 3.999999999999795)
+ testLocalGoodnessOfFit(dataSeries, 3, 1)
+ testFirstDerivativeAtPosition(dataSeries, 4, 11.902256346743348) // datapoint 15
+ testSecondDerivativeAtPosition(dataSeries, 4, 3.999999999999795)
+ testLocalGoodnessOfFit(dataSeries, 4, 1)
+ testFirstDerivativeAtPosition(dataSeries, 5, 12.24913411442477) // datapoint 16
+ testSecondDerivativeAtPosition(dataSeries, 5, 3.999999999999795)
+ testLocalGoodnessOfFit(dataSeries, 5, 1)
+ testFirstDerivativeAtPosition(dataSeries, 6, 12.586455695021433) // datapoint 17
+ testSecondDerivativeAtPosition(dataSeries, 6, 3.999999999999795)
+ testLocalGoodnessOfFit(dataSeries, 6, 1)
+ testFirstDerivativeAtPosition(dataSeries, 7, 12.914969894363256) // datapoint 18
+ testSecondDerivativeAtPosition(dataSeries, 7, 3.999999999999795)
+ testLocalGoodnessOfFit(dataSeries, 7, 1)
+ testFirstDerivativeAtPosition(dataSeries, 8, 13.235332552749918) // datapoint 19
+ testSecondDerivativeAtPosition(dataSeries, 8, 3.999999999999795)
+ testLocalGoodnessOfFit(dataSeries, 8, 1)
+ testFirstDerivativeAtPosition(dataSeries, 9, 13.548121943334223) // datapoint 20
+ testSecondDerivativeAtPosition(dataSeries, 9, 3.999999999999795)
+ testLocalGoodnessOfFit(dataSeries, 9, 1)
+ testFirstDerivativeAtPosition(dataSeries, 10, 13.853851038647207) // datapoint 21
+ testSecondDerivativeAtPosition(dataSeries, 10, 3.999999999999795)
+ testLocalGoodnessOfFit(dataSeries, 10, 1)
+ testFirstDerivativeAtPosition(dataSeries, 11, 14.152977390309033) // datapoint 22
+ testSecondDerivativeAtPosition(dataSeries, 11, 3.999999999999795)
+ testLocalGoodnessOfFit(dataSeries, 11, 1)
+ dataSeries.push(2.61147779153643, 24.0855436775217) // Datapoint 23, currentDt = 0,073233443959153
+ testLength(dataSeries, 12)
+ testIsReliable(dataSeries, true)
+ testCoefficientA(dataSeries, 1.9999999999997056)
+ testCoefficientB(dataSeries, 4.000000000001288)
+ testCoefficientC(dataSeries, -1.3553602684623911e-12)
+ testGoodnessOfFitEquals(dataSeries, 1)
+ testFirstDerivativeAtPosition(dataSeries, 0, 10.794950899141687) // datapoint 12
+ testSecondDerivativeAtPosition(dataSeries, 0, 3.999999999999411)
+ testLocalGoodnessOfFit(dataSeries, 0, 1)
+ testFirstDerivativeAtPosition(dataSeries, 1, 11.176249161702312) // datapoint 13
+ testSecondDerivativeAtPosition(dataSeries, 1, 3.999999999999411)
+ testLocalGoodnessOfFit(dataSeries, 1, 1)
+ testFirstDerivativeAtPosition(dataSeries, 2, 11.544961053811456) // datapoint 14
+ testSecondDerivativeAtPosition(dataSeries, 2, 3.999999999999411)
+ testLocalGoodnessOfFit(dataSeries, 2, 1)
+ testFirstDerivativeAtPosition(dataSeries, 3, 11.902256346743444) // datapoint 15
+ testSecondDerivativeAtPosition(dataSeries, 3, 3.999999999999411)
+ testLocalGoodnessOfFit(dataSeries, 3, 1)
+ testFirstDerivativeAtPosition(dataSeries, 4, 12.249134114424832) // datapoint 16
+ testSecondDerivativeAtPosition(dataSeries, 4, 3.999999999999411)
+ testLocalGoodnessOfFit(dataSeries, 4, 1)
+ testFirstDerivativeAtPosition(dataSeries, 5, 12.586455695021463) // datapoint 17
+ testSecondDerivativeAtPosition(dataSeries, 5, 3.999999999999411)
+ testLocalGoodnessOfFit(dataSeries, 5, 1)
+ testFirstDerivativeAtPosition(dataSeries, 6, 12.914969894363255) // datapoint 18
+ testSecondDerivativeAtPosition(dataSeries, 6, 3.999999999999411)
+ testLocalGoodnessOfFit(dataSeries, 6, 1)
+ testFirstDerivativeAtPosition(dataSeries, 7, 13.235332552749888) // datapoint 19
+ testSecondDerivativeAtPosition(dataSeries, 7, 3.999999999999411)
+ testLocalGoodnessOfFit(dataSeries, 7, 1)
+ testFirstDerivativeAtPosition(dataSeries, 8, 13.548121943334163) // datapoint 20
+ testSecondDerivativeAtPosition(dataSeries, 8, 3.999999999999411)
+ testLocalGoodnessOfFit(dataSeries, 8, 1)
+ testFirstDerivativeAtPosition(dataSeries, 9, 13.853851038647116) // datapoint 21
+ testSecondDerivativeAtPosition(dataSeries, 9, 3.999999999999411)
+ testLocalGoodnessOfFit(dataSeries, 9, 1)
+ testFirstDerivativeAtPosition(dataSeries, 10, 14.152977390308914) // datapoint 22
+ testSecondDerivativeAtPosition(dataSeries, 10, 3.999999999999411)
+ testLocalGoodnessOfFit(dataSeries, 10, 1)
+ testFirstDerivativeAtPosition(dataSeries, 11, 14.44591116614547) // datapoint 23
+ testSecondDerivativeAtPosition(dataSeries, 11, 3.999999999999411)
+ testLocalGoodnessOfFit(dataSeries, 11, 1)
+ dataSeries.push(2.68325543702296, 25.1327412287183) // Datapoint 24, currentDt = 0,071777645486524
+ testLength(dataSeries, 12)
+ testIsReliable(dataSeries, true)
+ testCoefficientA(dataSeries, 1.999999999999476)
+ testCoefficientB(dataSeries, 4.000000000002281)
+ testCoefficientC(dataSeries, -2.4211743721025414e-12)
+ testGoodnessOfFitEquals(dataSeries, 1)
+ testFirstDerivativeAtPosition(dataSeries, 0, 11.17624916170248) // datapoint 13
+ testSecondDerivativeAtPosition(dataSeries, 0, 3.999999999998952)
+ testLocalGoodnessOfFit(dataSeries, 0, 1)
+ testFirstDerivativeAtPosition(dataSeries, 1, 11.544961053811583) // datapoint 14
+ testSecondDerivativeAtPosition(dataSeries, 1, 3.999999999998952)
+ testLocalGoodnessOfFit(dataSeries, 1, 1)
+ testFirstDerivativeAtPosition(dataSeries, 2, 11.902256346743531) // datapoint 15
+ testSecondDerivativeAtPosition(dataSeries, 2, 3.999999999998952)
+ testLocalGoodnessOfFit(dataSeries, 2, 1)
+ testFirstDerivativeAtPosition(dataSeries, 3, 12.249134114424878) // datapoint 16
+ testSecondDerivativeAtPosition(dataSeries, 3, 3.999999999998952)
+ testLocalGoodnessOfFit(dataSeries, 3, 1)
+ testFirstDerivativeAtPosition(dataSeries, 4, 12.58645569502147) // datapoint 17
+ testSecondDerivativeAtPosition(dataSeries, 4, 3.999999999998952)
+ testLocalGoodnessOfFit(dataSeries, 4, 1)
+ testFirstDerivativeAtPosition(dataSeries, 5, 12.914969894363225) // datapoint 18
+ testSecondDerivativeAtPosition(dataSeries, 5, 3.999999999998952)
+ testLocalGoodnessOfFit(dataSeries, 5, 1)
+ testFirstDerivativeAtPosition(dataSeries, 6, 13.23533255274982) // datapoint 19
+ testSecondDerivativeAtPosition(dataSeries, 6, 3.999999999998952)
+ testLocalGoodnessOfFit(dataSeries, 6, 1)
+ testFirstDerivativeAtPosition(dataSeries, 7, 13.54812194333406) // datapoint 20
+ testSecondDerivativeAtPosition(dataSeries, 7, 3.999999999998952)
+ testLocalGoodnessOfFit(dataSeries, 7, 1)
+ testFirstDerivativeAtPosition(dataSeries, 8, 13.85385103864698) // datapoint 21
+ testSecondDerivativeAtPosition(dataSeries, 8, 3.999999999998952)
+ testLocalGoodnessOfFit(dataSeries, 8, 1)
+ testFirstDerivativeAtPosition(dataSeries, 9, 14.15297739030874) // datapoint 22
+ testSecondDerivativeAtPosition(dataSeries, 9, 3.999999999998952)
+ testLocalGoodnessOfFit(dataSeries, 9, 1)
+ testFirstDerivativeAtPosition(dataSeries, 10, 14.445911166145263) // datapoint 23
+ testSecondDerivativeAtPosition(dataSeries, 10, 3.999999999998952)
+ testLocalGoodnessOfFit(dataSeries, 10, 1)
+ testFirstDerivativeAtPosition(dataSeries, 11, 14.733021748091309) // datapoint 24
+ testSecondDerivativeAtPosition(dataSeries, 11, 3.999999999998952)
+ testLocalGoodnessOfFit(dataSeries, 11, 1)
+ dataSeries.push(2.75366079846827, 26.1799387799149) // Datapoint 25, currentDt = 0,070405361445316
+ testLength(dataSeries, 12)
+ testIsReliable(dataSeries, true)
+ testCoefficientA(dataSeries, 1.999999999999619)
+ testCoefficientB(dataSeries, 4.000000000001693)
+ testCoefficientC(dataSeries, -1.822542117224657e-12)
+ testGoodnessOfFitEquals(dataSeries, 1)
+ testFirstDerivativeAtPosition(dataSeries, 0, 11.544961053811535) // datapoint 14
+ testSecondDerivativeAtPosition(dataSeries, 0, 3.999999999999238)
+ testLocalGoodnessOfFit(dataSeries, 0, 1)
+ testFirstDerivativeAtPosition(dataSeries, 1, 11.902256346743506) // datapoint 15
+ testSecondDerivativeAtPosition(dataSeries, 1, 3.999999999999238)
+ testLocalGoodnessOfFit(dataSeries, 1, 1)
+ testFirstDerivativeAtPosition(dataSeries, 2, 12.24913411442488) // datapoint 16
+ testSecondDerivativeAtPosition(dataSeries, 2, 3.999999999999238)
+ testLocalGoodnessOfFit(dataSeries, 2, 1)
+ testFirstDerivativeAtPosition(dataSeries, 3, 12.586455695021497) // datapoint 17
+ testSecondDerivativeAtPosition(dataSeries, 3, 3.999999999999238)
+ testLocalGoodnessOfFit(dataSeries, 3, 1)
+ testFirstDerivativeAtPosition(dataSeries, 4, 12.914969894363274) // datapoint 18
+ testSecondDerivativeAtPosition(dataSeries, 4, 3.999999999999238)
+ testLocalGoodnessOfFit(dataSeries, 4, 1)
+ testFirstDerivativeAtPosition(dataSeries, 5, 13.235332552749894) // datapoint 19
+ testSecondDerivativeAtPosition(dataSeries, 5, 3.999999999999238)
+ testLocalGoodnessOfFit(dataSeries, 5, 1)
+ testFirstDerivativeAtPosition(dataSeries, 6, 13.548121943334154) // datapoint 20
+ testSecondDerivativeAtPosition(dataSeries, 6, 3.999999999999238)
+ testLocalGoodnessOfFit(dataSeries, 6, 1)
+ testFirstDerivativeAtPosition(dataSeries, 7, 13.853851038647095) // datapoint 21
+ testSecondDerivativeAtPosition(dataSeries, 7, 3.999999999999238)
+ testLocalGoodnessOfFit(dataSeries, 7, 1)
+ testFirstDerivativeAtPosition(dataSeries, 8, 14.152977390308878) // datapoint 22
+ testSecondDerivativeAtPosition(dataSeries, 8, 3.999999999999238)
+ testLocalGoodnessOfFit(dataSeries, 8, 1)
+ testFirstDerivativeAtPosition(dataSeries, 9, 14.445911166145423) // datapoint 23
+ testSecondDerivativeAtPosition(dataSeries, 9, 3.999999999999238)
+ testLocalGoodnessOfFit(dataSeries, 9, 1)
+ testFirstDerivativeAtPosition(dataSeries, 10, 14.733021748091488) // datapoint 24
+ testSecondDerivativeAtPosition(dataSeries, 10, 3.999999999999238)
+ testLocalGoodnessOfFit(dataSeries, 10, 1)
+ testFirstDerivativeAtPosition(dataSeries, 11, 15.014643193872676) // datapoint 25
+ testSecondDerivativeAtPosition(dataSeries, 11, 3.999999999999238)
+ testLocalGoodnessOfFit(dataSeries, 11, 1)
+ dataSeries.push(2.82276969821042, 27.2271363311115) // Datapoint 26, currentDt = 0,069108899742145
+ testLength(dataSeries, 12)
+ testIsReliable(dataSeries, true)
+ testCoefficientA(dataSeries, 1.9999999999996692)
+ testCoefficientB(dataSeries, 4.000000000001454)
+ testCoefficientC(dataSeries, -1.538325022920617e-12)
+ testGoodnessOfFitEquals(dataSeries, 1)
+ testFirstDerivativeAtPosition(dataSeries, 0, 11.902256346743467) // datapoint 15
+ testSecondDerivativeAtPosition(dataSeries, 0, 3.9999999999993383)
+ testLocalGoodnessOfFit(dataSeries, 0, 1)
+ testFirstDerivativeAtPosition(dataSeries, 1, 12.249134114424848) // datapoint 16
+ testSecondDerivativeAtPosition(dataSeries, 1, 3.9999999999993383)
+ testLocalGoodnessOfFit(dataSeries, 1, 1)
+ testFirstDerivativeAtPosition(dataSeries, 2, 12.586455695021474) // datapoint 17
+ testSecondDerivativeAtPosition(dataSeries, 2, 3.9999999999993383)
+ testLocalGoodnessOfFit(dataSeries, 2, 1)
+ testFirstDerivativeAtPosition(dataSeries, 3, 12.91496989436326) // datapoint 18
+ testSecondDerivativeAtPosition(dataSeries, 3, 3.9999999999993383)
+ testLocalGoodnessOfFit(dataSeries, 3, 1)
+ testFirstDerivativeAtPosition(dataSeries, 4, 13.235332552749885) // datapoint 19
+ testSecondDerivativeAtPosition(dataSeries, 4, 3.9999999999993383)
+ testLocalGoodnessOfFit(dataSeries, 4, 1)
+ testFirstDerivativeAtPosition(dataSeries, 5, 13.548121943334156) // datapoint 20
+ testSecondDerivativeAtPosition(dataSeries, 5, 3.9999999999993383)
+ testLocalGoodnessOfFit(dataSeries, 5, 1)
+ testFirstDerivativeAtPosition(dataSeries, 6, 13.853851038647104) // datapoint 21
+ testSecondDerivativeAtPosition(dataSeries, 6, 3.9999999999993383)
+ testLocalGoodnessOfFit(dataSeries, 6, 1)
+ testFirstDerivativeAtPosition(dataSeries, 7, 14.152977390308894) // datapoint 22
+ testSecondDerivativeAtPosition(dataSeries, 7, 3.9999999999993383)
+ testLocalGoodnessOfFit(dataSeries, 7, 1)
+ testFirstDerivativeAtPosition(dataSeries, 8, 14.445911166145446) // datapoint 23
+ testSecondDerivativeAtPosition(dataSeries, 8, 3.9999999999993383)
+ testLocalGoodnessOfFit(dataSeries, 8, 1)
+ testFirstDerivativeAtPosition(dataSeries, 9, 14.733021748091517) // datapoint 24
+ testSecondDerivativeAtPosition(dataSeries, 9, 3.9999999999993383)
+ testLocalGoodnessOfFit(dataSeries, 9, 1)
+ testFirstDerivativeAtPosition(dataSeries, 10, 15.014643193872711) // datapoint 25
+ testSecondDerivativeAtPosition(dataSeries, 10, 3.9999999999993383)
+ testLocalGoodnessOfFit(dataSeries, 10, 1)
+ testFirstDerivativeAtPosition(dataSeries, 11, 15.291078792841265) // datapoint 26
+ testSecondDerivativeAtPosition(dataSeries, 11, 3.9999999999993383)
+ testLocalGoodnessOfFit(dataSeries, 11, 1)
+ dataSeries.push(2.89065122327279, 28.2743338823081) // Datapoint 27, currentDt = 0,067881525062373
+ testLength(dataSeries, 12)
+ testIsReliable(dataSeries, true)
+ testCoefficientA(dataSeries, 1.9999999999999296)
+ testCoefficientB(dataSeries, 4.0000000000002345)
+ testCoefficientC(dataSeries, -1.341149413747189e-13)
+ testGoodnessOfFitEquals(dataSeries, 1)
+ testFirstDerivativeAtPosition(dataSeries, 0, 12.249134114424704) // datapoint 16
+ testSecondDerivativeAtPosition(dataSeries, 0, 3.9999999999998592)
+ testLocalGoodnessOfFit(dataSeries, 0, 1)
+ testFirstDerivativeAtPosition(dataSeries, 1, 12.586455695021373) // datapoint 17
+ testSecondDerivativeAtPosition(dataSeries, 1, 3.9999999999998592)
+ testLocalGoodnessOfFit(dataSeries, 1, 1)
+ testFirstDerivativeAtPosition(dataSeries, 2, 12.9149698943632) // datapoint 18
+ testSecondDerivativeAtPosition(dataSeries, 2, 3.9999999999998592)
+ testLocalGoodnessOfFit(dataSeries, 2, 1)
+ testFirstDerivativeAtPosition(dataSeries, 3, 13.235332552749869) // datapoint 19
+ testSecondDerivativeAtPosition(dataSeries, 3, 3.9999999999998592)
+ testLocalGoodnessOfFit(dataSeries, 3, 1)
+ testFirstDerivativeAtPosition(dataSeries, 4, 13.548121943334179) // datapoint 20
+ testSecondDerivativeAtPosition(dataSeries, 4, 3.9999999999998592)
+ testLocalGoodnessOfFit(dataSeries, 4, 1)
+ testFirstDerivativeAtPosition(dataSeries, 5, 13.853851038647168) // datapoint 21
+ testSecondDerivativeAtPosition(dataSeries, 5, 3.9999999999998592)
+ testLocalGoodnessOfFit(dataSeries, 5, 1)
+ testFirstDerivativeAtPosition(dataSeries, 6, 14.152977390308997) // datapoint 22
+ testSecondDerivativeAtPosition(dataSeries, 6, 3.9999999999998592)
+ testLocalGoodnessOfFit(dataSeries, 6, 1)
+ testFirstDerivativeAtPosition(dataSeries, 7, 14.445911166145587) // datapoint 23
+ testSecondDerivativeAtPosition(dataSeries, 7, 3.9999999999998592)
+ testLocalGoodnessOfFit(dataSeries, 7, 1)
+ testFirstDerivativeAtPosition(dataSeries, 8, 14.733021748091696) // datapoint 24
+ testSecondDerivativeAtPosition(dataSeries, 8, 3.9999999999998592)
+ testLocalGoodnessOfFit(dataSeries, 8, 1)
+ testFirstDerivativeAtPosition(dataSeries, 9, 15.014643193872928) // datapoint 25
+ testSecondDerivativeAtPosition(dataSeries, 9, 3.9999999999998592)
+ testLocalGoodnessOfFit(dataSeries, 9, 1)
+ testFirstDerivativeAtPosition(dataSeries, 10, 15.291078792841516) // datapoint 26
+ testSecondDerivativeAtPosition(dataSeries, 10, 3.9999999999998592)
+ testLocalGoodnessOfFit(dataSeries, 10, 1)
+ testFirstDerivativeAtPosition(dataSeries, 11, 15.562604893090988) // datapoint 27
+ testSecondDerivativeAtPosition(dataSeries, 11, 3.9999999999998592)
+ testLocalGoodnessOfFit(dataSeries, 11, 1)
+ dataSeries.push(2.95736853436123, 29.3215314335047) // Datapoint 28, currentDt = 0,066717311088441
+ testLength(dataSeries, 12)
+ testIsReliable(dataSeries, true)
+ testCoefficientA(dataSeries, 2.0000000000001585)
+ testCoefficientB(dataSeries, 3.999999999999079)
+ testCoefficientC(dataSeries, 1.3224976669334865e-12)
+ testGoodnessOfFitEquals(dataSeries, 1)
+ testFirstDerivativeAtPosition(dataSeries, 0, 12.5864556950212) // datapoint 17
+ testSecondDerivativeAtPosition(dataSeries, 0, 4.000000000000317)
+ testLocalGoodnessOfFit(dataSeries, 0, 1)
+ testFirstDerivativeAtPosition(dataSeries, 1, 12.914969894363065) // datapoint 18
+ testSecondDerivativeAtPosition(dataSeries, 1, 4.000000000000317)
+ testLocalGoodnessOfFit(dataSeries, 1, 1)
+ testFirstDerivativeAtPosition(dataSeries, 2, 13.235332552749771) // datapoint 19
+ testSecondDerivativeAtPosition(dataSeries, 2, 4.000000000000317)
+ testLocalGoodnessOfFit(dataSeries, 2, 1)
+ testFirstDerivativeAtPosition(dataSeries, 3, 13.548121943334117) // datapoint 20
+ testSecondDerivativeAtPosition(dataSeries, 3, 4.000000000000317)
+ testLocalGoodnessOfFit(dataSeries, 3, 1)
+ testFirstDerivativeAtPosition(dataSeries, 4, 13.85385103864714) // datapoint 21
+ testSecondDerivativeAtPosition(dataSeries, 4, 4.000000000000317)
+ testLocalGoodnessOfFit(dataSeries, 4, 1)
+ testFirstDerivativeAtPosition(dataSeries, 5, 14.152977390309005) // datapoint 22
+ testSecondDerivativeAtPosition(dataSeries, 5, 4.000000000000317)
+ testLocalGoodnessOfFit(dataSeries, 5, 1)
+ testFirstDerivativeAtPosition(dataSeries, 6, 14.445911166145628) // datapoint 23
+ testSecondDerivativeAtPosition(dataSeries, 6, 4.000000000000317)
+ testLocalGoodnessOfFit(dataSeries, 6, 1)
+ testFirstDerivativeAtPosition(dataSeries, 7, 14.733021748091769) // datapoint 24
+ testSecondDerivativeAtPosition(dataSeries, 7, 4.000000000000317)
+ testLocalGoodnessOfFit(dataSeries, 7, 1)
+ testFirstDerivativeAtPosition(dataSeries, 8, 15.014643193873034) // datapoint 25
+ testSecondDerivativeAtPosition(dataSeries, 8, 4.000000000000317)
+ testLocalGoodnessOfFit(dataSeries, 8, 1)
+ testFirstDerivativeAtPosition(dataSeries, 9, 15.291078792841653) // datapoint 26
+ testSecondDerivativeAtPosition(dataSeries, 9, 4.000000000000317)
+ testLocalGoodnessOfFit(dataSeries, 9, 1)
+ testFirstDerivativeAtPosition(dataSeries, 10, 15.562604893091155) // datapoint 27
+ testSecondDerivativeAtPosition(dataSeries, 10, 4.000000000000317)
+ testLocalGoodnessOfFit(dataSeries, 10, 1)
+ testFirstDerivativeAtPosition(dataSeries, 11, 15.829474137444937) // datapoint 28
+ testSecondDerivativeAtPosition(dataSeries, 11, 4.000000000000317)
+ testLocalGoodnessOfFit(dataSeries, 11, 1)
+ dataSeries.push(3.02297955405576, 30.3687289847013) // Datapoint 29, currentDt = 0,065611019694526
+ testLength(dataSeries, 12)
+ testIsReliable(dataSeries, true)
+ testCoefficientA(dataSeries, 2.0000000000002034)
+ testCoefficientB(dataSeries, 3.9999999999988223)
+ testCoefficientC(dataSeries, 1.6857626405908377e-12)
+ testGoodnessOfFitEquals(dataSeries, 1)
+ testFirstDerivativeAtPosition(dataSeries, 0, 12.914969894363008) // datapoint 18
+ testSecondDerivativeAtPosition(dataSeries, 0, 4.000000000000407)
+ testLocalGoodnessOfFit(dataSeries, 0, 1)
+ testFirstDerivativeAtPosition(dataSeries, 1, 13.235332552749721) // datapoint 19
+ testSecondDerivativeAtPosition(dataSeries, 1, 4.000000000000407)
+ testLocalGoodnessOfFit(dataSeries, 1, 1)
+ testFirstDerivativeAtPosition(dataSeries, 2, 13.548121943334074) // datapoint 20
+ testSecondDerivativeAtPosition(dataSeries, 2, 4.000000000000407)
+ testLocalGoodnessOfFit(dataSeries, 2, 1)
+ testFirstDerivativeAtPosition(dataSeries, 3, 13.853851038647104) // datapoint 21
+ testSecondDerivativeAtPosition(dataSeries, 3, 4.000000000000407)
+ testLocalGoodnessOfFit(dataSeries, 3, 1)
+ testFirstDerivativeAtPosition(dataSeries, 4, 14.152977390308974) // datapoint 22
+ testSecondDerivativeAtPosition(dataSeries, 4, 4.000000000000407)
+ testLocalGoodnessOfFit(dataSeries, 4, 1)
+ testFirstDerivativeAtPosition(dataSeries, 5, 14.445911166145605) // datapoint 23
+ testSecondDerivativeAtPosition(dataSeries, 5, 4.000000000000407)
+ testLocalGoodnessOfFit(dataSeries, 5, 1)
+ testFirstDerivativeAtPosition(dataSeries, 6, 14.733021748091753) // datapoint 24
+ testSecondDerivativeAtPosition(dataSeries, 6, 4.000000000000407)
+ testLocalGoodnessOfFit(dataSeries, 6, 1)
+ testFirstDerivativeAtPosition(dataSeries, 7, 15.014643193873024) // datapoint 25
+ testSecondDerivativeAtPosition(dataSeries, 7, 4.000000000000407)
+ testLocalGoodnessOfFit(dataSeries, 7, 1)
+ testFirstDerivativeAtPosition(dataSeries, 8, 15.291078792841649) // datapoint 26
+ testSecondDerivativeAtPosition(dataSeries, 8, 4.000000000000407)
+ testLocalGoodnessOfFit(dataSeries, 8, 1)
+ testFirstDerivativeAtPosition(dataSeries, 9, 15.562604893091159) // datapoint 27
+ testSecondDerivativeAtPosition(dataSeries, 9, 4.000000000000407)
+ testLocalGoodnessOfFit(dataSeries, 9, 1)
+ testFirstDerivativeAtPosition(dataSeries, 10, 15.829474137444945) // datapoint 28
+ testSecondDerivativeAtPosition(dataSeries, 10, 4.000000000000407)
+ testLocalGoodnessOfFit(dataSeries, 10, 1)
+ testFirstDerivativeAtPosition(dataSeries, 11, 16.091918216223092) // datapoint 29
+ testSecondDerivativeAtPosition(dataSeries, 11, 4.000000000000407)
+ testLocalGoodnessOfFit(dataSeries, 11, 1)
+ dataSeries.push(3.08753755553988, 31.4159265358979) // Datapoint 30, currentDt = 0,064558001484125
+ testLength(dataSeries, 12)
+ testIsReliable(dataSeries, true)
+ testCoefficientA(dataSeries, 2.0000000000004476)
+ testCoefficientB(dataSeries, 3.999999999997489)
+ testCoefficientC(dataSeries, 3.4852121189032914e-12)
+ testGoodnessOfFitEquals(dataSeries, 1)
+ testFirstDerivativeAtPosition(dataSeries, 0, 13.235332552749515) // datapoint 19
+ testSecondDerivativeAtPosition(dataSeries, 0, 4.000000000000895)
+ testLocalGoodnessOfFit(dataSeries, 0, 1)
+ testFirstDerivativeAtPosition(dataSeries, 1, 13.548121943333907) // datapoint 20
+ testSecondDerivativeAtPosition(dataSeries, 1, 4.000000000000895)
+ testLocalGoodnessOfFit(dataSeries, 1, 1)
+ testFirstDerivativeAtPosition(dataSeries, 2, 13.853851038646976) // datapoint 21
+ testSecondDerivativeAtPosition(dataSeries, 2, 4.000000000000895)
+ testLocalGoodnessOfFit(dataSeries, 2, 1)
+ testFirstDerivativeAtPosition(dataSeries, 3, 14.15297739030888) // datapoint 22
+ testSecondDerivativeAtPosition(dataSeries, 3, 4.000000000000895)
+ testLocalGoodnessOfFit(dataSeries, 3, 1)
+ testFirstDerivativeAtPosition(dataSeries, 4, 14.445911166145546) // datapoint 23
+ testSecondDerivativeAtPosition(dataSeries, 4, 4.000000000000895)
+ testLocalGoodnessOfFit(dataSeries, 4, 1)
+ testFirstDerivativeAtPosition(dataSeries, 5, 14.73302174809173) // datapoint 24
+ testSecondDerivativeAtPosition(dataSeries, 5, 4.000000000000895)
+ testLocalGoodnessOfFit(dataSeries, 5, 1)
+ testFirstDerivativeAtPosition(dataSeries, 6, 15.014643193873034) // datapoint 25
+ testSecondDerivativeAtPosition(dataSeries, 6, 4.000000000000895)
+ testLocalGoodnessOfFit(dataSeries, 6, 1)
+ testFirstDerivativeAtPosition(dataSeries, 7, 15.291078792841695) // datapoint 26
+ testSecondDerivativeAtPosition(dataSeries, 7, 4.000000000000895)
+ testLocalGoodnessOfFit(dataSeries, 7, 1)
+ testFirstDerivativeAtPosition(dataSeries, 8, 15.562604893091237) // datapoint 27
+ testSecondDerivativeAtPosition(dataSeries, 8, 4.000000000000895)
+ testLocalGoodnessOfFit(dataSeries, 8, 1)
+ testFirstDerivativeAtPosition(dataSeries, 9, 15.829474137445057) // datapoint 28
+ testSecondDerivativeAtPosition(dataSeries, 9, 4.000000000000895)
+ testLocalGoodnessOfFit(dataSeries, 9, 1)
+ testFirstDerivativeAtPosition(dataSeries, 10, 16.091918216223235) // datapoint 29
+ testSecondDerivativeAtPosition(dataSeries, 10, 4.000000000000895)
+ testLocalGoodnessOfFit(dataSeries, 10, 1)
+ testFirstDerivativeAtPosition(dataSeries, 11, 16.350150222159773) // datapoint 30
+ testSecondDerivativeAtPosition(dataSeries, 11, 4.000000000000895)
+ testLocalGoodnessOfFit(dataSeries, 11, 1)
+ dataSeries.push(3.15109166889232, 32.4631240870945) // Datapoint 31, currentDt = 0,063554113352442
+ testLength(dataSeries, 12)
+ testIsReliable(dataSeries, true)
+ testCoefficientA(dataSeries, 2.0000000000006226)
+ testCoefficientB(dataSeries, 3.9999999999965214)
+ testCoefficientC(dataSeries, 4.8263615326504805e-12)
+ testGoodnessOfFitEquals(dataSeries, 1)
+ testFirstDerivativeAtPosition(dataSeries, 0, 13.548121943333774) // datapoint 20
+ testSecondDerivativeAtPosition(dataSeries, 0, 4.000000000001245)
+ testLocalGoodnessOfFit(dataSeries, 0, 1)
+ testFirstDerivativeAtPosition(dataSeries, 1, 13.85385103864687) // datapoint 21
+ testSecondDerivativeAtPosition(dataSeries, 1, 4.000000000001245)
+ testLocalGoodnessOfFit(dataSeries, 1, 1)
+ testFirstDerivativeAtPosition(dataSeries, 2, 14.152977390308802) // datapoint 22
+ testSecondDerivativeAtPosition(dataSeries, 2, 4.000000000001245)
+ testLocalGoodnessOfFit(dataSeries, 2, 1)
+ testFirstDerivativeAtPosition(dataSeries, 3, 14.445911166145494) // datapoint 23
+ testSecondDerivativeAtPosition(dataSeries, 3, 4.000000000001245)
+ testLocalGoodnessOfFit(dataSeries, 3, 1)
+ testFirstDerivativeAtPosition(dataSeries, 4, 14.733021748091703) // datapoint 24
+ testSecondDerivativeAtPosition(dataSeries, 4, 4.000000000001245)
+ testLocalGoodnessOfFit(dataSeries, 4, 1)
+ testFirstDerivativeAtPosition(dataSeries, 5, 15.014643193873031) // datapoint 25
+ testSecondDerivativeAtPosition(dataSeries, 5, 4.000000000001245)
+ testLocalGoodnessOfFit(dataSeries, 5, 1)
+ testFirstDerivativeAtPosition(dataSeries, 6, 15.291078792841716) // datapoint 26
+ testSecondDerivativeAtPosition(dataSeries, 6, 4.000000000001245)
+ testLocalGoodnessOfFit(dataSeries, 6, 1)
+ testFirstDerivativeAtPosition(dataSeries, 7, 15.562604893091281) // datapoint 27
+ testSecondDerivativeAtPosition(dataSeries, 7, 4.000000000001245)
+ testLocalGoodnessOfFit(dataSeries, 7, 1)
+ testFirstDerivativeAtPosition(dataSeries, 8, 15.829474137445125) // datapoint 28
+ testSecondDerivativeAtPosition(dataSeries, 8, 4.000000000001245)
+ testLocalGoodnessOfFit(dataSeries, 8, 1)
+ testFirstDerivativeAtPosition(dataSeries, 9, 16.091918216223323) // datapoint 29
+ testSecondDerivativeAtPosition(dataSeries, 9, 4.000000000001245)
+ testLocalGoodnessOfFit(dataSeries, 9, 1)
+ testFirstDerivativeAtPosition(dataSeries, 10, 16.350150222159886) // datapoint 30
+ testSecondDerivativeAtPosition(dataSeries, 10, 4.000000000001245)
+ testLocalGoodnessOfFit(dataSeries, 10, 1)
+ testFirstDerivativeAtPosition(dataSeries, 11, 16.604366675569725) // datapoint 31
+ testSecondDerivativeAtPosition(dataSeries, 11, 4.000000000001245)
+ testLocalGoodnessOfFit(dataSeries, 11, 1)
+})
+
+function testLength (series, expectedValue) {
+ assert.ok(series.length() === expectedValue, `Expected value for length at X-position ${series.X.atSeriesEnd()} is ${expectedValue}, encountered a ${series.length()}`)
+}
+
+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 testLocalGoodnessOfFit (series, position, expectedValue) {
+ assert.ok(series.localGoodnessOfFit(position) === expectedValue, `Expected localGoodnessOfFit at X-position ${series.X.atSeriesEnd()} for position ${position} to be ${expectedValue}, encountered ${series.localGoodnessOfFit(position)}`)
+}
+
+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 testFirstDerivativeAtPosition (series, position, expectedValue) {
+ assert.ok(series.firstDerivativeAtPosition(position) === expectedValue, `Expected value for first derivative for position ${position} at X-position ${series.X.atSeriesEnd()} is ${expectedValue}, encountered a ${series.firstDerivativeAtPosition(position)}`)
+}
+
+function testSecondDerivativeAtPosition (series, position, expectedValue) {
+ assert.ok(series.secondDerivativeAtPosition(position) === expectedValue, `Expected value for second derivative for position ${position} at X-position ${series.X.atSeriesEnd()} is ${expectedValue}, encountered a ${series.secondDerivativeAtPosition(position)}`)
+}
+
+function testIsReliable (series, expectedValue) {
+ assert.ok(series.reliable() === expectedValue, `Expected value for isReliable at X-position ${series.X.atSeriesEnd()} is ${expectedValue}, encountered a ${series.reliable()}`)
+}
+
+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/WLSLinearSeries.js b/app/engine/utils/WLSLinearSeries.js
new file mode 100644
index 0000000000..b596616ea3
--- /dev/null
+++ b/app/engine/utils/WLSLinearSeries.js
@@ -0,0 +1,181 @@
+'use strict'
+/**
+ * @copyright [OpenRowingMonitor]{@link https://github.com/JaapvanEkris/openrowingmonitor}
+ *
+ * @file The WLSLinearSeries 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 Weighted Linear Regressor to determine the slope, intercept and R^2 of this series
+ * of x and y coordinates through Weighted Least Squares Regression.
+ *
+ * At creation it can be determined that the Series is limited (i.e. after it
+ * is filled, the oldest will be pushed out of the queue) or that the 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.
+ *
+ * This implementation uses concepts that are described here:
+ * https://www.colorado.edu/amath/sites/default/files/attached-files/ch12_0.pdf
+ *
+ * For weighted least squares:
+ * https://en.wikipedia.org/wiki/Weighted_least_squares
+ */
+import { createSeries } from './Series.js'
+
+import loglevel from 'loglevel'
+const log = loglevel.getLogger('RowingEngine')
+
+/**
+ * @param {integer} maxSeriesLength - the maximum length of the linear series, default = 0 for unlimited
+ */
+export function createWLSLinearSeries (maxSeriesLength = 0) {
+ const X = createSeries(maxSeriesLength)
+ const weight = createSeries(maxSeriesLength)
+ const WX = createSeries(maxSeriesLength)
+ const WY = createSeries(maxSeriesLength)
+ const WXX = createSeries(maxSeriesLength)
+ const WYY = createSeries(maxSeriesLength)
+ const WXY = createSeries(maxSeriesLength)
+ const Y = createSeries(maxSeriesLength)
+ let _slope = 0
+ let _intercept = 0
+ let _goodnessOfFit = 0
+
+ /**
+ * @param {float} x - the x value of the datapoint
+ * @param {float} y - the y value of the datapoint
+ * @param {float} w - the weight of the datapoint, default = 1
+ */
+ function push (x, y, w = 1) {
+ if (x === undefined || isNaN(x) || y === undefined || isNaN(y)) { return }
+
+ // Ensure weight is valid and positive
+ const _weight = (w === undefined || isNaN(w) || w <= 0) ? 1 : w
+
+ X.push(x)
+ Y.push(y)
+ weight.push(_weight)
+ WX.push(_weight * x)
+ WY.push(_weight * y)
+ WXX.push(_weight * x * x)
+ WYY.push(_weight * y * y)
+ WXY.push(_weight * x * y)
+
+ // Calculate regression parameters using Weighted Least Squares
+ const denominator = (weight.sum() * WXX.sum()) - (WX.sum() * WX.sum())
+ if (X.length() >= 2 && denominator !== 0) {
+ _slope = (weight.sum() * WXY.sum() - WX.sum() * WY.sum()) / denominator
+ _intercept = (WY.sum() - _slope * WX.sum()) / weight.sum()
+
+ // Calculate weighted R^2
+ const weighedAverageY = WY.sum() / weight.sum()
+ const sse = WYY.sum() - (2 * _intercept * WY.sum()) - (2 * _slope * WXY.sum()) +
+ (_intercept * _intercept * weight.sum()) + (2 * _slope * _intercept * WX.sum()) +
+ (_slope * _slope * WXX.sum())
+ const sst = WYY.sum() - (weighedAverageY * weighedAverageY * weight.sum())
+
+ _goodnessOfFit = (sst !== 0) ? 1 - (sse / sst) : 0
+ } else {
+ _slope = 0
+ _intercept = 0
+ _goodnessOfFit = 0
+ }
+ }
+
+ /**
+ * @returns {float} the slope of the linear function
+ */
+ function slope () {
+ return _slope
+ }
+
+ /**
+ * @returns {float} the intercept of the linear function
+ */
+ function intercept () {
+ return _intercept
+ }
+
+ /**
+ * @returns {integer} the lenght of the stored series
+ */
+ function length () {
+ return X.length()
+ }
+
+ /**
+ * @returns {float} the R^2 as a goodness of fit indicator
+ */
+ function goodnessOfFit () {
+ if (X.length() >= 2) {
+ return _goodnessOfFit
+ } else {
+ return 0
+ }
+ }
+
+ /**
+ * @param {float} x - the x value to be projected
+ * @returns {float} the resulting y value when projected via the linear function
+ */
+ function projectX (x) {
+ if (X.length() >= 2) {
+ return (_slope * x) + _intercept
+ } else {
+ return 0
+ }
+ }
+
+ /**
+ * @param {float} y - the y value to be solved
+ * @returns {float} the resulting x value when solved via the linear function
+ */
+ function projectY (y) {
+ if (X.length() >= 2 && _slope !== 0) {
+ return ((y - _intercept) / _slope)
+ } else {
+ log.error('WLS Regressor, attempted a Y-projection while slope was zero!')
+ return 0
+ }
+ }
+
+ /**
+ * @returns {boolean} whether the linear regression should be considered reliable to produce results
+ */
+ function reliable () {
+ return (X.length() >= 2 && _slope !== 0)
+ }
+
+ /**
+ * @description This function is used for clearing all data, typically when flywheel.js is completely reset
+ */
+ function reset () {
+ X.reset()
+ Y.reset()
+ weight.reset()
+ WX.reset()
+ WY.reset()
+ WXX.reset()
+ WYY.reset()
+ WXY.reset()
+ _slope = 0
+ _intercept = 0
+ _goodnessOfFit = 0
+ }
+
+ return {
+ push,
+ X,
+ Y,
+ weight,
+ slope,
+ intercept,
+ length,
+ goodnessOfFit,
+ projectX,
+ projectY,
+ reliable,
+ reset
+ }
+}
diff --git a/app/engine/utils/WLSLinearSeries.test.js b/app/engine/utils/WLSLinearSeries.test.js
new file mode 100644
index 0000000000..2d7ee4692b
--- /dev/null
+++ b/app/engine/utils/WLSLinearSeries.test.js
@@ -0,0 +1,340 @@
+'use strict'
+/**
+ * @copyright [OpenRowingMonitor]{@link https://github.com/JaapvanEkris/openrowingmonitor}
+ *
+ * @file This constains all tests for the WLS Linear Series
+ */
+import { test } from 'uvu'
+import * as assert from 'uvu/assert'
+
+import { createWLSLinearSeries } from './WLSLinearSeries.js'
+
+test('Correct behaviour of a series after initialisation', () => {
+ const dataSeries = createWLSLinearSeries(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 = createWLSLinearSeries(3)
+ testLength(dataSeries, 0)
+ dataSeries.push(5, 9, 1)
+ 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 = createWLSLinearSeries(3)
+ dataSeries.push(5, 9, 1)
+ dataSeries.push(3, 3, 1)
+ 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 = createWLSLinearSeries(3)
+ dataSeries.push(5, 9, 1)
+ dataSeries.push(3, 3, 1)
+ dataSeries.push(4, 6, 1)
+ 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 = createWLSLinearSeries(3)
+ dataSeries.push(5, 9, 1)
+ dataSeries.push(3, 3, 1)
+ dataSeries.push(4, 6, 1)
+ dataSeries.push(6, 12, 1)
+ 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 an unweighted series after several puhed values, function y = 3x - 6, noisefree, 5 datapoints', () => {
+ const dataSeries = createWLSLinearSeries(3)
+ dataSeries.push(5, 9, 1)
+ dataSeries.push(3, 3, 1)
+ dataSeries.push(4, 6, 1)
+ dataSeries.push(6, 12, 1)
+ dataSeries.push(1, -3, 1)
+ 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 uniformly weighted series after several puhed values, function y = 3x - 6, noisefree, 5 datapoints', () => {
+ const dataSeries = createWLSLinearSeries(3)
+ dataSeries.push(5, 9, 0.5)
+ dataSeries.push(3, 3, 0.5)
+ dataSeries.push(4, 6, 0.5)
+ dataSeries.push(6, 12, 0.5)
+ dataSeries.push(1, -3, 0.5)
+ 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('Series with 5 elements, with 2 noisy datapoints, ideal function y = 3x - 6, uniform weights', () => {
+ const dataSeries = createWLSLinearSeries(5)
+ dataSeries.push(5, 9, 1)
+ dataSeries.push(3, 2, 1)
+ dataSeries.push(4, 7, 1)
+ dataSeries.push(6, 12, 1)
+ dataSeries.push(1, -3, 1)
+ testSlopeEquals(dataSeries, 3.0675675675675675) // Theoretical noisefree value 3
+ testInterceptEquals(dataSeries, -6.256756756756756) // Theoretical noisefree value -6
+ testGoodnessOfFitEquals(dataSeries, 0.9863142179006205) // Ideal value 1
+ testXProjectionEquals(dataSeries, 1, -3.1891891891891886) // Theoretical noisefree value -3
+ testXProjectionEquals(dataSeries, 3, 2.9459459459459456) // Theoretical noisefree value 3
+ testXProjectionEquals(dataSeries, 4, 6.013513513513514) // Theoretical noisefree value 6
+ testXProjectionEquals(dataSeries, 5, 9.081081081081082) // Theoretical noisefree value 9
+ testXProjectionEquals(dataSeries, 6, 12.148648648648647) // Theoretical noisefree value 12
+})
+
+test('Series with 5 elements, with 2 noisy datapoints, ideal function y = 3x - 6, non-uniform weights', () => {
+ const dataSeries = createWLSLinearSeries(5)
+ dataSeries.push(5, 9, 1)
+ dataSeries.push(3, 2, 0.5)
+ dataSeries.push(4, 7, 0.5)
+ dataSeries.push(6, 12, 1)
+ dataSeries.push(1, -3, 1)
+ testSlopeEquals(dataSeries, 3.034632034632035) // Theoretical noisefree value 3
+ testInterceptEquals(dataSeries, -6.134199134199134) // Theoretical noisefree value -6
+ testGoodnessOfFitEquals(dataSeries, 0.9926631153882663) // Ideal value 1
+ testXProjectionEquals(dataSeries, 1, -3.0995670995670994) // Theoretical noisefree value -3
+ testXProjectionEquals(dataSeries, 3, 2.9696969696969706) // Theoretical noisefree value 3
+ testXProjectionEquals(dataSeries, 4, 6.004329004329005) // Theoretical noisefree value 6
+ testXProjectionEquals(dataSeries, 5, 9.03896103896104) // Theoretical noisefree value 9
+ testXProjectionEquals(dataSeries, 6, 12.073593073593075) // Theoretical noisefree value 12
+})
+
+// Test based on the Galton dataset, using unweighted (=OLS) regression
+// Example found at https://online.stat.psu.edu/stat501/lesson/13/13.1
+test('Unweighted series with 7 elements based on Galton dataset (OLS)', () => {
+ const dataSeries = createWLSLinearSeries(7)
+ dataSeries.push(0.21, 0.1726, 1)
+ dataSeries.push(0.2, 0.1707, 1)
+ dataSeries.push(0.19, 0.1637, 1)
+ dataSeries.push(0.18, 0.164, 1)
+ dataSeries.push(0.17, 0.1613, 1)
+ dataSeries.push(0.16, 0.1617, 1)
+ dataSeries.push(0.15, 0.1598, 1)
+ testSlopeEquals(dataSeries, 0.2100000000000111)
+ testInterceptEquals(dataSeries, 0.12702857142856944)
+ testGoodnessOfFitEquals(dataSeries, 0.8553954556248868)
+})
+
+// Test based on the Galton dataset, using weighted (=WLS) regression
+// Example found at https://online.stat.psu.edu/stat501/lesson/13/13.1
+test('Non-uniformly weighted series with 7 elements based on Galton dataset (WLS)', () => {
+ const dataSeries = createWLSLinearSeries(7)
+ dataSeries.push(0.21, 0.1726, 2530.272176)
+ dataSeries.push(0.2, 0.1707, 2662.5174)
+ dataSeries.push(0.19, 0.1637, 2781.783546)
+ dataSeries.push(0.18, 0.164, 2410.004991)
+ dataSeries.push(0.17, 0.1613, 3655.35019)
+ dataSeries.push(0.16, 0.1617, 3935.712498)
+ dataSeries.push(0.15, 0.1598, 3217.328273)
+ testSlopeEquals(dataSeries, 0.20480116324222641)
+ testInterceptEquals(dataSeries, 0.12796416521509518)
+ testGoodnessOfFitEquals(dataSeries, 0.8521213232768868)
+})
+
+test('Correct reset behaviour. Series with 4 datapoints and a reset', () => {
+ const dataSeries = createWLSLinearSeries(3)
+ dataSeries.push(5, 9, 1)
+ dataSeries.push(3, 3, 1)
+ dataSeries.push(4, 6, 1)
+ dataSeries.push(6, 12, 1)
+ 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)
+})
+
+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 testInterceptEquals (series, expectedValue) {
+ assert.ok(series.intercept() === expectedValue, `Expected intercept to be ${expectedValue}, encountered ${series.intercept()}`)
+}
+
+function testGoodnessOfFitEquals (series, expectedValue) {
+ assert.ok(series.goodnessOfFit() === expectedValue, `Expected goodnessOfFit to be ${expectedValue}, encountered ${series.goodnessOfFit()}`)
+}
+
+function testXProjectionEquals (series, value, expectedValue) {
+ assert.ok(series.projectX(value) === expectedValue, `Expected projectX at value ${value} to be ${expectedValue}, encountered ${series.projectX(value)}`)
+}
+
+test.run()
diff --git a/app/engine/utils/WeighedMedianSeries.js b/app/engine/utils/WeighedMedianSeries.js
new file mode 100644
index 0000000000..4a9ce1d37b
--- /dev/null
+++ b/app/engine/utils/WeighedMedianSeries.js
@@ -0,0 +1,214 @@
+'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 This object uses BinairySearchTrees for determining the Median, Min and Max values, making it usefull for larger series without hurting the CPU too much
+ *
+ * @param {number} [maxSeriesLength] The maximum length of the series (0 for unlimited)
+ */
+import { createLabelledBinarySearchTree } from './BinarySearchTree.js'
+
+export function createWeighedMedianSeries (maxSeriesLength = 0) {
+ /**
+ * @type {Array}
+ */
+ const binarySearchTree = createLabelledBinarySearchTree()
+ let positionArray = []
+ let seriesArray = []
+ let seriesSum = 0
+
+ /**
+ * @param {float} unique identifyer for destroying the datapoint
+ * @param {float} value to be added to the series
+ * @param {float} weight of value
+ */
+ function push (position, value, weight) {
+ if (value === undefined || isNaN(value)) { return }
+
+ binarySearchTree.push(position, value, weight)
+ // As we manage the size of the series based on weight, a single new value might trigger the removal of two old ones
+ if (maxSeriesLength > 0 && binarySearchTree.totalWeight() >= maxSeriesLength) { removeHead() }
+ if (maxSeriesLength > 0 && binarySearchTree.totalWeight() >= maxSeriesLength) { removeHead() }
+ seriesArray.push(value)
+ positionArray.push(position)
+ seriesSum += value
+ }
+
+ function removeHead () {
+ // The maximum of the array has been reached, we have to create room by removing the first
+ // value from the array
+ seriesSum -= seriesArray[0]
+ binarySearchTree.remove(positionArray[0])
+ positionArray.shift()
+ seriesArray.shift()
+ }
+
+ /**
+ * @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 (seriesArray.length > 0) {
+ return binarySearchTree.numberOfValuesAbove(testedValue)
+ } else {
+ return 0
+ }
+ }
+
+ /**
+ * @param {number} testedValue
+ * @output {number} number of values in the series below or equal to the tested value
+ */
+ function numberOfValuesEqualOrBelow (testedValue) {
+ if (seriesArray.length > 0) {
+ return binarySearchTree.numberOfValuesEqualOrBelow(testedValue)
+ } else {
+ return 0
+ }
+ }
+
+ /**
+ * @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) {
+ return binarySearchTree.minimum()
+ } else {
+ return 0
+ }
+ }
+
+ /**
+ * @output {float} largest value in the series
+ */
+ function maximum () {
+ if (seriesArray.length > 0) {
+ return binarySearchTree.maximum()
+ } else {
+ return 0
+ }
+ }
+
+ /**
+ * @output {float} median of the series
+ */
+ function median () {
+ if (seriesArray.length > 0) {
+ return binarySearchTree.median()
+ } else {
+ return 0
+ }
+ }
+
+ /**
+ * @output {float} weighed median of the series
+ */
+ function weighedMedian () {
+ if (seriesArray.length > 0) {
+ return binarySearchTree.weightedMedian()
+ } 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 () {
+ binarySearchTree.reset()
+ seriesArray = /** @type {Array} */(/** @type {unknown} */(null))
+ positionArray = []
+ seriesArray = []
+ seriesSum = 0
+ }
+
+ return {
+ push,
+ length,
+ atSeriesBegin,
+ atSeriesEnd,
+ get,
+ numberOfValuesAbove,
+ numberOfValuesEqualOrBelow,
+ sum,
+ average,
+ minimum,
+ maximum,
+ median,
+ weighedMedian,
+ series,
+ reset
+ }
+}
diff --git a/app/engine/utils/WeighedSeries.js b/app/engine/utils/WeighedSeries.js
new file mode 100644
index 0000000000..e5568ca1b7
--- /dev/null
+++ b/app/engine/utils/WeighedSeries.js
@@ -0,0 +1,168 @@
+'use strict'
+/**
+ * @copyright [OpenRowingMonitor]{@link https://github.com/JaapvanEkris/openrowingmonitor}
+ *
+ * @file This creates a weighed series with a maximum number of values. It allows for determining the Average, Weighed Averge, Median, Number of Positive, number of Negative. DO NOT USE MEDIAN ON LARGE SERIES!
+ */
+import { createSeries } from './Series.js'
+
+/**
+ * @param {integer} the maximum length of the weighed series, 0 for unlimited
+ * @param {float|undefined} the default value to return if a function can't calculate a value
+ */
+export function createWeighedSeries (maxSeriesLength = 0, defaultValue) {
+ const dataArray = createSeries(maxSeriesLength)
+ const weightArray = createSeries(maxSeriesLength)
+ const weightedArray = createSeries(maxSeriesLength)
+
+ /**
+ * @param {float} the value of the datapoint
+ * @param {float} the weight of the datapoint
+ */
+ function push (value, weight) {
+ if (value === undefined || isNaN(value) || weight === undefined || isNaN(weight)) { return }
+ dataArray.push(value)
+ weightArray.push(weight)
+ weightedArray.push(value * weight)
+ }
+
+ /**
+ * @returns {integer} the lenght of the stored series
+ */
+ function length () {
+ return dataArray.length()
+ }
+
+ /**
+ * @returns {float} the oldest value of the series (i.e. the one first added)
+ */
+ function atSeriesBegin () {
+ return dataArray.atSeriesBegin()
+ }
+
+ /**
+ * @returns {float} the youngest value of the series (i.e. the one last added)
+ */
+ function atSeriesEnd () {
+ return dataArray.atSeriesEnd()
+ }
+
+ /**
+ * @param {integer} position to be retrieved, starting at 0
+ * @returns {float} value at that specific postion in the series
+ */
+ function get (position) {
+ return dataArray.get(position)
+ }
+
+ /**
+ * @param {float} tested value
+ * @returns {integer} count of values in the series above the tested value
+ */
+ function numberOfValuesAbove (testedValue) {
+ return dataArray.numberOfValuesAbove(testedValue)
+ }
+
+ /**
+ * @param {float} tested value
+ * @returns {integer} number of values in the series below or equal to the tested value
+ */
+ function numberOfValuesEqualOrBelow (testedValue) {
+ return dataArray.numberOfValuesEqualOrBelow(testedValue)
+ }
+
+ /**
+ * @returns {float} sum of the entire series
+ */
+ function sum () {
+ return dataArray.sum()
+ }
+
+ /**
+ * @returns {float} average of the entire series
+ */
+ 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
+ }
+ }
+
+ /**
+ * @returns {float} the weighed average of the series
+ */
+ function weighedAverage () {
+ if (dataArray.length() > 0 && weightArray.sum() !== 0) {
+ return (weightedArray.sum() / weightArray.sum())
+ } else {
+ return defaultValue
+ }
+ }
+
+ /**
+ * @returns {float} smallest element in the series
+ */
+ function minimum () {
+ return dataArray.minimum()
+ }
+
+ /**
+ * @returns {float} largest value in the series
+ */
+ function maximum () {
+ return dataArray.maximum()
+ }
+
+ /**
+ * @returns {float} median of the series
+ * @description returns the median of the series. As this is a CPU intensive approach, DO NOT USE FOR LARGE SERIES!. For larger series, use the BinarySearchTree.js instead
+ */
+ function median () {
+ return dataArray.median()
+ }
+
+ /**
+ * @returns {boolean} if the weighed series results are to be considered reliable
+ */
+ function reliable () {
+ return dataArray.length() > 0
+ }
+
+ /**
+ * @returns {array} returns the entire series of datapoints
+ */
+ function series () {
+ return dataArray.series()
+ }
+
+ /**
+ * Resets the series to its initial state
+ */
+ 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/WeighedSeries.test.js b/app/engine/utils/WeighedSeries.test.js
new file mode 100644
index 0000000000..93928deb24
--- /dev/null
+++ b/app/engine/utils/WeighedSeries.test.js
@@ -0,0 +1,228 @@
+'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 { createWeighedSeries } from './WeighedSeries.js'
+
+test('Series behaviour with an empty series', () => {
+ const dataSeries = createWeighedSeries(3, undefined)
+ 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, undefined)
+ testWeighedAverage(dataSeries, undefined)
+ testMedian(dataSeries, 0)
+ testMinimum(dataSeries, 0)
+ testMaximum(dataSeries, 0)
+})
+
+test('Series behaviour with a single pushed value. Series = [9]', () => {
+ const dataSeries = createWeighedSeries(3, undefined)
+ dataSeries.push(9, 1)
+ 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)
+ testWeighedAverage(dataSeries, 9)
+ testMedian(dataSeries, 9)
+ testMinimum(dataSeries, 9)
+ testMaximum(dataSeries, 9)
+})
+
+test('Series behaviour with a second pushed value. Series = [9, 3]', () => {
+ const dataSeries = createWeighedSeries(3, undefined)
+ dataSeries.push(9, 1)
+ dataSeries.push(3, 0)
+ 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)
+ testWeighedAverage(dataSeries, 9)
+ testMedian(dataSeries, 6)
+ testMinimum(dataSeries, 3)
+ testMaximum(dataSeries, 9)
+})
+
+test('Series behaviour with a third pushed value. Series = [9, 3, 6]', () => {
+ const dataSeries = createWeighedSeries(3, undefined)
+ dataSeries.push(9, 1)
+ dataSeries.push(3, 0)
+ dataSeries.push(6, 1)
+ 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)
+ testWeighedAverage(dataSeries, 7.5)
+ testMedian(dataSeries, 6)
+ testMinimum(dataSeries, 3)
+ testMaximum(dataSeries, 9)
+})
+
+test('Series behaviour with a fourth pushed value. Series = [3, 6, 12]', () => {
+ const dataSeries = createWeighedSeries(3, undefined)
+ dataSeries.push(9, 0)
+ dataSeries.push(3, 0)
+ dataSeries.push(6, 1)
+ dataSeries.push(12, 1)
+ 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)
+ testWeighedAverage(dataSeries, 9)
+ testMedian(dataSeries, 6)
+ testMinimum(dataSeries, 3)
+ testMaximum(dataSeries, 12)
+})
+
+test('Series behaviour with a fifth pushed value. Series = [6, 12, -3]', () => {
+ const dataSeries = createWeighedSeries(3, undefined)
+ dataSeries.push(9, 1)
+ dataSeries.push(3, 1)
+ dataSeries.push(6, 1)
+ dataSeries.push(12, 1)
+ dataSeries.push(-3, 0.5)
+ 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)
+ testWeighedAverage(dataSeries, 6.6)
+ 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 = createWeighedSeries(3, undefined)
+ dataSeries.push(9, 1)
+ dataSeries.push(3, 1)
+ dataSeries.push(6, 1)
+ testLength(dataSeries, 3)
+ testMinimum(dataSeries, 3)
+ testMaximum(dataSeries, 9)
+ dataSeries.push(6, 1)
+ testLength(dataSeries, 3)
+ testMinimum(dataSeries, 3)
+ testMaximum(dataSeries, 6)
+ dataSeries.push(6, 1)
+ testLength(dataSeries, 3)
+ testMinimum(dataSeries, 6)
+ testMaximum(dataSeries, 6)
+})
+
+test('Series behaviour pushing out the min and max value, replacing them just in time.', () => {
+ const dataSeries = createWeighedSeries(3, undefined)
+ dataSeries.push(9, 1)
+ dataSeries.push(3, 1)
+ dataSeries.push(6, 1)
+ testMinimum(dataSeries, 3)
+ testMaximum(dataSeries, 9)
+ dataSeries.push(12, 1)
+ testMinimum(dataSeries, 3)
+ testMaximum(dataSeries, 12)
+ dataSeries.push(1, 1)
+ testMinimum(dataSeries, 1)
+ testMaximum(dataSeries, 12)
+})
+
+test('Series behaviour with a five pushed values followed by a reset, Series = []', () => {
+ const dataSeries = createWeighedSeries(3, undefined)
+ dataSeries.push(9, 1)
+ dataSeries.push(3, 1)
+ dataSeries.push(6, 1)
+ dataSeries.push(12, 1)
+ dataSeries.push(-3, 1)
+ 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, undefined)
+ testWeighedAverage(dataSeries, undefined)
+ 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 testWeighedAverage (series, expectedValue) {
+ assert.ok(series.weighedAverage() === expectedValue, `Expected weighedAverage to be ${expectedValue}, encountered ${series.weighedAverage()}`)
+}
+
+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/curveMetrics.js b/app/engine/utils/curveMetrics.js
index 4725ae74c7..92f809937b 100644
--- a/app/engine/utils/curveMetrics.js
+++ b/app/engine/utils/curveMetrics.js
@@ -1,22 +1,26 @@
'use strict'
-/*
- Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
-
- This keeps an array, for all in-stroke metrics
-*/
+/**
+ * @copyright {@link https://github.com/JaapvanEkris/openrowingmonitor|OpenRowingMonitor}
+ *
+ * @file This keeps an array, for all in-stroke metrics
+ */
import { createSeries } from './Series.js'
-function createCurveMetrics (precission = 0) {
+export function createCurveMetrics () {
const _curve = createSeries()
let _max = 0
+ let _peakPosition = 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.toFixed(precission))
- _max = Math.max(_max, inputValue)
+ _curve.push(inputValue)
+ if (inputValue >= _max) {
+ _peakPosition = _curve.length()
+ _max = Math.max(_max, inputValue)
+ }
totalInputXTime += deltaTime * inputValue
totaltime += deltaTime
} else {
@@ -33,6 +37,14 @@ function createCurveMetrics (precission = 0) {
}
}
+ function peakNormalizedPosition () {
+ if (_max > 0 && _peakPosition > 0 && _curve.length() > 0) {
+ return (_peakPosition / _curve.length())
+ } else {
+ return 0
+ }
+ }
+
function average () {
if (totaltime > 0 && totalInputXTime > 0) {
return totalInputXTime / totaltime
@@ -56,6 +68,7 @@ function createCurveMetrics (precission = 0) {
function reset () {
_curve.reset()
_max = 0
+ _peakPosition = 0
totalInputXTime = 0
totaltime = 0
}
@@ -63,11 +76,10 @@ function createCurveMetrics (precission = 0) {
return {
push,
peak,
+ peakNormalizedPosition,
average,
curve,
length,
reset
}
}
-
-export { createCurveMetrics }
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..d1e2fbd192
--- /dev/null
+++ b/app/engine/utils/workoutSegment.js
@@ -0,0 +1,809 @@
+'use strict'
+/**
+ * @copyright {@link https://github.com/JaapvanEkris/openrowingmonitor|OpenRowingMonitor}
+ *
+ * @file 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 checks on individual metrics, so it is long */
+import { createWLSLinearSeries } from './WLSLinearSeries.js'
+import { createInfiniteSeriesMetrics } from './InfiniteSeriesMetrics.js'
+import loglevel from 'loglevel'
+const log = loglevel.getLogger('RowingEngine')
+
+/**
+ * @description This function creates a workoutsegment
+ */
+export function createWorkoutSegment (config) {
+ const numOfDataPointsForAveraging = config.numOfPhasesForAveragingScreenData
+ const distanceOverTime = createWLSLinearSeries(Math.min(4, numOfDataPointsForAveraging))
+ const caloriesOverTime = createWLSLinearSeries(Math.min(4, numOfDataPointsForAveraging))
+ const _averageForce = createInfiniteSeriesMetrics()
+ const _power = createInfiniteSeriesMetrics()
+ const _linearVelocity = createInfiniteSeriesMetrics()
+ const _strokerate = createInfiniteSeriesMetrics()
+ const _strokedistance = createInfiniteSeriesMetrics()
+ const _caloriesPerHour = createInfiniteSeriesMetrics()
+ const _dragFactor = createInfiniteSeriesMetrics()
+ let _type = 'justrow'
+ let _startTimestamp
+ let _startMovingTime = 0
+ let _startLinearDistance = 0
+ let _startStrokeNumber = 0
+ let _startWork = 0
+ let _startCalories = 0
+ let _targetTime = 0
+ let _targetDistance = 0
+ let _targetCalories = 0
+ let _endMovingTime = 0
+ let _endLinearDistance = 0
+ let _endCalories = 0
+ let _totalNumberIntervals = 0
+ let _split = {
+ type: 'justrow',
+ targetDistance: 0,
+ targetTime: 0
+ }
+
+ /**
+ * @description This function sets the starting point of a workout segment
+ */
+ 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
+ _startWork = baseMetrics.totalWork
+ _startCalories = baseMetrics.totalCalories
+ _startStrokeNumber = baseMetrics.totalNumberOfStrokes
+ }
+
+ function setStartTimestamp (timestamp) {
+ _startTimestamp = timestamp
+ }
+
+ function getStartTimestamp () {
+ return _startTimestamp
+ }
+
+ /**
+ * @description This function summarizes a group of intervals into a single workout
+ */
+ function summarize (intervals) {
+ let intervalNumber = 0
+ let totalDistance = 0
+ let totalTime = 0
+ let totalCalories = 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 === 'calories' && intervals[intervalNumber].targetCalories > 0):
+ totalCalories = totalCalories + intervals[intervalNumber].targetCalories
+ 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 && totalCalories === 0):
+ setEnd({ type: 'distance', targetDistance: totalDistance })
+ break
+ case (totalTime > 0 && totalDistance === 0 && totalCalories === 0):
+ setEnd({ type: 'time', targetTime: totalTime })
+ break
+ case (totalCalories > 0 && totalTime === 0 && totalDistance === 0):
+ setEnd({ type: 'calories', targetCalories: totalCalories })
+ break
+ default:
+ setEnd({ type: 'justrow' })
+ }
+ break
+ default:
+ setEnd({ type: 'justrow' })
+ }
+ }
+
+ /**
+ * @description This function sets the segment parameters used
+ */
+ function setEnd (intervalSettings) {
+ // Set the primairy parameters
+ switch (true) {
+ case (intervalSettings.type === 'rest' && Number(intervalSettings.targetTime) > 0):
+ // A target time is set for a rest interval
+ _type = 'rest'
+ _targetTime = Number(intervalSettings.targetTime)
+ _targetDistance = 0
+ _targetCalories = 0
+ _endMovingTime = _startMovingTime + Number(intervalSettings.targetTime)
+ _endLinearDistance = 0
+ _endCalories = 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
+ _targetCalories = 0
+ _endMovingTime = _startMovingTime
+ _endLinearDistance = 0
+ _endCalories = 0
+ log.debug(` Workout parser, recognised undetermined ${_type} interval`)
+ break
+ case (intervalSettings.type === 'distance' && Number(intervalSettings.targetDistance) > 0):
+ // A target distance is set
+ _type = 'distance'
+ _targetTime = 0
+ _targetDistance = Number(intervalSettings.targetDistance)
+ _targetCalories = 0
+ _endMovingTime = 0
+ _endLinearDistance = _startLinearDistance + Number(intervalSettings.targetDistance)
+ _endCalories = 0
+ log.debug(` Workout parser, recognised ${_type} interval/split, ${_targetDistance} meters`)
+ break
+ case (intervalSettings.type === 'time' && Number(intervalSettings.targetTime) > 0):
+ // A target time is set
+ _type = 'time'
+ _targetTime = Number(intervalSettings.targetTime)
+ _targetDistance = 0
+ _targetCalories = 0
+ _endMovingTime = _startMovingTime + Number(intervalSettings.targetTime)
+ _endLinearDistance = 0
+ _endCalories = 0
+ log.debug(` Workout parser, recognised ${_type} interval/split, ${_targetTime} seconds`)
+ break
+ case (intervalSettings.type === 'calories' && Number(intervalSettings.targetCalories) > 0):
+ // A target time is set
+ _type = 'calories'
+ _targetTime = 0
+ _targetDistance = 0
+ _targetCalories = Number(intervalSettings.targetCalories)
+ _endMovingTime = 0
+ _endLinearDistance = 0
+ _endCalories = _startCalories + Number(intervalSettings.targetCalories)
+ log.debug(` Workout parser, recognised ${_type} interval/split, ${_targetCalories} kCal`)
+ break
+ case (intervalSettings.type === 'justrow'):
+ _type = 'justrow'
+ _targetTime = 0
+ _targetDistance = 0
+ _targetCalories = 0
+ _endMovingTime = 0
+ _endLinearDistance = 0
+ _endCalories = 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
+ _targetCalories = 0
+ _endMovingTime = 0
+ _endLinearDistance = 0
+ _endCalories = 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,
+ targetCalories: 0
+ }
+ break
+ case (!!intervalSettings.split && intervalSettings.split !== undefined && intervalSettings.split.type === 'distance' && Number(intervalSettings.split.targetDistance) > 0):
+ // A target distance is set
+ _split = {
+ type: 'distance',
+ targetDistance: Number(intervalSettings.split.targetDistance),
+ targetTime: 0,
+ targetCalories: 0
+ }
+ break
+ case (!!intervalSettings.split && intervalSettings.split !== undefined && intervalSettings.split.type === 'time' && Number(intervalSettings.split.targetTime) > 0):
+ // A target time is set
+ _split = {
+ type: 'time',
+ targetDistance: 0,
+ targetTime: Number(intervalSettings.split.targetTime),
+ targetCalories: 0
+ }
+ break
+ case (!!intervalSettings.split && intervalSettings.split !== undefined && intervalSettings.split.type === 'calories' && Number(intervalSettings.split.targetCalories) > 0):
+ // A target time is set
+ _split = {
+ type: 'calories',
+ targetDistance: 0,
+ targetTime: 0,
+ targetCalories: Number(intervalSettings.split.targetCalories)
+ }
+ break
+ case (!!intervalSettings.split && intervalSettings.split !== undefined && intervalSettings.split.type === 'justrow'):
+ _split = {
+ type: _type,
+ targetDistance: _targetDistance,
+ targetTime: _targetTime,
+ targetCalories: _targetCalories
+ }
+ break
+ case (!intervalSettings.split):
+ // Split is left empty, we default to the entire interval
+ _split = {
+ type: _type,
+ targetDistance: _targetDistance,
+ targetTime: _targetTime,
+ targetCalories: _targetCalories
+ }
+ break
+ default:
+ log.error(`Workout parser, unknown split type '${intervalSettings.split.type}', defaulting to copying interval type`)
+ _split = {
+ type: _type,
+ targetDistance: _targetDistance,
+ targetTime: _targetTime,
+ targetCalories: _targetCalories
+ }
+ }
+ }
+
+ /**
+ * @returns {string} the type of the workoutSegment
+ */
+ function type () {
+ return _type
+ }
+
+ /*
+ * @description This function is return the underlying split of a workoutsegment (typically an interval)
+ */
+ function getSplit () {
+ return _split
+ }
+
+ /**
+ * @returns {boolean} If the boundary of the planned segment has been reached
+ */
+ function isEndReached (baseMetrics) {
+ switch (_type) {
+ case 'distance':
+ if (_endLinearDistance > 0 && baseMetrics.totalLinearDistance >= _endLinearDistance) {
+ return true
+ } else {
+ return false
+ }
+ case 'time':
+ if (_endMovingTime > 0 && baseMetrics.totalMovingTime >= _endMovingTime) {
+ return true
+ } else {
+ return false
+ }
+ case 'calories':
+ if (_endCalories > 0 && baseMetrics.totalCalories >= _endCalories) {
+ return true
+ } else {
+ return false
+ }
+ default:
+ return false
+ }
+ }
+
+ /**
+ * @description This function returns the remaining split (used for managing unplanned pauses)
+ */
+ function remainder (baseMetrics) {
+ switch (_type) {
+ case ('distance'):
+ return {
+ type: _type,
+ targetDistance: distanceToEnd(baseMetrics)
+ }
+ case ('time'):
+ return {
+ type: _type,
+ targetTime: timeToEnd(baseMetrics)
+ }
+ case ('calories'):
+ return {
+ type: _type,
+ targetCalories: caloriesToEnd(baseMetrics)
+ }
+ default:
+ return {
+ type: _type,
+ targetTime: 0
+ }
+ }
+ }
+
+ /**
+ * @description Updates projectiondata and segment metrics
+ */
+ function push (baseMetrics) {
+ distanceOverTime.push(baseMetrics.totalMovingTime, baseMetrics.totalLinearDistance, 1)
+ caloriesOverTime.push(baseMetrics.totalMovingTime, baseMetrics.totalCalories, 1)
+ if (!!baseMetrics.cyclePower && !isNaN(baseMetrics.cyclePower) && baseMetrics.cyclePower > 0) { _power.push(baseMetrics.cyclePower) }
+ if (!!baseMetrics.driveAverageHandleForce && !isNaN(baseMetrics.driveAverageHandleForce) && baseMetrics.driveAverageHandleForce > 0) { _averageForce.push(baseMetrics.driveAverageHandleForce) }
+ 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) }
+ }
+
+ /*
+ * @description This function is used to precisely calculate the end of a workout segment after the sessionManager conlcudes it has passed the workoutSegment's boundary
+ */
+ 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.totalLinearDistance = _endLinearDistance
+ projectedMetrics.totalCalories = interpolatedCaloriesFromTime(prevMetrics, currMetrics, projectedMetrics.totalMovingTime)
+ 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.totalCalories = interpolatedCaloriesFromTime(prevMetrics, currMetrics, _endMovingTime)
+ projectedMetrics.modified = true
+ break
+ case (_type === 'calories' && _endCalories > 0 && currMetrics.totalCalories > _endCalories):
+ // We are in a calorie based interval, and overshot the targetCalories
+ projectedMetrics.totalCalories = _endCalories
+ projectedMetrics.totalMovingTime = interpolatedTimeFromCalories(prevMetrics, currMetrics, _endCalories)
+ projectedMetrics.totalLinearDistance = interpolatedDistance(prevMetrics, currMetrics, projectedMetrics.totalMovingTime)
+ 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
+ }
+
+ /*
+ * @description This function is used to precisely calculate the end time of a workout segment based on a target distance
+ * @see {@link https://en.wikipedia.org/wiki/Linear_interpolation|the math behind interpolation}
+ * @returns {float} the exact time where the distance barrier was crossed
+ */
+ function interpolatedTime (prevMetrics, currMetrics, targetDistance) {
+ if (prevMetrics.totalLinearDistance < targetDistance && targetDistance < currMetrics.totalLinearDistance) {
+ return (prevMetrics.totalMovingTime + ((currMetrics.totalMovingTime - prevMetrics.totalMovingTime) * ((targetDistance - prevMetrics.totalLinearDistance) / (currMetrics.totalLinearDistance - prevMetrics.totalLinearDistance))))
+ } else {
+ return currMetrics.totalMovingTime
+ }
+ }
+
+ /*
+ * @description This function is used to precisely calculate the end distance of a workout segment based on a target time
+ * @see {@link https://en.wikipedia.org/wiki/Linear_interpolation|the math behind interpolation}
+ * @returns {float} the exact distance where the time barrier was crossed
+ */
+ function interpolatedDistance (prevMetrics, currMetrics, targetTime) {
+ if (prevMetrics.totalMovingTime < targetTime && targetTime < currMetrics.totalMovingTime) {
+ return (prevMetrics.totalLinearDistance + ((currMetrics.totalLinearDistance - prevMetrics.totalLinearDistance) * ((targetTime - prevMetrics.totalMovingTime) / (currMetrics.totalMovingTime - prevMetrics.totalMovingTime))))
+ } else {
+ return currMetrics.totalLinearDistance
+ }
+ }
+
+ /*
+ * @description This function is used to precisely calculate the end time of a workout segment based on a target calories
+ * @see {@link https://en.wikipedia.org/wiki/Linear_interpolation|the math behind interpolation}
+ * @returns {float} the exact time where the calories barrier was crossed
+ */
+ function interpolatedTimeFromCalories (prevMetrics, currMetrics, targetCalories) {
+ if (prevMetrics.totalCalories < targetCalories && targetCalories < currMetrics.totalCalories) {
+ return (prevMetrics.totalMovingTime + ((currMetrics.totalMovingTime - prevMetrics.totalMovingTime) * ((targetCalories - prevMetrics.totalCalories) / (currMetrics.totalCalories - prevMetrics.totalCalories))))
+ } else {
+ return currMetrics.totalMovingTime
+ }
+ }
+
+ /*
+ * @description This function is used to precisely calculate the end calories of a workout segment based on a target time
+ * @see {@link https://en.wikipedia.org/wiki/Linear_interpolation|the math behind interpolation}
+ * @returns {float} the exact calories where the time barrier was crossed
+ */
+ function interpolatedCaloriesFromTime (prevMetrics, currMetrics, targetTime) {
+ if (prevMetrics.totalMovingTime < targetTime && targetTime < currMetrics.totalMovingTime) {
+ // See https://en.wikipedia.org/wiki/Linear_interpolation
+ return (prevMetrics.totalCalories + ((currMetrics.totalCalories - prevMetrics.totalCalories) * ((targetTime - prevMetrics.totalMovingTime) / (currMetrics.totalMovingTime - prevMetrics.totalMovingTime))))
+ } else {
+ return currMetrics.totalCalories
+ }
+ }
+
+ /**
+ * @description This function returns all the workoutSegment metrics for the current workoutSegment
+ */
+ 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: movingTimeSinceStart(baseMetrics),
+ target: targetTime(),
+ toEnd: timeToEnd(baseMetrics),
+ projectedEnd: projectedEndTime()
+ },
+ timeSpent: {
+ total: totalTime(baseMetrics),
+ moving: movingTimeSinceStart(baseMetrics),
+ rest: Math.max(restTime(baseMetrics), 0)
+ },
+ 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()
+ },
+ averageForce: {
+ average: _averageForce.average(),
+ minimum: _averageForce.minimum(),
+ maximum: _averageForce.maximum()
+ },
+ work: {
+ absoluteStart: _startWork,
+ sinceStart: Math.max(baseMetrics.totalWork - _startWork, 0)
+ },
+ calories: {
+ absoluteStart: _startCalories,
+ sinceStart: spentCalories(baseMetrics),
+ target: targetCalories(),
+ toEnd: caloriesToEnd(baseMetrics),
+ totalSpent: spentCalories(baseMetrics),
+ averagePerHour: _caloriesPerHour.average()
+ },
+ caloriesSpent: {
+ total: totalCalories(baseMetrics),
+ moving: spentCalories(baseMetrics),
+ rest: restCalories(baseMetrics)
+ },
+ 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()
+ }
+ }
+ }
+
+ /**
+ * @returns {number} the number of strokes since the start of the segment
+ */
+ function numberOfStrokes (baseMetrics) {
+ if (!isNaN(_startStrokeNumber) && _startStrokeNumber >= 0 && !isNaN(baseMetrics.totalNumberOfStrokes) && baseMetrics.totalNumberOfStrokes > _startStrokeNumber) {
+ return baseMetrics.totalNumberOfStrokes - _startStrokeNumber
+ } else {
+ return 0
+ }
+ }
+
+ /**
+ * @returns {float} the distance from the start of the workoutsegment
+ */
+ function distanceFromStart (baseMetrics) {
+ if (!isNaN(_startLinearDistance) && _startLinearDistance >= 0 && !isNaN(baseMetrics.totalLinearDistance) && baseMetrics.totalLinearDistance > _startLinearDistance) {
+ return baseMetrics.totalLinearDistance - _startLinearDistance
+ } else {
+ return 0
+ }
+ }
+
+ /**
+ * @returns {float} the target distance for this workoutsegment from the workout plan (only if type === 'distance')
+ */
+ function targetDistance () {
+ if (_type === 'distance' && _endLinearDistance > 0) {
+ return _targetDistance
+ } else {
+ return undefined
+ }
+ }
+
+ /**
+ * @returns {float} the remaining distance to the end of the workoutsegment
+ */
+ function distanceToEnd (baseMetrics) {
+ if (_type === 'distance' && _endLinearDistance > 0) {
+ // We have set a distance boundary
+ return _endLinearDistance - baseMetrics.totalLinearDistance
+ } else {
+ return undefined
+ }
+ }
+
+ /**
+ * @returns {float} the projected distance at the end of the workoutsegment (if type !== 'distance')
+ */
+ function projectedEndDistance () {
+ switch (true) {
+ case (_type === 'distance' && _endLinearDistance > 0):
+ return _targetDistance
+ case (_type === 'time' && _endMovingTime > 0 && distanceOverTime.reliable()):
+ // We are in a time based interval, so we need to project
+ return (distanceOverTime.projectX(_endMovingTime) - _startLinearDistance)
+ case (_type === 'calories' && _endCalories > 0 && distanceOverTime.reliable() && caloriesOverTime.reliable()):
+ return (distanceOverTime.projectX(caloriesOverTime.projectY(_endCalories)) - _startLinearDistance)
+ default:
+ return undefined
+ }
+ }
+
+ /**
+ * @returns {float} the moving time since the start of the workoutsegment
+ */
+ function movingTimeSinceStart (baseMetrics) {
+ if (!isNaN(_startMovingTime) && _startMovingTime >= 0 && !isNaN(baseMetrics.totalMovingTime) && baseMetrics.totalMovingTime > _startMovingTime) {
+ return Math.max(baseMetrics.totalMovingTime - _startMovingTime, 0)
+ } else {
+ return 0
+ }
+ }
+
+ /**
+ * @returns {float} the target time for this workoutsegment from the workout plan (only if type === 'time')
+ */
+ function targetTime () {
+ if (_type === 'time' && _endMovingTime > 0) {
+ // We have a distance boundary
+ return _targetTime
+ } else {
+ return undefined
+ }
+ }
+
+ /**
+ * @returns {float} the remaining time to the end of the workoutsegment
+ */
+ function timeToEnd (baseMetrics) {
+ if ((_type === 'time' || _type === 'rest') && _endMovingTime > 0) {
+ // We are in a time based interval
+ return _endMovingTime - baseMetrics.totalMovingTime
+ } else {
+ return undefined
+ }
+ }
+
+ /**
+ * @returns {float} the projected time to the end of the workoutsegment (if type !== 'time')
+ */
+ 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) - _startMovingTime)
+ case (_type === 'time' && _endMovingTime > 0):
+ return _targetTime
+ case (_type === 'calories' && _endCalories > 0 && caloriesOverTime.reliable()):
+ return (caloriesOverTime.projectY(_endCalories) - _startMovingTime)
+ default:
+ return undefined
+ }
+ }
+
+ /**
+ * @returns {float} the total time since start of the workoutsegment
+ */
+ function totalTime (baseMetrics) {
+ if (!isNaN(_startTimestamp) && _startTimestamp >= 0 && !isNaN(baseMetrics.timestamp) && baseMetrics.timestamp >= _startTimestamp) {
+ return Math.max(movingTimeSinceStart(baseMetrics), (baseMetrics.timestamp.getTime() - _startTimestamp.getTime()) / 1000)
+ } else {
+ return Math.max(movingTimeSinceStart(baseMetrics), 0)
+ }
+ }
+
+ /**
+ * @returns {float} the time spent not moving since start of the workoutsegment
+ */
+ 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
+ }
+ }
+
+ /**
+ * @returns {float} average linear velocity since the start of the workoutsgment
+ */
+ function averageLinearVelocity (baseMetrics) {
+ if (movingTimeSinceStart(baseMetrics) > 0) {
+ return Math.max(distanceFromStart(baseMetrics) / movingTimeSinceStart(baseMetrics), 0)
+ } else {
+ return (_linearVelocity.average() > 0 ? _linearVelocity.average() : 0)
+ }
+ }
+
+ /**
+ * @param {float} linear velocity
+ * @returns {float} pace per 500 meters
+ */
+ function linearVelocityToPace (linearVel) {
+ if (!isNaN(linearVel) && linearVel > 0) {
+ return (500.0 / linearVel)
+ } else {
+ return Infinity
+ }
+ }
+
+ /**
+ * @returns {float} the target calories for this workoutsegment from the workout plan (only if type === 'calories')
+ */
+ function targetCalories () {
+ if (_type === 'calories' && _endCalories > 0) {
+ return _targetCalories
+ } else {
+ return undefined
+ }
+ }
+
+ /**
+ * @returns {float} the calories spent moving since the start of the workoutsegment (i.e. active calories)
+ */
+ function spentCalories (baseMetrics) {
+ if (!isNaN(_startCalories) && _startCalories >= 0 && !isNaN(baseMetrics.totalCalories) && baseMetrics.totalCalories > _startCalories) {
+ return baseMetrics.totalCalories - _startCalories
+ } else {
+ return 0
+ }
+ }
+
+ /**
+ * @returns {float} the remaining calories to the end of the workoutsegment
+ */
+ function caloriesToEnd (baseMetrics) {
+ if (_type === 'calories' && _endCalories > 0) {
+ // We are in a time based interval
+ return _endCalories - baseMetrics.totalCalories
+ } else {
+ return undefined
+ }
+ }
+
+ /**
+ * @returns {float} the total calories since the start (active + rest) of the workoutSegment
+ */
+ function totalCalories (baseMetrics) {
+ return spentCalories(baseMetrics) + restCalories(baseMetrics)
+ }
+
+ /**
+ * @returns {float} the rest calories since the start of the workoutSegment
+ * Based on crude Basal Metabolic Rates, see https://my.clevelandclinic.org/health/body/basal-metabolic-rate-bmr
+ */
+ function restCalories (baseMetrics) {
+ if (config.userSettings.sex === 'male') {
+ return 0.0196296296296296 * restTime(baseMetrics)
+ } else {
+ return 0.0163194444444444 * restTime(baseMetrics)
+ }
+ }
+
+ /**
+ * @description This internal function resets the metrics of the segment, this is called after setting a new target
+ */
+ function resetSegmentMetrics () {
+ _linearVelocity.reset()
+ _strokerate.reset()
+ _strokedistance.reset()
+ _caloriesPerHour.reset()
+ _power.reset()
+ _dragFactor.reset()
+ _type = 'justrow'
+ _startTimestamp = undefined
+ _startMovingTime = 0
+ _startLinearDistance = 0
+ _startStrokeNumber = 0
+ _startWork = 0
+ _startCalories = 0
+ _targetTime = 0
+ _targetDistance = 0
+ _endMovingTime = 0
+ _endLinearDistance = 0
+ _split = {
+ type: 'justrow',
+ targetDistance: 0,
+ targetTime: 0
+ }
+ }
+
+ /**
+ * @description This externally exposed function resets all data from a workoutsegment, including the regressor used for projections
+ */
+ function reset () {
+ resetSegmentMetrics()
+ distanceOverTime.reset()
+ caloriesOverTime.reset()
+ }
+
+ return {
+ setStart,
+ setStartTimestamp,
+ getStartTimestamp,
+ summarize,
+ setEnd,
+ isEndReached,
+ interpolateEnd,
+ metrics,
+ movingTimeSinceStart,
+ timeToEnd,
+ type,
+ push,
+ getSplit,
+ remainder,
+ reset
+ }
+}
diff --git a/app/engine/utils/workoutSegment.test.js b/app/engine/utils/workoutSegment.test.js
new file mode 100644
index 0000000000..9f935b4487
--- /dev/null
+++ b/app/engine/utils/workoutSegment.test.js
@@ -0,0 +1,481 @@
+'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,
+ userSettings: {
+ sex: 'male'
+ }
+}
+
+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 368ab7ca73..61dbd29527 100644
--- a/app/gpio/GpioTimerService.js
+++ b/app/gpio/GpioTimerService.js
@@ -1,6 +1,6 @@
'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
@@ -27,7 +27,7 @@ export function createGpioTimerService () {
// setting priority of current process
os.setPriority(config.gpioPriority)
} catch (err) {
- log.debug('Gpio-service: FAILED to set priority of Gpio-Thread, are root permissions granted?')
+ log.debug(`Gpio-service: FAILED to set priority of Gpio-Thread, error ${err}, are root permissions granted?`)
}
}
@@ -52,9 +52,17 @@ export function createGpioTimerService () {
let previousTick = 0
// Define the alert handler
- sensor.on('alert', (level, currentTick) => {
+ sensor.on('alert', (level, rawCurrentTick) => {
if ((triggeredFlank === 'Both') || (triggeredFlank === 'Down' && level === 0) || (triggeredFlank === 'Up' && level === 1)) {
- const currentDt = ((currentTick >> 0) - (previousTick >> 0)) / 1e6
+ 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)
}
diff --git a/app/peripherals/PeripheralConstants.js b/app/peripherals/PeripheralConstants.js
new file mode 100644
index 0000000000..a5f9066b74
--- /dev/null
+++ b/app/peripherals/PeripheralConstants.js
@@ -0,0 +1,22 @@
+'use strict'
+/*
+ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
+*/
+/**
+ * @file Some constants used by the peripherals and especially the PM5 interface
+ *
+ * @remark please note: hardware versions exclude a software version, and thus might confuse the client
+ * See https://www.concept2.com/service/monitors/pm5/firmware for available versions
+ * For ErgZone, it is crucial to set the manufacturer to the appname to correctly handle our data
+ */
+export const PeripheralConstants = {
+ serial: '431099999',
+ model: 'PM5',
+ name: 'PM5 431099999',
+ hardwareRevision: '634',
+ firmwareRevision: '8200-000372-176.000',
+ manufacturer: `${process.env.npm_package_name || ''}`
+}
+
+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..ca29deae15
--- /dev/null
+++ b/app/peripherals/PeripheralManager.js
@@ -0,0 +1,472 @@
+'use strict'
+/**
+ * @copyright [OpenRowingMonitor]{@link https://github.com/JaapvanEkris/openrowingmonitor}
+ *
+ * @file 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.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..24975b61fb
--- /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..f42f90d189
--- /dev/null
+++ b/app/peripherals/ble/BleManager.js
@@ -0,0 +1,146 @@
+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) => `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 83%
rename from app/ble/BufferBuilder.js
rename to app/peripherals/ble/BufferBuilder.js
index 5aebc7f32c..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,9 @@ export default class BufferBuilder {
this._dataArray.push(buffer)
}
+ /**
+ * @param {number} value
+ */
writeUInt32LE (value) {
const _value = value || 0
const buffer = Buffer.alloc(4)
diff --git a/app/ble/BufferBuilder.test.js b/app/peripherals/ble/BufferBuilder.test.js
similarity index 92%
rename from app/ble/BufferBuilder.test.js
rename to app/peripherals/ble/BufferBuilder.test.js
index 72d7f133c5..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', () => {
@@ -52,6 +54,7 @@ test('negative writeUInt32LE should produce 4 bit buffer of 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..bcc1574ffd
--- /dev/null
+++ b/app/peripherals/ble/CpsPeripheral.js
@@ -0,0 +1,171 @@
+'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.smp.once('pairingRequest', () => {
+ _connection.smp.sendPairingFailed(NodeBleHost.SmpErrors.PAIRING_NOT_SUPPORTED)
+ log.debug('CPS pairing request rejected')
+ })
+
+ _connection.on('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..cee48895dd
--- /dev/null
+++ b/app/peripherals/ble/CscPeripheral.js
@@ -0,0 +1,169 @@
+'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.smp.once('pairingRequest', () => {
+ _connection.smp.sendPairingFailed(NodeBleHost.SmpErrors.PAIRING_NOT_SUPPORTED)
+ log.debug('CSC pairing request rejected')
+ })
+
+ _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..145dd01f60
--- /dev/null
+++ b/app/peripherals/ble/FtmsPeripheral.js
@@ -0,0 +1,154 @@
+'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.smp.once('pairingRequest', () => {
+ _connection.smp.sendPairingFailed(NodeBleHost.SmpErrors.PAIRING_NOT_SUPPORTED)
+ log.debug('FTMS pairing request rejected')
+ })
+
+ _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..4e7a8b970d
--- /dev/null
+++ b/app/peripherals/ble/Pm5Peripheral.js
@@ -0,0 +1,134 @@
+'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'
+import { DeviceInformationService } from './common/DeviceInformationService.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, new DeviceInformationService().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.smp.once('pairingRequest', () => {
+ _connection.smp.sendPairingFailed(NodeBleHost.SmpErrors.PAIRING_NOT_SUPPORTED)
+ log.debug('PM5 pairing request rejected')
+ })
+
+ _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..08973a6289
--- /dev/null
+++ b/app/peripherals/ble/ble-host.interface.js
@@ -0,0 +1,858 @@
+/*
+ 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