'use strict' /* Open Rowing Monitor, https://github.com/laberning/openrowingmonitor The Rowing Engine models the physics of a real rowing boat. It takes impulses from the flywheel of a rowing machine and estimates parameters such as energy, stroke rates and movement. This implementation uses concepts that are described here: Physics of Rowing by Anu Dudhia: http://eodg.atm.ox.ac.uk/user/dudhia/rowing/physics Also Dave Vernooy has some good explanations here: https://dvernooy.github.io/projects/ergware */ import loglevel from 'loglevel' import { createMovingAverager } from './averager/MovingAverager.js' import { createMovingFlankDetector } from './MovingFlankDetector.js' const log = loglevel.getLogger('RowingEngine') function createRowingEngine (rowerSettings) { let workoutHandler const flankDetector = createMovingFlankDetector(rowerSettings) const angularDisplacementPerImpulse = (2.0 * Math.PI) / rowerSettings.numOfImpulsesPerRevolution const movingDragAverage = createMovingAverager(rowerSettings.dampingConstantSmoothing, rowerSettings.dragFactor / 1000000) const dragFactorMaxUpwardChange = 1 + rowerSettings.dampingConstantMaxChange const dragFactorMaxDownwardChange = 1 - rowerSettings.dampingConstantMaxChange const minimumCycleLength = rowerSettings.minimumDriveTime + rowerSettings.minimumRecoveryTime let cyclePhase let totalTime let totalNumberOfImpulses let strokeNumber let drivePhaseStartTime let drivePhaseStartAngularDisplacement let drivePhaseLength let drivePhaseAngularDisplacement let driveLinearDistance let recoveryPhaseStartTime let recoveryPhaseAngularDisplacement let recoveryPhaseStartAngularDisplacement let recoveryPhaseLength let recoveryStartAngularVelocity let recoveryEndAngularVelocity let recoveryLinearDistance let currentDragFactor let dragFactor let cycleLength let linearCycleVelocity let totalLinearDistance let averagedCyclePower let currentTorque let previousAngularVelocity let currentAngularVelocity // we use the reset function to initialize the variables above reset() // called if the sensor detected an impulse, currentDt is an interval in seconds function handleRotationImpulse (currentDt) { // impulses that take longer than maximumImpulseTimeBeforePause seconds are considered a pause if (currentDt > rowerSettings.maximumImpulseTimeBeforePause) { workoutHandler.handlePause(currentDt) return } totalTime += currentDt totalNumberOfImpulses++ // detect where we are in the rowing phase (drive or recovery) flankDetector.pushValue(currentDt) // we implement a finite state machine that goes between "Drive" and "Recovery" phases, // which allows a phase-change if sufficient time has passed and there is a plausible flank if (cyclePhase === 'Drive') { // We currently are in the "Drive" phase, lets determine what the next phase is if (flankDetector.isFlywheelUnpowered()) { // The flank detector detects that the flywheel has no power exerted on it drivePhaseLength = (totalTime - flankDetector.timeToBeginOfFlank()) - drivePhaseStartTime if (drivePhaseLength >= rowerSettings.minimumDriveTime) { // We change into the "Recovery" phase since we have been long enough in the Drive phase, and we see a clear lack of power // exerted on the flywheel startRecoveryPhase(currentDt) cyclePhase = 'Recovery' } else { // We seem to have lost power to the flywheel, but it is too early according to the settings. We stay in the Drive Phase log.debug(`Time: ${totalTime.toFixed(4)} sec, impuls ${totalNumberOfImpulses}: flank suggests no power (${flankDetector.accelerationAtBeginOfFlank().toFixed(1)} rad/s2), but waiting for for recoveryPhaseLength (${recoveryPhaseLength.toFixed(4)} sec) to exceed minimumRecoveryTime (${rowerSettings.minimumRecoveryTime} sec)`) updateDrivePhase(currentDt) } } else { // We stay in the "Drive" phase as the acceleration is lacking updateDrivePhase(currentDt) } } else { // We currently are in the "Recovery" phase, lets determine what the next phase is if (flankDetector.isFlywheelPowered()) { // The flank detector consistently detects some force on the flywheel recoveryPhaseLength = (totalTime - flankDetector.timeToBeginOfFlank()) - recoveryPhaseStartTime if (recoveryPhaseLength >= rowerSettings.minimumRecoveryTime) { // We change into the "Drive" phase if we have been long enough in the "Recovery" phase, and we see a consistent force being // exerted on the flywheel startDrivePhase(currentDt) cyclePhase = 'Drive' } else { // We see a force, but the "Recovery" phase has been too short, we stay in the "Recovery" phase log.debug(`Time: ${totalTime.toFixed(4)} sec, impuls ${totalNumberOfImpulses}: flank suggests power (${flankDetector.accelerationAtBeginOfFlank().toFixed(1)} rad/s2), but waiting for recoveryPhaseLength (${recoveryPhaseLength.toFixed(4)} sec) to exceed minimumRecoveryTime (${rowerSettings.minimumRecoveryTime} sec)`) updateRecoveryPhase(currentDt) } } else { // No force on the flywheel, let's continue the "Drive" phase updateRecoveryPhase(currentDt) } } } function startDrivePhase (currentDt) { // First, we conclude the "Recovery" phase log.debug('*** recovery phase completed') if (rowerSettings.minimumRecoveryTime <= recoveryPhaseLength && rowerSettings.minimumDriveTime <= drivePhaseLength) { // We have a plausible cycle time cycleLength = recoveryPhaseLength + drivePhaseLength } else { log.debug(`CycleLength isn't plausible: recoveryPhaseLength ${recoveryPhaseLength.toFixed(4)} sec, drivePhaseLength = ${drivePhaseLength.toFixed(4)} s, maximumImpulseTimeBeforePause ${rowerSettings.maximumImpulseTimeBeforePause} s`) } recoveryPhaseAngularDisplacement = (totalNumberOfImpulses - recoveryPhaseStartAngularDisplacement) * angularDisplacementPerImpulse // Calculation of the drag-factor if (flankDetector.impulseLengthAtBeginFlank() > 0) { recoveryEndAngularVelocity = angularDisplacementPerImpulse / flankDetector.impulseLengthAtBeginFlank() if (recoveryPhaseLength >= rowerSettings.minimumRecoveryTime && recoveryStartAngularVelocity > 0 && recoveryEndAngularVelocity > 0) { // Prevent division by zero and keep useless data out of our calculations currentDragFactor = -1 * rowerSettings.flywheelInertia * ((1 / recoveryStartAngularVelocity) - (1 / recoveryEndAngularVelocity)) / recoveryPhaseLength if (rowerSettings.autoAdjustDragFactor) { if (currentDragFactor > (movingDragAverage.getAverage() * dragFactorMaxDownwardChange) && currentDragFactor < (movingDragAverage.getAverage() * dragFactorMaxUpwardChange)) { // If the calculated drag factor is close to what we expect movingDragAverage.pushValue(currentDragFactor) dragFactor = movingDragAverage.getAverage() log.info(`*** Calculated drag factor: ${(currentDragFactor * 1000000).toFixed(2)}`) } else { // The calculated drag factor is outside the plausible range log.info(`Calculated drag factor: ${(currentDragFactor * 1000000).toFixed(2)}, which is too far off the currently used dragfactor of ${movingDragAverage.getAverage() * 1000000}`) log.debug(`Time: ${totalTime.toFixed(4)} sec, impuls ${totalNumberOfImpulses}: recoveryStartAngularVelocity = ${recoveryStartAngularVelocity.toFixed(2)} rad/sec, recoveryEndAngularVelocity = ${recoveryEndAngularVelocity.toFixed(2)} rad/sec, recoveryPhaseLength = ${recoveryPhaseLength.toFixed(4)} sec`) if (currentDragFactor < (movingDragAverage.getAverage() * dragFactorMaxDownwardChange)) { // The current calculated dragfactor makes an abrupt downward change, let's follow the direction, but limit it to the maximum allowed change movingDragAverage.pushValue(movingDragAverage.getAverage() * dragFactorMaxDownwardChange) } else { // The current calculated dragfactor makes an abrupt upward change, let's follow the direction, but limit it to the maximum allowed change movingDragAverage.pushValue(movingDragAverage.getAverage() * dragFactorMaxUpwardChange) } dragFactor = movingDragAverage.getAverage() log.debug(`*** Applied drag factor: ${dragFactor * 1000000}`) } } else { log.info(`*** Calculated drag factor: ${(currentDragFactor * 1000000).toFixed(2)}`) } } else { log.error(`Time: ${totalTime.toFixed(4)} sec, impuls ${totalNumberOfImpulses}: division by 0 prevented, recoveryPhaseLength = ${recoveryPhaseLength} sec, recoveryStartAngularVelocity = ${recoveryStartAngularVelocity} rad/sec, recoveryEndAngularVelocity = ${recoveryEndAngularVelocity} rad/sec`) } } else { log.error(`Time: ${totalTime.toFixed(4)} sec, impuls ${totalNumberOfImpulses}: division by 0 prevented, impulseLengthAtBeginFlank = ${flankDetector.impulseLengthAtBeginFlank()} sec`) } // Calculate the key metrics recoveryLinearDistance = Math.pow((dragFactor / rowerSettings.magicConstant), 1.0 / 3.0) * recoveryPhaseAngularDisplacement totalLinearDistance += recoveryLinearDistance currentTorque = calculateTorque(currentDt) linearCycleVelocity = calculateLinearVelocity() averagedCyclePower = calculateCyclePower() // Next, we start the "Drive" Phase log.debug(`*** DRIVE phase started at time: ${totalTime.toFixed(4)} sec, impuls number ${totalNumberOfImpulses}`) strokeNumber++ drivePhaseStartTime = totalTime - flankDetector.timeToBeginOfFlank() drivePhaseStartAngularDisplacement = totalNumberOfImpulses - flankDetector.noImpulsesToBeginFlank() // Update the metrics if (workoutHandler) { workoutHandler.handleRecoveryEnd({ timeSinceStart: totalTime, power: averagedCyclePower, duration: cycleLength, strokeDistance: driveLinearDistance + recoveryLinearDistance, durationDrivePhase: drivePhaseLength, speed: linearCycleVelocity, distance: totalLinearDistance, numberOfStrokes: strokeNumber, instantaneousTorque: currentTorque, strokeState: 'DRIVING' }) } } function updateDrivePhase (currentDt) { // Update the key metrics on each impulse drivePhaseAngularDisplacement = ((totalNumberOfImpulses - flankDetector.noImpulsesToBeginFlank()) - drivePhaseStartAngularDisplacement) * angularDisplacementPerImpulse driveLinearDistance = Math.pow((dragFactor / rowerSettings.magicConstant), 1.0 / 3.0) * drivePhaseAngularDisplacement currentTorque = calculateTorque(currentDt) if (workoutHandler) { workoutHandler.updateKeyMetrics({ timeSinceStart: totalTime, distance: totalLinearDistance + driveLinearDistance, instantaneousTorque: currentTorque }) } } function startRecoveryPhase (currentDt) { // First, we conclude the "Drive" Phase log.debug('*** drive phase completed') if (rowerSettings.minimumRecoveryTime <= recoveryPhaseLength && rowerSettings.minimumDriveTime <= drivePhaseLength) { // We have a plausible cycle time cycleLength = recoveryPhaseLength + drivePhaseLength } else { log.debug(`CycleLength wasn't plausible: recoveryPhaseLength ${recoveryPhaseLength.toFixed(4)} sec, drivePhaseLength = ${drivePhaseLength.toFixed(4)} s`) } drivePhaseAngularDisplacement = ((totalNumberOfImpulses - flankDetector.noImpulsesToBeginFlank()) - drivePhaseStartAngularDisplacement) * angularDisplacementPerImpulse // driveEndAngularVelocity = angularDisplacementPerImpulse / flankDetector.impulseLengthAtBeginFlank() driveLinearDistance = Math.pow((dragFactor / rowerSettings.magicConstant), 1.0 / 3.0) * drivePhaseAngularDisplacement totalLinearDistance += driveLinearDistance currentTorque = calculateTorque(currentDt) // We display the AVERAGE speed in the display, NOT the top speed of the stroke linearCycleVelocity = calculateLinearVelocity() averagedCyclePower = calculateCyclePower() // Next, we start the "Recovery" Phase log.debug(`*** RECOVERY phase started at time: ${totalTime.toFixed(4)} sec, impuls number ${totalNumberOfImpulses}`) recoveryPhaseStartTime = totalTime - flankDetector.timeToBeginOfFlank() recoveryPhaseStartAngularDisplacement = totalNumberOfImpulses - flankDetector.noImpulsesToBeginFlank() if (flankDetector.impulseLengthAtBeginFlank() > 0) { recoveryStartAngularVelocity = angularDisplacementPerImpulse / flankDetector.impulseLengthAtBeginFlank() } else { log.error(`Time: ${totalTime.toFixed(4)} sec, impuls ${totalNumberOfImpulses}: division by 0 prevented, flankDetector.impulseLengthAtBeginFlank() is ${flankDetector.impulseLengthAtBeginFlank()} sec`) } // Update the metrics if (workoutHandler) { workoutHandler.handleDriveEnd({ timeSinceStart: totalTime, power: averagedCyclePower, duration: cycleLength, strokeDistance: driveLinearDistance + recoveryLinearDistance, durationDrivePhase: drivePhaseLength, speed: linearCycleVelocity, distance: totalLinearDistance, instantaneousTorque: currentTorque, strokeState: 'RECOVERY' }) } } function updateRecoveryPhase (currentDt) { // Update the key metrics on each impulse recoveryPhaseAngularDisplacement = ((totalNumberOfImpulses - flankDetector.noImpulsesToBeginFlank()) - recoveryPhaseStartAngularDisplacement) * angularDisplacementPerImpulse recoveryLinearDistance = Math.pow((dragFactor / rowerSettings.magicConstant), 1.0 / 3.0) * recoveryPhaseAngularDisplacement currentTorque = calculateTorque(currentDt) if (workoutHandler) { workoutHandler.updateKeyMetrics({ timeSinceStart: totalTime, distance: totalLinearDistance + recoveryLinearDistance, instantaneousTorque: currentTorque }) } } function calculateLinearVelocity () { // Here we calculate the AVERAGE speed for the displays, NOT the topspeed of the stroke let tempLinearVelocity = linearCycleVelocity if (drivePhaseLength > rowerSettings.minimumDriveTime && cycleLength > minimumCycleLength) { // There is no division by zero and the data data is plausible tempLinearVelocity = Math.pow((dragFactor / rowerSettings.magicConstant), 1.0 / 3.0) * ((recoveryPhaseAngularDisplacement + drivePhaseAngularDisplacement) / cycleLength) } else { log.error(`Time: ${totalTime.toFixed(4)} sec, impuls ${totalNumberOfImpulses}: cycle length was not plausible, CycleLength = ${cycleLength} sec`) } return tempLinearVelocity } function calculateCyclePower () { // Here we calculate the AVERAGE power for the displays, NOT the top power of the stroke let cyclePower = averagedCyclePower if (drivePhaseLength > rowerSettings.minimumDriveTime && cycleLength > minimumCycleLength) { // There is no division by zero and the data data is plausible cyclePower = dragFactor * Math.pow((recoveryPhaseAngularDisplacement + drivePhaseAngularDisplacement) / cycleLength, 3.0) } else { log.error(`Time: ${totalTime.toFixed(4)} sec, impuls ${totalNumberOfImpulses}: cycle length was not plausible, CycleLength = ${cycleLength} sec`) } return cyclePower } function calculateTorque (currentDt) { let torque = currentTorque if (currentDt > 0) { previousAngularVelocity = currentAngularVelocity currentAngularVelocity = angularDisplacementPerImpulse / currentDt torque = rowerSettings.flywheelInertia * ((currentAngularVelocity - previousAngularVelocity) / currentDt) + dragFactor * Math.pow(currentAngularVelocity, 2) } return torque } function reset () { // to init displacements with plausible defaults we assume, that one rowing cycle transforms to nine meters of distance... const defaultDisplacementForRowingCycle = 8.0 / Math.pow(((rowerSettings.dragFactor / 1000000) / rowerSettings.magicConstant), 1.0 / 3.0) movingDragAverage.reset() cyclePhase = 'Recovery' totalTime = 0.0 totalNumberOfImpulses = 0.0 strokeNumber = 0.0 drivePhaseStartTime = 0.0 drivePhaseStartAngularDisplacement = 0.0 drivePhaseLength = 2.0 * rowerSettings.minimumDriveTime // split defaultDisplacementForRowingCycle to aprox 1/3 for the drive phase drivePhaseAngularDisplacement = (1.0 / 3.0) * defaultDisplacementForRowingCycle driveLinearDistance = 0.0 // Make sure that the first CurrentDt will trigger a detected stroke by faking a recovery phase that is long enough recoveryPhaseStartTime = -2 * rowerSettings.minimumRecoveryTime // and split defaultDisplacementForRowingCycle to aprox 2/3 for the recovery phase recoveryPhaseAngularDisplacement = (2.0 / 3.0) * defaultDisplacementForRowingCycle // set this to the number of impulses required to generate the angular displacement as assumed above recoveryPhaseStartAngularDisplacement = Math.round(-1.0 * (2.0 / 3.0) * defaultDisplacementForRowingCycle / angularDisplacementPerImpulse) recoveryPhaseLength = 2.0 * rowerSettings.minimumRecoveryTime recoveryStartAngularVelocity = angularDisplacementPerImpulse / rowerSettings.minimumTimeBetweenImpulses recoveryEndAngularVelocity = angularDisplacementPerImpulse / rowerSettings.maximumTimeBetweenImpulses recoveryLinearDistance = 0.0 currentDragFactor = rowerSettings.dragFactor / 1000000 dragFactor = movingDragAverage.getAverage() cycleLength = minimumCycleLength linearCycleVelocity = 0.0 totalLinearDistance = 0.0 averagedCyclePower = 0.0 currentTorque = 0.0 previousAngularVelocity = 0.0 currentAngularVelocity = 0.0 } function notify (receiver) { workoutHandler = receiver } return { handleRotationImpulse, reset, notify } } export { createRowingEngine }