openrowingmonitor/app/engine/RowingStatistics.js

447 lines
21 KiB
JavaScript

'use strict'
/*
Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
This Module calculates the training specific metrics.
*/
import { EventEmitter } from 'events'
import { createRower } from './Rower.js'
import { createOLSLinearSeries } from './utils/OLSLinearSeries.js'
import { createStreamFilter } from './utils/StreamFilter.js'
import { createCurveAligner } from './utils/CurveAligner.js'
import loglevel from 'loglevel'
const log = loglevel.getLogger('RowingEngine')
function createRowingStatistics (config) {
const numOfDataPointsForAveraging = config.numOfPhasesForAveragingScreenData
const webUpdateInterval = config.webUpdateInterval
const peripheralUpdateInterval = config.peripheralUpdateInterval
const emitter = new EventEmitter()
const rower = createRower(config.rowerSettings)
const minimumStrokeTime = config.rowerSettings.minimumRecoveryTime + config.rowerSettings.minimumDriveTime
const maximumStrokeTime = config.rowerSettings.maximumStrokeTimeBeforePause
const cycleDuration = createStreamFilter(numOfDataPointsForAveraging, (minimumStrokeTime + maximumStrokeTime) / 2)
const cycleDistance = createStreamFilter(numOfDataPointsForAveraging, 0)
const cyclePower = createStreamFilter(numOfDataPointsForAveraging, 0)
const cycleLinearVelocity = createStreamFilter(numOfDataPointsForAveraging, 0)
let sessionStatus = 'WaitingForStart'
let intervalSettings = []
let currentIntervalNumber = -1
let intervalTargetDistance = 0
let intervalTargetTime = 0
let intervalPrevAccumulatedDistance = 0
let intervalPrevAccumulatedTime = 0
let heartrateResetTimer
let totalLinearDistance = 0.0
let totalMovingTime = 0
let totalNumberOfStrokes = 0
let driveLastStartTime = 0
let strokeCalories = 0
let strokeWork = 0
const calories = createOLSLinearSeries()
const distanceOverTime = createOLSLinearSeries(Math.min(4, numOfDataPointsForAveraging))
const driveDuration = createStreamFilter(numOfDataPointsForAveraging, config.rowerSettings.minimumDriveTime)
const driveLength = createStreamFilter(numOfDataPointsForAveraging, 1.1)
const driveDistance = createStreamFilter(numOfDataPointsForAveraging, 3)
const recoveryDuration = createStreamFilter(numOfDataPointsForAveraging, config.rowerSettings.minimumRecoveryTime)
const driveAverageHandleForce = createStreamFilter(numOfDataPointsForAveraging, 0.0)
const drivePeakHandleForce = createStreamFilter(numOfDataPointsForAveraging, 0.0)
const driveHandleForceCurve = createCurveAligner(config.rowerSettings.minumumForceBeforeStroke)
const driveHandleVelocityCurve = createCurveAligner(1.0)
const driveHandlePowerCurve = createCurveAligner(50)
let dragFactor = config.rowerSettings.dragFactor
let heartrate = 0
let heartrateBatteryLevel = 0
const postExerciseHR = []
let instantPower = 0.0
let lastStrokeState = 'WaitingForDrive'
// send metrics to the web clients periodically
setInterval(emitWebMetrics, webUpdateInterval)
// notify bluetooth peripherall each second (even if data did not change)
// todo: the FTMS protocol also supports that peripherals deliver a preferred update interval
// we could respect this and set the update rate accordingly
setInterval(emitPeripheralMetrics, peripheralUpdateInterval)
function handleRotationImpulse (currentDt) {
// Provide the rower with new data
rower.handleRotationImpulse(currentDt)
// This is the core of the finite state machine that defines all state transitions
switch (true) {
case (sessionStatus === 'WaitingForStart' && rower.strokeState() === 'Drive'):
sessionStatus = 'Rowing'
startTraining()
updateContinousMetrics()
emitMetrics('recoveryFinished')
break
case (sessionStatus === 'Paused' && rower.strokeState() === 'Drive'):
sessionStatus = 'Rowing'
resumeTraining()
updateContinousMetrics()
emitMetrics('recoveryFinished')
break
case (sessionStatus !== 'Stopped' && rower.strokeState() === 'Stopped'):
sessionStatus = 'Stopped'
// We need to emit the metrics AFTER the sessionstatus changes to anything other than "Rowing", which forces most merics to zero
// This is intended behaviour, as the rower/flywheel indicate the rower has stopped somehow
stopTraining()
break
case (sessionStatus === 'Rowing' && rower.strokeState() === 'WaitingForDrive'):
sessionStatus = 'Paused'
pauseTraining()
break
case (sessionStatus === 'Rowing' && lastStrokeState === 'Recovery' && rower.strokeState() === 'Drive' && isIntervalTargetReached() && isNextIntervalAvailable()):
updateContinousMetrics()
updateCycleMetrics()
handleRecoveryEnd()
activateNextIntervalParameters()
emitMetrics('intervalTargetReached')
break
case (sessionStatus === 'Rowing' && lastStrokeState === 'Recovery' && rower.strokeState() === 'Drive' && isIntervalTargetReached()):
updateContinousMetrics()
updateCycleMetrics()
handleRecoveryEnd()
stopTraining()
break
case (sessionStatus === 'Rowing' && lastStrokeState === 'Recovery' && rower.strokeState() === 'Drive'):
updateContinousMetrics()
updateCycleMetrics()
handleRecoveryEnd()
emitMetrics('recoveryFinished')
break
case (sessionStatus === 'Rowing' && lastStrokeState === 'Drive' && rower.strokeState() === 'Recovery' && isIntervalTargetReached() && isNextIntervalAvailable()):
updateContinousMetrics()
updateCycleMetrics()
handleDriveEnd()
activateNextIntervalParameters()
emitMetrics('intervalTargetReached')
break
case (sessionStatus === 'Rowing' && lastStrokeState === 'Drive' && rower.strokeState() === 'Recovery' && isIntervalTargetReached()):
updateContinousMetrics()
updateCycleMetrics()
handleDriveEnd()
stopTraining()
break
case (sessionStatus === 'Rowing' && lastStrokeState === 'Drive' && rower.strokeState() === 'Recovery'):
updateContinousMetrics()
updateCycleMetrics()
handleDriveEnd()
emitMetrics('driveFinished')
break
case (sessionStatus === 'Rowing' && isIntervalTargetReached() && isNextIntervalAvailable()):
updateContinousMetrics()
activateNextIntervalParameters()
emitMetrics('intervalTargetReached')
break
case (sessionStatus === 'Rowing' && isIntervalTargetReached()):
updateContinousMetrics()
stopTraining()
break
case (sessionStatus === 'Rowing'):
updateContinousMetrics()
break
case (sessionStatus === 'Paused'):
// We are in a paused state, we won't update any metrics
break
case (sessionStatus === 'WaitingForStart'):
// We can't change into the "Rowing" state since we are waiting for a drive phase that didn't come
break
case (sessionStatus === 'Stopped'):
// We are in a stopped state, so we won't update any metrics
break
default:
log.error(`Time: ${rower.totalMovingTimeSinceStart()}, state ${rower.strokeState()} found in the Rowing Statistics, which is not captured by Finite State Machine`)
}
lastStrokeState = rower.strokeState()
}
function startTraining () {
rower.allowMovement()
}
function allowResumeTraining () {
rower.allowMovement()
sessionStatus = 'WaitingForStart'
}
function resumeTraining () {
rower.allowMovement()
}
function stopTraining () {
rower.stopMoving()
lastStrokeState = 'Stopped'
// Emitting the metrics BEFORE the sessionstatus changes to anything other than "Rowing" forces most merics to zero
// As there are more than one way to this method, we FIRST emit the metrics and then set them to zero
// If they need to be forced to zero (as the flywheel seems to have stopped), this status has to be set before the call
emitMetrics('rowingStopped')
sessionStatus = 'Stopped'
postExerciseHR.splice(0, postExerciseHR.length)
measureRecoveryHR()
}
// clear the metrics in case the user pauses rowing
function pauseTraining () {
log.debug('*** Paused rowing ***')
rower.pauseMoving()
cycleDuration.reset()
cycleDistance.reset()
cyclePower.reset()
cycleLinearVelocity.reset()
lastStrokeState = 'WaitingForDrive'
// We need to emit the metrics BEFORE the sessionstatus changes to anything other than "Rowing", as it forces most merics to zero
emitMetrics('rowingPaused')
sessionStatus = 'Paused'
postExerciseHR.splice(0, postExerciseHR.length)
measureRecoveryHR()
}
function resetTraining () {
stopTraining()
rower.reset()
calories.reset()
rower.allowMovement()
totalMovingTime = 0
totalLinearDistance = 0.0
intervalSettings = []
currentIntervalNumber = -1
intervalTargetDistance = 0
intervalTargetTime = 0
intervalPrevAccumulatedDistance = 0
intervalPrevAccumulatedTime = 0
totalNumberOfStrokes = -1
driveLastStartTime = 0
distanceOverTime.reset()
driveDuration.reset()
cycleDuration.reset()
cycleDistance.reset()
cyclePower.reset()
strokeCalories = 0
strokeWork = 0
postExerciseHR.splice(0, postExerciseHR.length)
cycleLinearVelocity.reset()
lastStrokeState = 'WaitingForDrive'
emitMetrics('rowingPaused')
sessionStatus = 'WaitingForStart'
}
// initiated when updating key statistics
function updateContinousMetrics () {
totalMovingTime = rower.totalMovingTimeSinceStart()
totalLinearDistance = rower.totalLinearDistanceSinceStart()
instantPower = rower.instantHandlePower()
}
function updateCycleMetrics () {
distanceOverTime.push(rower.totalMovingTimeSinceStart(), rower.totalLinearDistanceSinceStart())
if (rower.cycleDuration() < maximumStrokeTime && rower.cycleDuration() > minimumStrokeTime) {
// stroke duration has to be credible to be accepted
cycleDuration.push(rower.cycleDuration())
cycleDistance.push(rower.cycleLinearDistance())
cycleLinearVelocity.push(rower.cycleLinearVelocity())
cyclePower.push(rower.cyclePower())
} else {
log.debug(`*** Stroke duration of ${rower.cycleDuration()} sec is considered unreliable, skipped update cycle statistics`)
}
}
function handleDriveEnd () {
driveDuration.push(rower.driveDuration())
driveLength.push(rower.driveLength())
driveDistance.push(rower.driveLinearDistance())
driveAverageHandleForce.push(rower.driveAverageHandleForce())
drivePeakHandleForce.push(rower.drivePeakHandleForce())
driveHandleForceCurve.push(rower.driveHandleForceCurve())
driveHandleVelocityCurve.push(rower.driveHandleVelocityCurve())
driveHandlePowerCurve.push(rower.driveHandlePowerCurve())
}
// initiated when the stroke state changes
function handleRecoveryEnd () {
totalNumberOfStrokes = rower.totalNumberOfStrokes()
driveLastStartTime = rower.driveLastStartTime()
recoveryDuration.push(rower.recoveryDuration())
dragFactor = rower.recoveryDragFactor()
// based on: http://eodg.atm.ox.ac.uk/user/dudhia/rowing/physics/ergometer.html#section11
strokeCalories = (4 * cyclePower.clean() + 350) * (cycleDuration.clean()) / 4200
strokeWork = cyclePower.clean() * cycleDuration.clean()
const totalCalories = calories.yAtSeriesEnd() + strokeCalories
calories.push(totalMovingTime, totalCalories)
}
function setIntervalParameters (intervalParameters) {
intervalSettings = intervalParameters
currentIntervalNumber = -1
if (intervalSettings.length > 0) {
log.info(`Workout recieved with ${intervalSettings.length} interval(s)`)
activateNextIntervalParameters()
} else {
// intervalParameters were empty, lets log this odd situation
log.error('Recieved workout containing no intervals')
}
}
function isIntervalTargetReached () {
// This tests wether the end of the current interval is reached
if ((intervalTargetDistance > 0 && rower.totalLinearDistanceSinceStart() >= intervalTargetDistance) || (intervalTargetTime > 0 && rower.totalMovingTimeSinceStart() >= intervalTargetTime)) {
return true
} else {
return false
}
}
function isNextIntervalAvailable () {
// This function tests whether there is a next interval available
if (currentIntervalNumber > -1 && intervalSettings.length > 0 && intervalSettings.length > (currentIntervalNumber + 1)) {
return true
} else {
return false
}
}
function activateNextIntervalParameters () {
if (intervalSettings.length > 0 && intervalSettings.length > (currentIntervalNumber + 1)) {
// This function sets the interval parameters in absolute distances/times
// Thus the interval target always is a projected "finishline" from the current position
intervalPrevAccumulatedTime = rower.totalMovingTimeSinceStart()
intervalPrevAccumulatedDistance = rower.totalLinearDistanceSinceStart()
currentIntervalNumber++
if (intervalSettings[currentIntervalNumber].targetDistance > 0) {
// A target distance is set
intervalTargetTime = 0
intervalTargetDistance = intervalPrevAccumulatedDistance + intervalSettings[currentIntervalNumber].targetDistance
log.info(`Interval settings for interval ${currentIntervalNumber + 1} of ${intervalSettings.length}: Distance target ${intervalSettings[currentIntervalNumber].targetDistance} meters`)
} else {
// A target time is set
intervalTargetTime = intervalPrevAccumulatedTime + intervalSettings[currentIntervalNumber].targetTime
intervalTargetDistance = 0
log.info(`Interval settings for interval ${currentIntervalNumber + 1} of ${intervalSettings.length}: time target ${secondsToTimeString(intervalSettings[currentIntervalNumber].targetTime)} minutes`)
}
} else {
log.error('Interval error: there is no next interval!')
}
}
// initiated when a new heart rate value is received from heart rate sensor
function handleHeartrateMeasurement (value) {
// set the heart rate to zero if we did not receive a value for some time
if (heartrateResetTimer)clearInterval(heartrateResetTimer)
heartrateResetTimer = setTimeout(() => {
heartrate = 0
heartrateBatteryLevel = 0
}, 6000)
heartrate = value.heartrate
heartrateBatteryLevel = value.batteryLevel
}
function measureRecoveryHR () {
// This function is called when the rowing session is stopped. postExerciseHR[0] is the last measured excercise HR
// Thus postExerciseHR[1] is Recovery HR after 1 min, etc..
if (heartrate !== undefined && heartrate > config.userSettings.restingHR && sessionStatus !== 'Rowing') {
log.debug(`*** HRR-${postExerciseHR.length}: ${heartrate}`)
postExerciseHR.push(heartrate)
if ((postExerciseHR.length > 1) && (postExerciseHR.length <= 4)) {
// We skip reporting postExerciseHR[0] and only report measuring postExerciseHR[1], postExerciseHR[2], postExerciseHR[3]
emitter.emit('HRRecoveryUpdate', postExerciseHR)
}
if (postExerciseHR.length < 4) {
// We haven't got three post-exercise HR measurements yet, let's schedule the next measurement
setTimeout(measureRecoveryHR, 60000)
}
}
}
function emitWebMetrics () {
emitMetrics('webMetricsUpdate')
}
function emitPeripheralMetrics () {
emitMetrics('peripheralMetricsUpdate')
}
function emitMetrics (emitType = 'webMetricsUpdate') {
emitter.emit(emitType, getMetrics())
}
function getMetrics () {
const cyclePace = cycleLinearVelocity.clean() !== 0 && cycleLinearVelocity.raw() > 0 && sessionStatus === 'Rowing' ? (500.0 / cycleLinearVelocity.clean()) : Infinity
return {
sessiontype: intervalTargetDistance > 0 ? 'Distance' : (intervalTargetTime > 0 ? 'Time' : 'JustRow'),
sessionStatus,
strokeState: rower.strokeState(),
totalMovingTime: totalMovingTime > 0 ? totalMovingTime : 0,
totalMovingTimeFormatted: intervalTargetTime > 0 ? secondsToTimeString(Math.round(Math.max(intervalTargetTime - totalMovingTime, 0))) : secondsToTimeString(Math.round(totalMovingTime - intervalPrevAccumulatedTime)),
totalNumberOfStrokes: totalNumberOfStrokes > 0 ? totalNumberOfStrokes : 0,
totalLinearDistance: totalLinearDistance > 0 ? totalLinearDistance : 0, // meters
totalLinearDistanceFormatted: intervalTargetDistance > 0 ? Math.max(intervalTargetDistance - totalLinearDistance, 0) : totalLinearDistance - intervalPrevAccumulatedDistance,
intervalNumber: Math.max(currentIntervalNumber + 1, 0), // Interval number
intervalMovingTime: totalMovingTime - intervalPrevAccumulatedTime,
intervalLinearDistance: totalLinearDistance - intervalPrevAccumulatedDistance,
strokeCalories: strokeCalories > 0 ? strokeCalories : 0, // kCal
strokeWork: strokeWork > 0 ? strokeWork : 0, // Joules
totalCalories: calories.yAtSeriesEnd() > 0 ? calories.yAtSeriesEnd() : 0, // kcal
totalCaloriesPerMinute: totalMovingTime > 60 ? caloriesPerPeriod(totalMovingTime - 60, totalMovingTime) : caloriesPerPeriod(0, 60),
totalCaloriesPerHour: totalMovingTime > 3600 ? caloriesPerPeriod(totalMovingTime - 3600, totalMovingTime) : caloriesPerPeriod(0, 3600),
cycleDuration: cycleDuration.clean() > minimumStrokeTime && cycleDuration.clean() < maximumStrokeTime && cycleLinearVelocity.raw() > 0 && sessionStatus === 'Rowing' ? cycleDuration.clean() : NaN, // seconds
cycleStrokeRate: cycleDuration.clean() > minimumStrokeTime && cycleLinearVelocity.raw() > 0 && sessionStatus === 'Rowing' ? (60.0 / cycleDuration.clean()) : 0, // strokeRate in SPM
cycleDistance: cycleDistance.raw() > 0 && cycleLinearVelocity.raw() > 0 && sessionStatus === 'Rowing' ? cycleDistance.clean() : 0, // meters
cycleLinearVelocity: cycleLinearVelocity.clean() > 0 && cycleLinearVelocity.raw() > 0 && sessionStatus === 'Rowing' ? cycleLinearVelocity.clean() : 0, // m/s
cyclePace: cycleLinearVelocity.raw() > 0 ? cyclePace : Infinity, // seconds/50 0m
cyclePaceFormatted: cycleLinearVelocity.raw() > 0 ? secondsToTimeString(Math.round(cyclePace)) : Infinity,
cyclePower: cyclePower.clean() > 0 && cycleLinearVelocity.raw() > 0 && sessionStatus === 'Rowing' ? cyclePower.clean() : 0, // watts
cycleProjectedEndTime: intervalTargetDistance > 0 ? distanceOverTime.projectY(intervalTargetDistance) : intervalTargetTime,
cycleProjectedEndLinearDistance: intervalTargetTime > 0 ? distanceOverTime.projectX(intervalTargetTime) : intervalTargetDistance,
driveLastStartTime: driveLastStartTime > 0 ? driveLastStartTime : 0,
driveDuration: driveDuration.clean() >= config.rowerSettings.minimumDriveTime && totalNumberOfStrokes > 0 && sessionStatus === 'Rowing' ? driveDuration.clean() : NaN, // seconds
driveLength: driveLength.clean() > 0 && sessionStatus === 'Rowing' ? driveLength.clean() : NaN, // meters of chain movement
driveDistance: driveDistance.clean() >= 0 && sessionStatus === 'Rowing' ? driveDistance.clean() : NaN, // meters
driveAverageHandleForce: driveAverageHandleForce.clean() > 0 && sessionStatus === 'Rowing' ? driveAverageHandleForce.clean() : NaN,
drivePeakHandleForce: drivePeakHandleForce.clean() > 0 && sessionStatus === 'Rowing' ? drivePeakHandleForce.clean() : NaN,
driveHandleForceCurve: drivePeakHandleForce.clean() > 0 && sessionStatus === 'Rowing' ? driveHandleForceCurve.lastCompleteCurve() : [NaN],
driveHandleVelocityCurve: drivePeakHandleForce.clean() > 0 && sessionStatus === 'Rowing' ? driveHandleVelocityCurve.lastCompleteCurve() : [NaN],
driveHandlePowerCurve: drivePeakHandleForce.clean() > 0 && sessionStatus === 'Rowing' ? driveHandlePowerCurve.lastCompleteCurve() : [NaN],
recoveryDuration: recoveryDuration.clean() >= config.rowerSettings.minimumRecoveryTime && totalNumberOfStrokes > 0 && sessionStatus === 'Rowing' ? recoveryDuration.clean() : NaN, // seconds
dragFactor: dragFactor > 0 ? dragFactor : config.rowerSettings.dragFactor, // Dragfactor
instantPower: instantPower > 0 && rower.strokeState() === 'Drive' ? instantPower : 0,
heartrate: heartrate > 30 ? heartrate : undefined,
heartrateBatteryLevel: heartrateBatteryLevel > 0 ? heartrateBatteryLevel : undefined // BE AWARE, changing undefined to NaN kills the GUI!!!
}
}
// converts a timeStamp in seconds to a human readable hh:mm:ss format
function secondsToTimeString (secondsTimeStamp) {
if (secondsTimeStamp === Infinity) return '∞'
const hours = Math.floor(secondsTimeStamp / 60 / 60)
const minutes = Math.floor(secondsTimeStamp / 60) - (hours * 60)
const seconds = Math.floor(secondsTimeStamp % 60)
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
} else {
return `${minutes}:${seconds.toString().padStart(2, '0')}`
}
}
function caloriesPerPeriod (periodBegin, periodEnd) {
const beginCalories = calories.projectX(periodBegin)
const endCalories = calories.projectX(periodEnd)
return (endCalories - beginCalories)
}
return Object.assign(emitter, {
handleHeartrateMeasurement,
handleRotationImpulse,
setIntervalParameters,
pause: pauseTraining,
stop: stopTraining,
resume: allowResumeTraining,
reset: resetTraining
})
}
export { createRowingStatistics }