openrowingmonitor/app/engine/RowingEngine.js

343 lines
18 KiB
JavaScript

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