Skip to content

Commit

Permalink
Improvement of the TS regression algorithm and stroke detection (#51)
Browse files Browse the repository at this point in the history
Change the startup and powerdown behaviour to depend on Angular velocity, as that is constructed using the TS-calculation, reducing the dependency on an individual measurement.

Inserted more explicit memory management by setting values to null before destroying the node, in order to make the Garbage collector's work a bit easier. This fixes a lot of memory leaks.

Performance improvement of the Quadratic TS Series: Implementation of a lazy algorithm to calculate B, C and goodnessOfFit, avoiding the unneccessary calculation of them when not needed/used.

Improved coding style: Instead of making explicit function calls, we now expose the underlying series, allowing use of their functions directly.

Adds different rowers, including the model C (see laberning#157 )
  • Loading branch information
JaapvanEkris authored Sep 3, 2024
1 parent 83ef012 commit 8d374a4
Show file tree
Hide file tree
Showing 24 changed files with 8,075 additions and 698 deletions.
71 changes: 31 additions & 40 deletions app/engine/Flywheel.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use strict'
/*
Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
This models the flywheel with all of its attributes, which we can also test for being powered
Expand All @@ -22,27 +22,27 @@

import loglevel from 'loglevel'
import { createStreamFilter } from './utils/StreamFilter.js'
import { createOLSLinearSeries } from './utils/OLSLinearSeries.js'
import { createTSLinearSeries } from './utils/FullTSLinearSeries.js'
import { createTSQuadraticSeries } from './utils/FullTSQuadraticSeries.js'
import { createWeighedSeries } from './utils/WeighedSeries.js'

const log = loglevel.getLogger('RowingEngine')

function createFlywheel (rowerSettings) {
export function createFlywheel (rowerSettings) {
const angularDisplacementPerImpulse = (2.0 * Math.PI) / rowerSettings.numOfImpulsesPerRevolution
const flankLength = rowerSettings.flankLength
const minimumDragFactorSamples = Math.floor(rowerSettings.minimumRecoveryTime / rowerSettings.maximumTimeBetweenImpulses)
const minumumTorqueBeforeStroke = rowerSettings.minumumForceBeforeStroke * (rowerSettings.sprocketRadius / 100)
const minimumAngularVelocity = angularDisplacementPerImpulse / rowerSettings.maximumTimeBetweenImpulses
const currentDt = createStreamFilter(rowerSettings.smoothing, rowerSettings.maximumTimeBetweenImpulses)
const _deltaTime = createOLSLinearSeries(flankLength)
const _deltaTime = createTSLinearSeries(flankLength)
const _angularDistance = createTSQuadraticSeries(flankLength)
const _angularVelocityMatrix = []
const _angularAccelerationMatrix = []
const drag = createWeighedSeries(rowerSettings.dragFactorSmoothing, (rowerSettings.dragFactor / 1000000))
const recoveryDeltaTime = createTSLinearSeries()
const strokedetectionMinimalGoodnessOfFit = rowerSettings.minimumStrokeQuality
const minumumRecoverySlope = createWeighedSeries(rowerSettings.dragFactorSmoothing, rowerSettings.minumumRecoverySlope)
let _angularVelocityMatrix = []
let _angularAccelerationMatrix = []
let _deltaTimeBeforeFlank
let _angularVelocityAtBeginFlank
let _angularVelocityBeforeFlank
Expand All @@ -62,8 +62,8 @@ function createFlywheel (rowerSettings) {
function pushValue (dataPoint) {
if (isNaN(dataPoint) || dataPoint < 0 || dataPoint > rowerSettings.maximumStrokeTimeBeforePause) {
// This typicaly happends after a pause, we need to fix this as it throws off all time calculations
log.debug(`*** WARNING: currentDt of ${dataPoint} sec isn't between 0 and maximumStrokeTimeBeforePause (${rowerSettings.maximumStrokeTimeBeforePause} sec)`)
dataPoint = currentDt.clean()
log.debug(`*** WARNING: currentDt of ${dataPoint} sec isn't between 0 and maximumStrokeTimeBeforePause (${rowerSettings.maximumStrokeTimeBeforePause} sec), vakue skipped`)
return
}

if (dataPoint > rowerSettings.maximumTimeBetweenImpulses && maintainMetrics) {
Expand All @@ -83,7 +83,7 @@ function createFlywheel (rowerSettings) {
// Also we nend feed the Drag calculation. We need to do this, BEFORE the array shifts, as the valueAtSeriesBeginvalue
// value before the shift is certain to be part of a specific rowing phase (i.e. Drive or Recovery), once the buffer is filled completely
totalNumberOfImpulses += 1
_deltaTimeBeforeFlank = _deltaTime.yAtSeriesBegin()
_deltaTimeBeforeFlank = _deltaTime.Y.atSeriesBegin()
totalTimeSpinning += _deltaTimeBeforeFlank
_angularVelocityBeforeFlank = _angularVelocityAtBeginFlank
_angularAccelerationBeforeFlank = _angularAccelerationAtBeginFlank
Expand All @@ -101,7 +101,7 @@ function createFlywheel (rowerSettings) {
}

// Let's feed the stroke detection algorithm
// Please note that deltaTime MUST use dirty data to be ale to use the OLS algorithms effictively (Otherwise the Goodness of Fit can't be used as a filter!)
// Please note that deltaTime MUST use dirty data to be ale to use the regression algorithms effictively (Otherwise the Goodness of Fit can't be used as a filter!)
currentRawTime += currentDt.raw()
currentAngularDistance += angularDisplacementPerImpulse
_deltaTime.push(currentRawTime, currentDt.raw())
Expand Down Expand Up @@ -228,52 +228,37 @@ function createFlywheel (rowerSettings) {
function isDwelling () {
// Check if the flywheel is spinning down beyond a recovery phase indicating that the rower has stopped rowing
// We conclude this based on
// * A decelerating flywheel as the slope of the CurrentDt's goes up
// * All CurrentDt's in the flank are above the maximum
if (_deltaTime.slope() > 0 && deltaTimesAbove(rowerSettings.maximumTimeBetweenImpulses)) {
// * The angular velocity at the begin of the flank is above the minimum angular velocity (dependent on maximumTimeBetweenImpulses)
// * The entire flank has a positive trend, i.e. the flywheel is decelerating consistent with the dragforce being present
if (_angularVelocityAtBeginFlank < minimumAngularVelocity && deltaTimeSlopeAbove(minumumRecoverySlope.weighedAverage())) {
return true
} else {
return false
}
}

function isAboveMinimumSpeed () {
// Check if the flywheel has reached its minimum speed. We conclude this based on all CurrentDt's in the flank are below
// the maximum, indicating a sufficiently fast flywheel
if (deltaTimesEqualorBelow(rowerSettings.maximumTimeBetweenImpulses)) {
// Check if the flywheel has reached its minimum speed. We conclude this based on the first element in the flank
// as this angular velocity is created by all curves that are in that flank and having an acceleration in the rest of the flank
if (_angularVelocityAtBeginFlank >= minimumAngularVelocity) {
return true
} else {
return false
}
}

function isUnpowered () {
if (deltaTimeSlopeAbove(minumumRecoverySlope.weighedAverage()) && torqueAbsent() && _deltaTime.length() >= flankLength) {
// We reached the minimum number of increasing currentDt values
// We consider the flywheel unpowered when there is an acceleration consistent with the drag being the only forces AND no torque being seen
// As in the first stroke drag is unreliable for automatic drag updating machines, torque can't be used when drag indicates it is unreliable for these machines
if (deltaTimeSlopeAbove(minumumRecoverySlope.weighedAverage()) && (torqueAbsent() || (rowerSettings.autoAdjustDragFactor && !drag.reliable()))) {
return true
} else {
return false
}
}

function isPowered () {
if ((deltaTimeSlopeBelow(minumumRecoverySlope.weighedAverage()) && torquePresent()) || _deltaTime.length() < flankLength) {
return true
} else {
return false
}
}

function deltaTimesAbove (threshold) {
if (_deltaTime.minimumY() >= threshold && _deltaTime.length() >= flankLength) {
return true
} else {
return false
}
}

function deltaTimesEqualorBelow (threshold) {
if (_deltaTime.maximumY() <= threshold && _deltaTime.length() >= flankLength) {
if (deltaTimeSlopeBelow(minumumRecoverySlope.weighedAverage()) && torquePresent()) {
return true
} else {
return false
Expand All @@ -284,6 +269,7 @@ function createFlywheel (rowerSettings) {
// This is a typical indication that the flywheel is accelerating. We use the slope of successive currentDt's
// A (more) negative slope indicates a powered flywheel. When set to 0, it determines whether the DeltaT's are decreasing
// When set to a value below 0, it will become more stringent. In automatic, a percentage of the current slope (i.e. dragfactor) is used
// Please note, as this acceleration isn't linear, _deltaTime.goodnessOfFit() will not be good by definition, so we need omit it
if (_deltaTime.slope() < threshold && _deltaTime.length() >= flankLength) {
return true
} else {
Expand All @@ -304,16 +290,19 @@ function createFlywheel (rowerSettings) {
}

function torquePresent () {
// This is a typical indication that the flywheel is decelerating which might work on some machines: successive currentDt's are increasing
if (_torqueAtBeginFlank > minumumTorqueBeforeStroke) {
// This is a typical indication that the flywheel is accelerating: the torque is above a certain threshold (so a force is present on the handle)
if (_torqueAtBeginFlank >= minumumTorqueBeforeStroke) {
return true
} else {
return false
}
}

function torqueAbsent () {
// This is a typical indication that the flywheel is Accelerating which might work on some machines: successive currentDt's are decreasing
// This is a typical indication that the flywheel is decelerating: the torque is below a certain threshold (so a force is absent on the handle)
// We need to consider the situation rowerSettings.autoAdjustDragFactor && !drag.reliable() as a high default dragfactor (as set via config) blocks the
// detection of the first recovery based on Torque, and thus the calculation of the true dragfactor in that setting.
// This let the recovery detection fall back onto slope-based stroke detection only for the first stroke (until drag is calculated reliably)
if (_torqueAtBeginFlank < minumumTorqueBeforeStroke) {
return true
} else {
Expand All @@ -337,6 +326,10 @@ function createFlywheel (rowerSettings) {
currentCleanTime = 0
currentRawTime = 0
currentAngularDistance = 0
_angularVelocityMatrix = null
_angularVelocityMatrix = []
_angularAccelerationMatrix = null
_angularAccelerationMatrix = []
_deltaTime.push(0, 0)
_angularDistance.push(0, 0)
_deltaTimeBeforeFlank = 0
Expand Down Expand Up @@ -366,5 +359,3 @@ function createFlywheel (rowerSettings) {
reset
}
}

export { createFlywheel }
33 changes: 23 additions & 10 deletions app/engine/Flywheel.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ test('Correct Flywheel behaviour at initialisation', () => {
testDragFactor(flywheel, 0.00011)
testIsDwelling(flywheel, false)
testIsUnpowered(flywheel, false)
testIsPowered(flywheel, true)
testIsPowered(flywheel, false)
})

// Test behaviour for one datapoint
Expand All @@ -67,7 +67,7 @@ test('Correct Flywheel behaviour for a noisefree stroke', () => {
testDragFactor(flywheel, 0.00011)
testIsDwelling(flywheel, false)
testIsUnpowered(flywheel, false)
testIsPowered(flywheel, true)
testIsPowered(flywheel, false)
flywheel.pushValue(0.011221636)
flywheel.pushValue(0.011175504)
flywheel.pushValue(0.01116456)
Expand Down Expand Up @@ -172,7 +172,7 @@ test('Correct Flywheel behaviour at maintainStateOnly', () => {
testDragFactor(flywheel, 0.00011)
testIsDwelling(flywheel, false)
testIsUnpowered(flywheel, false)
testIsPowered(flywheel, true)
testIsPowered(flywheel, false)
flywheel.maintainStateOnly()
flywheel.pushValue(0.011221636)
flywheel.pushValue(0.011175504)
Expand Down Expand Up @@ -270,8 +270,8 @@ test('Correct Flywheel behaviour with a NordicTrack RX800', async () => {
// Inject 10 strokes
await replayRowingSession(flywheel.pushValue, { filename: 'recordings/RX800.csv', realtime: false, loop: false })

testSpinningTime(flywheel, 22.65622640199999)
testAngularPosition(flywheel, 1446.7034169780998)
testSpinningTime(flywheel, 22.612226401999987)
testAngularPosition(flywheel, 1443.5618243245099)
// As we don't detect strokes here (this is a function of Rower.js, the dragcalculation shouldn't be triggered
testDragFactor(flywheel, (rowerProfiles.NordicTrack_RX800.dragFactor / 1000000))
})
Expand All @@ -285,12 +285,27 @@ test('Correct Flywheel behaviour with a full session on a SportsTech WRX700', as

// Inject 846 strokes
await replayRowingSession(flywheel.pushValue, { filename: 'recordings/WRX700_2magnets_session.csv', realtime: false, loop: false })
testSpinningTime(flywheel, 2342.741183077012)
testAngularPosition(flywheel, 37337.82868791469)
testSpinningTime(flywheel, 2340.0100514160117)
testAngularPosition(flywheel, 37325.26231730033)
// The dragfactor should remain static
testDragFactor(flywheel, (rowerProfiles.Sportstech_WRX700.dragFactor / 1000000))
})

test('A full session for a Concept2 Model C should produce plausible results', async () => {
const flywheel = createFlywheel(deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Concept2_Model_C))
testSpinningTime(flywheel, 0)
testAngularPosition(flywheel, 0)
testDragFactor(flywheel, (rowerProfiles.Concept2_Model_C.dragFactor / 1000000))
flywheel.maintainStateAndMetrics()

await replayRowingSession(flywheel.pushValue, { filename: 'recordings/Concept2_Model_C.csv', realtime: false, loop: false })

testSpinningTime(flywheel, 181.47141999999985)
testAngularPosition(flywheel, 15636.753834467596)
// As we don't detect strokes here (this is a function of Rower.js, the dragcalculation shouldn't be triggered
testDragFactor(flywheel, (rowerProfiles.Concept2_Model_C.dragFactor / 1000000))
})

test('A full session for a Concept2 RowErg should produce plausible results', async () => {
const flywheel = createFlywheel(deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Concept2_RowErg))
testSpinningTime(flywheel, 0)
Expand Down Expand Up @@ -348,10 +363,8 @@ function testIsPowered (flywheel, expectedValue) {
assert.ok(flywheel.isPowered() === expectedValue, `isPowered should be ${expectedValue} at ${flywheel.spinningTime()} sec, is ${flywheel.isPowered()}`)
}

/*
function reportAll (flywheel) {
function reportAll (flywheel) { // eslint-disable-line no-unused-vars
assert.ok(0, `deltaTime: ${flywheel.deltaTime()}, spinningTime: ${flywheel.spinningTime()}, ang. pos: ${flywheel.angularPosition()}, ang. vel: ${flywheel.angularVelocity()}, Ang. acc: ${flywheel.angularAcceleration()}, Torque: ${flywheel.torque()}, DF: ${flywheel.dragFactor()}`)
}
*/

test.run()
33 changes: 22 additions & 11 deletions app/engine/Rower.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use strict'
/*
Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor
The Rowing Engine models the physics of a real rowing boat.
It takes impulses from the flywheel of a rowing machine and estimates
Expand All @@ -17,7 +17,7 @@ import { createCurveMetrics } from './utils/curveMetrics.js'

const log = loglevel.getLogger('RowingEngine')

function createRower (rowerSettings) {
export function createRower (rowerSettings) {
const flywheel = createFlywheel(rowerSettings)
const sprocketRadius = rowerSettings.sprocketRadius / 100
const driveHandleForce = createCurveMetrics()
Expand Down Expand Up @@ -53,19 +53,29 @@ function createRower (rowerSettings) {
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
case (_strokeState === 'WaitingForDrive' && flywheel.isAboveMinimumSpeed()):
// We are above the minimum speed, so we can leave the WaitingForDrive state
// As we are not certain what caused the "WaitingForDrive", we explicitly start the flywheel maintaining metrics again
flywheel.maintainStateAndMetrics()
_strokeState = 'Drive'
startDrivePhase()
if (flywheel.isUnpowered()) {
// We change into the "REcovery" phase, as somehow there is no clear force exerted on the flywheel
log.debug(`*** Rowing (re)started with a RECOVERY phase at time: ${flywheel.spinningTime().toFixed(4)} sec`)
_totalNumberOfStrokes++
_strokeState = 'Recovery'
startRecoveryPhase()
} else {
// 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`)
_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()):
case (_strokeState === 'Drive' && ((flywheel.spinningTime() - drivePhaseStartTime) >= rowerSettings.minimumDriveTime || _totalNumberOfStrokes < 1) && 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
// In the first stroke, we might not exceed the minimumdrivetime in the first stroke, so we shouldn't allow it to limit us.
log.debug(`*** RECOVERY phase started at time: ${flywheel.spinningTime().toFixed(4)} sec`)
_strokeState = 'Recovery'
endDrivePhase()
Expand Down Expand Up @@ -325,6 +335,9 @@ function createRower (rowerSettings) {
function reset () {
_strokeState = 'WaitingForDrive'
flywheel.reset()
driveHandleForce.reset()
driveHandleVelocity.reset()
driveHandlePower.reset()
_totalNumberOfStrokes = -1.0
drivePhaseStartTime = 0.0
drivePhaseStartAngularPosition = 0.0
Expand Down Expand Up @@ -372,5 +385,3 @@ function createRower (rowerSettings) {
reset
}
}

export { createRower }
Loading

0 comments on commit 8d374a4

Please sign in to comment.