openrowingmonitor/app/engine/RowingEngine.js

232 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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 { createMovingFlankDetector } from './MovingFlankDetector.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.
let 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
let kDamp = jMoment * omegaDotDivOmegaSquare
let workoutHandler
const kDampEstimatorAverager = createWeightedAverager(5)
const flankDetector = createMovingFlankDetector(rowerSettings)
let prevDt = rowerSettings.maximumTimeBetweenImpulses
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
}
// remember the state of drive phase from the previous impulse, we need it to detect state changes
wasInDrivePhase = isInDrivePhase
// STEP 1: reduce noise in the measurements by applying some sanity checks
// noise filter on the value of currentDt: it should be within sane levels and should not deviate too much from the previous reading
if (currentDt < rowerSettings.minimumTimeBetweenImpulses || currentDt > rowerSettings.maximumTimeBetweenImpulses || currentDt < (rowerSettings.maximumDownwardChange * prevDt) || currentDt > (rowerSettings.maximumUpwardChange * prevDt)) {
// impulses are outside plausible ranges, so we assume it is close to the previous one
currentDt = prevDt
log.debug(`noise filter corrected currentDt, ${currentDt} was dubious, changed to ${prevDt}`)
}
prevDt = currentDt
// each revolution of the flywheel adds distance of distancePerRevolution
// s = (k/c)^(1/3)*θ
const distancePerRevolution = 2.0 * Math.PI * Math.pow((kDamp / c), 1.0 / 3.0)
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
// STEP 2: detect where we are in the rowing phase (drive or recovery)
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 {
flankDetector.pushValue(currentDt)
// Here we use a finite state machine that goes between "Drive" and "Recovery", provinding sufficient time has passed and there is a credible flank
// We analyse the current impulse, depending on where we are in the stroke
if (wasInDrivePhase) {
// during the previous impulse, we were in the "Drive" phase
const strokeElapsed = timer.getValue('drive')
// finish drive phase if we have been long enough in the Drive phase, and we see a clear deceleration
isInDrivePhase = !((strokeElapsed > rowerSettings.minimumDriveTime) && flankDetector.isDecelerating())
} else {
// during the previous impulse, we were in the "Recovery" phase
const recoveryElapsed = timer.getValue('stroke')
// if we are long enough in the Recovery phase, and we see a clear acceleration, we need to change to the Drive phase
isInDrivePhase = ((recoveryElapsed > rowerSettings.minimumRecoveryTime) && flankDetector.isAccelerating())
}
}
// STEP 3: 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))
}
const _kDamp = jMoment * (-1 * kDampEstimatorAverager.weightedAverage())
const _omegaDotDivOmegaSquare = -1 * kDampEstimatorAverager.weightedAverage()
log.debug(`estimated kDamp: ${_kDamp}`)
log.info(`estimated omegaDotDivOmegaSquare: ${_omegaDotDivOmegaSquare}`)
if (rowerSettings.autoAdjustDampingConstant) {
log.debug('auto adjusting kDamp and omegaDotDivOmegaSquare to new values')
kDamp = _kDamp
omegaDotDivOmegaSquare = _omegaDotDivOmegaSquare
}
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({
// if the recoveryPhase is shorter than 0.2 seconds we set it to 2 seconds, this mitigates the problem
// that we do not have a recovery phase on the first stroke
power: (jPower + kPower) / (((strokeElapsed - driveElapsed) < 0.2) ? strokeElapsed + 2 : 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 }