Improvement of the startup behaviour and external stroke definition (#46)

* Improvement of the startup behaviour

These changes improve the startup behaviour: ORM will now always start with a detected drive-phase, which will trigger recording etc. This changes several initialisations of variables to make sure they enable the triggering of a "Drive" at the beginning of a stroke. Also workoutHandler.handleStrokeEnd has been renamed to workoutHandler.handleDriveEnd, as this is the clear end of that stroke-phase.

* Renamed handleStrokeEnd to handleDriveEnd

Renamed handleStrokeEnd to handleDriveEnd, as this is the clear end of that stroke-phase, as the current model alternates between Drive+Recovery and Recovery+Drive strokes to keep feeding the metrics, there isn't a very clear "End of the Stroke".

* Redefinition of a stroke

Traditionally, a stroke is defined as a Drive phase, followed by a Recovery Phase. This is also expected by Garmin and EXR as Concept2 implements it this way as well. This implementation makes it more consistent with that interpretation, while leaving room for intermediate updates. This should make Bluetooth behaviour and metrics recording more consistent.
This commit is contained in:
Jaap van Ekris 2022-01-28 19:56:55 +01:00 committed by GitHub
parent 426f982683
commit 335083c37d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 33 additions and 28 deletions

View File

@ -20,7 +20,7 @@ function createMovingFlankDetector (rowerSettings) {
const angularVelocity = new Array(rowerSettings.flankLength + 1) const angularVelocity = new Array(rowerSettings.flankLength + 1)
angularVelocity.fill(angularDisplacementPerImpulse / rowerSettings.minimumTimeBetweenImpulses) angularVelocity.fill(angularDisplacementPerImpulse / rowerSettings.minimumTimeBetweenImpulses)
const angularAcceleration = new Array(rowerSettings.flankLength + 1) const angularAcceleration = new Array(rowerSettings.flankLength + 1)
angularAcceleration.fill(0) angularAcceleration.fill(0.1)
const movingAverage = createMovingAverager(rowerSettings.smoothing, rowerSettings.maximumTimeBetweenImpulses) const movingAverage = createMovingAverager(rowerSettings.smoothing, rowerSettings.maximumTimeBetweenImpulses)
let numberOfSequentialCorrections = 0 let numberOfSequentialCorrections = 0
const maxNumberOfSequentialCorrections = (rowerSettings.smoothing >= 2 ? rowerSettings.smoothing : 2) const maxNumberOfSequentialCorrections = (rowerSettings.smoothing >= 2 ? rowerSettings.smoothing : 2)

View File

@ -19,23 +19,23 @@ const log = loglevel.getLogger('RowingEngine')
function createRowingEngine (rowerSettings) { function createRowingEngine (rowerSettings) {
let workoutHandler let workoutHandler
const flankDetector = createMovingFlankDetector(rowerSettings) const flankDetector = createMovingFlankDetector(rowerSettings)
let cyclePhase = 'Drive' let cyclePhase = 'Recovery'
let totalTime = 0.0 let totalTime = 0.0
let totalNumberOfImpulses = 0.0 let totalNumberOfImpulses = 0.0
let strokeNumber = 0.0 let strokeNumber = 0.0
const angularDisplacementPerImpulse = (2.0 * Math.PI) / rowerSettings.numOfImpulsesPerRevolution const angularDisplacementPerImpulse = (2.0 * Math.PI) / rowerSettings.numOfImpulsesPerRevolution
let drivePhaseStartTime = 0.0 let drivePhaseStartTime = 0.0
let drivePhaseStartAngularDisplacement = 0.0 let drivePhaseStartAngularDisplacement = 0.0
let drivePhaseLength = rowerSettings.minimumDriveTime let drivePhaseLength = 2.0 * rowerSettings.minimumDriveTime
let drivePhaseAngularDisplacement = rowerSettings.numOfImpulsesPerRevolution let drivePhaseAngularDisplacement = rowerSettings.numOfImpulsesPerRevolution
// let driveStartAngularVelocity = 0 // let driveStartAngularVelocity = 0
// let driveEndAngularVelocity = angularDisplacementPerImpulse / rowerSettings.minimumTimeBetweenImpulses // let driveEndAngularVelocity = angularDisplacementPerImpulse / rowerSettings.minimumTimeBetweenImpulses
let driveLinearDistance = 0.0 let driveLinearDistance = 0.0
// let drivePhaseEnergyProduced = 0.0 // let drivePhaseEnergyProduced = 0.0
let recoveryPhaseStartTime = 0.0 let recoveryPhaseStartTime = -2 * rowerSettings.minimumRecoveryTime // Make sure that the first CurrentDt will trigger a detected stroke by faking a recovery phase that is long enough
let recoveryPhaseStartAngularDisplacement = 0.0 let recoveryPhaseStartAngularDisplacement = -1.0 * rowerSettings.numOfImpulsesPerRevolution
let recoveryPhaseAngularDisplacement = rowerSettings.numOfImpulsesPerRevolution let recoveryPhaseAngularDisplacement = rowerSettings.numOfImpulsesPerRevolution
let recoveryPhaseLength = rowerSettings.minimumRecoveryTime let recoveryPhaseLength = 2.0 * rowerSettings.minimumRecoveryTime
let recoveryStartAngularVelocity = angularDisplacementPerImpulse / rowerSettings.minimumTimeBetweenImpulses let recoveryStartAngularVelocity = angularDisplacementPerImpulse / rowerSettings.minimumTimeBetweenImpulses
let recoveryEndAngularVelocity = angularDisplacementPerImpulse / rowerSettings.maximumTimeBetweenImpulses let recoveryEndAngularVelocity = angularDisplacementPerImpulse / rowerSettings.maximumTimeBetweenImpulses
let recoveryLinearDistance = 0.0 let recoveryLinearDistance = 0.0
@ -233,7 +233,7 @@ function createRowingEngine (rowerSettings) {
// Update the metrics // Update the metrics
if (workoutHandler) { if (workoutHandler) {
workoutHandler.handleStrokeEnd({ workoutHandler.handleDriveEnd({
timeSinceStart: totalTime, timeSinceStart: totalTime,
power: averagedCyclePower, power: averagedCyclePower,
duration: cycleLength, duration: cycleLength,
@ -296,32 +296,35 @@ function createRowingEngine (rowerSettings) {
} }
function reset () { function reset () {
cyclePhase = 'Drive' cyclePhase = 'Recovery'
totalTime = 0.0 totalTime = 0.0
totalNumberOfImpulses = 0.0 totalNumberOfImpulses = 0.0
strokeNumber = 0.0 strokeNumber = 0.0
drivePhaseStartTime = 0.0 drivePhaseStartTime = 0.0
drivePhaseStartAngularDisplacement = 0.0 drivePhaseStartAngularDisplacement = 0.0
drivePhaseLength = 0.0 drivePhaseLength = 2.0 * rowerSettings.minimumDriveTime
drivePhaseAngularDisplacement = rowerSettings.numOfImpulsesPerRevolution drivePhaseAngularDisplacement = rowerSettings.numOfImpulsesPerRevolution
// driveStartAngularVelocity = 0 // driveStartAngularVelocity = 0
// driveEndAngularVelocity = angularDisplacementPerImpulse / rowerSettings.minimumTimeBetweenImpulses // driveEndAngularVelocity = angularDisplacementPerImpulse / rowerSettings.minimumTimeBetweenImpulses
driveLinearDistance = 0.0 driveLinearDistance = 0.0
// drivePhaseEnergyProduced = 0.0 // drivePhaseEnergyProduced = 0.0
recoveryPhaseStartTime = 0.0 recoveryPhaseStartTime = -2 * rowerSettings.minimumRecoveryTime // Make sure that the first CurrentDt will trigger a detected stroke by faking a recovery phase that is long enough
recoveryPhaseStartAngularDisplacement = 0.0 recoveryPhaseStartAngularDisplacement = -1.0 * rowerSettings.numOfImpulsesPerRevolution
recoveryPhaseAngularDisplacement = rowerSettings.numOfImpulsesPerRevolution recoveryPhaseAngularDisplacement = rowerSettings.numOfImpulsesPerRevolution
recoveryPhaseLength = rowerSettings.minimumRecoveryTime recoveryPhaseLength = 2.0 * rowerSettings.minimumRecoveryTime
recoveryStartAngularVelocity = angularDisplacementPerImpulse / rowerSettings.minimumTimeBetweenImpulses recoveryStartAngularVelocity = angularDisplacementPerImpulse / rowerSettings.minimumTimeBetweenImpulses
recoveryEndAngularVelocity = angularDisplacementPerImpulse / rowerSettings.maximumTimeBetweenImpulses recoveryEndAngularVelocity = angularDisplacementPerImpulse / rowerSettings.maximumTimeBetweenImpulses
recoveryLinearDistance = 0.0 recoveryLinearDistance = 0.0
currentDragFactor = rowerSettings.dragFactor / 1000000 currentDragFactor = rowerSettings.dragFactor / 1000000
movingDragAverage.reset() movingDragAverage.reset()
dragFactor = movingDragAverage.getAverage() dragFactor = movingDragAverage.getAverage()
cycleLength = 0.0 cycleLength = minimumCycleLength
linearCycleVelocity = 0.0 linearCycleVelocity = 0.0
totalLinearDistance = 0.0 totalLinearDistance = 0.0
averagedCyclePower = 0.0 averagedCyclePower = 0.0
currentTorque = 0.0
previousAngularVelocity = 0.0
currentAngularVelocity = 0.0
} }
function notify (receiver) { function notify (receiver) {

View File

@ -17,7 +17,7 @@ log.setLevel('warn')
const createWorkoutEvaluator = function () { const createWorkoutEvaluator = function () {
const strokes = [] const strokes = []
function handleStrokeEnd (stroke) { function handleDriveEnd (stroke) {
strokes.push(stroke) strokes.push(stroke)
log.info(`stroke: ${strokes.length}, power: ${Math.round(stroke.power)}w, duration: ${stroke.duration.toFixed(2)}s, ` + 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`) ` drivePhase: ${stroke.durationDrivePhase.toFixed(2)}s, distance: ${stroke.distance.toFixed(2)}m`)
@ -42,7 +42,7 @@ const createWorkoutEvaluator = function () {
} }
return { return {
handleStrokeEnd, handleDriveEnd,
handleRecoveryEnd, handleRecoveryEnd,
updateKeyMetrics, updateKeyMetrics,
handlePause, handlePause,
@ -61,7 +61,7 @@ test('sample data for WRX700 should produce plausible results with rower profile
await replayRowingSession(rowingEngine.handleRotationImpulse, { filename: 'recordings/WRX700_2magnets.csv' }) await replayRowingSession(rowingEngine.handleRotationImpulse, { filename: 'recordings/WRX700_2magnets.csv' })
assert.is(workoutEvaluator.getNumOfStrokes(), 16, 'number of strokes does not meet expectation') assert.is(workoutEvaluator.getNumOfStrokes(), 16, 'number of strokes does not meet expectation')
assertPowerRange(workoutEvaluator, 50, 220) assertPowerRange(workoutEvaluator, 50, 220)
assertDistanceRange(workoutEvaluator, 158, 162) assertDistanceRange(workoutEvaluator, 159, 163)
assertStrokeDistanceSumMatchesTotal(workoutEvaluator) assertStrokeDistanceSumMatchesTotal(workoutEvaluator)
}) })
@ -72,7 +72,7 @@ test('sample data for DKNR320 should produce plausible results with rower profil
await replayRowingSession(rowingEngine.handleRotationImpulse, { filename: 'recordings/DKNR320.csv' }) await replayRowingSession(rowingEngine.handleRotationImpulse, { filename: 'recordings/DKNR320.csv' })
assert.is(workoutEvaluator.getNumOfStrokes(), 10, 'number of strokes does not meet expectation') assert.is(workoutEvaluator.getNumOfStrokes(), 10, 'number of strokes does not meet expectation')
assertPowerRange(workoutEvaluator, 75, 200) assertPowerRange(workoutEvaluator, 75, 200)
assertDistanceRange(workoutEvaluator, 64, 67) assertDistanceRange(workoutEvaluator, 65, 68)
assertStrokeDistanceSumMatchesTotal(workoutEvaluator) assertStrokeDistanceSumMatchesTotal(workoutEvaluator)
}) })

View File

@ -50,8 +50,8 @@ function createRowingStatistics (config) {
} }
} }
function handleStrokeEnd (stroke) { function handleDriveEnd (stroke) {
// if we do not get a stroke for timeBetweenStrokesBeforePause milliseconds we treat this as a rowing pause // if we do not get a drive 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)
@ -77,7 +77,7 @@ function createRowingStatistics (config) {
lastStrokeState = stroke.strokeState lastStrokeState = stroke.strokeState
lastStrokeSpeed = stroke.speed lastStrokeSpeed = stroke.speed
instantaneousTorque = stroke.instantaneousTorque instantaneousTorque = stroke.instantaneousTorque
emitter.emit('strokeFinished', getMetrics()) emitter.emit('driveFinished', getMetrics())
} }
// initiated by the rowing engine in case an impulse was not considered // initiated by the rowing engine in case an impulse was not considered
@ -209,7 +209,7 @@ function createRowingStatistics (config) {
} }
return Object.assign(emitter, { return Object.assign(emitter, {
handleStrokeEnd, handleDriveEnd,
handlePause, handlePause,
handleHeartrateMeasurement, handleHeartrateMeasurement,
handleRecoveryEnd, handleRecoveryEnd,

View File

@ -78,19 +78,21 @@ const rowingStatistics = createRowingStatistics(config)
rowingEngine.notify(rowingStatistics) rowingEngine.notify(rowingStatistics)
const workoutRecorder = createWorkoutRecorder() const workoutRecorder = createWorkoutRecorder()
rowingStatistics.on('strokeFinished', (metrics) => { rowingStatistics.on('driveFinished', (metrics) => {
webServer.notifyClients(metrics)
peripheralManager.notifyMetrics('strokeStateChanged', metrics)
})
rowingStatistics.on('recoveryFinished', (metrics) => {
log.info(`stroke: ${metrics.strokesTotal}, dur: ${metrics.strokeTime.toFixed(2)}s, power: ${Math.round(metrics.power)}w` + log.info(`stroke: ${metrics.strokesTotal}, dur: ${metrics.strokeTime.toFixed(2)}s, power: ${Math.round(metrics.power)}w` +
`, split: ${metrics.splitFormatted}, ratio: ${metrics.powerRatio.toFixed(2)}, dist: ${metrics.distanceTotal.toFixed(1)}m` + `, split: ${metrics.splitFormatted}, ratio: ${metrics.powerRatio.toFixed(2)}, dist: ${metrics.distanceTotal.toFixed(1)}m` +
`, cal: ${metrics.caloriesTotal.toFixed(1)}kcal, SPM: ${metrics.strokesPerMinute.toFixed(1)}, speed: ${metrics.speed.toFixed(2)}km/h` + `, cal: ${metrics.caloriesTotal.toFixed(1)}kcal, SPM: ${metrics.strokesPerMinute.toFixed(1)}, speed: ${metrics.speed.toFixed(2)}km/h` +
`, cal/hour: ${metrics.caloriesPerHour.toFixed(1)}kcal, cal/minute: ${metrics.caloriesPerMinute.toFixed(1)}kcal`) `, cal/hour: ${metrics.caloriesPerHour.toFixed(1)}kcal, cal/minute: ${metrics.caloriesPerMinute.toFixed(1)}kcal`)
webServer.notifyClients(metrics) webServer.notifyClients(metrics)
peripheralManager.notifyMetrics('strokeFinished', metrics) peripheralManager.notifyMetrics('strokeFinished', metrics)
workoutRecorder.recordStroke(metrics) if (metrics.sessionStatus === 'rowing' && metrics.strokesTotal > 0) {
}) workoutRecorder.recordStroke(metrics)
}
rowingStatistics.on('recoveryFinished', (metrics) => {
webServer.notifyClients(metrics)
peripheralManager.notifyMetrics('strokeStateChanged', metrics)
}) })
rowingStatistics.on('metricsUpdate', (metrics) => { rowingStatistics.on('metricsUpdate', (metrics) => {