194 lines
8.7 KiB
JavaScript
194 lines
8.7 KiB
JavaScript
'use strict'
|
|
/*
|
|
Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
|
|
|
|
A Detector used to test for up-going and down-going flanks
|
|
|
|
Please note: The array contains flankLength + 1 measured currentDt's, thus flankLength number of flanks between them
|
|
They are arranged that dataPoints[0] is the youngest, and dataPoints[flankLength] the oldest
|
|
*/
|
|
import loglevel from 'loglevel'
|
|
import { createMovingAverager } from './averager/MovingAverager.js'
|
|
const log = loglevel.getLogger('RowingEngine')
|
|
|
|
function createMovingFlankDetector (rowerSettings) {
|
|
const angularDisplacementPerImpulse = (2.0 * Math.PI) / rowerSettings.numOfImpulsesPerRevolution
|
|
const dirtyDataPoints = new Array(rowerSettings.flankLength + 1)
|
|
dirtyDataPoints.fill(rowerSettings.maximumTimeBetweenImpulses)
|
|
const cleanDataPoints = new Array(rowerSettings.flankLength + 1)
|
|
cleanDataPoints.fill(rowerSettings.maximumTimeBetweenImpulses)
|
|
const angularVelocity = new Array(rowerSettings.flankLength + 1)
|
|
angularVelocity.fill(angularDisplacementPerImpulse / rowerSettings.minimumTimeBetweenImpulses)
|
|
const angularAcceleration = new Array(rowerSettings.flankLength + 1)
|
|
angularAcceleration.fill(0)
|
|
const movingAverage = createMovingAverager(rowerSettings.smoothing, rowerSettings.maximumTimeBetweenImpulses)
|
|
let numberOfSequentialCorrections = 0
|
|
const maxNumberOfSequentialCorrections = (rowerSettings.smoothing >= 2 ? rowerSettings.smoothing : 2)
|
|
|
|
function pushValue (dataPoint) {
|
|
// add the new dataPoint to the array, we have to move data points starting at the oldest ones
|
|
let i = rowerSettings.flankLength
|
|
while (i > 0) {
|
|
// older data points are moved toward the higher numbers
|
|
dirtyDataPoints[i] = dirtyDataPoints[i - 1]
|
|
cleanDataPoints[i] = cleanDataPoints[i - 1]
|
|
angularVelocity[i] = angularVelocity[i - 1]
|
|
angularAcceleration[i] = angularAcceleration[i - 1]
|
|
i = i - 1
|
|
}
|
|
dirtyDataPoints[0] = dataPoint
|
|
|
|
// reduce noise in the measurements by applying some sanity checks
|
|
// noise filter on the value of dataPoint: it should be within sane levels and should not deviate too much from the previous reading
|
|
if (dataPoint < rowerSettings.minimumTimeBetweenImpulses || dataPoint > rowerSettings.maximumTimeBetweenImpulses) {
|
|
// impulseTime is outside plausible ranges, so we assume it is close to the previous clean one
|
|
log.debug(`noise filter corrected currentDt, ${dataPoint} was not between minimumTimeBetweenImpulses and maximumTimeBetweenImpulses, changed to ${cleanDataPoints[1]}`)
|
|
dataPoint = cleanDataPoints[1]
|
|
}
|
|
|
|
// lets test if pushing this value would fit the curve we are looking for
|
|
movingAverage.pushValue(dataPoint)
|
|
|
|
if (movingAverage.getAverage() > (rowerSettings.maximumDownwardChange * cleanDataPoints[1]) && movingAverage.getAverage() < (rowerSettings.maximumUpwardChange * cleanDataPoints[1])) {
|
|
numberOfSequentialCorrections = 0
|
|
} else {
|
|
// impulses are outside plausible ranges
|
|
if (numberOfSequentialCorrections <= maxNumberOfSequentialCorrections) {
|
|
// We haven't made too many corrections, so we assume it is close to the previous one
|
|
log.debug(`noise filter corrected currentDt, ${dataPoint} was too much of an accelleration/decelleration with respect to ${movingAverage.getAverage()}, changed to previous value, ${cleanDataPoints[1]}`)
|
|
movingAverage.replaceLastPushedValue(cleanDataPoints[1])
|
|
} else {
|
|
// We made too many corrections (typically, one currentDt is too long, the next is to short or vice versa), let's allow the algorithm to pick it up otherwise we might get stuck
|
|
log.debug(`noise filter wanted to corrected currentDt (${dataPoint} sec), but it had already made ${numberOfSequentialCorrections} corrections, filter temporarily disabled`)
|
|
}
|
|
numberOfSequentialCorrections = numberOfSequentialCorrections + 1
|
|
}
|
|
|
|
// determine the moving average, to reduce noise
|
|
cleanDataPoints[0] = movingAverage.getAverage()
|
|
|
|
// determine the derived data
|
|
if (cleanDataPoints[0] > 0) {
|
|
angularVelocity[0] = angularDisplacementPerImpulse / cleanDataPoints[0]
|
|
angularAcceleration[0] = (angularVelocity[0] - angularVelocity[1]) / cleanDataPoints[0]
|
|
} else {
|
|
log.error('Impuls of 0 seconds encountered, this should not be possible (division by 0 prevented)')
|
|
angularVelocity[0] = 0
|
|
angularAcceleration[0] = 0
|
|
}
|
|
}
|
|
|
|
function isFlywheelUnpowered () {
|
|
let numberOfErrors = 0
|
|
if (rowerSettings.naturalDeceleration < 0) {
|
|
// A valid natural deceleration of the flywheel has been provided, this has to be maintained for a flank length
|
|
// to count as an indication for an unpowered flywheel
|
|
// Please note that angularAcceleration[] contains flank-information already, so we need to check from
|
|
// rowerSettings.flankLength -1 until 0 flanks
|
|
let i = rowerSettings.flankLength - 1
|
|
while (i >= 0) {
|
|
if (angularAcceleration[i] > rowerSettings.naturalDeceleration) {
|
|
// There seems to be some power present, so we detected an error
|
|
numberOfErrors = numberOfErrors + 1
|
|
}
|
|
i = i - 1
|
|
}
|
|
} else {
|
|
// No valid natural deceleration has been provided, we rely on pure deceleration for recovery detection
|
|
let i = rowerSettings.flankLength
|
|
while (i > 0) {
|
|
if (cleanDataPoints[i] >= cleanDataPoints[i - 1]) {
|
|
// Oldest interval (dataPoints[i]) is larger than the younger one (datapoint[i-1], as the distance is
|
|
// fixed, we are accelerating
|
|
numberOfErrors = numberOfErrors + 1
|
|
}
|
|
i = i - 1
|
|
}
|
|
}
|
|
if (numberOfErrors > rowerSettings.numberOfErrorsAllowed) {
|
|
return false
|
|
} else {
|
|
return true
|
|
}
|
|
}
|
|
|
|
function isFlywheelPowered () {
|
|
let numberOfErrors = 0
|
|
if (rowerSettings.naturalDeceleration < 0) {
|
|
// A valid natural deceleration of the flywheel has been provided, this has to be consistently encountered
|
|
// for a flank length to count as an indication of a powered flywheel
|
|
// Please note that angularAcceleration[] contains flank-information already, so we need to check from
|
|
// rowerSettings.flankLength -1 until 0 flanks
|
|
let i = rowerSettings.flankLength - 1
|
|
while (i >= 0) {
|
|
if (angularAcceleration[i] < rowerSettings.naturalDeceleration) {
|
|
// Some deceleration is below the natural deceleration, so we detected an error
|
|
numberOfErrors = numberOfErrors + 1
|
|
}
|
|
i = i - 1
|
|
}
|
|
} else {
|
|
// No valid natural deceleration of the flywheel has been provided, we rely on pure acceleration for stroke detection
|
|
let i = rowerSettings.flankLength
|
|
while (i > 1) {
|
|
if (cleanDataPoints[i] < cleanDataPoints[i - 1]) {
|
|
// Oldest interval (dataPoints[i]) is shorter than the younger one (datapoint[i-1], as the distance is fixed, we
|
|
// discovered a deceleration
|
|
numberOfErrors = numberOfErrors + 1
|
|
}
|
|
i = i - 1
|
|
}
|
|
if (cleanDataPoints[1] <= cleanDataPoints[0]) {
|
|
// We handle the last measurement more specifically: at least the youngest measurement must be really accelerating
|
|
// This prevents when the currentDt "flatlines" (i.e. error correction kicks in) a ghost-stroke is detected
|
|
numberOfErrors = numberOfErrors + 1
|
|
}
|
|
}
|
|
if (numberOfErrors > rowerSettings.numberOfErrorsAllowed) {
|
|
return false
|
|
} else {
|
|
return true
|
|
}
|
|
}
|
|
|
|
function timeToBeginOfFlank () {
|
|
// We expect the curve to bend between dirtyDataPoints[rowerSettings.flankLength] and dirtyDataPoints[rowerSettings.flankLength+1],
|
|
// as acceleration FOLLOWS the start of the pulling the handle, we assume it must have started before that
|
|
let i = rowerSettings.flankLength
|
|
let total = 0.0
|
|
while (i >= 0) {
|
|
total += dirtyDataPoints[i]
|
|
i = i - 1
|
|
}
|
|
return total
|
|
}
|
|
|
|
function noImpulsesToBeginFlank () {
|
|
return rowerSettings.flankLength
|
|
}
|
|
|
|
function impulseLengthAtBeginFlank () {
|
|
// As this is fed into the speed calculation where small changes have big effects, and we typically use it when
|
|
// the curve is in a plateau, we return the cleaned data and not the dirty data
|
|
// Regardless of the way to determine the acceleration, cleanDataPoints[rowerSettings.flankLength] is always the
|
|
// impulse at the beginning of the flank being investigated
|
|
return cleanDataPoints[rowerSettings.flankLength]
|
|
}
|
|
|
|
function accelerationAtBeginOfFlank () {
|
|
return angularAcceleration[rowerSettings.flankLength - 1]
|
|
}
|
|
|
|
return {
|
|
pushValue,
|
|
isFlywheelUnpowered,
|
|
isFlywheelPowered,
|
|
timeToBeginOfFlank,
|
|
noImpulsesToBeginFlank,
|
|
impulseLengthAtBeginFlank,
|
|
accelerationAtBeginOfFlank
|
|
}
|
|
}
|
|
|
|
export { createMovingFlankDetector }
|