198 lines
8.4 KiB
JavaScript
198 lines
8.4 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 { createWeightedAverager } from './WeightedAverager.js'
|
||
import { createTimer } from './Timer.js'
|
||
|
||
const log = loglevel.getLogger('RowingEngine')
|
||
|
||
function createRowingEngine (rowerSettings) {
|
||
// How many impulses are triggered per revolution of the flywheel
|
||
// i.e. the number of magnets if used with a reed sensor
|
||
const numOfImpulsesPerRevolution = rowerSettings.numOfImpulsesPerRevolution
|
||
|
||
// Needed to determine the damping constant of the rowing machine. This value can be measured in the recovery phase
|
||
// of the stroke (some ergometers do this constantly).
|
||
// However I still keep it constant here, as I still have to figure out the damping physics of a water rower (see below)
|
||
// To measure it for your rowing machine, comment in the logging at the end of "startDrivePhase" function. Then do some
|
||
// strokes on the rower and estimate a value.
|
||
const omegaDotDivOmegaSquare = rowerSettings.omegaDotDivOmegaSquare
|
||
|
||
// The moment of inertia of the flywheel kg*m^2
|
||
// A way to measure it is outlined here: https://dvernooy.github.io/projects/ergware/, "Flywheel moment of inertia"
|
||
// You could also roughly estimate it by just doing some strokes and the comparing the calculated power values for
|
||
// plausibility. Note that the power also depends on omegaDotDivOmegaSquare (see above).
|
||
const jMoment = rowerSettings.jMoment
|
||
|
||
// Set this to true if you are using a water rower
|
||
// The mass of the water starts rotating, when you pull the handle, and therefore acts
|
||
// like a massive flywheel
|
||
// Liquids are a tricky thing and therefore the dumping constant does not seem to be
|
||
// that constant on water rowers...
|
||
// This is WIP, but for now this setting is used to figure out the drive and recovery phases
|
||
// differently on water rowers
|
||
const liquidFlywheel = rowerSettings.liquidFlywheel
|
||
|
||
// A constant that is commonly used to convert flywheel revolutions to a rowed distance
|
||
// see here: http://eodg.atm.ox.ac.uk/user/dudhia/rowing/physics/ergometer.html#section9
|
||
const c = rowerSettings.magicConstant
|
||
|
||
// jMoment * ωdot = -kDamp * ω^2 during non-power part of stroke
|
||
const kDamp = jMoment * omegaDotDivOmegaSquare
|
||
|
||
// s = (k/c)^(1/3)*θ
|
||
const distancePerRevolution = 2.0 * Math.PI * Math.pow((kDamp / c), 1.0 / 3.0)
|
||
|
||
let workoutHandler
|
||
const kDampEstimatorAverager = createWeightedAverager(3)
|
||
let kPower = 0.0
|
||
let jPower = 0.0
|
||
let kDampEstimator = 0.0
|
||
let strokeElapsed = 0.0
|
||
let driveElapsed = 0.0
|
||
let strokeDistance = 0.0
|
||
const omegaVector = new Array(2)
|
||
const omegaDotVector = new Array(2)
|
||
let omegaDotDot = 0.0
|
||
|
||
const timer = createTimer()
|
||
|
||
omegaVector.fill(0.0)
|
||
omegaDotVector.fill(0.0)
|
||
let isInDrivePhase = true
|
||
let wasInDrivePhase = false
|
||
|
||
// a rowing session always starts with a drive phase
|
||
timer.start('drive')
|
||
timer.start('stroke')
|
||
|
||
// called if the sensor detected an impulse, currentDt is an interval in seconds
|
||
function handleRotationImpulse (currentDt) {
|
||
// impulses that take longer than 3 seconds are considered a pause
|
||
if (currentDt > 3.0) {
|
||
workoutHandler.handlePause(currentDt)
|
||
return
|
||
}
|
||
|
||
// each revolution of the flywheel adds distance of distancePerRevolution
|
||
strokeDistance += distancePerRevolution / numOfImpulsesPerRevolution
|
||
|
||
omegaVector[1] = omegaVector[0]
|
||
|
||
// angular speed ω = 2π𝑛, revolutions per minute 𝑛 = 1/𝑑t, 𝑑t is the time for one revolution of flywheel
|
||
// => ω = 2π/measured time for last impulse * impulses per revolution
|
||
omegaVector[0] = (2.0 * Math.PI) / (currentDt * numOfImpulsesPerRevolution)
|
||
// angular velocity ωdot = 𝑑ω/𝑑t
|
||
omegaDotVector[1] = omegaDotVector[0]
|
||
omegaDotVector[0] = (omegaVector[0] - omegaVector[1]) / (currentDt)
|
||
// we use the derivative of the velocity (ωdotdot) to classify the different phases of the stroke
|
||
omegaDotDot = (omegaDotVector[0] - omegaDotVector[1]) / (currentDt)
|
||
|
||
// a stroke consists of a drive phase (when you pull the handle) and a recovery phase (when the handle returns)
|
||
// calculate screeners to find drive portion of stroke - see spreadsheet if you want to understand this
|
||
// if ((omegaDotDot > -40.0) && (omegaDotDot < 40.0)) {
|
||
|
||
// the acceleration is constant if ωdotdot is 0, we expand the range, since measurements are imperfect
|
||
const accelerationIsChanging = !((omegaDotDot > -20.0) && (omegaDotDot < 20.0))
|
||
|
||
// the acceleration is positive if ωdot > 0, we expand the range, since measurements are imperfect
|
||
// used to be 15
|
||
const accelerationIsPositive = omegaDotVector[0] > 0
|
||
|
||
wasInDrivePhase = isInDrivePhase
|
||
|
||
if (liquidFlywheel) {
|
||
// Identification of drive and recovery phase on water rowers is still Work in Progress
|
||
// ω does not seem to decay that linear on water rower in recovery phase, so this would not be
|
||
// a good indicator here.
|
||
// Currently we just differentiate by checking if we are accelerating. This gives a stable indicator
|
||
// but probably we are missing the final part of the drive phase by doing so.
|
||
// This would mean, that the stroke ratio and the estimation of kDamp is a bit off.
|
||
// todo: do some measurements and find a better stable indicator for water rowers
|
||
isInDrivePhase = accelerationIsPositive
|
||
} else {
|
||
// ω decays linear on rowers with a solid flywheel, so we can use that to differentiate the phases
|
||
isInDrivePhase = accelerationIsChanging || (accelerationIsPositive && wasInDrivePhase)
|
||
}
|
||
|
||
// handle the current impulse, depending on where we are in the stroke
|
||
if (isInDrivePhase && !wasInDrivePhase) { startDrivePhase(currentDt) }
|
||
if (!isInDrivePhase && wasInDrivePhase) { startRecoveryPhase() }
|
||
if (isInDrivePhase && wasInDrivePhase) { updateDrivePhase(currentDt) }
|
||
if (!isInDrivePhase && !wasInDrivePhase) { updateRecoveryPhase(currentDt) }
|
||
|
||
timer.updateTimers(currentDt)
|
||
log.debug(`𝑑t: ${currentDt} ω: ${omegaVector[0].toFixed(2)} ωdot: ${omegaDotVector[0].toFixed(2)} ωdotdot: ${omegaDotDot.toFixed(2)} aPos: ${accelerationIsPositive} aChange: ${accelerationIsChanging}`)
|
||
}
|
||
|
||
function startDrivePhase (currentDt) {
|
||
log.debug('*** drive phase started')
|
||
timer.start('drive')
|
||
jPower = 0.0
|
||
kPower = 0.0
|
||
if (strokeElapsed - driveElapsed !== 0) {
|
||
kDampEstimatorAverager.pushValue(kDampEstimator / (strokeElapsed - driveElapsed))
|
||
}
|
||
log.debug(`estimated kDamp: ${jMoment * (-1 * kDampEstimatorAverager.weightedAverage())}`)
|
||
log.info(`estimated omegaDotDivOmegaSquare: ${-1 * kDampEstimatorAverager.weightedAverage()}`)
|
||
workoutHandler.handleStrokeStateChanged({
|
||
strokeState: 'DRIVING'
|
||
})
|
||
}
|
||
|
||
function updateDrivePhase (currentDt) {
|
||
jPower = jPower + jMoment * omegaVector[0] * omegaDotVector[0] * currentDt
|
||
kPower = kPower + kDamp * (omegaVector[0] * omegaVector[0] * omegaVector[0]) * currentDt
|
||
log.debug(`Jpower: ${jPower}, kPower: ${kPower}`)
|
||
}
|
||
|
||
function startRecoveryPhase () {
|
||
driveElapsed = timer.getValue('drive')
|
||
timer.stop('drive')
|
||
strokeElapsed = timer.getValue('stroke')
|
||
timer.stop('stroke')
|
||
log.debug(`driveElapsed: ${driveElapsed}, strokeElapsed: ${strokeElapsed}`)
|
||
timer.start('stroke')
|
||
|
||
if (strokeElapsed !== 0 && workoutHandler) {
|
||
workoutHandler.handleStroke({
|
||
power: (jPower + kPower) / strokeElapsed,
|
||
duration: strokeElapsed,
|
||
durationDrivePhase: driveElapsed,
|
||
distance: strokeDistance,
|
||
strokeState: 'RECOVERY'
|
||
})
|
||
}
|
||
|
||
// stroke finished, reset stroke specific measurements
|
||
kDampEstimator = 0.0
|
||
strokeDistance = 0
|
||
log.debug('*** recovery phase started')
|
||
}
|
||
|
||
function updateRecoveryPhase (currentDt) {
|
||
kDampEstimator = kDampEstimator + (omegaDotVector[0] / (omegaVector[0] * omegaVector[0])) * currentDt
|
||
}
|
||
|
||
function notify (receiver) {
|
||
workoutHandler = receiver
|
||
}
|
||
|
||
return {
|
||
handleRotationImpulse,
|
||
notify
|
||
}
|
||
}
|
||
|
||
export { createRowingEngine }
|