Improved Rowing Engine (#24)

* Update to accomodate the new RowingEngine

Update of the MovingFlankDetector to provide the values needed to determine the speeds at the beginning of the flank.

* Created the MovingAverager for RowingEngine 2.0

Added the MovingAverager to accomodate the RowingEngine 2.0

* Update to RowingEngine 2.0

Update based on the great theoretical work of Anu Dudhia and the practical experiences of Dave Venrooy.

* Added to explain RowingEngine 2.0 design

A rationale for explaining the RowingEngine 2.0 design, based on the great theoretical work of Anu Dudhia, and the practical work of Dave Venrooy and Marinus van Holst, as well as the great feedback from Lars Berning.

* Update to accomodate the new RowingEngine

Update to the rower settings to accommodate the new RowingEngine. Please note that the modifications need to be verified by the respective users.

* Modifications to the RX800 tests

Modifications to the RX800 to bring it in line with the Concept2 Power and distance measurements.

* BUGFIX: Prevent ghost-strokes at end of session

This change to isAccelerating prevents stroke-detection to detect a stroke when the currentDt "flatlines". This typically happens when the error correction kicks in when you quit rowing (and thus the rowerSettings.maximumTimeBetweenImpulses is exceeded). It is bad (flatlining isn't acceleration), and this ghost-stroke causes havoc in the GUI: all measurements are reset again to random values instead of the nice indicators normally used.

* BUGFIX: Prevent ghost-strokes at end of session

This change to isAccelerating prevents stroke-detection to detect a stroke when the currentDt "flatlines". This typically happens when the error correction kicks in when you quit rowing (and thus the rowerSettings.maximumTimeBetweenImpulses is exceeded). It is bad (flatlining isn't acceleration), and this ghost-stroke causes havoc in the GUI: all measurements are reset again to random values instead of the nice indicators normally used.

* Added defensive programming

In some rare cases not rounding led to a buffer overflow, so added defensive programming to prevent this.

* Update MovingFlankDetector.js

* Improved stroke detection algorithm accuracy

Improvement of the Stroke detection algorithm: when naturalDeceleration is specified in the settings, it will only consider something a Revocery when this level of deceleration is encountered for the FlankLength.

* Made the RowingEngine more stateful

Removed some bugs, added a lot of defensive programming and made the RowingEngine report the key statistics every impuls to create a more fluid experience on the monitor. To do this, the rowingEngine maintins and reports absolute values to rowingStatistics, instead of relative.

* Improved stroke detection algorithm accuracy

Improvement of the Stroke detection algorithm: when naturalDeceleration is specified in the settings, it will only consider something a Revocery when this level of deceleration is encountered for the FlankLength.

* Modification to fit new RowingEngine

* Addition of new parameters for stroke detection

Added new parameters for stroke detection and monitor fluidity

* Fixed missing bracket

* Updated to more fluid RowingStatistics

Moved the timer from a static timer to the raw measurements of the flywheel, and the distance more frequently updated, to keep all data consistent. Also made the GUI-updates more fluid, responsive and dependent on settings: this more fluid GUI update mechanism is dependent on the screenUpdateInterval setting and moving averager dependent on a setting (numOfPhasesForAveragingScreenData)

* Made all monitor updates consistent

* Accommodate more frequent update of the monitors

Update to accommodate the more frequent updates, and to accommodate the reset for the RowingEngine

* improves some testcases, fixes some minor code styling and duplicate definitions

* Fixed a comment about dragfactor

* Added parameters for the new RowingStatistics.js

Added screenUpdateInterval, numOfPhasesForAveragingScreenData and maxCycleTimeBeforePause as config parameters to remove statically determined parameters in RowingStatistics.js

* Added RX800 as second testbed

* fixes some problems that caused the engine to crash

* uses non sanitized durations for now

* updates some settings, fixes some typos

* wires the correct timer configuration, fixes a problem where SPM would not reset to 0

* fixes a crash problem in workout recorder, fixes fallback value for strokeTime

* relaxes constraint on stroke time

* updates some explanatory text, updates some rower parameters

Co-authored-by: Lars Berning <151194+laberning@users.noreply.github.com>
This commit is contained in:
Jaap van Ekris 2021-11-07 14:53:11 +01:00 committed by GitHub
parent ce62f2af84
commit 7ee25292fc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 727 additions and 380 deletions

View File

@ -5,10 +5,10 @@
An open source rowing monitor for rowing exercise machines. It upgrades a rowing machine into a real smart trainer that can be used with training applications and games.
Open Rowing Monitor is a Node.js application that runs on a Raspberry Pi and measures the rotation of the rower's flywheel to calculate rowing specific metrics, such as power, split time, speed, stroke rate, distance and calories.
Open Rowing Monitor is a Node.js application that runs on a Raspberry Pi and measures the rotation of the rower's flywheel (or similar) to calculate rowing specific metrics, such as power, split time, speed, stroke rate, distance and calories.
I currently develop and test it with a Sportstech WRX700 waterrower. But it should run fine with any rowing machine that uses an air or water resistance mechanism as long as you can add something to measure the speed of the flywheel.
I suspect it works well with DIY rowing machines like the [Openergo](https://openergo.webs.com) too.
We currently develop and test it with a Sportstech WRX700 waterrower and a NordicTrack RX-800 hybrid magetic/airrower. But it should run fine with any rowing machine that uses an air or water resistance mechanism as long as you can add something to measure the speed of the flywheel.
We suspect it works well with DIY rowing machines like the [Openergo](https://openergo.webs.com) too.
## Features

View File

@ -68,26 +68,26 @@ export default class IndoorBikeDataCharacteristic extends bleno.Characteristic {
// 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.speed * 100)
bufferBuilder.writeUInt16LE(Math.round(data.speed * 100))
// Instantaneous Cadence in rotations per minute (we use this to communicate the strokes per minute)
bufferBuilder.writeUInt16LE(data.strokesPerMinute * 2)
bufferBuilder.writeUInt16LE(Math.round(data.strokesPerMinute * 2))
// Total Distance in meters
bufferBuilder.writeUInt24LE(data.distanceTotal)
bufferBuilder.writeUInt24LE(Math.round(data.distanceTotal))
// Instantaneous Power in watts
bufferBuilder.writeUInt16LE(data.power)
bufferBuilder.writeUInt16LE(Math.round(data.power))
// Energy
// Total energy in kcal
bufferBuilder.writeUInt16LE(data.caloriesTotal)
bufferBuilder.writeUInt16LE(Math.round(data.caloriesTotal))
// Energy per hour
// The Energy per Hour field represents the average expended energy of a user during a
// period of one hour.
bufferBuilder.writeUInt16LE(data.caloriesPerHour)
bufferBuilder.writeUInt16LE(Math.round(data.caloriesPerHour))
// Energy per minute
bufferBuilder.writeUInt8(data.caloriesPerMinute)
bufferBuilder.writeUInt8(Math.round(data.caloriesPerMinute))
// Heart Rate: Beats per minute with a resolution of 1
bufferBuilder.writeUInt8(data.heartrate)
bufferBuilder.writeUInt8(Math.round(data.heartrate))
// Elapsed Time: Seconds with a resolution of 1
bufferBuilder.writeUInt16LE(data.durationTotal)
bufferBuilder.writeUInt16LE(Math.round(data.durationTotal))
const buffer = bufferBuilder.getBuffer()
if (buffer.length > this._subscriberMaxValueSize) {

View File

@ -62,29 +62,29 @@ export default class RowerDataCharacteristic extends bleno.Characteristic {
// 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(data.strokesPerMinute * 2)
bufferBuilder.writeUInt8(Math.round(data.strokesPerMinute * 2))
// Stroke Count
bufferBuilder.writeUInt16LE(data.strokesTotal)
bufferBuilder.writeUInt16LE(Math.round(data.strokesTotal))
// Total Distance in meters
bufferBuilder.writeUInt24LE(data.distanceTotal)
bufferBuilder.writeUInt24LE(Math.round(data.distanceTotal))
// Instantaneous Pace in seconds/500m
// if split is infinite (i.e. while pausing), use the highest possible number
bufferBuilder.writeUInt16LE(data.split !== Infinity ? data.split : 0xFFFF)
bufferBuilder.writeUInt16LE(data.split !== Infinity ? Math.round(data.split) : 0xFFFF)
// Instantaneous Power in watts
bufferBuilder.writeUInt16LE(data.power)
bufferBuilder.writeUInt16LE(Math.round(data.power))
// Energy in kcal
// Total energy in kcal
bufferBuilder.writeUInt16LE(data.caloriesTotal)
bufferBuilder.writeUInt16LE(Math.round(data.caloriesTotal))
// Energy per hour
// The Energy per Hour field represents the average expended energy of a user during a
// period of one hour.
bufferBuilder.writeUInt16LE(data.caloriesPerHour)
bufferBuilder.writeUInt16LE(Math.round(data.caloriesPerHour))
// Energy per minute
bufferBuilder.writeUInt8(data.caloriesPerMinute)
bufferBuilder.writeUInt8(Math.round(data.caloriesPerMinute))
// Heart Rate: Beats per minute with a resolution of 1
bufferBuilder.writeUInt8(data.heartrate)
bufferBuilder.writeUInt8(Math.round(data.heartrate))
// Elapsed Time: Seconds with a resolution of 1
bufferBuilder.writeUInt16LE(data.durationTotal)
bufferBuilder.writeUInt16LE(Math.round(data.durationTotal))
const buffer = bufferBuilder.getBuffer()
if (buffer.length > this._subscriberMaxValueSize) {

View File

@ -38,22 +38,22 @@ export default class AdditionalStatus extends bleno.Characteristic {
if (this._updateValueCallback || this._multiplexedCharacteristic.centralSubscribed()) {
const bufferBuilder = new BufferBuilder()
// elapsedTime: UInt24LE in 0.01 sec
bufferBuilder.writeUInt24LE(data.durationTotal * 100)
bufferBuilder.writeUInt24LE(Math.round(data.durationTotal * 100))
// speed: UInt16LE in 0.001 m/sec
bufferBuilder.writeUInt16LE(data.speed * 1000 / 3.6)
bufferBuilder.writeUInt16LE(Math.round(data.speed * 1000 / 3.6))
// strokeRate: UInt8 in strokes/min
bufferBuilder.writeUInt8(data.strokesPerMinute)
bufferBuilder.writeUInt8(Math.round(data.strokesPerMinute))
// heartrate: UInt8 in bpm, 255 if invalid
bufferBuilder.writeUInt8(data.heartrate)
bufferBuilder.writeUInt8(Math.round(data.heartrate))
// currentPace: UInt16LE in 0.01 sec/500m
// if split is infinite (i.e. while pausing), use the highest possible number
bufferBuilder.writeUInt16LE(data.split !== Infinity ? data.split * 100 : 0xFFFF)
bufferBuilder.writeUInt16LE(data.split !== Infinity ? Math.round(data.split * 100) : 0xFFFF)
// averagePace: UInt16LE in 0.01 sec/500m
let averagePace = 0
if (data.distanceTotal && data.distanceTotal !== 0) {
averagePace = data.durationTotal / data.distanceTotal * 500
}
bufferBuilder.writeUInt16LE(averagePace * 100)
bufferBuilder.writeUInt16LE(Math.round(averagePace * 100))
// restDistance: UInt16LE
bufferBuilder.writeUInt16LE(0)
// restTime: UInt24LE in 0.01 sec
@ -62,7 +62,7 @@ export default class AdditionalStatus extends bleno.Characteristic {
// the multiplexer uses a slightly different format for the AdditionalStatus
// it adds averagePower before the ergMachineType
// averagePower: UInt16LE in watts
bufferBuilder.writeUInt16LE(data.power)
bufferBuilder.writeUInt16LE(Math.round(data.power))
}
// ergMachineType: 0 TYPE_STATIC_D
bufferBuilder.writeUInt8(0)

View File

@ -38,17 +38,17 @@ export default class AdditionalStatus2 extends bleno.Characteristic {
if (this._updateValueCallback || this._multiplexedCharacteristic.centralSubscribed()) {
const bufferBuilder = new BufferBuilder()
// elapsedTime: UInt24LE in 0.01 sec
bufferBuilder.writeUInt24LE(data.durationTotal * 100)
bufferBuilder.writeUInt24LE(Math.round(data.durationTotal * 100))
// intervalCount: UInt8
bufferBuilder.writeUInt8(0)
if (this._updateValueCallback) {
// the multiplexer uses a slightly different format for the AdditionalStatus2
// it skips averagePower before totalCalories
// averagePower: UInt16LE in watts
bufferBuilder.writeUInt16LE(data.power)
bufferBuilder.writeUInt16LE(Math.round(data.power))
}
// totalCalories: UInt16LE in cal
bufferBuilder.writeUInt16LE(data.caloriesTotal)
bufferBuilder.writeUInt16LE(Math.round(data.caloriesTotal))
// splitAveragePace: UInt16LE in 0.01 sec/500m
bufferBuilder.writeUInt16LE(0 * 100)
// splitAveragePower UInt16LE in watts

View File

@ -38,13 +38,13 @@ export default class AdditionalStrokeData extends bleno.Characteristic {
if (this._updateValueCallback || this._multiplexedCharacteristic.centralSubscribed()) {
const bufferBuilder = new BufferBuilder()
// elapsedTime: UInt24LE in 0.01 sec
bufferBuilder.writeUInt24LE(data.durationTotal * 100)
bufferBuilder.writeUInt24LE(Math.round(data.durationTotal * 100))
// strokePower: UInt16LE in watts
bufferBuilder.writeUInt16LE(data.power)
bufferBuilder.writeUInt16LE(Math.round(data.power))
// strokeCalories: UInt16LE in cal
bufferBuilder.writeUInt16LE(0)
// strokeCount: UInt16LE
bufferBuilder.writeUInt16LE(data.strokesTotal)
bufferBuilder.writeUInt16LE(Math.round(data.strokesTotal))
// projectedWorkTime: UInt24LE in 1 sec
bufferBuilder.writeUInt24LE(0)
// projectedWorkDistance: UInt24LE in 1 m

View File

@ -38,9 +38,9 @@ export default class GeneralStatus extends bleno.Characteristic {
if (this._updateValueCallback || this._multiplexedCharacteristic.centralSubscribed()) {
const bufferBuilder = new BufferBuilder()
// elapsedTime: UInt24LE in 0.01 sec
bufferBuilder.writeUInt24LE(data.durationTotal * 100)
bufferBuilder.writeUInt24LE(Math.round(data.durationTotal * 100))
// distance: UInt24LE in 0.1 m
bufferBuilder.writeUInt24LE(data.distanceTotal * 10)
bufferBuilder.writeUInt24LE(Math.round(data.distanceTotal * 10))
// workoutType: UInt8 will always use 0 (WORKOUTTYPE_JUSTROW_NOSPLITS)
bufferBuilder.writeUInt8(0)
// intervalType: UInt8 will always use 255 (NONE)
@ -52,7 +52,7 @@ export default class GeneralStatus extends bleno.Characteristic {
// strokeState: UInt8 2 DRIVING, 4 RECOVERY
bufferBuilder.writeUInt8(data.strokeState === 'DRIVING' ? 2 : 4)
// totalWorkDistance: UInt24LE in 1 m
bufferBuilder.writeUInt24LE(data.distanceTotal)
bufferBuilder.writeUInt24LE(Math.round(data.distanceTotal))
// workoutDuration: UInt24LE in 0.01 sec (if type TIME)
bufferBuilder.writeUInt24LE(0 * 100)
// workoutDurationType: UInt8 0 TIME, 1 CALORIES, 2 DISTANCE, 3 WATTS

View File

@ -39,9 +39,9 @@ export default class StrokeData extends bleno.Characteristic {
if (this._updateValueCallback || this._multiplexedCharacteristic.centralSubscribed()) {
const bufferBuilder = new BufferBuilder()
// elapsedTime: UInt24LE in 0.01 sec
bufferBuilder.writeUInt24LE(data.durationTotal * 100)
bufferBuilder.writeUInt24LE(Math.round(data.durationTotal * 100))
// distance: UInt24LE in 0.1 m
bufferBuilder.writeUInt24LE(data.distanceTotal * 10)
bufferBuilder.writeUInt24LE(Math.round(data.distanceTotal * 10))
// driveLength: UInt8 in 0.01 m
bufferBuilder.writeUInt8(0 * 100)
// driveTime: UInt8 in 0.01 s

View File

@ -0,0 +1,55 @@
'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 flankLenght + 1 measured currentDt's, thus flankLenght number of flanks between them
They are arranged that dataPoints[0] is the youngest, and dataPoints[flankLength] the youngest
*/
function createMovingAverager (length, initValue) {
let dataPoints
reset()
function pushValue (dataPoint) {
// add the new dataPoint to the array, we have to move datapoints starting at the oldst ones
let i = length - 1
while (i > 0) {
// older datapoints are moved toward the higher numbers
dataPoints[i] = dataPoints[i - 1]
i = i - 1
}
dataPoints[0] = dataPoint
}
function replaceLastPushedValue (dataPoint) {
// replace the newest dataPoint in the array, as it was faulty
dataPoints[0] = dataPoint
}
function getMovingAverage () {
let i = length - 1
let arrayTotal = 0.0
while (i >= 0) {
// summarise the value of the moving average
arrayTotal = arrayTotal + dataPoints[i]
i = i - 1
}
const arrayAverage = arrayTotal / length
return arrayAverage
}
function reset () {
dataPoints = new Array(length)
dataPoints.fill(initValue)
}
return {
pushValue,
replaceLastPushedValue,
getMovingAverage,
reset
}
}
export { createMovingAverager }

View File

@ -5,33 +5,91 @@
This keeps an array, which we can test for an upgoing or downgoing flank
Please note: The array contains flankLenght + 1 measured currentDt's, thus flankLenght number of flanks between them
They are arranged that dataPoints[0] is the youngest, and dataPoints[flankLength] the youngest
They are arranged that dataPoints[0] is the youngest, and dataPoints[flankLength] the oldest
*/
import loglevel from 'loglevel'
import { createMovingAverager } from './MovingAverager.js'
const log = loglevel.getLogger('RowingEngine')
function createMovingFlankDetector (rowerSettings) {
const dataPoints = new Array(rowerSettings.flankLength + 1)
dataPoints.fill(rowerSettings.maximumTimeBetweenImpulses)
const angularDisplacementPerImpulse = (2.0 * Math.PI) / rowerSettings.numOfImpulsesPerRevolution
const dirtyDataPoints = new Array(rowerSettings.flankLength + 1)
dirtyDataPoints.fill(rowerSettings.maximumTimeBetweenImpulses)
const cleanDataPoints = new Array(rowerSettings.flankLength + 1)
cleanDataPoints.fill(rowerSettings.maximumTimeBetweenImpulses)
const angularVelocity = new Array(rowerSettings.flankLength + 1)
angularVelocity.fill(angularDisplacementPerImpulse / rowerSettings.minimumTimeBetweenImpulses)
const angularAcceleration = new Array(rowerSettings.flankLength + 1)
angularAcceleration.fill(0)
const movingAverage = createMovingAverager(rowerSettings.smoothing, rowerSettings.maximumTimeBetweenImpulses)
function pushValue (dataPoint) {
// add the new dataPoint to the array, we have to move datapoints starting at the oldst ones
let i = rowerSettings.flankLength
while (i > 0) {
// older datapoints are moved toward the higher numbers
dataPoints[i] = dataPoints[i - 1]
dirtyDataPoints[i] = dirtyDataPoints[i - 1]
cleanDataPoints[i] = cleanDataPoints[i - 1]
angularVelocity[i] = angularVelocity[i - 1]
angularAcceleration[i] = angularAcceleration[i - 1]
i = i - 1
}
dataPoints[0] = dataPoint
dirtyDataPoints[0] = dataPoint
// reduce noise in the measurements by applying some sanity checks
// noise filter on the value of dataPoint: it should be within sane levels and should not deviate too much from the previous reading
if (dataPoint < rowerSettings.minimumTimeBetweenImpulses || dataPoint > rowerSettings.maximumTimeBetweenImpulses) {
// impulseTime is outside plausible ranges, so we assume it is close to the previous clean one
log.debug(`noise filter corrected currentDt, ${dataPoint} was not between minimumTimeBetweenImpulses and maximumTimeBetweenImpulses, changed to ${cleanDataPoints[1]}`)
dataPoint = cleanDataPoints[1]
}
// lets test if pushing this value would fit the curve we are looking for
movingAverage.pushValue(dataPoint)
if (movingAverage.getMovingAverage() < (rowerSettings.maximumDownwardChange * cleanDataPoints[1]) || movingAverage.getMovingAverage() > (rowerSettings.maximumUpwardChange * cleanDataPoints[1])) {
// impulses are outside plausible ranges, so we assume it is close to the previous one
log.debug(`noise filter corrected currentDt, ${dataPoint} was too much of an accelleration/decelleration with respect to ${movingAverage.getMovingAverage()}, changed to previous value, ${cleanDataPoints[1]}`)
movingAverage.replaceLastPushedValue(cleanDataPoints[1])
}
// determine the moving average, to reduce noise
cleanDataPoints[0] = movingAverage.getMovingAverage()
// determine the derived data
if (cleanDataPoints[0] > 0) {
angularVelocity[0] = angularDisplacementPerImpulse / cleanDataPoints[0]
angularAcceleration[0] = (angularVelocity[0] - angularVelocity[1]) / cleanDataPoints[0]
} else {
log.error('Impuls of 0 seconds encountered, this should not be possible (division by 0 prevented)')
angularVelocity[0] = 0
angularAcceleration[0] = 0
}
}
function isDecelerating () {
let i = rowerSettings.flankLength
function isFlywheelUnpowered () {
let numberOfErrors = 0
while (i > 0) {
if (dataPoints[i] < dataPoints[i - 1]) {
// Oldest interval (dataPoints[i]) is shorter than the younger one (datapoint[i-1], as the distance is fixed, we are decelerating
} else {
numberOfErrors = numberOfErrors + 1
if (rowerSettings.naturalDeceleration < 0) {
// A valid natural deceleration of the flywheel has been provided, this has to be maintained for a flanklength to count as an indication for an unpowered flywheel
// Please note that angularAcceleration[] contains flank-information already, so we need to check from rowerSettings.flankLength -1 until 0 flanks
let i = rowerSettings.flankLength - 1
while (i >= 0) {
if (angularAcceleration[i] > rowerSettings.naturalDeceleration) {
// There seems to be some power present, so we detecten an error
numberOfErrors = numberOfErrors + 1
}
i = i - 1
}
} else {
// No valid natural deceleration has been provided, we rely on pure deceleration for recovery detection
let i = rowerSettings.flankLength
while (i > 0) {
if (cleanDataPoints[i] >= cleanDataPoints[i - 1]) {
// Oldest interval (dataPoints[i]) is larger than the younger one (datapoint[i-1], as the distance is fixed, we are accelerating
numberOfErrors = numberOfErrors + 1
}
i = i - 1
}
i = i - 1
}
if (numberOfErrors > rowerSettings.numberOfErrorsAllowed) {
return false
@ -40,16 +98,34 @@ function createMovingFlankDetector (rowerSettings) {
}
}
function isAccelerating () {
let i = rowerSettings.flankLength
function isFlywheelPowered () {
let numberOfErrors = 0
while (i > 0) {
if (dataPoints[i] > dataPoints[i - 1]) {
// Oldest interval (dataPoints[i]) is longer than the younger one (datapoint[i-1], as the distance is fixed, we are accelerating
} else {
if (rowerSettings.naturalDeceleration < 0) {
// A valid natural deceleration of the flywheel has been provided, this has to be consistently be encountered for a flanklength to count as an indication of a powered flywheel
// Please note that angularAcceleration[] contains flank-information already, so we need to check from rowerSettings.flankLength -1 until 0 flanks
let i = rowerSettings.flankLength - 1
while (i >= 0) {
if (angularAcceleration[i] < rowerSettings.naturalDeceleration) {
// Some deceleration is below the natural deceleration, so we detected an error
numberOfErrors = numberOfErrors + 1
}
i = i - 1
}
} else {
// No valid natural deceleration of the flywheel has been provided, we rely on pure acceleration for stroke detection
let i = rowerSettings.flankLength
while (i > 1) {
if (cleanDataPoints[i] < cleanDataPoints[i - 1]) {
// Oldest interval (dataPoints[i]) is shorter than the younger one (datapoint[i-1], as the distance is fixed, we discovered a deceleration
numberOfErrors = numberOfErrors + 1
}
i = i - 1
}
if (cleanDataPoints[1] <= cleanDataPoints[0]) {
// We handle the last measurement more specifically: at least the youngest measurement must be really accelerating
// This prevents when the currentDt "flatlines" (i.e. error correction kicks in) a ghost-stroke is detected
numberOfErrors = numberOfErrors + 1
}
i = i - 1
}
if (numberOfErrors > rowerSettings.numberOfErrorsAllowed) {
return false
@ -58,10 +134,41 @@ function createMovingFlankDetector (rowerSettings) {
}
}
function timeToBeginOfFlank () {
// You expect the curve to bend between dirtyDataPoints[rowerSettings.flankLength] and dirtyDataPoints[rowerSettings.flankLength+1],
// as acceleration FOLLOWS the start of the pulling the handle, we assume it must have started before that
let i = rowerSettings.flankLength
let total = 0.0
while (i >= 0) {
total += dirtyDataPoints[i]
i = i - 1
}
return total
}
function noImpulsesToBeginFlank () {
return rowerSettings.flankLength
}
function impulseLengthAtBeginFlank () {
// As this is fed into the speed calculation where small changes have big effects, and we typically use it when the curve is in a plateau,
// we return the cleaned data and not the dirty data
// Regardless of the way to determine the acceleration, cleanDataPoints[rowerSettings.flankLength] is always the impuls at the beginning of the flank being investigated
return cleanDataPoints[rowerSettings.flankLength]
}
function accelerationAtBeginOfFlank () {
return angularAcceleration[rowerSettings.flankLength - 1]
}
return {
pushValue,
isDecelerating,
isAccelerating
isFlywheelUnpowered,
isFlywheelPowered,
timeToBeginOfFlank,
noImpulsesToBeginFlank,
impulseLengthAtBeginFlank,
accelerationAtBeginOfFlank
}
}

View File

@ -11,211 +11,312 @@
Also Dave Vernooy has some good explanations here: https://dvernooy.github.io/projects/ergware
*/
import loglevel from 'loglevel'
import { createWeightedAverager } from './WeightedAverager.js'
import { createMovingAverager } from './MovingAverager.js'
import { createMovingFlankDetector } from './MovingFlankDetector.js'
import { createTimer } from './Timer.js'
const log = loglevel.getLogger('RowingEngine')
function createRowingEngine (rowerSettings) {
// How many impulses are triggered per revolution of the flywheel
// i.e. the number of magnets if used with a reed sensor
const numOfImpulsesPerRevolution = rowerSettings.numOfImpulsesPerRevolution
// Needed to determine the damping constant of the rowing machine. This value can be measured in the recovery phase
// of the stroke (some ergometers do this constantly).
// However I still keep it constant here, as I still have to figure out the damping physics of a water rower (see below)
// To measure it for your rowing machine, comment in the logging at the end of "startDrivePhase" function. Then do some
// strokes on the rower and estimate a value.
let omegaDotDivOmegaSquare = rowerSettings.omegaDotDivOmegaSquare
// The moment of inertia of the flywheel kg*m^2
// A way to measure it is outlined here: https://dvernooy.github.io/projects/ergware/, "Flywheel moment of inertia"
// You could also roughly estimate it by just doing some strokes and the comparing the calculated power values for
// plausibility. Note that the power also depends on omegaDotDivOmegaSquare (see above).
const jMoment = rowerSettings.jMoment
// Set this to true if you are using a water rower
// The mass of the water starts rotating, when you pull the handle, and therefore acts
// like a massive flywheel
// Liquids are a tricky thing and therefore the dumping constant does not seem to be
// that constant on water rowers...
// This is WIP, but for now this setting is used to figure out the drive and recovery phases
// differently on water rowers
const liquidFlywheel = rowerSettings.liquidFlywheel
// A constant that is commonly used to convert flywheel revolutions to a rowed distance
// see here: http://eodg.atm.ox.ac.uk/user/dudhia/rowing/physics/ergometer.html#section9
const c = rowerSettings.magicConstant
// jMoment * ωdot = -kDamp * ω^2 during non-power part of stroke
let kDamp = jMoment * omegaDotDivOmegaSquare
let workoutHandler
const kDampEstimatorAverager = createWeightedAverager(5)
const flankDetector = createMovingFlankDetector(rowerSettings)
let prevDt = rowerSettings.maximumTimeBetweenImpulses
let kPower = 0.0
let jPower = 0.0
let kDampEstimator = 0.0
let strokeElapsed = 0.0
let driveElapsed = 0.0
let strokeDistance = 0.0
const omegaVector = new Array(2)
const omegaDotVector = new Array(2)
let omegaDotDot = 0.0
const timer = createTimer()
omegaVector.fill(0.0)
omegaDotVector.fill(0.0)
let isInDrivePhase = true
let wasInDrivePhase = false
// a rowing session always starts with a drive phase
timer.start('drive')
timer.start('stroke')
let movementAllowed = true
let cyclePhase = 'Drive'
let totalTime = 0.0
let totalNumberOfImpulses = 0.0
let strokeNumber = 0.0
const angularDisplacementPerImpulse = (2.0 * Math.PI) / rowerSettings.numOfImpulsesPerRevolution
let drivePhaseStartTime = 0.0
let drivePhaseStartAngularDisplacement = 0.0
let drivePhaseLength = rowerSettings.minimumDriveTime
let drivePhaseAngularDisplacement = rowerSettings.numOfImpulsesPerRevolution
// let driveStartAngularVelocity = 0
// let driveEndAngularVelocity = angularDisplacementPerImpulse / rowerSettings.minimumTimeBetweenImpulses
let driveLinearDistance = 0.0
// let drivePhaseEnergyProduced = 0.0
let recoveryPhaseStartTime = 0.0
let recoveryPhaseStartAngularDisplacement = 0.0
let recoveryPhaseAngularDisplacement = rowerSettings.numOfImpulsesPerRevolution
let recoveryPhaseLength = rowerSettings.minimumRecoveryTime
let recoveryStartAngularVelocity = angularDisplacementPerImpulse / rowerSettings.minimumTimeBetweenImpulses
let recoveryEndAngularVelocity = angularDisplacementPerImpulse / rowerSettings.maximumTimeBetweenImpulses
let recoveryLinearDistance = 0.0
let currentDragFactor = rowerSettings.dragFactor / 1000000
const movingDragAverage = createMovingAverager(5, currentDragFactor)
let dragFactor = movingDragAverage.getMovingAverage()
const minimumCycleLenght = rowerSettings.minimumDriveTime + rowerSettings.minimumRecoveryTime
let cycleLenght = minimumCycleLenght
let linearCycleVelocity = 0.0
let totalLinearDistance = 0.0
let averagedCyclePower = 0.0
let currentTorque = 0.0
let previousAngularVelocity = 0.0
let currentAngularVelocity = 0.0
// called if the sensor detected an impulse, currentDt is an interval in seconds
function handleRotationImpulse (currentDt) {
// impulses that take longer than 3 seconds are considered a pause
if (currentDt > 3.0) {
// First we check if the rower is allowed to move
if (movementAllowed !== true) {
// The rower isn't allowed to move
return
}
// impulses that take longer than maximumImpulseTimeBeforePause seconds are considered a pause
if (currentDt > rowerSettings.maximumImpulseTimeBeforePause) {
workoutHandler.handlePause(currentDt)
return
}
// remember the state of drive phase from the previous impulse, we need it to detect state changes
wasInDrivePhase = isInDrivePhase
// STEP 1: reduce noise in the measurements by applying some sanity checks
// noise filter on the value of currentDt: it should be within sane levels and should not deviate too much from the previous reading
if (currentDt < rowerSettings.minimumTimeBetweenImpulses || currentDt > rowerSettings.maximumTimeBetweenImpulses || currentDt < (rowerSettings.maximumDownwardChange * prevDt) || currentDt > (rowerSettings.maximumUpwardChange * prevDt)) {
// impulses are outside plausible ranges, so we assume it is close to the previous one
currentDt = prevDt
log.debug(`noise filter corrected currentDt, ${currentDt} was dubious, changed to ${prevDt}`)
}
prevDt = currentDt
// each revolution of the flywheel adds distance of distancePerRevolution
// s = (k/c)^(1/3)*θ
const distancePerRevolution = 2.0 * Math.PI * Math.pow((kDamp / c), 1.0 / 3.0)
strokeDistance += distancePerRevolution / numOfImpulsesPerRevolution
omegaVector[1] = omegaVector[0]
// angular speed ω = 2π𝑛, revolutions per minute 𝑛 = 1/𝑑t, 𝑑t is the time for one revolution of flywheel
// => ω = 2π/measured time for last impulse * impulses per revolution
omegaVector[0] = (2.0 * Math.PI) / (currentDt * numOfImpulsesPerRevolution)
// angular velocity ωdot = 𝑑ω/𝑑t
omegaDotVector[1] = omegaDotVector[0]
omegaDotVector[0] = (omegaVector[0] - omegaVector[1]) / (currentDt)
// we use the derivative of the velocity (ωdotdot) to classify the different phases of the stroke
omegaDotDot = (omegaDotVector[0] - omegaDotVector[1]) / (currentDt)
// a stroke consists of a drive phase (when you pull the handle) and a recovery phase (when the handle returns)
// calculate screeners to find drive portion of stroke - see spreadsheet if you want to understand this
// if ((omegaDotDot > -40.0) && (omegaDotDot < 40.0)) {
// the acceleration is constant if ωdotdot is 0, we expand the range, since measurements are imperfect
const accelerationIsChanging = !((omegaDotDot > -20.0) && (omegaDotDot < 20.0))
// the acceleration is positive if ωdot > 0, we expand the range, since measurements are imperfect
// used to be 15
const accelerationIsPositive = omegaDotVector[0] > 0
totalTime += currentDt
totalNumberOfImpulses++
// STEP 2: detect where we are in the rowing phase (drive or recovery)
if (liquidFlywheel) {
// Identification of drive and recovery phase on water rowers is still Work in Progress
// ω does not seem to decay that linear on water rower in recovery phase, so this would not be
// a good indicator here.
// Currently we just differentiate by checking if we are accelerating. This gives a stable indicator
// but probably we are missing the final part of the drive phase by doing so.
// This would mean, that the stroke ratio and the estimation of kDamp is a bit off.
// todo: do some measurements and find a better stable indicator for water rowers
isInDrivePhase = accelerationIsPositive
} else {
flankDetector.pushValue(currentDt)
// Here we use a finite state machine that goes between "Drive" and "Recovery", provinding sufficient time has passed and there is a credible flank
// We analyse the current impulse, depending on where we are in the stroke
if (wasInDrivePhase) {
// during the previous impulse, we were in the "Drive" phase
const strokeElapsed = timer.getValue('drive')
// finish drive phase if we have been long enough in the Drive phase, and we see a clear deceleration
isInDrivePhase = !((strokeElapsed > rowerSettings.minimumDriveTime) && flankDetector.isDecelerating())
flankDetector.pushValue(currentDt)
// Here we implement the finite state machine that goes between "Drive" and "Recovery" phases,
// It will allow a phase-change provinding sufficient time has passed and there is a credible flank
if (cyclePhase === 'Drive') {
// We currently are in the "Drive" phase, lets determine what the next phase is
if (flankDetector.isFlywheelUnpowered()) {
// The flankdetector detects that the flywheel has no power excerted on it
drivePhaseLength = (totalTime - flankDetector.timeToBeginOfFlank()) - drivePhaseStartTime
if (drivePhaseLength >= rowerSettings.minimumDriveTime) {
// We change into the Revocevery phase since we have been long enough in the Drive phase, and we see a clear lack of power excerted on the flywheel
startRecoveryPhase(currentDt)
cyclePhase = 'Recovery'
} else {
// We seem to have lost power to the flywheel, but it is too early according to the settings. We stay in the Drive Phase
log.debug(`Time: ${totalTime.toFixed(4)} sec, impuls ${totalNumberOfImpulses}: flank suggests no power (${flankDetector.accelerationAtBeginOfFlank().toFixed(1)} rad/s2), but waiting for for recoveryPhaseLength (${recoveryPhaseLength.toFixed(4)} sec) to exceed minimumRecoveryTime (${rowerSettings.minimumRecoveryTime} sec)`)
updateDrivePhase(currentDt)
}
} else {
// during the previous impulse, we were in the "Recovery" phase
const recoveryElapsed = timer.getValue('stroke')
// if we are long enough in the Recovery phase, and we see a clear acceleration, we need to change to the Drive phase
isInDrivePhase = ((recoveryElapsed > rowerSettings.minimumRecoveryTime) && flankDetector.isAccelerating())
// We stay in the "Drive" phase as the acceleration is lacking
updateDrivePhase(currentDt)
}
} else {
// We currently are in the "Recovery" phase, lets determine what the next phase is
if (flankDetector.isFlywheelPowered()) {
// The flankdetector consistently detects some force on the flywheel
recoveryPhaseLength = (totalTime - flankDetector.timeToBeginOfFlank()) - recoveryPhaseStartTime
if (recoveryPhaseLength >= rowerSettings.minimumRecoveryTime) {
// We change into the Drive phase if we have been long enough in the Recovery phase, and we see a conistent force being excerted on the flywheel
startDrivePhase(currentDt)
cyclePhase = 'Drive'
} else {
// We see a force, but the recovery phase has been too short, we stay in the recovery phase
log.debug(`Time: ${totalTime.toFixed(4)} sec, impuls ${totalNumberOfImpulses}: flank suggests power (${flankDetector.accelerationAtBeginOfFlank().toFixed(1)} rad/s2), but waiting for recoveryPhaseLength (${recoveryPhaseLength.toFixed(4)} sec) to exceed minimumRecoveryTime (${rowerSettings.minimumRecoveryTime} sec)`)
updateRecoveryPhase(currentDt)
}
} else {
// No force on the flywheel, let's continue the drive phase
updateRecoveryPhase(currentDt)
}
}
// STEP 3: handle the current impulse, depending on where we are in the stroke
if (isInDrivePhase && !wasInDrivePhase) { startDrivePhase(currentDt) }
if (!isInDrivePhase && wasInDrivePhase) { startRecoveryPhase() }
if (isInDrivePhase && wasInDrivePhase) { updateDrivePhase(currentDt) }
if (!isInDrivePhase && !wasInDrivePhase) { updateRecoveryPhase(currentDt) }
timer.updateTimers(currentDt)
log.debug(`𝑑t: ${currentDt} ω: ${omegaVector[0].toFixed(2)} ωdot: ${omegaDotVector[0].toFixed(2)} ωdotdot: ${omegaDotDot.toFixed(2)} aPos: ${accelerationIsPositive} aChange: ${accelerationIsChanging}`)
}
function startDrivePhase (currentDt) {
log.debug('*** drive phase started')
timer.start('drive')
jPower = 0.0
kPower = 0.0
if (strokeElapsed - driveElapsed !== 0) {
kDampEstimatorAverager.pushValue(kDampEstimator / (strokeElapsed - driveElapsed))
// First, we conclude the recovery phase
log.debug('*** recovery phase completed')
if (rowerSettings.minimumRecoveryTime <= recoveryPhaseLength && rowerSettings.minimumDriveTime <= drivePhaseLength) {
// We have a credible cycletime
cycleLenght = recoveryPhaseLength + drivePhaseLength
} else {
log.debug(`Cyclelenght isn't credible: recoveryPhaseLength ${recoveryPhaseLength.toFixed(4)} sec, drivePhaseLength = ${drivePhaseLength.toFixed(4)} s, maximumImpulseTimeBeforePause ${rowerSettings.maximumImpulseTimeBeforePause} s`)
}
const _kDamp = jMoment * (-1 * kDampEstimatorAverager.weightedAverage())
const _omegaDotDivOmegaSquare = -1 * kDampEstimatorAverager.weightedAverage()
log.debug(`estimated kDamp: ${_kDamp}`)
log.info(`estimated omegaDotDivOmegaSquare: ${_omegaDotDivOmegaSquare}`)
if (rowerSettings.autoAdjustDampingConstant) {
log.debug('auto adjusting kDamp and omegaDotDivOmegaSquare to new values')
kDamp = _kDamp
omegaDotDivOmegaSquare = _omegaDotDivOmegaSquare
recoveryPhaseAngularDisplacement = (totalNumberOfImpulses - recoveryPhaseStartAngularDisplacement) * angularDisplacementPerImpulse
// Calculation of the drag-factor
if (flankDetector.impulseLengthAtBeginFlank() > 0) {
recoveryEndAngularVelocity = angularDisplacementPerImpulse / flankDetector.impulseLengthAtBeginFlank()
if (recoveryPhaseLength >= rowerSettings.minimumRecoveryTime && recoveryStartAngularVelocity > 0 && recoveryEndAngularVelocity > 0) {
// Prevent division by zero and keep useless data out of our calculations
currentDragFactor = -1 * rowerSettings.flywheelInertia * ((1 / recoveryStartAngularVelocity) - (1 / recoveryEndAngularVelocity)) / recoveryPhaseLength
if (rowerSettings.autoAdjustDampingConstant) {
if (currentDragFactor > (movingDragAverage.getMovingAverage() * 0.75) && currentDragFactor < (movingDragAverage.getMovingAverage() * 1.40)) {
// If the calculated dragfactor is close to that we expect
movingDragAverage.pushValue(currentDragFactor)
dragFactor = movingDragAverage.getMovingAverage()
log.info(`*** Calculated drag factor: ${(currentDragFactor * 1000000).toFixed(2)}`)
} else {
// The calculated drag factor is outside the credible ranges
log.info(`Calculated drag factor: ${(currentDragFactor * 1000000).toFixed(2)}, which is too far off the currently used dragfactor of ${movingDragAverage.getMovingAverage() * 1000000}`)
log.debug(`Time: ${totalTime.toFixed(4)} sec, impuls ${totalNumberOfImpulses}: recoveryStartAngularVelocity = ${recoveryStartAngularVelocity.toFixed(2)} rad/sec, recoveryEndAngularVelocity = ${recoveryEndAngularVelocity.toFixed(2)} rad/sec, recoveryPhaseLength = ${recoveryPhaseLength.toFixed(4)} sec`)
}
} else {
log.info(`*** Calculated drag factor: ${(currentDragFactor * 1000000).toFixed(2)}`)
}
} else {
log.error(`Time: ${totalTime.toFixed(4)} sec, impuls ${totalNumberOfImpulses}: division by 0 prevented, recoveryPhaseLength = ${recoveryPhaseLength} sec, recoveryStartAngularVelocity = ${recoveryStartAngularVelocity} rad/sec, recoveryEndAngularVelocity = ${recoveryEndAngularVelocity} rad/sec`)
}
} else {
log.error(`Time: ${totalTime.toFixed(4)} sec, impuls ${totalNumberOfImpulses}: division by 0 prevented, impulseLengthAtBeginFlank = ${flankDetector.impulseLengthAtBeginFlank()} sec`)
}
// Calculate the key metrics
recoveryLinearDistance = Math.pow((dragFactor / rowerSettings.magicConstant), 1.0 / 3.0) * recoveryPhaseAngularDisplacement
totalLinearDistance += recoveryLinearDistance
if (currentDt > 0) {
previousAngularVelocity = currentAngularVelocity
currentAngularVelocity = angularDisplacementPerImpulse / currentDt
currentTorque = rowerSettings.flywheelInertia * ((currentAngularVelocity - previousAngularVelocity) / currentDt) + dragFactor * Math.pow(currentAngularVelocity, 2)
}
if (cycleLenght >= minimumCycleLenght) {
// There is no division by zero and the data data is credible
linearCycleVelocity = Math.pow((dragFactor / rowerSettings.magicConstant), 1.0 / 3.0) * ((recoveryPhaseAngularDisplacement + drivePhaseAngularDisplacement) / cycleLenght)
averagedCyclePower = dragFactor * Math.pow((recoveryPhaseAngularDisplacement + drivePhaseAngularDisplacement) / cycleLenght, 3.0)
} else {
log.error(`Time: ${totalTime.toFixed(4)} sec, impuls ${totalNumberOfImpulses}: cycle length was not credible, cycleLenght = ${cycleLenght} sec`)
}
// Next, we start the Drive Phase
log.debug(`*** DRIVE phase started at time: ${totalTime.toFixed(4)} sec, impuls number ${totalNumberOfImpulses}`)
strokeNumber++
drivePhaseStartTime = totalTime - flankDetector.timeToBeginOfFlank()
drivePhaseStartAngularDisplacement = totalNumberOfImpulses - flankDetector.noImpulsesToBeginFlank()
// driveStartAngularVelocity = angularDisplacementPerImpulse / flankDetector.impulseLengthAtBeginFlank()
// Update the metrics
if (workoutHandler) {
workoutHandler.handleRecoveryEnd({
timeSinceStart: totalTime,
// currDragFactor : currentDragFactor,
power: averagedCyclePower,
duration: cycleLenght,
strokeDistance: driveLinearDistance + recoveryLinearDistance,
durationDrivePhase: drivePhaseLength,
speed: linearCycleVelocity,
distance: totalLinearDistance,
numberOfStrokes: strokeNumber,
instantanousTorque: currentTorque,
strokeState: 'DRIVING'
})
}
workoutHandler.handleStrokeStateChanged({
strokeState: 'DRIVING'
})
}
function updateDrivePhase (currentDt) {
jPower = jPower + jMoment * omegaVector[0] * omegaDotVector[0] * currentDt
kPower = kPower + kDamp * (omegaVector[0] * omegaVector[0] * omegaVector[0]) * currentDt
log.debug(`Jpower: ${jPower}, kPower: ${kPower}`)
// Update the key metrics on each impulse
drivePhaseAngularDisplacement = ((totalNumberOfImpulses - flankDetector.noImpulsesToBeginFlank()) - drivePhaseStartAngularDisplacement) * angularDisplacementPerImpulse
driveLinearDistance = Math.pow((dragFactor / rowerSettings.magicConstant), 1.0 / 3.0) * drivePhaseAngularDisplacement
if (currentDt > 0) {
previousAngularVelocity = currentAngularVelocity
currentAngularVelocity = angularDisplacementPerImpulse / currentDt
currentTorque = rowerSettings.flywheelInertia * ((currentAngularVelocity - previousAngularVelocity) / currentDt) + dragFactor * Math.pow(currentAngularVelocity, 2)
}
if (workoutHandler) {
workoutHandler.updateKeyMetrics({
timeSinceStart: totalTime,
distance: totalLinearDistance + driveLinearDistance,
instantanousTorque: currentTorque
})
}
}
function startRecoveryPhase () {
driveElapsed = timer.getValue('drive')
timer.stop('drive')
strokeElapsed = timer.getValue('stroke')
timer.stop('stroke')
log.debug(`driveElapsed: ${driveElapsed}, strokeElapsed: ${strokeElapsed}`)
timer.start('stroke')
function startRecoveryPhase (currentDt) {
// First, we conclude the Drive Phase
log.debug('*** drive phase completed')
if (rowerSettings.minimumRecoveryTime <= recoveryPhaseLength && rowerSettings.minimumDriveTime <= drivePhaseLength) {
// We have a credible cycletime
cycleLenght = recoveryPhaseLength + drivePhaseLength
} else {
log.debug(`Cycleleght wasn't credible: recoveryPhaseLength ${recoveryPhaseLength.toFixed(4)} sec, drivePhaseLength = ${drivePhaseLength.toFixed(4)} s`)
}
drivePhaseAngularDisplacement = ((totalNumberOfImpulses - flankDetector.noImpulsesToBeginFlank()) - drivePhaseStartAngularDisplacement) * angularDisplacementPerImpulse
// driveEndAngularVelocity = angularDisplacementPerImpulse / flankDetector.impulseLengthAtBeginFlank()
driveLinearDistance = Math.pow((dragFactor / rowerSettings.magicConstant), 1.0 / 3.0) * drivePhaseAngularDisplacement
totalLinearDistance += driveLinearDistance
if (currentDt > 0) {
previousAngularVelocity = currentAngularVelocity
currentAngularVelocity = angularDisplacementPerImpulse / currentDt
currentTorque = rowerSettings.flywheelInertia * ((currentAngularVelocity - previousAngularVelocity) / currentDt) + dragFactor * Math.pow(currentAngularVelocity, 2)
}
// We display the AVERAGE speed in the display, NOT the topspeed of the stroke
if (drivePhaseLength > rowerSettings.minimumDriveTime && cycleLenght > minimumCycleLenght) {
// let's prevent division's by zero and make sure data is credible
linearCycleVelocity = Math.pow((dragFactor / rowerSettings.magicConstant), 1.0 / 3.0) * ((drivePhaseAngularDisplacement + recoveryPhaseAngularDisplacement) / cycleLenght)
// drivePhaseEnergyProduced = rowerSettings.flywheelInertia * ((driveEndAngularVelocity - driveStartAngularVelocity) / drivePhaseLength) * drivePhaseAngularDisplacement + dragFactor * Math.pow(driveEndAngularVelocity, 2) * drivePh$
averagedCyclePower = dragFactor * Math.pow((recoveryPhaseAngularDisplacement + drivePhaseAngularDisplacement) / cycleLenght, 3.0)
} else {
log.error(`Time: ${totalTime.toFixed(4)} sec, impuls ${totalNumberOfImpulses}: cycle length was not credible, drivePhaseLength = ${drivePhaseLength.toFixed(4)} sec, cycleLenght = ${cycleLenght.toFixed(4)} sec`)
}
if (strokeElapsed !== 0 && workoutHandler) {
workoutHandler.handleStroke({
// if the recoveryPhase is shorter than 0.2 seconds we set it to 2 seconds, this mitigates the problem
// that we do not have a recovery phase on the first stroke
power: (jPower + kPower) / (((strokeElapsed - driveElapsed) < 0.2) ? strokeElapsed + 2 : strokeElapsed),
duration: strokeElapsed,
durationDrivePhase: driveElapsed,
distance: strokeDistance,
// Next, we start the Recovery Phase
log.debug(`*** RECOVERY phase started at time: ${totalTime.toFixed(4)} sec, impuls number ${totalNumberOfImpulses}`)
recoveryPhaseStartTime = totalTime - flankDetector.timeToBeginOfFlank()
recoveryPhaseStartAngularDisplacement = totalNumberOfImpulses - flankDetector.noImpulsesToBeginFlank()
if (flankDetector.impulseLengthAtBeginFlank() > 0) {
recoveryStartAngularVelocity = angularDisplacementPerImpulse / flankDetector.impulseLengthAtBeginFlank()
} else {
log.error(`Time: ${totalTime.toFixed(4)} sec, impuls ${totalNumberOfImpulses}: division by 0 prevented, flankDetector.impulseLengthAtBeginFlank() is ${flankDetector.impulseLengthAtBeginFlank()} sec`)
}
// Update the metrics
if (workoutHandler) {
workoutHandler.handleStrokeEnd({
timeSinceStart: totalTime,
power: averagedCyclePower,
duration: cycleLenght,
strokeDistance: driveLinearDistance + recoveryLinearDistance,
durationDrivePhase: drivePhaseLength,
speed: linearCycleVelocity,
distance: totalLinearDistance,
instantanousTorque: currentTorque,
strokeState: 'RECOVERY'
})
}
// stroke finished, reset stroke specific measurements
kDampEstimator = 0.0
strokeDistance = 0
log.debug('*** recovery phase started')
}
function updateRecoveryPhase (currentDt) {
kDampEstimator = kDampEstimator + (omegaDotVector[0] / (omegaVector[0] * omegaVector[0])) * currentDt
// Update the key metrics on each impulse
recoveryPhaseAngularDisplacement = ((totalNumberOfImpulses - flankDetector.noImpulsesToBeginFlank()) - recoveryPhaseStartAngularDisplacement) * angularDisplacementPerImpulse
recoveryLinearDistance = Math.pow((dragFactor / rowerSettings.magicConstant), 1.0 / 3.0) * recoveryPhaseAngularDisplacement
if (currentDt > 0) {
previousAngularVelocity = currentAngularVelocity
currentAngularVelocity = angularDisplacementPerImpulse / currentDt
currentTorque = rowerSettings.flywheelInertia * ((currentAngularVelocity - previousAngularVelocity) / currentDt) + dragFactor * Math.pow(currentAngularVelocity, 2)
}
if (workoutHandler) {
workoutHandler.updateKeyMetrics({
timeSinceStart: totalTime,
distance: totalLinearDistance + recoveryLinearDistance,
instantanousTorque: currentTorque
})
}
}
function allowMovement () {
movementAllowed = true
}
function stopMoving () {
movementAllowed = false
}
function reset () {
cyclePhase = 'Drive'
totalTime = 0.0
totalNumberOfImpulses = 0.0
strokeNumber = 0.0
drivePhaseStartTime = 0.0
drivePhaseStartAngularDisplacement = 0.0
drivePhaseLength = 0.0
drivePhaseAngularDisplacement = rowerSettings.numOfImpulsesPerRevolution
// driveStartAngularVelocity = 0
// driveEndAngularVelocity = angularDisplacementPerImpulse / rowerSettings.minimumTimeBetweenImpulses
driveLinearDistance = 0.0
// drivePhaseEnergyProduced = 0.0
recoveryPhaseStartTime = 0.0
recoveryPhaseStartAngularDisplacement = 0.0
recoveryPhaseAngularDisplacement = rowerSettings.numOfImpulsesPerRevolution
recoveryPhaseLength = rowerSettings.minimumRecoveryTime
recoveryStartAngularVelocity = angularDisplacementPerImpulse / rowerSettings.minimumTimeBetweenImpulses
recoveryEndAngularVelocity = angularDisplacementPerImpulse / rowerSettings.maximumTimeBetweenImpulses
recoveryLinearDistance = 0.0
currentDragFactor = rowerSettings.dragFactor / 1000000
movingDragAverage.reset()
dragFactor = movingDragAverage.getMovingAverage()
cycleLenght = 0.0
linearCycleVelocity = 0.0
totalLinearDistance = 0.0
averagedCyclePower = 0.0
}
function notify (receiver) {
@ -224,6 +325,9 @@ function createRowingEngine (rowerSettings) {
return {
handleRotationImpulse,
allowMovement,
stopMoving,
reset,
notify
}
}

View File

@ -17,12 +17,13 @@ log.setLevel('warn')
const createWorkoutEvaluator = function () {
const strokes = []
function handleStroke (stroke) {
function handleStrokeEnd (stroke) {
strokes.push(stroke)
log.info(`stroke: ${strokes.length}, power: ${Math.round(stroke.power)}w, duration: ${stroke.duration.toFixed(2)}s, ` +
` drivePhase: ${stroke.durationDrivePhase.toFixed(2)}s, distance: ${stroke.distance.toFixed(2)}m`)
}
function handleStrokeStateChanged () {}
function updateKeyMetrics () {}
function handleRecoveryEnd () {}
function handlePause () {}
function getNumOfStrokes () {
return strokes.length
@ -33,17 +34,23 @@ const createWorkoutEvaluator = function () {
function getMinStrokePower () {
return strokes.map((stroke) => stroke.power).reduce((acc, power) => Math.max(acc, power))
}
function getDistance () {
return strokes.reduce((acc, stroke) => acc + stroke.distance, 0)
function getDistanceSum () {
return strokes.map((stroke) => stroke.strokeDistance).reduce((acc, strokeDistance) => acc + strokeDistance)
}
function getDistanceTotal () {
return strokes[strokes.length - 1].distance
}
return {
handleStroke,
handleStrokeStateChanged,
handleStrokeEnd,
handleRecoveryEnd,
updateKeyMetrics,
handlePause,
getNumOfStrokes,
getMaxStrokePower,
getMinStrokePower,
getDistance
getDistanceSum,
getDistanceTotal
}
}
@ -54,7 +61,8 @@ test('sample data for WRX700 should produce plausible results with rower profile
await replayRowingSession(rowingEngine.handleRotationImpulse, { filename: 'recordings/WRX700_2magnets.csv' })
assert.is(workoutEvaluator.getNumOfStrokes(), 16, 'number of strokes does not meet expectation')
assertPowerRange(workoutEvaluator, 50, 220)
assertDistanceRange(workoutEvaluator, 140, 144)
assertDistanceRange(workoutEvaluator, 158, 162)
assertStrokeDistanceSumMatchesTotal(workoutEvaluator)
})
test('sample data for DKNR320 should produce plausible results with rower profile', async () => {
@ -65,6 +73,7 @@ test('sample data for DKNR320 should produce plausible results with rower profil
assert.is(workoutEvaluator.getNumOfStrokes(), 10, 'number of strokes does not meet expectation')
assertPowerRange(workoutEvaluator, 75, 200)
assertDistanceRange(workoutEvaluator, 64, 67)
assertStrokeDistanceSumMatchesTotal(workoutEvaluator)
})
test('sample data for RX800 should produce plausible results with rower profile', async () => {
@ -73,8 +82,9 @@ test('sample data for RX800 should produce plausible results with rower profile'
rowingEngine.notify(workoutEvaluator)
await replayRowingSession(rowingEngine.handleRotationImpulse, { filename: 'recordings/RX800.csv' })
assert.is(workoutEvaluator.getNumOfStrokes(), 10, 'number of strokes does not meet expectation')
assertPowerRange(workoutEvaluator, 160, 270)
assertDistanceRange(workoutEvaluator, 78, 92)
assertPowerRange(workoutEvaluator, 80, 200)
assertDistanceRange(workoutEvaluator, 70, 80)
assertStrokeDistanceSumMatchesTotal(workoutEvaluator)
})
function assertPowerRange (evaluator, minPower, maxPower) {
@ -83,6 +93,11 @@ function assertPowerRange (evaluator, minPower, maxPower) {
}
function assertDistanceRange (evaluator, minDistance, maxDistance) {
assert.ok(evaluator.getDistance() >= minDistance && evaluator.getDistance() <= maxDistance, `distance should be between ${minDistance}m and ${maxDistance}m, but is ${evaluator.getDistance().toFixed(2)}m`)
assert.ok(evaluator.getDistanceSum() >= minDistance && evaluator.getDistanceSum() <= maxDistance, `distance should be between ${minDistance}m and ${maxDistance}m, but is ${evaluator.getDistanceSum().toFixed(2)}m`)
}
function assertStrokeDistanceSumMatchesTotal (evaluator) {
assert.ok(evaluator.getDistanceSum().toFixed(2) === evaluator.getDistanceTotal().toFixed(2), `sum of distance of all strokes is ${evaluator.getDistanceSum().toFixed(2)}m, but total in last stroke is ${evaluator.getDistanceTotal().toFixed(2)}m`)
}
test.run()

View File

@ -8,11 +8,15 @@ import { EventEmitter } from 'events'
import { createMovingIntervalAverager } from './MovingIntervalAverager.js'
import { createWeightedAverager } from './WeightedAverager.js'
// The number of strokes that are considered when averaging the calculated metrics
// Higher values create more stable metrics but make them less responsive
const numOfDataPointsForAveraging = 3
import loglevel from 'loglevel'
const log = loglevel.getLogger('RowingEngine')
function createRowingStatistics () {
function createRowingStatistics (config) {
const numOfDataPointsForAveraging = config.numOfPhasesForAveragingScreenData
const screenUpdateInterval = config.screenUpdateInterval
const minimumStrokeTime = config.rowerSettings.minimumRecoveryTime + config.rowerSettings.minimumDriveTime
const maximumStrokeTime = config.maximumStrokeTime
const timeBetweenStrokesBeforePause = maximumStrokeTime * 1000
const emitter = new EventEmitter()
const strokeAverager = createWeightedAverager(numOfDataPointsForAveraging)
const powerAverager = createWeightedAverager(numOfDataPointsForAveraging)
@ -21,7 +25,6 @@ function createRowingStatistics () {
const caloriesAveragerMinute = createMovingIntervalAverager(60)
const caloriesAveragerHour = createMovingIntervalAverager(60 * 60)
let trainingRunning = false
let durationTimer
let rowingPausedTimer
let heartrateResetTimer
let distanceTotal = 0.0
@ -31,12 +34,14 @@ function createRowingStatistics () {
let heartrate
let heartrateBatteryLevel = 0
let lastStrokeDuration = 0.0
let instantanousTorque = 0.0
let lastStrokeDistance = 0.0
let lastStrokeSpeed = 0.0
let lastStrokeState = 'RECOVERY'
let lastMetrics = {}
// send metrics to the clients periodically, if the data has changed
setInterval(emitMetrics, 1000)
setInterval(emitMetrics, screenUpdateInterval)
function emitMetrics () {
const currentMetrics = getMetrics()
if (Object.entries(currentMetrics).toString() !== Object.entries(lastMetrics).toString()) {
@ -45,28 +50,35 @@ function createRowingStatistics () {
}
}
function handleStroke (stroke) {
function handleStrokeEnd (stroke) {
if (!trainingRunning) startTraining()
// if we do not get a stroke for 6 seconds we treat this as a rowing pause
// if we do not get a stroke for timeBetweenStrokesBeforePause miliseconds we treat this as a rowing pause
if (rowingPausedTimer)clearInterval(rowingPausedTimer)
rowingPausedTimer = setTimeout(() => pauseRowing(), 6000)
rowingPausedTimer = setTimeout(() => pauseRowing(), timeBetweenStrokesBeforePause)
// based on: http://eodg.atm.ox.ac.uk/user/dudhia/rowing/physics/ergometer.html#section11
const calories = (4 * powerAverager.weightedAverage() + 350) * (stroke.duration) / 4200
durationTotal = stroke.timeSinceStart
powerAverager.pushValue(stroke.power)
speedAverager.pushValue(stroke.distance / stroke.duration)
powerRatioAverager.pushValue(stroke.durationDrivePhase / stroke.duration)
strokeAverager.pushValue(stroke.duration)
caloriesAveragerMinute.pushValue(calories, stroke.duration)
caloriesAveragerHour.pushValue(calories, stroke.duration)
caloriesTotal += calories
distanceTotal += stroke.distance
strokesTotal++
lastStrokeDuration = stroke.duration
lastStrokeDistance = stroke.distance
lastStrokeState = stroke.strokeState
speedAverager.pushValue(stroke.speed)
if (stroke.duration < timeBetweenStrokesBeforePause && stroke.duration > minimumStrokeTime) {
// stroke duration has to be credible to be accepted
powerRatioAverager.pushValue(stroke.durationDrivePhase / stroke.duration)
strokeAverager.pushValue(stroke.duration)
caloriesAveragerMinute.pushValue(calories, stroke.duration)
caloriesAveragerHour.pushValue(calories, stroke.duration)
} else {
log.debug(`*** Stroke duration of ${stroke.duration} sec is considered unreliable, skipped update stroke statistics`)
}
caloriesTotal += calories
lastStrokeDuration = stroke.duration
distanceTotal = stroke.distance
lastStrokeDistance = stroke.strokeDistance
lastStrokeState = stroke.strokeState
lastStrokeSpeed = stroke.speed
instantanousTorque = stroke.instantanousTorque
emitter.emit('strokeFinished', getMetrics())
}
@ -79,11 +91,33 @@ function createRowingStatistics () {
}
// initiated when the stroke state changes
function handleStrokeStateChanged (state) {
function handleRecoveryEnd (stroke) {
// todo: wee need a better mechanism to communicate strokeState updates
// this is an initial hacky attempt to see if we can use it for the C2-pm5 protocol
lastStrokeState = state.strokeState
emitter.emit('strokeStateChanged', getMetrics())
durationTotal = stroke.timeSinceStart
powerAverager.pushValue(stroke.power)
speedAverager.pushValue(stroke.speed)
if (stroke.duration < timeBetweenStrokesBeforePause && stroke.duration > minimumStrokeTime) {
// stroke duration has to be credible to be accepted
powerRatioAverager.pushValue(stroke.durationDrivePhase / stroke.duration)
strokeAverager.pushValue(stroke.duration)
} else {
log.debug(`*** Stroke duration of ${stroke.duration} sec is considered unreliable, skipped update stroke statistics`)
}
distanceTotal = stroke.distance
strokesTotal = stroke.numberOfStrokes
lastStrokeDistance = stroke.strokeDistance
lastStrokeState = stroke.strokeState
lastStrokeSpeed = stroke.speed
instantanousTorque = stroke.instantanousTorque
emitter.emit('recoveryFinished', getMetrics())
}
// initiated when updating key statistics
function updateKeyMetrics (stroke) {
durationTotal = stroke.timeSinceStart
distanceTotal = stroke.distance
instantanousTorque = stroke.instantanousTorque
}
// initiated when new heart rate value is received from heart rate sensor
@ -93,29 +127,34 @@ function createRowingStatistics () {
heartrateResetTimer = setTimeout(() => {
heartrate = 0
heartrateBatteryLevel = 0
}, 2000)
}, 6000)
heartrate = value.heartrate
heartrateBatteryLevel = value.batteryLevel
}
function getMetrics () {
const splitTime = speedAverager.weightedAverage() !== 0 ? (500.0 / speedAverager.weightedAverage()) : Infinity
const splitTime = speedAverager.weightedAverage() !== 0 && lastStrokeSpeed > 0 ? (500.0 / speedAverager.weightedAverage()) : Infinity
// todo: due to sanitization we currently do not use a consistent time throughout the engine
// We will rework this section to use both absolute and sanitized time in the appropriate places.
// We will also polish up the events for the recovery and drive phase, so we get clean complete strokes from the first stroke onwards.
const averagedStrokeTime = strokeAverager.weightedAverage() > minimumStrokeTime && strokeAverager.weightedAverage() < maximumStrokeTime && lastStrokeSpeed > 0 ? strokeAverager.weightedAverage() : 0 // seconds
return {
durationTotal,
durationTotalFormatted: secondsToTimeString(durationTotal),
strokesTotal,
distanceTotal: distanceTotal, // meters
distanceTotal: distanceTotal > 0 ? distanceTotal : 0, // meters
caloriesTotal: caloriesTotal, // kcal
caloriesPerMinute: caloriesAveragerMinute.average(),
caloriesPerHour: caloriesAveragerHour.average(),
caloriesPerMinute: caloriesAveragerMinute.average() > 0 ? caloriesAveragerMinute.average() : 0,
caloriesPerHour: caloriesAveragerHour.average() > 0 ? caloriesAveragerHour.average() : 0,
strokeTime: lastStrokeDuration, // seconds
distance: lastStrokeDistance, // meters
power: powerAverager.weightedAverage(), // watts
distance: lastStrokeDistance > 0 && lastStrokeSpeed > 0 ? lastStrokeDistance : 0, // meters
power: powerAverager.weightedAverage() > 0 && lastStrokeSpeed > 0 ? powerAverager.weightedAverage() : 0, // watts
split: splitTime, // seconds/500m
splitFormatted: secondsToTimeString(splitTime),
powerRatio: powerRatioAverager.weightedAverage(),
strokesPerMinute: strokeAverager.weightedAverage() !== 0 ? (60.0 / strokeAverager.weightedAverage()) : 0,
speed: (speedAverager.weightedAverage() * 3.6), // km/h
powerRatio: powerRatioAverager.weightedAverage() > 0 && lastStrokeSpeed > 0 ? powerRatioAverager.weightedAverage() : 0,
instantanousTorque: instantanousTorque,
strokesPerMinute: averagedStrokeTime !== 0 ? (60.0 / averagedStrokeTime) : 0,
speed: speedAverager.weightedAverage() > 0 && lastStrokeSpeed > 0 ? (speedAverager.weightedAverage() * 3.6) : 0, // km/h
strokeState: lastStrokeState,
heartrate,
heartrateBatteryLevel
@ -124,12 +163,10 @@ function createRowingStatistics () {
function startTraining () {
trainingRunning = true
startDurationTimer()
}
function stopTraining () {
trainingRunning = false
stopDurationTimer()
if (rowingPausedTimer)clearInterval(rowingPausedTimer)
}
@ -157,17 +194,6 @@ function createRowingStatistics () {
emitter.emit('rowingPaused')
}
function startDurationTimer () {
durationTimer = setInterval(() => {
durationTotal++
}, 1000)
}
function stopDurationTimer () {
clearInterval(durationTimer)
durationTimer = undefined
}
// converts a timeStamp in seconds to a human readable hh:mm:ss format
function secondsToTimeString (secondsTimeStamp) {
if (secondsTimeStamp === Infinity) return '∞'
@ -180,10 +206,11 @@ function createRowingStatistics () {
}
return Object.assign(emitter, {
handleStroke,
handleStrokeEnd,
handlePause,
handleHeartrateMeasurement,
handleStrokeStateChanged,
handleRecoveryEnd,
updateKeyMetrics,
reset: resetTraining
})
}

View File

@ -167,8 +167,8 @@ function createWorkoutRecorder () {
fs.writeFile(workout.filename, tcxXml, (err) => { if (err) log.error(err) })
}
function reset () {
createRecordings()
async function reset () {
await createRecordings()
strokes = []
rotationImpulses = []
startTime = undefined
@ -178,7 +178,7 @@ function createWorkoutRecorder () {
createRecordings()
}
function createRecordings () {
async function createRecordings () {
if (!config.recordRawData && !config.createTcxFiles) {
return
}
@ -186,17 +186,16 @@ function createWorkoutRecorder () {
const minimumRecordingTimeInSeconds = 10
const rotationImpulseTimeTotal = rotationImpulses.reduce((acc, impulse) => acc + impulse, 0)
const strokeTimeTotal = strokes.reduce((acc, stroke) => acc + stroke.strokeTime, 0)
if (rotationImpulseTimeTotal < minimumRecordingTimeInSeconds && strokeTimeTotal < minimumRecordingTimeInSeconds) {
if (rotationImpulseTimeTotal < minimumRecordingTimeInSeconds || strokeTimeTotal < minimumRecordingTimeInSeconds) {
log.debug(`recording time is less than ${minimumRecordingTimeInSeconds}s, skipping creation of recording files...`)
return
}
if (config.recordRawData) {
createRawDataFile()
await createRawDataFile()
}
if (config.createTcxFiles) {
createTcxFile()
await createTcxFile()
}
}

View File

@ -61,9 +61,10 @@ peripheralManager.on('control', (event) => {
})
function resetWorkout () {
workoutRecorder.reset()
rowingEngine.reset()
rowingStatistics.reset()
peripheralManager.notifyStatus({ name: 'reset' })
workoutRecorder.reset()
}
const gpioTimerService = fork('./app/gpio/GpioTimerService.js')
@ -76,7 +77,7 @@ function handleRotationImpulse (dataPoint) {
}
const rowingEngine = createRowingEngine(config.rowerSettings)
const rowingStatistics = createRowingStatistics()
const rowingStatistics = createRowingStatistics(config)
rowingEngine.notify(rowingStatistics)
const workoutRecorder = createWorkoutRecorder()
@ -90,11 +91,12 @@ rowingStatistics.on('strokeFinished', (metrics) => {
workoutRecorder.recordStroke(metrics)
})
rowingStatistics.on('strokeStateChanged', (metrics) => {
rowingStatistics.on('recoveryFinished', (metrics) => { // @@ COPY ME TO GOOD FILE !!
webServer.notifyClients(metrics)
peripheralManager.notifyMetrics('strokeStateChanged', metrics)
})
rowingStatistics.on('metricsUpdate', (metrics) => {
rowingStatistics.on('metricsUpdate', (metrics) => { // @@ COPY ME TO GOOD FILE !!
webServer.notifyClients(metrics)
peripheralManager.notifyMetrics('metricsUpdate', metrics)
})

View File

@ -66,6 +66,24 @@ export default {
// Most bike training applications are fine with any device name
ftmsBikePeripheralName: 'OpenRowingBike',
// The interval for updating all your screens (i.e. the monitor, but also bluetooth devices) in ms.
// Advised is to update at least once per second, to make sure the timer moves nice and smoothly.
// Around 100 ms results in a very smooth experience that the distance display also updates smoothly
// Please note that using a 100ms or less will result in more work for your rapberry pi
screenUpdateInterval: 1000,
// The number of stroke phases (i.e. Drive or Recovery) used to smoothen the data displayed on your
// screens (i.e. the monitor, but also bluetooth devices, etc.). A nice smooth experience is found at 6
// phases, a much more volatile (but more accurate and responsive) is found around 3. The minimum is 1,
// but for recreational rowers that might feel much too restless to be useful
numOfPhasesForAveragingScreenData: 6,
// The time between strokes in seconds before the rower considers it a pause. Default value is set to 6.
// It is not recommended to go below this value, as not recognizing a stroke could result in a pause
// (as a typical stroke is between 2 to 3 seconds for recreational rowers). Increase it when you have
// issues with your stroke detection and the rower is pausing unexpectedly
maximumStrokeTime: 10,
// The rower specific settings. Either choose a profile from config/rowerProfiles.js or
// define the settings individually. If you find good settings for a new rowing device
// please send them to us (together with a raw recording of 10 strokes) so we can add

View File

@ -16,37 +16,58 @@ export default {
// i.e. the number of magnets if used with a reed sensor
numOfImpulsesPerRevolution: 1,
// NOISE FILTER SETTINGS
// Filter Settings to reduce noise in the measured data
// Minimum and maximum duration between impulses in seconds during active rowing. Measurements outside of this range
// will be replaced by a default value.
// are replaced by a default value.
minimumTimeBetweenImpulses: 0.014,
maximumTimeBetweenImpulses: 0.5,
// Percentage change between successive intervals
maximumDownwardChange: 0.25, // effectively the maximum deceleration
maximumUpwardChange: 1.75, // effectively the maximum acceleration
// Percentage change between successive intervals before measurements are considered invalid
maximumDownwardChange: 0.25, // effectively the maximum acceleration
maximumUpwardChange: 1.75, // effectively the maximum decceleration
// Smoothing determines the length of the running average for certain volatile measurements, 1 effectively turns it off
smoothing: 1,
// STROKE DETECTION SETTINGS
// Flank length determines the minimum number of consecutive increasing/decreasing measuments that are needed before the stroke detection
// considers a drive phase change
// numberOfErrorsAllowed allows for a more noisy approach, but shouldn't be needed
flankLength: 2,
numberOfErrorsAllowed: 0,
// Settings for the rowing phase detection (in seconds)
minimumDriveTime: 0.500,
minimumRecoveryTime: 0.800,
// Needed to determine the damping constant of the rowing machine. This value can be measured in the recovery phase
// Natural deceleration is used to distinguish between a powered and unpowered flywheel.
// This must be a NEGATIVE number and indicates the level of deceleration required to interpret it as a free spinning
// flywheel. The best way to find the correct value for your rowing machine is a try and error approach.
// You can also set this to zero (or positive), to use the more robust, but not so precise acceleration-based stroke
// detection algorithm.
naturalDeceleration: 0,
// Error reducing settings for the rowing phase detection (in seconds)
maximumImpulseTimeBeforePause: 3.0, // maximum time between impulses before the rowing engine considers it a pause
minimumDriveTime: 0.300, // minimum time of the drive phase
minimumRecoveryTime: 1.200, // minimum time of the recovery phase
// Needed to determine the drag factor of the rowing machine. This value can be measured in the recovery phase
// of the stroke.
// To display it for your rowing machine, set the logging level of the RowingEngine to 'info'. Then start rowing and
// you will see the measured values in the log.
// Just as a frame of reference: the Concept2 can display this factor from the menu, where it is multiplied with 1.000.000
// For a new Concept2 the Drag Factor ranges between 80 (Damper setting 1) and 220 (Damper setting 10). Other rowers are
// in the range of 150 to 450 (NordicTrack).
// Open Rowing Monitor can also automatically adjust this value based on the measured damping. To do so, set the setting
// autoAdjustDampingConstant to true (see below).
omegaDotDivOmegaSquare: 0.02,
dragFactor: 1500,
// The moment of inertia of the flywheel kg*m^2
// A way to measure it is outlined here: https://dvernooy.github.io/projects/ergware/, "Flywheel moment of inertia"
// You could also roughly estimate it by just doing some strokes and the comparing the calculated power values for
// plausibility. Note that the power also depends on omegaDotDivOmegaSquare (see above).
jMoment: 0.49,
// plausibility. Note that the power also depends on the drag factor (see above).
flywheelInertia: 0.5,
// Set this to true, if you want to automatically update omegaDotDivOmegaSquare and kDamp based on the measured
// Set this to true, if you want to automatically update the drag factor based on the measured
// values in the stroke recovery phase. If your rower produces stable damping values, then this could be a good
// option to dynamically adjust your measurements to the damper setting of your rower.
// When your machine's power and speed readings are too volatile it is wise to turn it off
autoAdjustDampingConstant: false,
// A constant that is commonly used to convert flywheel revolutions to a rowed distance
@ -55,103 +76,102 @@ export default {
// to their expectations. So for your rower, you have to find a credible distance for your effort.
// Also note that the rowed distance also depends on jMoment, so please calibrate that before changing this constant.
// PLEASE NOTE: Increasing this number decreases your rowed meters
magicConstant: 2.8,
// Set this to true if you are using a water rower
// The mass of the water starts rotating, when you pull the handle, and therefore acts
// like a massive flywheel
// Liquids are a tricky thing and therefore the dumping constant does not seem to be
// that constant on water rowers...
// This is WIP, but for now this setting is used to figure out the drive and recovery phases
// differently on water rowers
liquidFlywheel: false
magicConstant: 2.8
},
// Sportstech WRX700
WRX700: {
numOfImpulsesPerRevolution: 2,
minimumTimeBetweenImpulses: 0.05,
maximumTimeBetweenImpulses: 1,
omegaDotDivOmegaSquare: 0.046,
jMoment: 0.49,
liquidFlywheel: true
naturalDeceleration: -5.0,
flywheelInertia: 0.45,
dragFactor: 22000
},
// DKN R-320 Air Rower
DKNR320: {
numOfImpulsesPerRevolution: 1,
minimumTimeBetweenImpulses: 0.15,
maximumTimeBetweenImpulses: 0.5,
omegaDotDivOmegaSquare: 0.019,
jMoment: 0.4,
liquidFlywheel: true
flywheelInertia: 0.41,
dragFactor: 4000
},
// NordicTrack RX800 Air Rower
RX800: {
numOfImpulsesPerRevolution: 4,
liquidFlywheel: false,
// Damper setting 10
/* Damper setting 10
minimumTimeBetweenImpulses: 0.018,
maximumTimeBetweenImpulses: 0.0338,
maximumDownwardChange: 0.69,
maximumUpwardChange: 1.3,
flankLength: 3,
numberOfErrorsAllowed: 0,
minimumDriveTime: 0.300,
minimumRecoveryTime: 0.750,
omegaDotDivOmegaSquare: 0.00543660639574872,
jMoment: 0.174,
magicConstant: 3.75
//
smoothing: 3,
maximumDownwardChange: 0.88,
maximumUpwardChange: 1.11,
flankLength: 9,
numberOfErrorsAllowed: 2,
naturalDeceleration: -11.5, // perfect runs
minimumDriveTime: 0.40,
minimumRecoveryTime: 0.90,
flywheelInertia: 0.146,
dragFactor: 560
*/
/* Damper setting 8
minimumTimeBetweenImpulses: 0.017,
maximumTimeBetweenImpulses: 0.034,
smoothing: 3,
maximumDownwardChange: 0.8,
maximumUpwardChange: 1.15,
minimumDriveTime: 0.300,
minimumRecoveryTime: 0.750,
omegaDotDivOmegaSquare: 0.005305471,
jMoment: 0.155, // still under investigation
magicConstant: 4 // still under investigation
flankLength: 9,
numberOfErrorsAllowed: 2,
naturalDeceleration: -10.25, // perfect runs
minimumDriveTime: 0.30,
minimumRecoveryTime: 0.90,
flywheelInertia: 0.131,
dragFactor: 440
*/
/* Damper setting 6
minimumTimeBetweenImpulses: 0.017,
maximumTimeBetweenImpulses: 0.034,
maximumDownwardChange: 0.85,
maximumUpwardChange: 1.15,
minimumDriveTime: 0.300,
minimumRecoveryTime: 0.750,
omegaDotDivOmegaSquare: 0.0047,
jMoment: 0.135, // still under investigation
magicConstant: 4.25 // still under investigation
*/
// Damper setting 6
minimumTimeBetweenImpulses: 0.00925,
maximumTimeBetweenImpulses: 0.038,
smoothing: 3,
maximumDownwardChange: 0.86,
maximumUpwardChange: 1.13,
flankLength: 9,
numberOfErrorsAllowed: 2,
// naturalDeceleration: -8.5, // perfect runs IIII
naturalDeceleration: -8.6, // perfect runs IIIXI
minimumDriveTime: 0.28,
minimumRecoveryTime: 0.90,
flywheelInertia: 0.131,
dragFactor: 310
//
/* Damper setting 4
minimumTimeBetweenImpulses: 0.019,
maximumTimeBetweenImpulses: 0.032,
maximumDownwardChange: 0.70,
maximumUpwardChange: 1.30,
minimumDriveTime: 0.300,
minimumRecoveryTime: 0.750,
omegaDotDivOmegaSquare: 0.00355272,
jMoment: 0.125,
magicConstant: 4.4
minimumTimeBetweenImpulses: 0.00925,
maximumTimeBetweenImpulses: 0.0335,
smoothing: 3,
maximumDownwardChange: 0.890,
maximumUpwardChange: 1.07,
flankLength: 10,
numberOfErrorsAllowed: 2,
naturalDeceleration: -5.5, // perfect runs I
minimumDriveTime: 0.24,
minimumRecoveryTime: 0.90,
flywheelInertia: 0.140,
dragFactor: 255
*/
/* Damper setting 2
minimumTimeBetweenImpulses: 0.016,
maximumTimeBetweenImpulses: 0.033,
maximumDownwardChange: 0.85,
maximumUpwardChange: 1.15,
minimumDriveTime: 0.300,
minimumRecoveryTime: 0.750,
omegaDotDivOmegaSquare: 0.002263966,
jMoment: 0.111,
magicConstant: 4.6
minimumTimeBetweenImpulses: 0.00925,
maximumTimeBetweenImpulses: 0.030,
smoothing: 4,
maximumDownwardChange: 0.962,
maximumUpwardChange: 1.07,
flankLength: 11,
numberOfErrorsAllowed: 2,
naturalDeceleration: -2.45, // perfect runs
minimumDriveTime: 0.28,
minimumRecoveryTime: 0.90,
flywheelInertia: 0.155,
dragFactor: 155,
magicConstant: 2.8
*/
}
}

Binary file not shown.