diff --git a/app/engine/Flywheel.js b/app/engine/Flywheel.js
index 5eaab18d20..6b8f260251 100644
--- a/app/engine/Flywheel.js
+++ b/app/engine/Flywheel.js
@@ -23,6 +23,7 @@
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'
@@ -38,10 +39,10 @@ function createFlywheel (rowerSettings) {
const _angularDistance = createTSQuadraticSeries(flankLength)
const _angularVelocityMatrix = []
const _angularAccelerationMatrix = []
- const drag = createStreamFilter(rowerSettings.dragFactorSmoothing, (rowerSettings.dragFactor / 1000000))
- const recoveryDeltaTime = createOLSLinearSeries()
+ const drag = createWeighedSeries(rowerSettings.dragFactorSmoothing, (rowerSettings.dragFactor / 1000000))
+ const recoveryDeltaTime = createTSLinearSeries()
const strokedetectionMinimalGoodnessOfFit = rowerSettings.minimumStrokeQuality
- const minumumRecoverySlope = createStreamFilter(rowerSettings.dragFactorSmoothing, rowerSettings.minumumRecoverySlope)
+ const minumumRecoverySlope = createWeighedSeries(rowerSettings.dragFactorSmoothing, rowerSettings.minumumRecoverySlope)
let _deltaTimeBeforeFlank
let _angularVelocityAtBeginFlank
let _angularVelocityBeforeFlank
@@ -117,8 +118,8 @@ function createFlywheel (rowerSettings) {
}
// Let's make room for a new set of values for angular velocity and acceleration
- _angularVelocityMatrix[_angularVelocityMatrix.length] = createWeighedSeries()
- _angularAccelerationMatrix[_angularAccelerationMatrix.length] = createWeighedSeries()
+ _angularVelocityMatrix[_angularVelocityMatrix.length] = createWeighedSeries(flankLength, 0)
+ _angularAccelerationMatrix[_angularAccelerationMatrix.length] = createWeighedSeries(flankLength, 0)
let i = 0
const goodnessOfFit = _angularDistance.goodnessOfFit()
@@ -133,7 +134,7 @@ function createFlywheel (rowerSettings) {
_angularAccelerationAtBeginFlank = _angularAccelerationMatrix[0].weighedAverage()
// And finally calculate the torque
- _torqueAtBeginFlank = (rowerSettings.flywheelInertia * _angularAccelerationAtBeginFlank + drag.clean() * Math.pow(_angularVelocityAtBeginFlank, 2))
+ _torqueAtBeginFlank = (rowerSettings.flywheelInertia * _angularAccelerationAtBeginFlank + drag.weighedAverage() * Math.pow(_angularVelocityAtBeginFlank, 2))
}
function maintainStateOnly () {
@@ -153,24 +154,28 @@ function createFlywheel (rowerSettings) {
// Completion of the recovery phase
inRecoveryPhase = false
+ // As goodnessOfFit is calculated in-situ (for 220 datapoints on a C2) and is CPU intensive, we only calculate it only once and reuse the cached value
+ const goodnessOfFit = recoveryDeltaTime.goodnessOfFit()
+
// Calculation of the drag-factor
- if (rowerSettings.autoAdjustDragFactor && recoveryDeltaTime.length() > minimumDragFactorSamples && recoveryDeltaTime.slope() > 0 && (!drag.reliable() || recoveryDeltaTime.goodnessOfFit() >= rowerSettings.minimumDragQuality)) {
- drag.push(slopeToDrag(recoveryDeltaTime.slope()))
- log.debug(`*** Calculated drag factor: ${(slopeToDrag(recoveryDeltaTime.slope()) * 1000000).toFixed(4)}, no. samples: ${recoveryDeltaTime.length()}, Goodness of Fit: ${recoveryDeltaTime.goodnessOfFit().toFixed(4)}`)
+ if (rowerSettings.autoAdjustDragFactor && recoveryDeltaTime.length() > minimumDragFactorSamples && recoveryDeltaTime.slope() > 0 && (!drag.reliable() || goodnessOfFit >= rowerSettings.minimumDragQuality)) {
+ drag.push(slopeToDrag(recoveryDeltaTime.slope()), goodnessOfFit)
+
+ log.debug(`*** Calculated drag factor: ${(slopeToDrag(recoveryDeltaTime.slope()) * 1000000).toFixed(4)}, no. samples: ${recoveryDeltaTime.length()}, Goodness of Fit: ${goodnessOfFit.toFixed(4)}`)
if (rowerSettings.autoAdjustRecoverySlope) {
// We are allowed to autoadjust stroke detection slope as well, so let's do that
- minumumRecoverySlope.push((1 - rowerSettings.autoAdjustRecoverySlopeMargin) * recoveryDeltaTime.slope())
- log.debug(`*** Calculated recovery slope: ${recoveryDeltaTime.slope().toFixed(6)}, Goodness of Fit: ${recoveryDeltaTime.goodnessOfFit().toFixed(4)}`)
+ minumumRecoverySlope.push((1 - rowerSettings.autoAdjustRecoverySlopeMargin) * recoveryDeltaTime.slope(), goodnessOfFit)
+ log.debug(`*** Calculated recovery slope: ${recoveryDeltaTime.slope().toFixed(6)}, Goodness of Fit: ${goodnessOfFit.toFixed(4)}`)
} else {
// We aren't allowed to adjust the slope, let's report the slope to help help the user configure it
- log.debug(`*** Calculated recovery slope: ${recoveryDeltaTime.slope().toFixed(6)}, Goodness of Fit: ${recoveryDeltaTime.goodnessOfFit().toFixed(4)}, not used as autoAdjustRecoverySlope isn't set to true`)
+ log.debug(`*** Calculated recovery slope: ${recoveryDeltaTime.slope().toFixed(6)}, Goodness of Fit: ${goodnessOfFit.toFixed(4)}, not used as autoAdjustRecoverySlope isn't set to true`)
}
} else {
if (!rowerSettings.autoAdjustDragFactor) {
// autoAdjustDampingConstant = false, thus the update is skipped, but let's log the dragfactor anyway
log.debug(`*** Calculated drag factor: ${(slopeToDrag(recoveryDeltaTime.slope()) * 1000000).toFixed(4)}, slope: ${recoveryDeltaTime.slope().toFixed(8)}, not used because autoAdjustDragFactor is not true`)
} else {
- log.debug(`*** Calculated drag factor: ${(slopeToDrag(recoveryDeltaTime.slope()) * 1000000).toFixed(4)}, not used because reliability was too low. no. samples: ${recoveryDeltaTime.length()}, fit: ${recoveryDeltaTime.goodnessOfFit().toFixed(4)}`)
+ log.debug(`*** Calculated drag factor: ${(slopeToDrag(recoveryDeltaTime.slope()) * 1000000).toFixed(4)}, not used because reliability was too low. no. samples: ${recoveryDeltaTime.length()}, fit: ${goodnessOfFit.toFixed(4)}`)
}
}
}
@@ -217,7 +222,7 @@ function createFlywheel (rowerSettings) {
function dragFactor () {
// Ths function returns the current dragfactor of the flywheel
- return drag.clean()
+ return drag.weighedAverage()
}
function isDwelling () {
@@ -243,9 +248,8 @@ function createFlywheel (rowerSettings) {
}
function isUnpowered () {
- if (deltaTimeSlopeAbove(minumumRecoverySlope.clean()) && torqueAbsent() && _deltaTime.length() >= flankLength) {
+ if (deltaTimeSlopeAbove(minumumRecoverySlope.weighedAverage()) && torqueAbsent() && _deltaTime.length() >= flankLength) {
// We reached the minimum number of increasing currentDt values
- // log.info(`*** INFO: recovery detected based on due to slope exceeding recoveryslope = ${deltaTimeSlopeAbove(minumumRecoverySlope.clean())}, exceeding minumumForceBeforeStroke = ${torqueAbsent()}`)
return true
} else {
return false
@@ -253,7 +257,7 @@ function createFlywheel (rowerSettings) {
}
function isPowered () {
- if ((deltaTimeSlopeBelow(minumumRecoverySlope.clean()) && torquePresent()) || _deltaTime.length() < flankLength) {
+ if ((deltaTimeSlopeBelow(minumumRecoverySlope.weighedAverage()) && torquePresent()) || _deltaTime.length() < flankLength) {
return true
} else {
return false
diff --git a/app/engine/Rower.test.js b/app/engine/Rower.test.js
index 577a9bb874..357911a625 100644
--- a/app/engine/Rower.test.js
+++ b/app/engine/Rower.test.js
@@ -2,7 +2,7 @@
/*
This test is a test of the Rower object, that tests wether this object fills all fields correctly, given one validated rower, (the
- Concept2 RowErg) using a validated cycle of strokes. This thoroughly tests the raw physics of the translation of Angfular physics
+ Concept2 RowErg) using a validated cycle of strokes. This thoroughly tests the raw physics of the translation of Angular physics
to Linear physics. The combination with all possible known rowers is tested when testing the above function RowingStatistics, as
these statistics are dependent on these settings as well.
*/
@@ -169,20 +169,20 @@ test('Test behaviour for three perfect identical strokes, including settingling
rower.handleRotationImpulse(0.010386684)
testStrokeState(rower, 'Drive')
testTotalMovingTimeSinceStart(rower, 0.44915539800000004)
- testTotalLinearDistanceSinceStart(rower, 1.6075387252606492)
+ testTotalLinearDistanceSinceStart(rower, 1.5912564829320934)
testTotalNumberOfStrokes(rower, 2)
testCycleDuration(rower, 0.34889498300000005)
- testCycleLinearDistance(rower, 0.9667532273482231)
+ testCycleLinearDistance(rower, 0.9504709850196674)
testCycleLinearVelocity(rower, 3.2650920019419694)
testCyclePower(rower, 97.46401557792097)
testDriveDuration(rower, 0.19636192600000005)
- testDriveLinearDistance(rower, 0.4683645067496696)
+ testDriveLinearDistance(rower, 0.4520822644211139)
testDriveLength(rower, 0.2638937829015426)
- testDriveAverageHandleForce(rower, 270.4531194469761)
- testDrivePeakHandleForce(rower, 418.918391852085)
+ testDriveAverageHandleForce(rower, 251.04336322997108)
+ testDrivePeakHandleForce(rower, 396.7011215867992)
testRecoveryDuration(rower, 0.152533057)
- testRecoveryDragFactor(rower, 343.6343564104484)
- testInstantHandlePower(rower, 556.0136323793809)
+ testRecoveryDragFactor(rower, 309.02744980039836)
+ testInstantHandlePower(rower, 526.5255378434941)
// Recovery second stroke starts here
rower.handleRotationImpulse(0.010769)
rower.handleRotationImpulse(0.010707554)
@@ -201,19 +201,19 @@ test('Test behaviour for three perfect identical strokes, including settingling
rower.handleRotationImpulse(0.011209919)
testStrokeState(rower, 'Recovery')
testTotalMovingTimeSinceStart(rower, 0.6101840930000001)
- testTotalLinearDistanceSinceStart(rower, 2.388146236510099)
+ testTotalLinearDistanceSinceStart(rower, 2.3447269236339507)
testTotalNumberOfStrokes(rower, 2)
testCycleDuration(rower, 0.40310000200000007)
- testCycleLinearDistance(rower, 1.2489720179991195)
- testCycleLinearVelocity(rower, 4.776726663843188)
- testCyclePower(rower, 305.1751755713177)
+ testCycleLinearDistance(rower, 1.2055527051229706)
+ testCycleLinearVelocity(rower, 4.6106683482425606)
+ testCyclePower(rower, 274.4414360493952)
testDriveDuration(rower, 0.25056694500000004)
- testDriveLinearDistance(rower, 1.1969315172491561)
+ testDriveLinearDistance(rower, 1.1553213424095137)
testDriveLength(rower, 0.3371976114853044)
- testDriveAverageHandleForce(rower, 312.83830506634683)
- testDrivePeakHandleForce(rower, 480.0144155860976)
+ testDriveAverageHandleForce(rower, 290.98159585708896)
+ testDrivePeakHandleForce(rower, 456.9929898648157)
testRecoveryDuration(rower, 0.152533057)
- testRecoveryDragFactor(rower, 343.6343564104484) // As we decelerate the flywheel quite fast, this is expected
+ testRecoveryDragFactor(rower, 309.02744980039836) // As we decelerate the flywheel quite fast, this is expected
testInstantHandlePower(rower, 0)
// Drive third stroke starts here
rower.handleRotationImpulse(0.011221636)
@@ -237,20 +237,20 @@ test('Test behaviour for three perfect identical strokes, including settingling
rower.handleRotationImpulse(0.010386684)
testStrokeState(rower, 'Drive')
testTotalMovingTimeSinceStart(rower, 0.8203921620000004)
- testTotalLinearDistanceSinceStart(rower, 3.3769157507594016)
+ testTotalLinearDistanceSinceStart(rower, 3.2991228151896355)
testTotalNumberOfStrokes(rower, 3)
testCycleDuration(rower, 0.3490464680000002)
- testCycleLinearDistance(rower, 1.0408100149992658)
- testCycleLinearVelocity(rower, 4.7709866068572415)
- testCyclePower(rower, 304.0763360087651)
+ testCycleLinearDistance(rower, 1.004627254269142)
+ testCycleLinearVelocity(rower, 4.6051278388258226)
+ testCyclePower(rower, 273.453258990202)
testDriveDuration(rower, 0.25056694500000004)
- testDriveLinearDistance(rower, 0.5724455082495962)
+ testDriveLinearDistance(rower, 0.552544989848028)
testDriveLength(rower, 0.3371976114853044)
- testDriveAverageHandleForce(rower, 245.5974258934615)
- testDrivePeakHandleForce(rower, 418.91839185069534)
+ testDriveAverageHandleForce(rower, 223.750606354492)
+ testDrivePeakHandleForce(rower, 396.7011215854034)
testRecoveryDuration(rower, 0.09847952300000018)
- testRecoveryDragFactor(rower, 343.6343564104484)
- testInstantHandlePower(rower, 556.0136323776126)
+ testRecoveryDragFactor(rower, 309.02744980039836)
+ testInstantHandlePower(rower, 526.5255378417136)
// Recovery third stroke starts here
rower.handleRotationImpulse(0.010769)
rower.handleRotationImpulse(0.010707554)
@@ -269,19 +269,19 @@ test('Test behaviour for three perfect identical strokes, including settingling
rower.handleRotationImpulse(0.011209919)
testStrokeState(rower, 'Recovery')
testTotalMovingTimeSinceStart(rower, 0.9814208570000005)
- testTotalLinearDistanceSinceStart(rower, 4.157523262008851)
+ testTotalLinearDistanceSinceStart(rower, 4.052593255891493)
testTotalNumberOfStrokes(rower, 3)
testCycleDuration(rower, 0.3712367640000004)
- testCycleLinearDistance(rower, 1.353053019499046)
- testCycleLinearVelocity(rower, 4.76616864783023)
- testCyclePower(rower, 303.1560556797095)
+ testCycleLinearDistance(rower, 1.3060154305498848)
+ testCycleLinearVelocity(rower, 4.600477371517923)
+ testCyclePower(rower, 272.62565872880714)
testDriveDuration(rower, 0.2727572410000002)
- testDriveLinearDistance(rower, 1.3010125187490824)
+ testDriveLinearDistance(rower, 1.2557840678364274)
testDriveLength(rower, 0.36651914291880905)
- testDriveAverageHandleForce(rower, 295.526758358351)
- testDrivePeakHandleForce(rower, 480.01441558492223)
+ testDriveAverageHandleForce(rower, 272.7765993429924)
+ testDrivePeakHandleForce(rower, 456.99298986363897)
testRecoveryDuration(rower, 0.09847952300000018)
- testRecoveryDragFactor(rower, 343.6343564104484)
+ testRecoveryDragFactor(rower, 309.02744980039836)
testInstantHandlePower(rower, 0)
// Dwelling state starts here
rower.handleRotationImpulse(0.020769)
@@ -302,18 +302,18 @@ test('Test behaviour for three perfect identical strokes, including settingling
testStrokeState(rower, 'WaitingForDrive')
testTotalMovingTimeSinceStart(rower, 1.1137102920000004)
testTotalNumberOfStrokes(rower, 3)
- testTotalLinearDistanceSinceStart(rower, 4.782009271008411)
+ testTotalLinearDistanceSinceStart(rower, 4.655369608452978)
testCycleDuration(rower, 0.4157688410000001)
- testCycleLinearDistance(rower, 1.9775390284986059)
- testCycleLinearVelocity(rower, 4.756342547801953)
- testCyclePower(rower, 301.28492718029133)
+ testCycleLinearDistance(rower, 1.90879178311137)
+ testCycleLinearVelocity(rower, 4.590992866421583)
+ testCyclePower(rower, 270.94296880669305)
testDriveDuration(rower, 0.2727572410000002)
- testDriveLinearDistance(rower, 1.3010125187490824)
+ testDriveLinearDistance(rower, 1.2557840678364274)
testDriveLength(rower, 0.36651914291880905)
- testDriveAverageHandleForce(rower, 295.526758358351)
- testDrivePeakHandleForce(rower, 480.01441558492223)
+ testDriveAverageHandleForce(rower, 272.7765993429924)
+ testDrivePeakHandleForce(rower, 456.99298986363897)
testRecoveryDuration(rower, 0.1430115999999999)
- testRecoveryDragFactor(rower, 343.6343564104484)
+ testRecoveryDragFactor(rower, 309.02744980039836)
testInstantHandlePower(rower, 0)
})
@@ -379,10 +379,10 @@ test('sample data for NordicTrack RX800 should produce plausible results', async
await replayRowingSession(rower.handleRotationImpulse, { filename: 'recordings/RX800.csv', realtime: false, loop: false })
testTotalMovingTimeSinceStart(rower, 17.389910236000024)
- testTotalLinearDistanceSinceStart(rower, 62.365436964788074)
+ testTotalLinearDistanceSinceStart(rower, 62.49982252262572)
testTotalNumberOfStrokes(rower, 8)
// As dragFactor is dynamic, it should have changed
- testRecoveryDragFactor(rower, 490.4918403723983)
+ testRecoveryDragFactor(rower, 493.1277530352103)
})
test('A full session for SportsTech WRX700 should produce plausible results', async () => {
@@ -410,11 +410,11 @@ test('A full session for a Concept2 RowErg should produce plausible results', as
await replayRowingSession(rower.handleRotationImpulse, { filename: 'recordings/Concept2_RowErg_Session_2000meters.csv', realtime: false, loop: false })
- testTotalMovingTimeSinceStart(rower, 590.3787439999999)
- testTotalLinearDistanceSinceStart(rower, 2029.6167445192161)
+ testTotalMovingTimeSinceStart(rower, 590.111937)
+ testTotalLinearDistanceSinceStart(rower, 2029.6932502534587)
testTotalNumberOfStrokes(rower, 206)
// As dragFactor isn't static, it should have changed
- testRecoveryDragFactor(rower, 80.60603626039024)
+ testRecoveryDragFactor(rower, 80.79039510767821)
})
function testStrokeState (rower, expectedValue) {
diff --git a/app/engine/utils/WeighedSeries.js b/app/engine/utils/WeighedSeries.js
index 8a7183d3de..4bbbe5c0b6 100644
--- a/app/engine/utils/WeighedSeries.js
+++ b/app/engine/utils/WeighedSeries.js
@@ -2,13 +2,13 @@
/*
Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
- This creates a series with weights
- It allows for determining the Average, Weighed Average, Median, Number of Positive, number of Negative
+ This creates a series with a maximum number of values
+ It allows for determining the Average, Median, Number of Positive, number of Negative
*/
import { createSeries } from './Series.js'
-function createWeighedSeries (maxSeriesLength) {
+function createWeighedSeries (maxSeriesLength, defaultValue) {
const dataArray = createSeries(maxSeriesLength)
const weightArray = createSeries(maxSeriesLength)
const weightedArray = createSeries(maxSeriesLength)
@@ -48,14 +48,20 @@ function createWeighedSeries (maxSeriesLength) {
}
function average () {
- return dataArray.average()
+ if (dataArray.length() > 0) {
+ // The series contains sufficient values to be valid
+ return dataArray.average()
+ } else {
+ // The array isn't sufficiently filled
+ return defaultValue
+ }
}
function weighedAverage () {
if (dataArray.length() > 0 && weightArray.sum() !== 0) {
return (weightedArray.sum() / weightArray.sum())
} else {
- return undefined
+ return defaultValue
}
}
@@ -71,6 +77,10 @@ function createWeighedSeries (maxSeriesLength) {
return dataArray.median()
}
+ function reliable () {
+ return dataArray.length() > 0
+ }
+
function series () {
return dataArray.series()
}
@@ -96,6 +106,7 @@ function createWeighedSeries (maxSeriesLength) {
maximum,
median,
series,
+ reliable,
reset
}
}
diff --git a/config/rowerProfiles.js b/config/rowerProfiles.js
index 7939d9ba4d..3cc5c4f61e 100644
--- a/config/rowerProfiles.js
+++ b/config/rowerProfiles.js
@@ -130,17 +130,17 @@ export default {
minimumDragQuality: 0.95,
dragFactorSmoothing: 3,
minimumTimeBetweenImpulses: 0.005,
- maximumTimeBetweenImpulses: 0.020,
+ maximumTimeBetweenImpulses: 0.0145,
flankLength: 12,
smoothing: 1,
minimumStrokeQuality: 0.36,
- minumumForceBeforeStroke: 10,
+ minumumForceBeforeStroke: 11,
minumumRecoverySlope: 0.00070,
autoAdjustRecoverySlope: true,
- autoAdjustRecoverySlopeMargin: 0.04,
+ autoAdjustRecoverySlopeMargin: 0.01,
minimumDriveTime: 0.40,
minimumRecoveryTime: 0.90,
- flywheelInertia: 0.10130,
+ flywheelInertia: 0.10148,
magicConstant: 2.8
},
diff --git a/docs/physics_openrowingmonitor.md b/docs/physics_openrowingmonitor.md
index e60f633c51..d0877a762f 100644
--- a/docs/physics_openrowingmonitor.md
+++ b/docs/physics_openrowingmonitor.md
@@ -169,7 +169,7 @@ $$ k = slope \* {I \* Impulses Per Rotation \over 2π} $$
As this formula shows, the drag factor is effectively determined by the slope of the line created by *time since start* on the *x*-axis and the corresponding *CurrentDt* on the *y*-axis, for each recovery phase.
-This slope can be determined through linear regression (see [[5]](#5) and [[6]](#6)) for the collection of datapoints for a specific recovery phase. This approach also brings this calculation as close as possible to the raw data, and doesn't use individual *currentDt*'s as a divider, which are explicit design goals to reduce data volatility. Although simple linear regression (OLS) isn't robust in nature, its algorithm has proven to be sufficiently robust to be applied, especially when filtering on low R2. On a Concept2, the typical R2 is around 0.96 for steady state rowing. The approach of using r2 has the benefit of completely relying on metrics contained in the algorithm itself for quality control: the algorithm itself signals a bad fit due to too much noise in the calculation. Alternative approaches typically rely on cross-stroke repeatability of the drag calculation, which could ignore drag changes despite a good fit with the data. Practical experiments show that an R2-based filter outperforms any other across-stroke noise dampening filter.
+This slope can be determined through linear regression (see [[5]](#5) and [[6]](#6)) for the collection of datapoints for a specific recovery phase. This approach also brings this calculation as close as possible to the raw data, and doesn't use individual *currentDt*'s as a divider, which are explicit design goals to reduce data volatility. For determining the slope, we use the linear Theil-Sen Estimator, which is sufficiently robust against noise, especially when filtering on low R2. On a Concept2, the typical R2 is around 0.96 (low drag) to 0.99 (high drag) for steady state rowing. The approach of using r2 has the benefit of completely relying on metrics contained in the algorithm itself for quality control: the algorithm itself signals a bad fit due to too much noise in the calculation. Additionally, as the drag does not change much from stroke to stroke, a running weighed average across several strokes is used, where the R2 is used as its weight. This has the benefit of favouring better fitting curves over less optimal fitting curves (despite all being above the R2 threshold set). Practical experiments show that this approach outperforms any other noise dampening filter.
### Determining the "Torque" of the flywheel
@@ -342,7 +342,7 @@ Based on a simple experiment, downloading the exported data of several rowing se
$$ \overline{u} = ({k \over C})^{1/3} * \overline{\omega} $$
-As both k and ω can change from cycle to cycle, this calculation should be performed for each cycle. It should be noted that this formula is also robust against missed strokes: a missed drive or recovery phase will lump two strokes together, but as the Average Angular Velocity $\overline{\omega}$ will average out across these strokes. Although undesired behaviour in itself, it will isolate linear velocity calculations from errors in the stroke detection in practice.
+As both k and $\overline{\omega}$ can change from cycle to cycle, this calculation should be performed for each cycle. It should be noted that this formula is also robust against missed strokes: a missed drive or recovery phase will lump two strokes together, but as the Average Angular Velocity $\overline{\omega}$ will average out across these strokes. Although missing strokes is undesired behaviour in itself, this approach will isolate linear velocity calculations from errors in the stroke detection in practice.
### Linear distance
@@ -428,13 +428,13 @@ The Theil–Sen estimator can be expanded to apply to Quadratic functions, where
For the drag-factor calculation (and the closely related recovery slope detection), we observe three things:
-* The number of datapoints in the recovery phase isn't known in advance, and is subject to significant change due to variations in recovery time (i.e. sprints), making both the Incomplete Theil–Sen estimator and Theil–Sen estimator incapable of calculating their slopes in the stream as the efficient implementations require a fixed window. This results in a near O(N2) calculation at the start of the *Drive* phase. Given the number of datapoints often encountered (a recoveryphase on a Concept 2 contains around 200 datapoints), this is a significant issue that could disrupt the application. OLS has a O(1) complexity for continous datastreams;
+* The number of datapoints in the recovery phase isn't known in advance, and is subject to significant change due to variations in recovery time (i.e. sprints), making the Incomplete Theil–Sen estimator incapable of calculating their slopes in the stream as the efficient implementations require a fixed window. OLS has a O(1) complexity for continous datastreams, and has proven to be sufficiently robust for most practical use. Using the Linear Theil-sen estimator results in a near O(N) calculation at the start of the *Drive* phase (where N is the length of the recovery in datapoints). The Quadratic Theil-sen estimator results in a O(N2) calculation at the start of the *Drive* phase. Given the number of datapoints often encountered (a recoveryphase on a Concept 2 contains around 200 datapoints), this is a significant CPU-load that could disrupt the application;
-* In non-time critical replays of earlier recorded rowing sessions, both the Incomplete Theil–Sen estimator and Theil–Sen estimator performed worse than OLS: OLS with a high pass filter on r2 resulted in a much more stable dragfactor than the Incomplete Theil–Sen estimator and Theil–Sen estimator did. This suggests that the OLS algorithm combined with a requirement for a sufficiently high r2 handles the outliers sufficiently to prevent drag poisoning.
+* In non-time critical replays of earlier recorded rowing sessions, both the Incomplete Theil–Sen estimator performed worse than OLS: OLS with a high pass filter on r2 resulted in a much more stable dragfactor than the Incomplete Theil–Sen estimator did. The Theil–Sen estimator, in combination with a filter on r2 has shown to be even a bit more robust than OLS. This suggests that the OLS algorithm combined with a requirement for a sufficiently high r2 handles the outliers sufficiently to prevent drag poisoning and thus provide a stable dragfactor for all calculations. The Linear Theil-Sen estimator outperfomed OLS by a small margin, but noticeably improved stroke detection where OLS could not regardless of parameterisation.
-* Applying Quadratic OLS regression does not improve its results
+* Applying Quadratic OLS regression does not improve its results when compared to Linear OLS regression or Linear TS. For the drag (and thus recovery slope) calculation, the Linear Theil-Sen estimator has a slightly better performance then OLS, while keeping CPU-load acceptable for a data-intensive rowing machine (Concept 2, 12 datapoints flank, 200 datapoints in the recovery). A Quadratic theil-Sen based drag calculation has shown to be too CPU-intensive. For the stroke detection itself, OLS and Linear Theil-Sen deliver the same results, while OLS is less CPU intensive.
-Therefore, we choose to apply the OLS Linear Regression model for the calculation of the dragfactor and the related recovery slope detection.
+Therefore, we choose to apply the Linear Theil-Sen estimator for the calculation of the dragfactor and the related recovery slope detection, and use OLS for the stroke detection.
#### Regression algorithm used for Angular velocity and Angular Acceleration
@@ -452,7 +452,7 @@ A simplified formula is provided by [[1]](#1) (formula 9.1), [[2]](#2) and [[3]]
$$ \overline{P} = k \* \overline{\omega}^3 $$
-Open Rowing Monitor uses the latter simplified version. As shown by academic research [[15]](#15), this is sufficiently reliable and accurate providing that that ω doesn't vary much across subsequent strokes. When there is a significant acceleration or decelleration of the flywheel across subsequent strokes (at the start, during acceleration in sprints or due to stroke-by-stroke variation), the calculated power starts to deviate from the externally applied power.
+Open Rowing Monitor uses the latter simplified version. As shown by academic research [[15]](#15), this is sufficiently reliable and accurate providing that that ω doesn't vary much across subsequent strokes. When there is a significant acceleration or decelleration of the flywheel across subsequent strokes (at the start, during acceleration in sprints or due to stroke-by-stroke variation), the reported/calculated power starts to deviate from the externally applied power.
Currently, this is an accepted issue, as the simplified formula has the huge benefit of being much more robust against errors in both the *CurrentDt*/ω measurement and the stroke detection algorithm. As Concept 2 seems to have taken shortcut in a thoroughly matured product [[15]](#15), we are not inclined to change this quickly. Especially as the robustness of both the ω calculation and stroke phase detection varies across types of rowing machines, it is an improvement that should be handled with extreme caution.
@@ -462,15 +462,15 @@ Abandoning the numerical approach for a regression based approach has resulted w
The (implied) underlying assumption underpinning the use of Quadratic Theil-Senn regression approach is that the Angular Accelration α is constant, or near constant by approximation in the flank under measurment. In essence, quadratic Theil-Senn regression would be fitting if the acceleration would be a constant, and the relation of θ, α and ω thus would be captured in θ = 1/2 \* α \* t2 + ω \* t. We do realize that in rowing the Angular Accelration α, by nature of the rowing stroke, will vary based on the position in the Drive phase: the ideal force curve is a heystack, thus the force on the flywheel varies in time.
-As the number of datapoints in a *Flanklength* in the relation to the total number of datapoints in a stroke is relatively small, we use quadratic Theil-Senn regression as an approximation on a smaller interval. In tests, quadratic regression has proven to outperform (i.e. less suspect to noise in the signal) both the numerical approach with noise filtering and the linear regression methods. When using the right efficient algorithm, this has the strong benefit of being robust to noise, at the cost of a O(n2) calculation per new datapoint (where n is the flanklength). Currently, we consider this is a decent approximation while maintaining an sufficiently efficient algorithm to be able to process all data in the datastream in time.
+As the number of datapoints in a *Flanklength* in the relation to the total number of datapoints in a stroke is relatively small, we use quadratic Theil-Senn regression as an approximation on a smaller interval. In tests, quadratic regression has proven to outperform (i.e. less suspect to noise in the signal) both the numerical approach with noise filtering and the linear regression methods. When using the right efficient algorithm, this has the strong benefit of being robust to noise, at the cost of a O(n2) calculation per new datapoint (where n is the flanklength). Looking at the resulting fit of the Quadratic Theil-Sen estimator, we see that it consistently is above 0.98, which is an extremely good fit given the noise in the Concept 2 RowErg data. Therefore, we consider this is a sufficiently decent approximation while maintaining an sufficiently efficient algorithm to be able to process all data in the datastream in time.
-Although the determination of angular velocity ω and angular acceleration α based on Quadratic Theil-Senn regression over the time versus angular distance θ works decently, it does not respect the true dynamic nature of angular acceleration α. From a pure mathematical perspective, a higher order polynomial would be more appropriate. A cubic regressor, or even better a fourth order polynomal have shown to be better mathematical approximation of the time versus distance function for a Concept2 RowErg. We can inmagine there are better suited third polynomal (cubic) approaches available that can robustly calculate α and ω as a function of time, based on the relation between time and θ. However, getting these to work in a datastream with very tight limitations on CPU-time and memory across many configurations is quite challenging.
+Although the determination of angular velocity ω and angular acceleration α based on Quadratic Theil-Senn regression over the time versus angular distance θ works decently, we realize it does not respect the true dynamic nature of angular acceleration α. From a pure mathematical perspective, a higher order polynomial would be more appropriate. A cubic regressor, or even better a fourth order polynomal have shown to be better mathematical approximation of the time versus distance function for a Concept2 RowErg. We can inmagine there are better suited third polynomal (cubic) approaches available that can robustly calculate α and ω as a function of time, based on the relation between time and θ. However, getting these to work in a datastream with very tight limitations on CPU-time and memory across many configurations is quite challenging.
However, there are some current practical objections against using these more complex methods:
-* Higher order polynomials are less stable in nature, and overfitting is a real issue. As the displacement of magets can present itself as a sinoid-like curve, 3rd or higher polynomials are inclined to follow that curve. As this might introduce wild shocks in our metrics, this might be a potential issue for application;
+* Higher order polynomials are less stable in nature, and overfitting is a real issue. As the displacement of magets can present itself as a sinoid-like curve (as the Concept 2 RowErg shows), 3rd or higher polynomials are inclined to follow that curve. As this might introduce wild shocks in our metrics, this might be a potential issue for application;
* A key limitation is the available number of datapoints. For the determination of a polynomial of the n-th order, you need at least n+1 datapoints (which in Open Rowing Monitor translates to a `flankLength`). Some rowers, for example the Sportstech WRX700, only deliver 5 to 6 datapoints for the entire drive phase, thus putting explicit limits on the number of datapoints available for such an approximation.
-* Calculating a higher order polynomial in a robust way, for example by Theil-Senn regression, is CPU intensive. A quadratic approach requires a O(n2) calculation when a datapoint is added to the flank. Our estimate is that with current known robust polynomial regression methods, a cubic approach requires at least a O(n3) calculation, and a 4th polynomial a O(n4) calculation. With smaller flanks (which determines the n) this has proven to be doable, but for machines which produce a lot of datapoints, and thus have more noise and a typically bigger `flankLength` (like the C2 RowErg and Nordictrack RX-800, both with a 11 `flankLength`), this becomes an issue: we consider completing 103 or even 104 complex calculations within the 5 miliseconds that is available before the next datapoint arrives, impossible.
+* Calculating a higher order polynomial in a robust way, for example by Theil-Senn regression, is CPU intensive. A quadratic approach requires a O(n2) calculation when a new datapoint is added to the sliding window (i.e. the flank). Our estimate is that with current known robust polynomial regression methods, a cubic approach requires at least a O(n3) calculation, and a 4th polynomial a O(n4) calculation. With smaller flanks (which determines the n) this has proven to be doable, but for machines which produce a lot of datapoints, and thus have more noise and a typically bigger `flankLength` (like the C2 RowErg and Nordictrack RX-800, both with a 12 `flankLength`), this becomes an issue: we consider completing 103 or even 104 complex calculations within the 5 miliseconds that is available before the next datapoint arrives, impossible.
We also observe specific practical issues, which could result in structurally overfitting the dataset, nihilating its noise reduction effect. As the following sample of three rotations of a Concept2 flywheel shows, due to production tolerances or deliberate design constructs, there are **systematic** errors in the data due to magnet placement or magnet polarity. This results in systematic issues in the datastream:
@@ -482,11 +482,15 @@ We also observe that in several areas the theoretical best approach did not deli
This doesn't definitively exclude the use of more complex polynomial regression methods: alternative methods for higher polynomials within a datastream could be as CPU intensive as Theil-Senn Quadratic regression now, and their use could be isolated to specific combination of Raspberry hardware and settings. Thus, this will remain an active area of investigation for future versions.
-### Use of Quadratic Theil-Senn regression and a median filter for determining α and ω
+### Use of Quadratic Theil-Senn regression and a weighed average filter for determining α and ω
-For a specific flank, our quadratic regression algorithm calculates a single α for the entire flank and the individual ω's for each point on that flank. As a datapoint will be part of several flank calculations, we obtain several α's and ω's that are valid approximations for that specific datapoint. To obtain the most stable result, we opt for the median of all valid values for α and ω to calculate the definitive approximation of α and ω for that specific datapoint. Although this approach has proven very robust, and even necessary to prevent noise from disturbing powercurves, it is very conservative. For example, when compared to Concept 2's results, the powercurves have the same shape, but the peak values are considerable lower.
+For a specific flank, our quadratic regression algorithm calculates a single α for the entire flank and the individual ω's for each point on that flank. The flank acts like a sliding window: on each new datapoint the window slides one datapoint, and thus recalculates the critical parameters. Thus, as a datapoint will be part of several flank calculations, we obtain several α's and ω's that are valid approximations for that specific datapoint. Once the datapoint slides out of the sliding window, there are *flankLength* number of approximations for ω and α. A key question is how to combine these multiple approximations α and ω into a single true value for these parameters.
-Reducing extreme values while maintaining the true data volatility is a subject for further improvement.
+To obtain the most stable result, a median of all valid values for α and ω can be used to calculate the definitive approximation of α and ω for that specific datapoint. Although this approach has proven very robust, and even necessary to prevent noise from disturbing powercurves, it is very conservative. For example, when compared to Concept 2's results, the powercurves have the same shape, but the peak values are considerable lower. It also has the downside of producing "blocky" force cuves.
+
+Using a weighed averager resulted in slightly more stable results and resulted in smoother force curves. The weight is based on the r2: better fitting curves will result in a heiger weigt in the calculation, thus preferring approximations that are a better fit with the data. This approach resulted in smoother (less blocky) force curves while retaining the responsiveness of the force curve.
+
+Reducing extreme values while maintaining the true data responsiveness is a subject for further improvement.
## References