'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 }