openrowingmonitor/app/engine/MovingFlankDetector.js

183 lines
8.0 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)
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])) {
// impulses are outside plausible ranges, 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])
}
// 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 }