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