From e85491acb9ee67941fb4f6753c12418d07e8f9e4 Mon Sep 17 00:00:00 2001
From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com>
Date: Sun, 9 Jan 2022 12:09:02 +0100
Subject: [PATCH] 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>
---
app/ble/pm5/characteristic/GeneralStatus.js | 4 +--
app/engine/RowingEngine.js | 24 +++---------------
app/engine/RowingStatistics.js | 27 ++++++++++++---------
docs/attribution.md | 6 +++--
docs/physics_openrowingmonitor.md | 10 +++++---
5 files changed, 31 insertions(+), 40 deletions(-)
diff --git a/app/ble/pm5/characteristic/GeneralStatus.js b/app/ble/pm5/characteristic/GeneralStatus.js
index 85c1e0b..2748b41 100644
--- a/app/ble/pm5/characteristic/GeneralStatus.js
+++ b/app/ble/pm5/characteristic/GeneralStatus.js
@@ -46,9 +46,9 @@ export default class GeneralStatus extends bleno.Characteristic {
// intervalType: UInt8 will always use 255 (NONE)
bufferBuilder.writeUInt8(255)
// 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
- bufferBuilder.writeUInt8(1)
+ bufferBuilder.writeUInt8(data.sessionState === 'rowing' ? 1 : 0)
// strokeState: UInt8 2 DRIVING, 4 RECOVERY
bufferBuilder.writeUInt8(data.strokeState === 'DRIVING' ? 2 : 4)
// totalWorkDistance: UInt24LE in 1 m
diff --git a/app/engine/RowingEngine.js b/app/engine/RowingEngine.js
index 9385ce9..0de00a3 100644
--- a/app/engine/RowingEngine.js
+++ b/app/engine/RowingEngine.js
@@ -149,11 +149,7 @@ function createRowingEngine (rowerSettings) {
// 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)
- }
+ currentTorque = calculateTorque(currentDt)
linearCycleVelocity = calculateLinearVelocity()
averagedCyclePower = calculateCyclePower()
@@ -186,11 +182,7 @@ function createRowingEngine (rowerSettings) {
// 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 = calculateTorque(currentDt)
- }
+ currentTorque = calculateTorque(currentDt)
if (workoutHandler) {
workoutHandler.updateKeyMetrics({
timeSinceStart: totalTime,
@@ -213,11 +205,7 @@ function createRowingEngine (rowerSettings) {
// 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 = calculateTorque(currentDt)
- }
+ currentTorque = calculateTorque(currentDt)
// We display the AVERAGE speed in the display, NOT the top speed of the stroke
linearCycleVelocity = calculateLinearVelocity()
averagedCyclePower = calculateCyclePower()
@@ -252,11 +240,7 @@ function createRowingEngine (rowerSettings) {
// 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 = calculateTorque(currentDt)
- }
+ currentTorque = calculateTorque(currentDt)
if (workoutHandler) {
workoutHandler.updateKeyMetrics({
timeSinceStart: totalTime,
diff --git a/app/engine/RowingStatistics.js b/app/engine/RowingStatistics.js
index 8ba86d3..9179c0b 100644
--- a/app/engine/RowingStatistics.js
+++ b/app/engine/RowingStatistics.js
@@ -24,14 +24,14 @@ function createRowingStatistics (config) {
const powerRatioAverager = createWeightedAverager(numOfDataPointsForAveraging)
const caloriesAveragerMinute = createMovingIntervalAverager(60)
const caloriesAveragerHour = createMovingIntervalAverager(60 * 60)
- let trainingRunning = false
+ let sessionState = 'waitingForStart'
let rowingPausedTimer
let heartrateResetTimer
let distanceTotal = 0.0
let durationTotal = 0
let strokesTotal = 0
let caloriesTotal = 0.0
- let heartrate
+ let heartrate = 0
let heartrateBatteryLevel = 0
let lastStrokeDuration = 0.0
let instantaneousTorque = 0.0
@@ -51,8 +51,6 @@ function createRowingStatistics (config) {
}
function handleStrokeEnd (stroke) {
- if (!trainingRunning) startTraining()
-
// if we do not get a stroke for timeBetweenStrokesBeforePause milliseconds we treat this as a rowing pause
if (rowingPausedTimer)clearInterval(rowingPausedTimer)
rowingPausedTimer = setTimeout(() => pauseRowing(), timeBetweenStrokesBeforePause)
@@ -85,6 +83,7 @@ function createRowingStatistics (config) {
// initiated by the rowing engine in case an impulse was not considered
// because it was too large
function handlePause (duration) {
+ sessionState = 'paused'
caloriesAveragerMinute.pushValue(0, duration)
caloriesAveragerHour.pushValue(0, duration)
emitter.emit('rowingPaused')
@@ -94,6 +93,7 @@ function createRowingStatistics (config) {
function handleRecoveryEnd (stroke) {
// todo: we need a better mechanism to communicate strokeState updates
// this is an initial hacky attempt to see if we can use it for the C2-pm5 protocol
+ if (sessionState !== 'rowing') startTraining()
durationTotal = stroke.timeSinceStart
powerAverager.pushValue(stroke.power)
speedAverager.pushValue(stroke.speed)
@@ -137,8 +137,9 @@ function createRowingStatistics (config) {
// todo: due to sanitization we currently do not use a consistent time throughout the engine
// We will rework this section to use both absolute and sanitized time in the appropriate places.
// We will also polish up the events for the recovery and drive phase, so we get clean complete strokes from the first stroke onwards.
- const averagedStrokeTime = strokeAverager.getAverage() > minimumStrokeTime && strokeAverager.getAverage() < maximumStrokeTime && lastStrokeSpeed > 0 ? strokeAverager.getAverage() : 0 // seconds
+ const averagedStrokeTime = strokeAverager.getAverage() > minimumStrokeTime && strokeAverager.getAverage() < maximumStrokeTime && lastStrokeSpeed > 0 && sessionState === 'rowing' ? strokeAverager.getAverage() : 0 // seconds
return {
+ sessionState,
durationTotal,
durationTotalFormatted: secondsToTimeString(durationTotal),
strokesTotal,
@@ -147,14 +148,14 @@ function createRowingStatistics (config) {
caloriesPerMinute: caloriesAveragerMinute.getAverage() > 0 ? caloriesAveragerMinute.getAverage() : 0,
caloriesPerHour: caloriesAveragerHour.getAverage() > 0 ? caloriesAveragerHour.getAverage() : 0,
strokeTime: lastStrokeDuration, // seconds
- distance: lastStrokeDistance > 0 && lastStrokeSpeed > 0 ? lastStrokeDistance : 0, // meters
- power: powerAverager.getAverage() > 0 && lastStrokeSpeed > 0 ? powerAverager.getAverage() : 0, // watts
+ distance: lastStrokeDistance > 0 && lastStrokeSpeed > 0 && sessionState === 'rowing' ? lastStrokeDistance : 0, // meters
+ power: powerAverager.getAverage() > 0 && lastStrokeSpeed > 0 && sessionState === 'rowing' ? powerAverager.getAverage() : 0, // watts
split: splitTime, // seconds/500m
splitFormatted: secondsToTimeString(splitTime),
- powerRatio: powerRatioAverager.getAverage() > 0 && lastStrokeSpeed > 0 ? powerRatioAverager.getAverage() : 0,
+ powerRatio: powerRatioAverager.getAverage() > 0 && lastStrokeSpeed > 0 && sessionState === 'rowing' ? powerRatioAverager.getAverage() : 0,
instantaneousTorque: instantaneousTorque,
- strokesPerMinute: averagedStrokeTime !== 0 ? (60.0 / averagedStrokeTime) : 0,
- speed: speedAverager.getAverage() > 0 && lastStrokeSpeed > 0 ? (speedAverager.getAverage() * 3.6) : 0, // km/h
+ strokesPerMinute: averagedStrokeTime !== 0 && sessionState === 'rowing' ? (60.0 / averagedStrokeTime) : 0,
+ speed: speedAverager.getAverage() > 0 && lastStrokeSpeed > 0 && sessionState === 'rowing' ? (speedAverager.getAverage() * 3.6) : 0, // km/h
strokeState: lastStrokeState,
heartrate,
heartrateBatteryLevel
@@ -162,11 +163,11 @@ function createRowingStatistics (config) {
}
function startTraining () {
- trainingRunning = true
+ sessionState = 'rowing'
}
function stopTraining () {
- trainingRunning = false
+ sessionState = 'stopped'
if (rowingPausedTimer)clearInterval(rowingPausedTimer)
}
@@ -182,6 +183,7 @@ function createRowingStatistics (config) {
powerAverager.reset()
speedAverager.reset()
powerRatioAverager.reset()
+ sessionState = 'waitingForStart'
}
// clear the metrics in case the user pauses rowing
@@ -191,6 +193,7 @@ function createRowingStatistics (config) {
speedAverager.reset()
powerRatioAverager.reset()
lastStrokeState = 'RECOVERY'
+ sessionState = 'paused'
emitter.emit('rowingPaused')
}
diff --git a/docs/attribution.md b/docs/attribution.md
index 06cc09c..5e21640 100644
--- a/docs/attribution.md
+++ b/docs/attribution.md
@@ -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.
-* 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/).
diff --git a/docs/physics_openrowingmonitor.md b/docs/physics_openrowingmonitor.md
index 663aec7..2d5af62 100644
--- a/docs/physics_openrowingmonitor.md
+++ b/docs/physics_openrowingmonitor.md
@@ -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 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
@@ -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.
-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):
>
@@ -280,3 +280,5 @@ Again, this is a systematic (overestimation) of the power, which will be systema
[3] Dave Vernooy, "Open Source Ergometer ErgWare"
[4]
+
+[5] Fan blade Physics and a Peek inside C2's Black Box, Nomath