openrowingmonitor/app/engine/RowingStatistics.js

219 lines
9.0 KiB
JavaScript

'use strict'
/*
Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
This Module calculates the training specific metrics.
*/
import { EventEmitter } from 'events'
import { createMovingIntervalAverager } from './averager/MovingIntervalAverager.js'
import { createWeightedAverager } from './averager/WeightedAverager.js'
import loglevel from 'loglevel'
const log = loglevel.getLogger('RowingEngine')
function createRowingStatistics (config) {
const numOfDataPointsForAveraging = config.numOfPhasesForAveragingScreenData
const screenUpdateInterval = config.screenUpdateInterval
const minimumStrokeTime = config.rowerSettings.minimumRecoveryTime + config.rowerSettings.minimumDriveTime
const maximumStrokeTime = config.maximumStrokeTime
const timeBetweenStrokesBeforePause = maximumStrokeTime * 1000
const emitter = new EventEmitter()
const strokeAverager = createWeightedAverager(numOfDataPointsForAveraging)
const powerAverager = createWeightedAverager(numOfDataPointsForAveraging)
const speedAverager = createWeightedAverager(numOfDataPointsForAveraging)
const powerRatioAverager = createWeightedAverager(numOfDataPointsForAveraging)
const caloriesAveragerMinute = createMovingIntervalAverager(60)
const caloriesAveragerHour = createMovingIntervalAverager(60 * 60)
let trainingRunning = false
let rowingPausedTimer
let heartrateResetTimer
let distanceTotal = 0.0
let durationTotal = 0
let strokesTotal = 0
let caloriesTotal = 0.0
let heartrate
let heartrateBatteryLevel = 0
let lastStrokeDuration = 0.0
let instantaneousTorque = 0.0
let lastStrokeDistance = 0.0
let lastStrokeSpeed = 0.0
let lastStrokeState = 'RECOVERY'
let lastMetrics = {}
// send metrics to the clients periodically, if the data has changed
setInterval(emitMetrics, screenUpdateInterval)
function emitMetrics () {
const currentMetrics = getMetrics()
if (Object.entries(currentMetrics).toString() !== Object.entries(lastMetrics).toString()) {
emitter.emit('metricsUpdate', currentMetrics)
lastMetrics = currentMetrics
}
}
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)
// based on: http://eodg.atm.ox.ac.uk/user/dudhia/rowing/physics/ergometer.html#section11
const calories = (4 * powerAverager.getAverage() + 350) * (stroke.duration) / 4200
durationTotal = stroke.timeSinceStart
powerAverager.pushValue(stroke.power)
speedAverager.pushValue(stroke.speed)
if (stroke.duration < timeBetweenStrokesBeforePause && stroke.duration > minimumStrokeTime) {
// stroke duration has to be plausible to be accepted
powerRatioAverager.pushValue(stroke.durationDrivePhase / stroke.duration)
strokeAverager.pushValue(stroke.duration)
caloriesAveragerMinute.pushValue(calories, stroke.duration)
caloriesAveragerHour.pushValue(calories, stroke.duration)
} else {
log.debug(`*** Stroke duration of ${stroke.duration} sec is considered unreliable, skipped update stroke statistics`)
}
caloriesTotal += calories
lastStrokeDuration = stroke.duration
distanceTotal = stroke.distance
lastStrokeDistance = stroke.strokeDistance
lastStrokeState = stroke.strokeState
lastStrokeSpeed = stroke.speed
instantaneousTorque = stroke.instantaneousTorque
emitter.emit('strokeFinished', getMetrics())
}
// initiated by the rowing engine in case an impulse was not considered
// because it was too large
function handlePause (duration) {
caloriesAveragerMinute.pushValue(0, duration)
caloriesAveragerHour.pushValue(0, duration)
emitter.emit('rowingPaused')
}
// initiated when the stroke state changes
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
durationTotal = stroke.timeSinceStart
powerAverager.pushValue(stroke.power)
speedAverager.pushValue(stroke.speed)
if (stroke.duration < timeBetweenStrokesBeforePause && stroke.duration > minimumStrokeTime) {
// stroke duration has to be plausible to be accepted
powerRatioAverager.pushValue(stroke.durationDrivePhase / stroke.duration)
strokeAverager.pushValue(stroke.duration)
} else {
log.debug(`*** Stroke duration of ${stroke.duration} sec is considered unreliable, skipped update stroke statistics`)
}
distanceTotal = stroke.distance
strokesTotal = stroke.numberOfStrokes
lastStrokeDistance = stroke.strokeDistance
lastStrokeState = stroke.strokeState
lastStrokeSpeed = stroke.speed
instantaneousTorque = stroke.instantaneousTorque
emitter.emit('recoveryFinished', getMetrics())
}
// initiated when updating key statistics
function updateKeyMetrics (stroke) {
durationTotal = stroke.timeSinceStart
distanceTotal = stroke.distance
instantaneousTorque = stroke.instantaneousTorque
}
// 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 getMetrics () {
const splitTime = speedAverager.getAverage() !== 0 && lastStrokeSpeed > 0 ? (500.0 / speedAverager.getAverage()) : Infinity
// 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
return {
durationTotal,
durationTotalFormatted: secondsToTimeString(durationTotal),
strokesTotal,
distanceTotal: distanceTotal > 0 ? distanceTotal : 0, // meters
caloriesTotal: caloriesTotal, // kcal
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
split: splitTime, // seconds/500m
splitFormatted: secondsToTimeString(splitTime),
powerRatio: powerRatioAverager.getAverage() > 0 && lastStrokeSpeed > 0 ? 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
strokeState: lastStrokeState,
heartrate,
heartrateBatteryLevel
}
}
function startTraining () {
trainingRunning = true
}
function stopTraining () {
trainingRunning = false
if (rowingPausedTimer)clearInterval(rowingPausedTimer)
}
function resetTraining () {
stopTraining()
distanceTotal = 0.0
strokesTotal = 0
caloriesTotal = 0.0
durationTotal = 0
caloriesAveragerMinute.reset()
caloriesAveragerHour.reset()
strokeAverager.reset()
powerAverager.reset()
speedAverager.reset()
powerRatioAverager.reset()
}
// clear the metrics in case the user pauses rowing
function pauseRowing () {
strokeAverager.reset()
powerAverager.reset()
speedAverager.reset()
powerRatioAverager.reset()
lastStrokeState = 'RECOVERY'
emitter.emit('rowingPaused')
}
// 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)
let timeString = hours > 0 ? ` ${hours.toString().padStart(2, '0')}:` : ''
timeString += `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
return timeString
}
return Object.assign(emitter, {
handleStrokeEnd,
handlePause,
handleHeartrateMeasurement,
handleRecoveryEnd,
updateKeyMetrics,
reset: resetTraining
})
}
export { createRowingStatistics }