diff --git a/README.md b/README.md index bb9fc33..46d8960 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/ble/ftms/IndoorBikeDataCharacteristic.js b/app/ble/ftms/IndoorBikeDataCharacteristic.js index b7cebef..141a49c 100644 --- a/app/ble/ftms/IndoorBikeDataCharacteristic.js +++ b/app/ble/ftms/IndoorBikeDataCharacteristic.js @@ -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) { diff --git a/app/ble/ftms/RowerDataCharacteristic.js b/app/ble/ftms/RowerDataCharacteristic.js index 319a2d4..2c03ca7 100644 --- a/app/ble/ftms/RowerDataCharacteristic.js +++ b/app/ble/ftms/RowerDataCharacteristic.js @@ -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) { diff --git a/app/ble/pm5/characteristic/AdditionalStatus.js b/app/ble/pm5/characteristic/AdditionalStatus.js index 9775d07..0e8dd26 100644 --- a/app/ble/pm5/characteristic/AdditionalStatus.js +++ b/app/ble/pm5/characteristic/AdditionalStatus.js @@ -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) diff --git a/app/ble/pm5/characteristic/AdditionalStatus2.js b/app/ble/pm5/characteristic/AdditionalStatus2.js index eb41e93..67f54f1 100644 --- a/app/ble/pm5/characteristic/AdditionalStatus2.js +++ b/app/ble/pm5/characteristic/AdditionalStatus2.js @@ -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 diff --git a/app/ble/pm5/characteristic/AdditionalStrokeData.js b/app/ble/pm5/characteristic/AdditionalStrokeData.js index 3c75397..3b6e7bf 100644 --- a/app/ble/pm5/characteristic/AdditionalStrokeData.js +++ b/app/ble/pm5/characteristic/AdditionalStrokeData.js @@ -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 diff --git a/app/ble/pm5/characteristic/GeneralStatus.js b/app/ble/pm5/characteristic/GeneralStatus.js index 246b26e..85c1e0b 100644 --- a/app/ble/pm5/characteristic/GeneralStatus.js +++ b/app/ble/pm5/characteristic/GeneralStatus.js @@ -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 diff --git a/app/ble/pm5/characteristic/StrokeData.js b/app/ble/pm5/characteristic/StrokeData.js index 1e363a1..ededdb2 100644 --- a/app/ble/pm5/characteristic/StrokeData.js +++ b/app/ble/pm5/characteristic/StrokeData.js @@ -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 diff --git a/app/engine/MovingAverager.js b/app/engine/MovingAverager.js new file mode 100644 index 0000000..224b727 --- /dev/null +++ b/app/engine/MovingAverager.js @@ -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 } diff --git a/app/engine/MovingFlankDetector.js b/app/engine/MovingFlankDetector.js index 34d8915..8ab9386 100644 --- a/app/engine/MovingFlankDetector.js +++ b/app/engine/MovingFlankDetector.js @@ -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 } } diff --git a/app/engine/RowingEngine.js b/app/engine/RowingEngine.js index 6509642..8301242 100644 --- a/app/engine/RowingEngine.js +++ b/app/engine/RowingEngine.js @@ -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 } } diff --git a/app/engine/RowingEngine.test.js b/app/engine/RowingEngine.test.js index ba85c96..13fe2e7 100644 --- a/app/engine/RowingEngine.test.js +++ b/app/engine/RowingEngine.test.js @@ -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() diff --git a/app/engine/RowingStatistics.js b/app/engine/RowingStatistics.js index 4bd8013..b377bee 100644 --- a/app/engine/RowingStatistics.js +++ b/app/engine/RowingStatistics.js @@ -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 }) } diff --git a/app/engine/WorkoutRecorder.js b/app/engine/WorkoutRecorder.js index c783606..c281dd2 100644 --- a/app/engine/WorkoutRecorder.js +++ b/app/engine/WorkoutRecorder.js @@ -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() } } diff --git a/app/server.js b/app/server.js index 1c25534..63720a7 100644 --- a/app/server.js +++ b/app/server.js @@ -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) }) diff --git a/config/default.config.js b/config/default.config.js index a2d8238..fdd78be 100644 --- a/config/default.config.js +++ b/config/default.config.js @@ -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 diff --git a/config/rowerProfiles.js b/config/rowerProfiles.js index fe971e3..64b230d 100644 --- a/config/rowerProfiles.js +++ b/config/rowerProfiles.js @@ -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 */ } } diff --git a/docs/Physics of the OpenRowingMonitor.docx b/docs/Physics of the OpenRowingMonitor.docx new file mode 100644 index 0000000..4e924a8 Binary files /dev/null and b/docs/Physics of the OpenRowingMonitor.docx differ