More refined session state (#33)

* A more refined rowing session state (PM5)

Introduces a more refined session state needed for the PM5 rower session simulation, as well as a cleaner setup for the metrics to stop when the rower is stopped.

* Added the WorkoutState and rowingState

Added the WorkoutState and rowingState to the PM5 interface, to prevent EXR to start too early.

* Added a link to the brilliant work of Nomath

Added a link to Nomath's work, as it is a brilliant piece of work that independently verifies our calculations and provides a basis for support of the Concept2 rower.

* Bugfix in Instantanous Torque calculation

The Instantanous Torque calculation wasn't cleaned up sufficiently, causing issues in the powercurve.

* unifies reset of heartrate value at different places

* unifies naming with other variables status -> state

* just some minor link adjustments in documentation

Co-authored-by: Lars Berning <151194+laberning@users.noreply.github.com>
This commit is contained in:
Jaap van Ekris 2022-01-09 12:09:02 +01:00 committed by GitHub
parent ab02153c2d
commit e85491acb9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 31 additions and 40 deletions

View File

@ -46,9 +46,9 @@ export default class GeneralStatus extends bleno.Characteristic {
// intervalType: UInt8 will always use 255 (NONE) // intervalType: UInt8 will always use 255 (NONE)
bufferBuilder.writeUInt8(255) bufferBuilder.writeUInt8(255)
// workoutState: UInt8 0 WAITTOBEGIN, 1 WORKOUTROW, 10 WORKOUTEND // workoutState: UInt8 0 WAITTOBEGIN, 1 WORKOUTROW, 10 WORKOUTEND
bufferBuilder.writeUInt8(1) bufferBuilder.writeUInt8(data.sessionState === 'rowing' ? 1 : (data.sessionState === 'waitingForStart' ? 0 : 10))
// rowingState: UInt8 0 INACTIVE, 1 ACTIVE // rowingState: UInt8 0 INACTIVE, 1 ACTIVE
bufferBuilder.writeUInt8(1) bufferBuilder.writeUInt8(data.sessionState === 'rowing' ? 1 : 0)
// strokeState: UInt8 2 DRIVING, 4 RECOVERY // strokeState: UInt8 2 DRIVING, 4 RECOVERY
bufferBuilder.writeUInt8(data.strokeState === 'DRIVING' ? 2 : 4) bufferBuilder.writeUInt8(data.strokeState === 'DRIVING' ? 2 : 4)
// totalWorkDistance: UInt24LE in 1 m // totalWorkDistance: UInt24LE in 1 m

View File

@ -149,11 +149,7 @@ function createRowingEngine (rowerSettings) {
// Calculate the key metrics // Calculate the key metrics
recoveryLinearDistance = Math.pow((dragFactor / rowerSettings.magicConstant), 1.0 / 3.0) * recoveryPhaseAngularDisplacement recoveryLinearDistance = Math.pow((dragFactor / rowerSettings.magicConstant), 1.0 / 3.0) * recoveryPhaseAngularDisplacement
totalLinearDistance += recoveryLinearDistance totalLinearDistance += recoveryLinearDistance
if (currentDt > 0) { currentTorque = calculateTorque(currentDt)
previousAngularVelocity = currentAngularVelocity
currentAngularVelocity = angularDisplacementPerImpulse / currentDt
currentTorque = rowerSettings.flywheelInertia * ((currentAngularVelocity - previousAngularVelocity) / currentDt) + dragFactor * Math.pow(currentAngularVelocity, 2)
}
linearCycleVelocity = calculateLinearVelocity() linearCycleVelocity = calculateLinearVelocity()
averagedCyclePower = calculateCyclePower() averagedCyclePower = calculateCyclePower()
@ -186,11 +182,7 @@ function createRowingEngine (rowerSettings) {
// Update the key metrics on each impulse // Update the key metrics on each impulse
drivePhaseAngularDisplacement = ((totalNumberOfImpulses - flankDetector.noImpulsesToBeginFlank()) - drivePhaseStartAngularDisplacement) * angularDisplacementPerImpulse drivePhaseAngularDisplacement = ((totalNumberOfImpulses - flankDetector.noImpulsesToBeginFlank()) - drivePhaseStartAngularDisplacement) * angularDisplacementPerImpulse
driveLinearDistance = Math.pow((dragFactor / rowerSettings.magicConstant), 1.0 / 3.0) * drivePhaseAngularDisplacement driveLinearDistance = Math.pow((dragFactor / rowerSettings.magicConstant), 1.0 / 3.0) * drivePhaseAngularDisplacement
if (currentDt > 0) { currentTorque = calculateTorque(currentDt)
previousAngularVelocity = currentAngularVelocity
currentAngularVelocity = angularDisplacementPerImpulse / currentDt
currentTorque = calculateTorque(currentDt)
}
if (workoutHandler) { if (workoutHandler) {
workoutHandler.updateKeyMetrics({ workoutHandler.updateKeyMetrics({
timeSinceStart: totalTime, timeSinceStart: totalTime,
@ -213,11 +205,7 @@ function createRowingEngine (rowerSettings) {
// driveEndAngularVelocity = angularDisplacementPerImpulse / flankDetector.impulseLengthAtBeginFlank() // driveEndAngularVelocity = angularDisplacementPerImpulse / flankDetector.impulseLengthAtBeginFlank()
driveLinearDistance = Math.pow((dragFactor / rowerSettings.magicConstant), 1.0 / 3.0) * drivePhaseAngularDisplacement driveLinearDistance = Math.pow((dragFactor / rowerSettings.magicConstant), 1.0 / 3.0) * drivePhaseAngularDisplacement
totalLinearDistance += driveLinearDistance totalLinearDistance += driveLinearDistance
if (currentDt > 0) { currentTorque = calculateTorque(currentDt)
previousAngularVelocity = currentAngularVelocity
currentAngularVelocity = angularDisplacementPerImpulse / currentDt
currentTorque = calculateTorque(currentDt)
}
// We display the AVERAGE speed in the display, NOT the top speed of the stroke // We display the AVERAGE speed in the display, NOT the top speed of the stroke
linearCycleVelocity = calculateLinearVelocity() linearCycleVelocity = calculateLinearVelocity()
averagedCyclePower = calculateCyclePower() averagedCyclePower = calculateCyclePower()
@ -252,11 +240,7 @@ function createRowingEngine (rowerSettings) {
// Update the key metrics on each impulse // Update the key metrics on each impulse
recoveryPhaseAngularDisplacement = ((totalNumberOfImpulses - flankDetector.noImpulsesToBeginFlank()) - recoveryPhaseStartAngularDisplacement) * angularDisplacementPerImpulse recoveryPhaseAngularDisplacement = ((totalNumberOfImpulses - flankDetector.noImpulsesToBeginFlank()) - recoveryPhaseStartAngularDisplacement) * angularDisplacementPerImpulse
recoveryLinearDistance = Math.pow((dragFactor / rowerSettings.magicConstant), 1.0 / 3.0) * recoveryPhaseAngularDisplacement recoveryLinearDistance = Math.pow((dragFactor / rowerSettings.magicConstant), 1.0 / 3.0) * recoveryPhaseAngularDisplacement
if (currentDt > 0) { currentTorque = calculateTorque(currentDt)
previousAngularVelocity = currentAngularVelocity
currentAngularVelocity = angularDisplacementPerImpulse / currentDt
currentTorque = calculateTorque(currentDt)
}
if (workoutHandler) { if (workoutHandler) {
workoutHandler.updateKeyMetrics({ workoutHandler.updateKeyMetrics({
timeSinceStart: totalTime, timeSinceStart: totalTime,

View File

@ -24,14 +24,14 @@ function createRowingStatistics (config) {
const powerRatioAverager = createWeightedAverager(numOfDataPointsForAveraging) const powerRatioAverager = createWeightedAverager(numOfDataPointsForAveraging)
const caloriesAveragerMinute = createMovingIntervalAverager(60) const caloriesAveragerMinute = createMovingIntervalAverager(60)
const caloriesAveragerHour = createMovingIntervalAverager(60 * 60) const caloriesAveragerHour = createMovingIntervalAverager(60 * 60)
let trainingRunning = false let sessionState = 'waitingForStart'
let rowingPausedTimer let rowingPausedTimer
let heartrateResetTimer let heartrateResetTimer
let distanceTotal = 0.0 let distanceTotal = 0.0
let durationTotal = 0 let durationTotal = 0
let strokesTotal = 0 let strokesTotal = 0
let caloriesTotal = 0.0 let caloriesTotal = 0.0
let heartrate let heartrate = 0
let heartrateBatteryLevel = 0 let heartrateBatteryLevel = 0
let lastStrokeDuration = 0.0 let lastStrokeDuration = 0.0
let instantaneousTorque = 0.0 let instantaneousTorque = 0.0
@ -51,8 +51,6 @@ function createRowingStatistics (config) {
} }
function handleStrokeEnd (stroke) { function handleStrokeEnd (stroke) {
if (!trainingRunning) startTraining()
// if we do not get a stroke for timeBetweenStrokesBeforePause milliseconds we treat this as a rowing pause // if we do not get a stroke for timeBetweenStrokesBeforePause milliseconds we treat this as a rowing pause
if (rowingPausedTimer)clearInterval(rowingPausedTimer) if (rowingPausedTimer)clearInterval(rowingPausedTimer)
rowingPausedTimer = setTimeout(() => pauseRowing(), timeBetweenStrokesBeforePause) rowingPausedTimer = setTimeout(() => pauseRowing(), timeBetweenStrokesBeforePause)
@ -85,6 +83,7 @@ function createRowingStatistics (config) {
// initiated by the rowing engine in case an impulse was not considered // initiated by the rowing engine in case an impulse was not considered
// because it was too large // because it was too large
function handlePause (duration) { function handlePause (duration) {
sessionState = 'paused'
caloriesAveragerMinute.pushValue(0, duration) caloriesAveragerMinute.pushValue(0, duration)
caloriesAveragerHour.pushValue(0, duration) caloriesAveragerHour.pushValue(0, duration)
emitter.emit('rowingPaused') emitter.emit('rowingPaused')
@ -94,6 +93,7 @@ function createRowingStatistics (config) {
function handleRecoveryEnd (stroke) { function handleRecoveryEnd (stroke) {
// todo: we need a better mechanism to communicate strokeState updates // todo: we need a better mechanism to communicate strokeState updates
// this is an initial hacky attempt to see if we can use it for the C2-pm5 protocol // this is an initial hacky attempt to see if we can use it for the C2-pm5 protocol
if (sessionState !== 'rowing') startTraining()
durationTotal = stroke.timeSinceStart durationTotal = stroke.timeSinceStart
powerAverager.pushValue(stroke.power) powerAverager.pushValue(stroke.power)
speedAverager.pushValue(stroke.speed) speedAverager.pushValue(stroke.speed)
@ -137,8 +137,9 @@ function createRowingStatistics (config) {
// todo: due to sanitization we currently do not use a consistent time throughout the engine // 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 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. // We will also polish up the events for the recovery and drive phase, so we get clean complete strokes from the first stroke onwards.
const averagedStrokeTime = strokeAverager.getAverage() > minimumStrokeTime && strokeAverager.getAverage() < maximumStrokeTime && lastStrokeSpeed > 0 ? strokeAverager.getAverage() : 0 // seconds const averagedStrokeTime = strokeAverager.getAverage() > minimumStrokeTime && strokeAverager.getAverage() < maximumStrokeTime && lastStrokeSpeed > 0 && sessionState === 'rowing' ? strokeAverager.getAverage() : 0 // seconds
return { return {
sessionState,
durationTotal, durationTotal,
durationTotalFormatted: secondsToTimeString(durationTotal), durationTotalFormatted: secondsToTimeString(durationTotal),
strokesTotal, strokesTotal,
@ -147,14 +148,14 @@ function createRowingStatistics (config) {
caloriesPerMinute: caloriesAveragerMinute.getAverage() > 0 ? caloriesAveragerMinute.getAverage() : 0, caloriesPerMinute: caloriesAveragerMinute.getAverage() > 0 ? caloriesAveragerMinute.getAverage() : 0,
caloriesPerHour: caloriesAveragerHour.getAverage() > 0 ? caloriesAveragerHour.getAverage() : 0, caloriesPerHour: caloriesAveragerHour.getAverage() > 0 ? caloriesAveragerHour.getAverage() : 0,
strokeTime: lastStrokeDuration, // seconds strokeTime: lastStrokeDuration, // seconds
distance: lastStrokeDistance > 0 && lastStrokeSpeed > 0 ? lastStrokeDistance : 0, // meters distance: lastStrokeDistance > 0 && lastStrokeSpeed > 0 && sessionState === 'rowing' ? lastStrokeDistance : 0, // meters
power: powerAverager.getAverage() > 0 && lastStrokeSpeed > 0 ? powerAverager.getAverage() : 0, // watts power: powerAverager.getAverage() > 0 && lastStrokeSpeed > 0 && sessionState === 'rowing' ? powerAverager.getAverage() : 0, // watts
split: splitTime, // seconds/500m split: splitTime, // seconds/500m
splitFormatted: secondsToTimeString(splitTime), splitFormatted: secondsToTimeString(splitTime),
powerRatio: powerRatioAverager.getAverage() > 0 && lastStrokeSpeed > 0 ? powerRatioAverager.getAverage() : 0, powerRatio: powerRatioAverager.getAverage() > 0 && lastStrokeSpeed > 0 && sessionState === 'rowing' ? powerRatioAverager.getAverage() : 0,
instantaneousTorque: instantaneousTorque, instantaneousTorque: instantaneousTorque,
strokesPerMinute: averagedStrokeTime !== 0 ? (60.0 / averagedStrokeTime) : 0, strokesPerMinute: averagedStrokeTime !== 0 && sessionState === 'rowing' ? (60.0 / averagedStrokeTime) : 0,
speed: speedAverager.getAverage() > 0 && lastStrokeSpeed > 0 ? (speedAverager.getAverage() * 3.6) : 0, // km/h speed: speedAverager.getAverage() > 0 && lastStrokeSpeed > 0 && sessionState === 'rowing' ? (speedAverager.getAverage() * 3.6) : 0, // km/h
strokeState: lastStrokeState, strokeState: lastStrokeState,
heartrate, heartrate,
heartrateBatteryLevel heartrateBatteryLevel
@ -162,11 +163,11 @@ function createRowingStatistics (config) {
} }
function startTraining () { function startTraining () {
trainingRunning = true sessionState = 'rowing'
} }
function stopTraining () { function stopTraining () {
trainingRunning = false sessionState = 'stopped'
if (rowingPausedTimer)clearInterval(rowingPausedTimer) if (rowingPausedTimer)clearInterval(rowingPausedTimer)
} }
@ -182,6 +183,7 @@ function createRowingStatistics (config) {
powerAverager.reset() powerAverager.reset()
speedAverager.reset() speedAverager.reset()
powerRatioAverager.reset() powerRatioAverager.reset()
sessionState = 'waitingForStart'
} }
// clear the metrics in case the user pauses rowing // clear the metrics in case the user pauses rowing
@ -191,6 +193,7 @@ function createRowingStatistics (config) {
speedAverager.reset() speedAverager.reset()
powerRatioAverager.reset() powerRatioAverager.reset()
lastStrokeState = 'RECOVERY' lastStrokeState = 'RECOVERY'
sessionState = 'paused'
emitter.emit('rowingPaused') emitter.emit('rowingPaused')
} }

View File

@ -6,9 +6,11 @@ Open Rowing Monitor uses some great work by others. Thank you for all the great
* Dave Vernooy's project description on [ErgWare](https://dvernooy.github.io/projects/ergware) has some good information on the maths involved in a rowing ergometer. * Dave Vernooy's project description on [ErgWare](https://dvernooy.github.io/projects/ergware) has some good information on the maths involved in a rowing ergometer.
* Bluetooth is quite a complex beast, luckily the Bluetooth SIG releases all the [specifications here](https://www.bluetooth.com/specifications/specs). * Nomath has done a very impressive [Reverse engineering of the actual workings of the Concept 2 PM5](https://www.c2forum.com/viewtopic.php?f=7&t=194719), including experimentally checking drag calculations.
* The app icon is based on this [image of a rowing machine](https://thenounproject.com/term/rowing-machine/659265) by [Gan Khoon Lay](https://thenounproject.com/leremy/) licensed under [CC BY 2.0](https://creativecommons.org/licenses/by/2.0/). * Bluetooth is quite a complex beast, luckily the Bluetooth SIG releases all the [Bluetooth Specifications](https://www.bluetooth.com/specifications/specs).
* The app icon is based on this [Image of a rowing machine](https://thenounproject.com/term/rowing-machine/659265) by [Gan Khoon Lay](https://thenounproject.com/leremy/) licensed under [CC BY 2.0](https://creativecommons.org/licenses/by/2.0/).
* The frontend uses some icons from [Font Awesome](https://fontawesome.com/), licensed under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). * The frontend uses some icons from [Font Awesome](https://fontawesome.com/), licensed under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/).

View File

@ -97,11 +97,11 @@ There are several key metrics that underpin the performance measurement of a row
* The **Angular Velocity** of the flywheel in Radians/second: in essence the number of (partial) rotations of the flywheel per second. As the *Angular Displacement* is fixed for a specific rowing machine, the *Angular Velocity* is (*angular displacement between impulses*) / (time between impulses); * The **Angular Velocity** of the flywheel in Radians/second: in essence the number of (partial) rotations of the flywheel per second. As the *Angular Displacement* is fixed for a specific rowing machine, the *Angular Velocity* is (*angular displacement between impulses*) / (time between impulses);
* The **Angular Acceleration** of the flywheel in Radians/second^2^: the acceleration/deceleration of the flywheel; * The **Angular Acceleration** of the flywheel (in Radians/second^2): the acceleration/deceleration of the flywheel;
* The *estimated* **Linear Distance** of the boat in Meters: the distance the boat is expected to travel; * The *estimated* **Linear Distance** of the boat (in Meters): the distance the boat is expected to travel;
* _estimated_ **Linear Velocity** of the boat in Meters/Second: the speed at which the boat is expected to travel. * _estimated_ **Linear Velocity** of the boat (in Meters/Second): the speed at which the boat is expected to travel.
## Measurements during the recovery phase ## Measurements during the recovery phase
@ -116,7 +116,7 @@ Although not the first phase in a cycle, it is an important phase as it deducts
In the recovery phase, the only force exerted on the flywheel is the (air/water/magnetic)resistance. Thus we can calculate the Drag factor of the Flywheel based on the entire phase. In the recovery phase, the only force exerted on the flywheel is the (air/water/magnetic)resistance. Thus we can calculate the Drag factor of the Flywheel based on the entire phase.
As [[1]](#1) describes in formula 7.2: As [[1]](#1) describes in formula 7.2, which is also experimentally verified by Nomath on a Concept 2 [[5]](#5):
> <img src="https://render.githubusercontent.com/render/math?math=k=Id(1/\omega)/dt"> > <img src="https://render.githubusercontent.com/render/math?math=k=Id(1/\omega)/dt">
@ -280,3 +280,5 @@ Again, this is a systematic (overestimation) of the power, which will be systema
<a id="3">[3]</a> Dave Vernooy, "Open Source Ergometer ErgWare" <https://dvernooy.github.io/projects/ergware/> <a id="3">[3]</a> Dave Vernooy, "Open Source Ergometer ErgWare" <https://dvernooy.github.io/projects/ergware/>
<a id="4">[4]</a> <https://github.com/dvernooy/ErgWare/blob/master/v0.5/main/main.ino> <a id="4">[4]</a> <https://github.com/dvernooy/ErgWare/blob/master/v0.5/main/main.ino>
<a id="5">[5]</a> Fan blade Physics and a Peek inside C2's Black Box, Nomath <https://www.c2forum.com/viewtopic.php?f=7&t=194719>