377 lines
16 KiB
JavaScript
377 lines
16 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 { createFlywheel } from './Flywheel.js'
|
|
import { createCurveMetrics } from './utils/curveMetrics.js'
|
|
|
|
const log = loglevel.getLogger('RowingEngine')
|
|
|
|
function createRower (rowerSettings) {
|
|
const flywheel = createFlywheel(rowerSettings)
|
|
const sprocketRadius = rowerSettings.sprocketRadius / 100
|
|
const driveHandleForce = createCurveMetrics()
|
|
const driveHandleVelocity = createCurveMetrics()
|
|
const driveHandlePower = createCurveMetrics()
|
|
let _strokeState = 'WaitingForDrive'
|
|
let _totalNumberOfStrokes = -1.0
|
|
let recoveryPhaseStartTime = 0.0
|
|
let _recoveryDuration = 0.0
|
|
let drivePhaseStartTime = 0.0
|
|
let _driveDuration = 0.0
|
|
let drivePhaseStartAngularPosition = 0.0
|
|
let drivePhaseAngularDisplacement = 0.0
|
|
let _driveLinearDistance = 0.0
|
|
let recoveryPhaseStartAngularPosition = 0.0
|
|
let recoveryPhaseAngularDisplacement = 0.0
|
|
let _recoveryLinearDistance = 0.0
|
|
const minimumCycleDuration = rowerSettings.minimumDriveTime + rowerSettings.minimumRecoveryTime
|
|
let _cycleDuration = minimumCycleDuration
|
|
let _cycleLinearVelocity = 0.0
|
|
let _cyclePower = 0.0
|
|
let totalLinearDistance = 0.0
|
|
let preliminaryTotalLinearDistance = 0.0
|
|
let _driveLength = 0.0
|
|
|
|
// called if the sensor detected an impulse, currentDt is an interval in seconds
|
|
function handleRotationImpulse (currentDt) {
|
|
// Provide the flywheel with new data
|
|
flywheel.pushValue(currentDt)
|
|
|
|
// This is the core of the finite state machine that defines all state transitions
|
|
switch (true) {
|
|
case (_strokeState === 'Stopped'):
|
|
// We are in a stopped state, so don't do anything
|
|
break
|
|
case (_strokeState === 'WaitingForDrive' && flywheel.isPowered() && flywheel.isAboveMinimumSpeed()):
|
|
// We change into the "Drive" phase since were waiting for a drive phase, and we see a clear force exerted on the flywheel
|
|
log.debug(`*** Rowing (re)started with a DRIVE phase at time: ${flywheel.spinningTime().toFixed(4)} sec`)
|
|
// As we are not certain what caused the "WaitingForDrive" (a fresh start or a restart after pause),, we explicitly start the flywheel maintaining metrics again
|
|
flywheel.maintainStateAndMetrics()
|
|
_strokeState = 'Drive'
|
|
startDrivePhase()
|
|
break
|
|
case (_strokeState === 'WaitingForDrive'):
|
|
// We can't change into the "Drive" phase since we are waiting for a drive phase, but there isn't a clear force exerted on the flywheel. So, there is nothing more to do
|
|
break
|
|
case (_strokeState === 'Drive' && ((flywheel.spinningTime() - drivePhaseStartTime) >= rowerSettings.minimumDriveTime) && flywheel.isUnpowered()):
|
|
// 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
|
|
log.debug(`*** RECOVERY phase started at time: ${flywheel.spinningTime().toFixed(4)} sec`)
|
|
_strokeState = 'Recovery'
|
|
endDrivePhase()
|
|
startRecoveryPhase()
|
|
break
|
|
case (_strokeState === 'Drive' && flywheel.isUnpowered()):
|
|
// 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: ${flywheel.spinningTime().toFixed(4)} sec: Delta Time trend is upwards, suggests no power, but waiting for drive phase length (${(flywheel.spinningTime() - drivePhaseStartTime).toFixed(4)} sec) to exceed minimumDriveTime (${rowerSettings.minimumDriveTime} sec)`)
|
|
updateDrivePhase()
|
|
break
|
|
case (_strokeState === 'Drive'):
|
|
// We stay in the "Drive" phase as the decceleration is lacking
|
|
updateDrivePhase()
|
|
break
|
|
case (_strokeState === 'Recovery' && ((flywheel.spinningTime() - drivePhaseStartTime) >= rowerSettings.maximumStrokeTimeBeforePause) && flywheel.isDwelling()):
|
|
// The Flywheel is spinning too slowly to create valid CurrentDt's and the last Drive started over maximumStrokeTime ago, we consider it a pause
|
|
log.debug(`*** PAUSED rowing at time: ${flywheel.spinningTime().toFixed(4)} sec, rower hasn't moved in ${(flywheel.spinningTime() - drivePhaseStartTime).toFixed(4)} seconds and flywheel is dwelling`)
|
|
flywheel.maintainStateOnly()
|
|
_strokeState = 'WaitingForDrive'
|
|
endRecoveryPhase()
|
|
break
|
|
case (_strokeState === 'Recovery' && ((flywheel.spinningTime() - recoveryPhaseStartTime) >= rowerSettings.minimumRecoveryTime) && flywheel.isPowered()):
|
|
// We change into the "Drive" phase since we have been long enough in the Recovery phase, and we see a clear force
|
|
// exerted on the flywheel
|
|
log.debug(`*** DRIVE phase started at time: ${flywheel.spinningTime().toFixed(4)} sec`)
|
|
_strokeState = 'Drive'
|
|
endRecoveryPhase()
|
|
startDrivePhase()
|
|
break
|
|
case (_strokeState === 'Recovery' && flywheel.isPowered()):
|
|
// We see a force, but the "Recovery" phase has been too short, we stay in the "Recovery" phase
|
|
log.debug(`Time: ${flywheel.spinningTime().toFixed(4)} sec: Delta Time trend is downwards, suggesting power, but waiting for recovery phase length (${(flywheel.spinningTime() - recoveryPhaseStartTime).toFixed(4)} sec) to exceed minimumRecoveryTime (${rowerSettings.minimumRecoveryTime} sec)`)
|
|
updateRecoveryPhase()
|
|
break
|
|
case (_strokeState === 'Recovery'):
|
|
// No force on the flywheel, let's continue the "Recovery" phase of the stroke
|
|
updateRecoveryPhase()
|
|
break
|
|
default:
|
|
log.error(`Time: ${flywheel.spinningTime().toFixed(4)} sec, state ${_strokeState} found in the Rowing Engine, which is not captured by Finite State Machine`)
|
|
}
|
|
}
|
|
|
|
function startDrivePhase () {
|
|
// Next, we start the Drive Phase
|
|
_totalNumberOfStrokes++
|
|
drivePhaseStartTime = flywheel.spinningTime()
|
|
drivePhaseStartAngularPosition = flywheel.angularPosition()
|
|
driveHandleForce.reset()
|
|
const forceOnHandle = flywheel.torque() / sprocketRadius
|
|
driveHandleForce.push(flywheel.deltaTime(), forceOnHandle)
|
|
driveHandleVelocity.reset()
|
|
const velocityOfHandle = flywheel.angularVelocity() * sprocketRadius
|
|
driveHandleVelocity.push(flywheel.deltaTime(), velocityOfHandle)
|
|
driveHandlePower.reset()
|
|
const powerOnHandle = flywheel.torque() * flywheel.angularVelocity()
|
|
driveHandlePower.push(flywheel.deltaTime(), powerOnHandle)
|
|
}
|
|
|
|
function updateDrivePhase () {
|
|
// Update the key metrics on each impulse
|
|
drivePhaseAngularDisplacement = flywheel.angularPosition() - drivePhaseStartAngularPosition
|
|
_driveLinearDistance = calculateLinearDistance(drivePhaseAngularDisplacement, (flywheel.spinningTime() - drivePhaseStartTime))
|
|
preliminaryTotalLinearDistance = totalLinearDistance + _driveLinearDistance
|
|
const forceOnHandle = flywheel.torque() / sprocketRadius
|
|
driveHandleForce.push(flywheel.deltaTime(), forceOnHandle)
|
|
const velocityOfHandle = flywheel.angularVelocity() * sprocketRadius
|
|
driveHandleVelocity.push(flywheel.deltaTime(), velocityOfHandle)
|
|
const powerOnHandle = flywheel.torque() * flywheel.angularVelocity()
|
|
driveHandlePower.push(flywheel.deltaTime(), powerOnHandle)
|
|
}
|
|
|
|
function endDrivePhase () {
|
|
// Here, we conclude the Drive Phase
|
|
// The FSM guarantees that we have a credible driveDuration and cycletime
|
|
_driveDuration = flywheel.spinningTime() - drivePhaseStartTime
|
|
_cycleDuration = _recoveryDuration + _driveDuration
|
|
drivePhaseAngularDisplacement = flywheel.angularPosition() - drivePhaseStartAngularPosition
|
|
_driveLength = drivePhaseAngularDisplacement * sprocketRadius
|
|
_driveLinearDistance = calculateLinearDistance(drivePhaseAngularDisplacement, _driveDuration)
|
|
totalLinearDistance += _driveLinearDistance
|
|
_cyclePower = calculateCyclePower()
|
|
_cycleLinearVelocity = calculateLinearVelocity(drivePhaseAngularDisplacement + recoveryPhaseAngularDisplacement, _cycleDuration)
|
|
preliminaryTotalLinearDistance = totalLinearDistance
|
|
}
|
|
|
|
function startRecoveryPhase () {
|
|
// Next, we start the Recovery Phase
|
|
recoveryPhaseStartTime = flywheel.spinningTime()
|
|
recoveryPhaseStartAngularPosition = flywheel.angularPosition()
|
|
flywheel.markRecoveryPhaseStart()
|
|
}
|
|
|
|
function updateRecoveryPhase () {
|
|
// Update the key metrics on each impulse
|
|
recoveryPhaseAngularDisplacement = flywheel.angularPosition() - recoveryPhaseStartAngularPosition
|
|
_recoveryLinearDistance = calculateLinearDistance(recoveryPhaseAngularDisplacement, (flywheel.spinningTime() - recoveryPhaseStartTime))
|
|
preliminaryTotalLinearDistance = totalLinearDistance + _recoveryLinearDistance
|
|
}
|
|
|
|
function endRecoveryPhase () {
|
|
// First, we conclude the recovery phase
|
|
// The FSM guarantees that we have a credible recoveryDuration and cycletime
|
|
_recoveryDuration = flywheel.spinningTime() - recoveryPhaseStartTime
|
|
_cycleDuration = _recoveryDuration + _driveDuration
|
|
recoveryPhaseAngularDisplacement = flywheel.angularPosition() - recoveryPhaseStartAngularPosition
|
|
_recoveryLinearDistance = calculateLinearDistance(recoveryPhaseAngularDisplacement, _recoveryDuration)
|
|
totalLinearDistance += _recoveryLinearDistance
|
|
preliminaryTotalLinearDistance = totalLinearDistance
|
|
_cycleLinearVelocity = calculateLinearVelocity(drivePhaseAngularDisplacement + recoveryPhaseAngularDisplacement, _cycleDuration)
|
|
_cyclePower = calculateCyclePower()
|
|
flywheel.markRecoveryPhaseCompleted()
|
|
}
|
|
|
|
function calculateLinearDistance (baseAngularDisplacement, baseTime) {
|
|
if (baseAngularDisplacement >= 0) {
|
|
return Math.pow((flywheel.dragFactor() / rowerSettings.magicConstant), 1.0 / 3.0) * baseAngularDisplacement
|
|
} else {
|
|
log.error(`Time: ${flywheel.spinningTime().toFixed(4)} sec: calculateLinearDistance error: baseAngularDisplacement was not credible, baseTime: ${baseAngularDisplacement}`)
|
|
return 0
|
|
}
|
|
}
|
|
|
|
function calculateLinearVelocity (baseAngularDisplacement, baseTime) {
|
|
// Here we calculate the AVERAGE speed for the displays, NOT the topspeed of the stroke
|
|
const prevLinearVelocity = _cycleLinearVelocity
|
|
if (baseAngularDisplacement > 0 && baseTime > 0) {
|
|
// let's prevent division's by zero and make sure data is credible
|
|
const baseAngularVelocity = baseAngularDisplacement / baseTime
|
|
return Math.pow((flywheel.dragFactor() / rowerSettings.magicConstant), 1.0 / 3.0) * baseAngularVelocity
|
|
} else {
|
|
log.error(`Time: ${flywheel.spinningTime().toFixed(4)} sec: calculateLinearVelocity error, Angular Displacement = ${baseAngularDisplacement}, baseTime = ${baseTime}`)
|
|
return prevLinearVelocity
|
|
}
|
|
}
|
|
|
|
function calculateCyclePower () {
|
|
// Here we calculate the AVERAGE power for the displays, NOT the top power of the stroke
|
|
const prevCyclePower = _cyclePower
|
|
if (_driveDuration >= rowerSettings.minimumDriveTime && _cycleDuration >= minimumCycleDuration) {
|
|
// let's prevent division's by zero and make sure data is credible
|
|
return flywheel.dragFactor() * Math.pow((recoveryPhaseAngularDisplacement + drivePhaseAngularDisplacement) / _cycleDuration, 3.0)
|
|
} else {
|
|
log.error(`Time: ${flywheel.spinningTime().toFixed(4)} sec: calculateCyclePower error: driveDuration = ${_driveDuration.toFixed(4)} sec, _cycleDuration = ${_cycleDuration.toFixed(4)} sec`)
|
|
return prevCyclePower
|
|
}
|
|
}
|
|
|
|
function strokeState () {
|
|
return _strokeState
|
|
}
|
|
|
|
function totalNumberOfStrokes () {
|
|
return _totalNumberOfStrokes
|
|
}
|
|
|
|
function totalMovingTimeSinceStart () {
|
|
return flywheel.spinningTime()
|
|
}
|
|
|
|
function driveLastStartTime () {
|
|
return drivePhaseStartTime
|
|
}
|
|
|
|
function totalLinearDistanceSinceStart () {
|
|
return Math.max(preliminaryTotalLinearDistance, totalLinearDistance)
|
|
}
|
|
|
|
function cycleDuration () {
|
|
// ToDo: return 0 in the situation where the first cycle hasn't completed yet
|
|
return _cycleDuration
|
|
}
|
|
|
|
function cycleLinearDistance () {
|
|
// ToDo: return 0 in the situation where the first cycle hasn't completed yet
|
|
return _driveLinearDistance + _recoveryLinearDistance
|
|
}
|
|
|
|
function cycleLinearVelocity () {
|
|
// ToDo: return 0 in the situation where the first cycle hasn't completed yet
|
|
return _cycleLinearVelocity
|
|
}
|
|
|
|
function cyclePower () {
|
|
// ToDo: return 0 in the situation where the first cycle hasn't completed yet
|
|
return _cyclePower
|
|
}
|
|
|
|
function driveDuration () {
|
|
return _driveDuration
|
|
}
|
|
|
|
function driveLinearDistance () {
|
|
return _driveLinearDistance
|
|
}
|
|
|
|
function driveLength () {
|
|
return _driveLength
|
|
}
|
|
|
|
function driveAverageHandleForce () {
|
|
return driveHandleForce.average()
|
|
}
|
|
|
|
function drivePeakHandleForce () {
|
|
return driveHandleForce.peak()
|
|
}
|
|
|
|
function driveHandleForceCurve () {
|
|
return driveHandleForce.curve()
|
|
}
|
|
|
|
function driveHandleVelocityCurve () {
|
|
return driveHandleVelocity.curve()
|
|
}
|
|
|
|
function driveHandlePowerCurve () {
|
|
return driveHandlePower.curve()
|
|
}
|
|
|
|
function recoveryDuration () {
|
|
return _recoveryDuration
|
|
}
|
|
|
|
function recoveryDragFactor () {
|
|
return flywheel.dragFactor() * 1000000
|
|
}
|
|
|
|
function instantHandlePower () {
|
|
if (_strokeState === 'Drive') {
|
|
return flywheel.torque() * flywheel.angularVelocity()
|
|
} else {
|
|
return 0
|
|
}
|
|
}
|
|
|
|
function allowMovement () {
|
|
if (_strokeState === 'Stopped') {
|
|
// We have to check whether there actually was a stop/pause, in order to prevent weird behaviour from the state machine
|
|
log.debug(`*** ALLOW MOVEMENT command by RowingEngine recieved at time: ${flywheel.spinningTime().toFixed(4)} sec`)
|
|
_strokeState = 'WaitingForDrive'
|
|
}
|
|
}
|
|
|
|
function pauseMoving () {
|
|
log.debug(`*** PAUSE MOVING command recieved by RowingEngine at time: ${flywheel.spinningTime().toFixed(4)} sec, distance: ${preliminaryTotalLinearDistance.toFixed(2)} meters`)
|
|
flywheel.maintainStateOnly()
|
|
_strokeState = 'WaitingForDrive'
|
|
}
|
|
|
|
function stopMoving () {
|
|
log.debug(`*** STOP MOVING command recieved by RowingEngine at time: ${flywheel.spinningTime().toFixed(4)} sec, distance: ${preliminaryTotalLinearDistance.toFixed(2)} meters`)
|
|
flywheel.maintainStateOnly()
|
|
_strokeState = 'Stopped'
|
|
}
|
|
|
|
function reset () {
|
|
_strokeState = 'WaitingForDrive'
|
|
flywheel.reset()
|
|
_totalNumberOfStrokes = -1.0
|
|
drivePhaseStartTime = 0.0
|
|
drivePhaseStartAngularPosition = 0.0
|
|
_driveDuration = 0.0
|
|
drivePhaseAngularDisplacement = 0.0
|
|
_driveLinearDistance = 0.0
|
|
recoveryPhaseStartTime = 0.0
|
|
_recoveryDuration = 0.0
|
|
recoveryPhaseStartAngularPosition = 0.0
|
|
recoveryPhaseAngularDisplacement = 0.0
|
|
_recoveryLinearDistance = 0.0
|
|
_cycleDuration = 0.0
|
|
_cycleLinearVelocity = 0.0
|
|
totalLinearDistance = 0.0
|
|
preliminaryTotalLinearDistance = 0.0
|
|
_cyclePower = 0.0
|
|
_driveLength = 0.0
|
|
}
|
|
|
|
return {
|
|
handleRotationImpulse,
|
|
allowMovement,
|
|
pauseMoving,
|
|
stopMoving,
|
|
strokeState,
|
|
totalNumberOfStrokes,
|
|
driveLastStartTime,
|
|
totalMovingTimeSinceStart,
|
|
totalLinearDistanceSinceStart,
|
|
cycleDuration,
|
|
cycleLinearDistance,
|
|
cycleLinearVelocity,
|
|
cyclePower,
|
|
driveDuration,
|
|
driveLinearDistance,
|
|
driveLength,
|
|
driveAverageHandleForce,
|
|
drivePeakHandleForce,
|
|
driveHandleForceCurve,
|
|
driveHandleVelocityCurve,
|
|
driveHandlePowerCurve,
|
|
recoveryDuration,
|
|
recoveryDragFactor,
|
|
instantHandlePower,
|
|
reset
|
|
}
|
|
}
|
|
|
|
export { createRower }
|