From 7e2477d5680424ebc76ac21de9c9a6d8a886bc68 Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Thu, 25 Dec 2025 20:56:19 +0100 Subject: [PATCH 001/118] Generalisation to a more general weighted series --- app/engine/utils/OLSLinearSeries.js | 129 -------------------- app/engine/utils/WLSLinearSeries.js | 180 ++++++++++++++++++++++++++++ 2 files changed, 180 insertions(+), 129 deletions(-) delete mode 100644 app/engine/utils/OLSLinearSeries.js create mode 100644 app/engine/utils/WLSLinearSeries.js diff --git a/app/engine/utils/OLSLinearSeries.js b/app/engine/utils/OLSLinearSeries.js deleted file mode 100644 index 6d0c26541e..0000000000 --- a/app/engine/utils/OLSLinearSeries.js +++ /dev/null @@ -1,129 +0,0 @@ -'use strict' -/* - Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor - - The LinearSeries is a datatype that represents a Linear Series. It allows - values to be retrieved (like a FiFo buffer, or Queue) but it also includes - a Linear Regressor to determine the slope, intercept and R^2 of this timeseries - of x any y coordinates through Simple Linear Regression. - - At creation it can be determined that the Time Series is limited (i.e. after it - is filled, the oldest will be pushed out of the queue) or that the the time series - is unlimited (will only expand). The latter is activated by calling the creation with - an empty argument. - - please note that for unlimited series it is up to the calling function to handle resetting - the Linear Series when needed through the reset() call. - - A key constraint is to prevent heavy calculations at the end (due to large - array based curve fitting) as this function is also used to calculate - drag at the end of the recovery phase, which might happen on a Pi zero - - This implementation uses concepts that are described here: - https://www.colorado.edu/amath/sites/default/files/attached-files/ch12_0.pdf -*/ - -import { createSeries } from './Series.js' - -import loglevel from 'loglevel' -const log = loglevel.getLogger('RowingEngine') - -export function createOLSLinearSeries (maxSeriesLength = 0) { - const X = createSeries(maxSeriesLength) - const XX = createSeries(maxSeriesLength) - const Y = createSeries(maxSeriesLength) - const YY = createSeries(maxSeriesLength) - const XY = createSeries(maxSeriesLength) - let _slope = 0 - let _intercept = 0 - let _goodnessOfFit = 0 - - function push (x, y) { - if (x === undefined || isNaN(x) || y === undefined || isNaN(y)) { return } - X.push(x) - XX.push(x * x) - Y.push(y) - YY.push(y * y) - XY.push(x * y) - - // Let's approximate the line through OLS - if (X.length() >= 2 && X.sum() > 0) { - _slope = (X.length() * XY.sum() - X.sum() * Y.sum()) / (X.length() * XX.sum() - X.sum() * X.sum()) - _intercept = (Y.sum() - (_slope * X.sum())) / X.length() - const sse = YY.sum() - (_intercept * Y.sum()) - (_slope * XY.sum()) - const sst = YY.sum() - (Math.pow(Y.sum(), 2) / X.length()) - _goodnessOfFit = 1 - (sse / sst) - } else { - _slope = 0 - _intercept = 0 - _goodnessOfFit = 0 - } - } - - function slope () { - return _slope - } - - function intercept () { - return _intercept - } - - function length () { - return X.length() - } - - function goodnessOfFit () { - // This function returns the R^2 as a goodness of fit indicator - if (X.length() >= 2) { - return _goodnessOfFit - } else { - return 0 - } - } - - function projectX (x) { - if (X.length() >= 2) { - return (_slope * x) + _intercept - } else { - return 0 - } - } - - function projectY (y) { - if (X.length() >= 2 && _slope !== 0) { - return ((y - _intercept) / _slope) - } else { - log.error('OLS Regressor, attempted a Y-projection while slope was zero!') - return 0 - } - } - - function reliable () { - return (X.length() >= 2 && _slope !== 0) - } - - function reset () { - X.reset() - XX.reset() - Y.reset() - YY.reset() - XY.reset() - _slope = 0 - _intercept = 0 - _goodnessOfFit = 0 - } - - return { - push, - X, - Y, - slope, - intercept, - length, - goodnessOfFit, - projectX, - projectY, - reliable, - reset - } -} diff --git a/app/engine/utils/WLSLinearSeries.js b/app/engine/utils/WLSLinearSeries.js new file mode 100644 index 0000000000..4069f47269 --- /dev/null +++ b/app/engine/utils/WLSLinearSeries.js @@ -0,0 +1,180 @@ +'use strict' +/* + Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor +*/ +/** + * The WLSLinearSeries is a datatype that represents a Linear Series. It allows + * values to be retrieved (like a FiFo buffer, or Queue) but it also includes + * a Weighted Linear Regressor to determine the slope, intercept and R^2 of this timeseries + * of x and y coordinates through Weighted Least Squares Regression. + * + * At creation it can be determined that the Series is limited (i.e. after it + * is filled, the oldest will be pushed out of the queue) or that the series + * is unlimited (will only expand). The latter is activated by calling the creation with + * an empty argument. + * + * please note that for unlimited series it is up to the calling function to handle resetting + * the Linear Series when needed through the reset() call. + * + * This implementation uses concepts that are described here: + * https://www.colorado.edu/amath/sites/default/files/attached-files/ch12_0.pdf + * + * For weighted least squares: + * https://en.wikipedia.org/wiki/Weighted_least_squares + */ + +import { createSeries } from './Series.js' + +import loglevel from 'loglevel' +const log = loglevel.getLogger('RowingEngine') + +/** + * @param {integer} the maximum length of the linear series, 0 for unlimited + */ +export function createWLSLinearSeries (maxSeriesLength = 0) { + const X = createSeries(maxSeriesLength) + const weight = createSeries(maxSeriesLength) + const WX = createSeries(maxSeriesLength) + const WY = createSeries(maxSeriesLength) + const WXX = createSeries(maxSeriesLength) + const WYY = createSeries(maxSeriesLength) + const WXY = createSeries(maxSeriesLength) + const Y = createSeries(maxSeriesLength) + let _slope = 0 + let _intercept = 0 + let _goodnessOfFit = 0 + + /** + * @param {float} the x value of the datapoint + * @param {float} the y value of the datapoint + * @param {float} the observation weight of the datapoint + */ + function push (x, y, w = 1) { + if (x === undefined || isNaN(x) || y === undefined || isNaN(y)) { return } + + // Ensure weight is valid and positive + const _weight = (w === undefined || isNaN(w) || w <= 0) ? 1 : w + + X.push(x) + Y.push(y) + weight.push(_weight) + WX.push(_weight * x) + WY.push(_weight * y) + WXX.push(_weight * x * x) + WYY.push(_weight * y * y) + WXY.push(_weight * x * y) + + // Calculate regression parameters using Weighted Least Squares + const denominator = (weight.sum() * WXX.sum()) - (WX.sum() * WX.sum()) + if (X.length() >= 2 && denominator !== 0) { + _slope = (weight.sum() * WXY.sum() - WX.sum() * WY.sum()) / denominator + _intercept = (WY.sum() - _slope * WX.sum()) / weight.sum() + + // Calculate weighted R^2 + const yMean = WY.sum() / weight.sum() + const ss_res = WYY.sum() - (2 * _intercept * WY.sum()) - (2 * _slope * WXY.sum()) + + (_intercept * _intercept * weight.sum()) + (2 * _slope * _intercept * WX.sum()) + + (_slope * _slope * WXX.sum()) + const ss_tot = WYY.sum() - (yMean * yMean * weight.sum()) + + _goodnessOfFit = (ss_tot !== 0) ? 1 - (ss_res / ss_tot) : 0 + } else { + _slope = 0 + _intercept = 0 + _goodnessOfFit = 0 + } + } + + /** + * @returns {float} the slope of the linear function + */ + function slope () { + return _slope + } + + /** + * @returns {float} the intercept of the linear function + */ + function intercept () { + return _intercept + } + + /** + * @returns {integer} the lenght of the stored series + */ + function length () { + return X.length() + } + + /** + * @returns {float} the R^2 as a goodness of fit indicator + */ + function goodnessOfFit () { + if (X.length() >= 2) { + return _goodnessOfFit + } else { + return 0 + } + } + + /** + * @param {float} the x value to be projected + * @returns {float} the resulting y value when projected via the linear function + */ + function projectX (x) { + if (X.length() >= 2) { + return (_slope * x) + _intercept + } else { + return 0 + } + } + + /** + * @param {float} the y value to be (reverse) projected + * @returns {float} the resulting x value when projected via the linear function + */ + function projectY (y) { + if (X.length() >= 2 && _slope !== 0) { + return ((y - _intercept) / _slope) + } else { + log. error('WLS Regressor, attempted a Y-projection while slope was zero!') + return 0 + } + } + + /** + * @returns {boolean} whether the linear regression should be considered reliable to produce results + */ + function reliable () { + return (X.length() >= 2 && _slope !== 0) + } + + function reset () { + X.reset() + Y.reset() + W.reset() + WX.reset() + WY.reset() + WXX.reset() + WYY.reset() + WXY.reset() + _slope = 0 + _intercept = 0 + _goodnessOfFit = 0 + } + + return { + push, + X, + Y, + weight, + slope, + intercept, + length, + goodnessOfFit, + projectX, + projectY, + reliable, + reset + } +} From 2d1d86ff9f6ead3e40dc51e456471eff9b47eb8f Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Thu, 25 Dec 2025 21:00:43 +0100 Subject: [PATCH 002/118] Adaptation to Weighted generalisation --- ...Series.test.js => WLSLinearSeries.test.js} | 66 +++++++++---------- 1 file changed, 33 insertions(+), 33 deletions(-) rename app/engine/utils/{OLSLinearSeries.test.js => WLSLinearSeries.test.js} (90%) diff --git a/app/engine/utils/OLSLinearSeries.test.js b/app/engine/utils/WLSLinearSeries.test.js similarity index 90% rename from app/engine/utils/OLSLinearSeries.test.js rename to app/engine/utils/WLSLinearSeries.test.js index 9bf25cc3c0..5575f5ec7b 100644 --- a/app/engine/utils/OLSLinearSeries.test.js +++ b/app/engine/utils/WLSLinearSeries.test.js @@ -5,10 +5,10 @@ import { test } from 'uvu' import * as assert from 'uvu/assert' -import { createOLSLinearSeries } from './OLSLinearSeries.js' +import { createWLSLinearSeries } from './OLSLinearSeries.js' test('Correct behaviour of a series after initialisation', () => { - const dataSeries = createOLSLinearSeries(3) + const dataSeries = createWLSLinearSeries(3) testLength(dataSeries, 0) testXAtSeriesBegin(dataSeries, 0) testYAtSeriesBegin(dataSeries, 0) @@ -30,9 +30,9 @@ test('Correct behaviour of a series after initialisation', () => { }) test('Correct behaviour of a series after several puhed values, function y = 3x + 6, noisefree, 1 datapoint', () => { - const dataSeries = createOLSLinearSeries(3) + const dataSeries = createWLSLinearSeries(3) testLength(dataSeries, 0) - dataSeries.push(5, 9) + dataSeries.push(5, 9, 1) testLength(dataSeries, 1) testXAtSeriesBegin(dataSeries, 5) testYAtSeriesBegin(dataSeries, 9) @@ -54,9 +54,9 @@ test('Correct behaviour of a series after several puhed values, function y = 3x }) test('Correct behaviour of a series after several puhed values, function y = 3x + 6, noisefree, 2 datapoints', () => { - const dataSeries = createOLSLinearSeries(3) - dataSeries.push(5, 9) - dataSeries.push(3, 3) + const dataSeries = createWLSLinearSeries(3) + dataSeries.push(5, 9, 1) + dataSeries.push(3, 3, 1) testLength(dataSeries, 2) testXAtSeriesBegin(dataSeries, 5) testYAtSeriesBegin(dataSeries, 9) @@ -78,10 +78,10 @@ test('Correct behaviour of a series after several puhed values, function y = 3x }) test('Correct behaviour of a series after several puhed values, function y = 3x + 6, noisefree, 3 datapoints', () => { - const dataSeries = createOLSLinearSeries(3) - dataSeries.push(5, 9) - dataSeries.push(3, 3) - dataSeries.push(4, 6) + const dataSeries = createWLSLinearSeries(3) + dataSeries.push(5, 9, 1) + dataSeries.push(3, 3, 1) + dataSeries.push(4, 6, 1) testLength(dataSeries, 3) testXAtSeriesBegin(dataSeries, 5) testYAtSeriesBegin(dataSeries, 9) @@ -103,11 +103,11 @@ test('Correct behaviour of a series after several puhed values, function y = 3x }) test('Correct behaviour of a series after several puhed values, function y = 3x + 6, noisefree, 4 datapoints', () => { - const dataSeries = createOLSLinearSeries(3) - dataSeries.push(5, 9) - dataSeries.push(3, 3) - dataSeries.push(4, 6) - dataSeries.push(6, 12) + const dataSeries = createWLSLinearSeries(3) + dataSeries.push(5, 9, 1) + dataSeries.push(3, 3, 1) + dataSeries.push(4, 6, 1) + dataSeries.push(6, 12, 1) testLength(dataSeries, 3) testXAtSeriesBegin(dataSeries, 3) testYAtSeriesBegin(dataSeries, 3) @@ -129,12 +129,12 @@ test('Correct behaviour of a series after several puhed values, function y = 3x }) test('Correct behaviour of a series after several puhed values, function y = 3x + 6, noisefree, 5 datapoints', () => { - const dataSeries = createOLSLinearSeries(3) - dataSeries.push(5, 9) - dataSeries.push(3, 3) - dataSeries.push(4, 6) - dataSeries.push(6, 12) - dataSeries.push(1, -3) + const dataSeries = createWLSLinearSeries(3) + dataSeries.push(5, 9, 1) + dataSeries.push(3, 3, 1) + dataSeries.push(4, 6, 1) + dataSeries.push(6, 12, 1) + dataSeries.push(1, -3, 1) testLength(dataSeries, 3) testXAtSeriesBegin(dataSeries, 4) testYAtSeriesBegin(dataSeries, 6) @@ -156,11 +156,11 @@ test('Correct behaviour of a series after several puhed values, function y = 3x }) test('Correct behaviour of a series after several puhed values, function y = 3x + 6, noisefree, 4 datapoints and a reset', () => { - const dataSeries = createOLSLinearSeries(3) - dataSeries.push(5, 9) - dataSeries.push(3, 3) - dataSeries.push(4, 6) - dataSeries.push(6, 12) + const dataSeries = createWLSLinearSeries(3) + dataSeries.push(5, 9, 1) + dataSeries.push(3, 3, 1) + dataSeries.push(4, 6, 1) + dataSeries.push(6, 12, 1) dataSeries.reset() testLength(dataSeries, 0) testXAtSeriesBegin(dataSeries, 0) @@ -183,12 +183,12 @@ test('Correct behaviour of a series after several puhed values, function y = 3x }) test('Series with 5 elements, with 2 noisy datapoints', () => { - const dataSeries = createOLSLinearSeries(5) - dataSeries.push(5, 9) - dataSeries.push(3, 2) - dataSeries.push(4, 7) - dataSeries.push(6, 12) - dataSeries.push(1, -3) + const dataSeries = createWLSLinearSeries(5) + dataSeries.push(5, 9, 1) + dataSeries.push(3, 2, 1) + dataSeries.push(4, 7, 1) + dataSeries.push(6, 12, 1) + dataSeries.push(1, -3, 1) testSlopeBetween(dataSeries, 2.9, 3.1) testInterceptBetween(dataSeries, -6.3, -5.8) testGoodnessOfFitBetween(dataSeries, 0.9, 1.0) From d1a33bf2402a7ebf033b79e138cbc1bf676fbfff Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Thu, 25 Dec 2025 21:04:48 +0100 Subject: [PATCH 003/118] Replace OLSLinearSeries with WLSLinearSeries --- app/engine/utils/workoutSegment.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/engine/utils/workoutSegment.js b/app/engine/utils/workoutSegment.js index 4861bf5f83..cbd9236f07 100644 --- a/app/engine/utils/workoutSegment.js +++ b/app/engine/utils/workoutSegment.js @@ -7,15 +7,15 @@ * @see {@link https://github.com/JaapvanEkris/openrowingmonitor/blob/main/docs/Architecture.md#session-interval-and-split-boundaries-in-sessionmanagerjs|the description of the concepts used} */ /* eslint-disable max-lines -- This contains a lot of defensive programming, so it is long */ -import { createOLSLinearSeries } from './OLSLinearSeries.js' +import { createWLSLinearSeries } from './WLSLinearSeries.js' import { createSeries } from './Series.js' import loglevel from 'loglevel' const log = loglevel.getLogger('RowingEngine') export function createWorkoutSegment (config) { const numOfDataPointsForAveraging = config.numOfPhasesForAveragingScreenData - const distanceOverTime = createOLSLinearSeries(Math.min(4, numOfDataPointsForAveraging)) - const caloriesOverTime = createOLSLinearSeries(Math.min(4, numOfDataPointsForAveraging)) + const distanceOverTime = createWLSLinearSeries(Math.min(4, numOfDataPointsForAveraging)) + const caloriesOverTime = createWLSLinearSeries(Math.min(4, numOfDataPointsForAveraging)) const _power = createSeries() const _linearVelocity = createSeries() const _strokerate = createSeries() @@ -342,8 +342,8 @@ export function createWorkoutSegment (config) { * Updates projectiondata and segment metrics */ function push (baseMetrics) { - distanceOverTime.push(baseMetrics.totalMovingTime, baseMetrics.totalLinearDistance) - caloriesOverTime.push(baseMetrics.totalMovingTime, baseMetrics.totalCalories) + distanceOverTime.push(baseMetrics.totalMovingTime, baseMetrics.totalLinearDistance, 1) + caloriesOverTime.push(baseMetrics.totalMovingTime, baseMetrics.totalCalories, 1) if (!!baseMetrics.cyclePower && !isNaN(baseMetrics.cyclePower) && baseMetrics.cyclePower > 0) { _power.push(baseMetrics.cyclePower) } if (!!baseMetrics.cycleLinearVelocity && !isNaN(baseMetrics.cycleLinearVelocity) && baseMetrics.cycleLinearVelocity > 0) { _linearVelocity.push(baseMetrics.cycleLinearVelocity) } if (!!baseMetrics.cycleStrokeRate && !isNaN(baseMetrics.cycleStrokeRate) && baseMetrics.cycleStrokeRate > 0) { _strokerate.push(baseMetrics.cycleStrokeRate) } From 0f8c5e228343f2f3ec77082c2a1bdd3591ef589f Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Thu, 25 Dec 2025 21:07:13 +0100 Subject: [PATCH 004/118] Replace OLSLinearSeries with WLSLinearSeries for calories --- app/engine/RowingStatistics.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/engine/RowingStatistics.js b/app/engine/RowingStatistics.js index 6e0f41dca8..98dfd0deee 100644 --- a/app/engine/RowingStatistics.js +++ b/app/engine/RowingStatistics.js @@ -7,7 +7,7 @@ * @see {@link https://github.com/JaapvanEkris/openrowingmonitor/blob/main/docs/Architecture.md#rowingstatisticsjs|the architecture description} */ import { createRower } from './Rower.js' -import { createOLSLinearSeries } from './utils/OLSLinearSeries.js' +import { createWLSLinearSeries } from './utils/OLSLinearSeries.js' import { createStreamFilter } from './utils/StreamFilter.js' import { createCurveAligner } from './utils/CurveAligner.js' @@ -32,7 +32,7 @@ export function createRowingStatistics (config) { let strokeCalories = 0 let totalCalories = 0 let strokeWork = 0 - const calories = createOLSLinearSeries() + const calories = createWLSLinearSeries() const driveDuration = createStreamFilter(halfNumOfDataPointsForAveraging, undefined) const driveLength = createStreamFilter(halfNumOfDataPointsForAveraging, undefined) const driveDistance = createStreamFilter(halfNumOfDataPointsForAveraging, undefined) @@ -211,7 +211,7 @@ export function createRowingStatistics (config) { strokeWork = rower.driveFlywheelWork() strokeCalories = ((4 * rower.driveFlywheelWork()) + (350 * cycleDuration.clean())) / 4200 if (cyclePower.reliable() && cycleDuration.reliable()) { - calories.push(totalMovingTime, totalCalories) + calories.push(totalMovingTime, totalCalories, 1) } } } @@ -230,7 +230,7 @@ export function createRowingStatistics (config) { } if (cyclePower.reliable() && cycleDuration.reliable()) { - calories.push(totalMovingTime, totalCalories) + calories.push(totalMovingTime, totalCalories, 1) } } From 003ad853f2f256b8029d28598dde4845c872a6a8 Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Thu, 25 Dec 2025 21:11:00 +0100 Subject: [PATCH 005/118] Fix Lint errors --- app/engine/utils/WLSLinearSeries.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/engine/utils/WLSLinearSeries.js b/app/engine/utils/WLSLinearSeries.js index 4069f47269..955a6fe598 100644 --- a/app/engine/utils/WLSLinearSeries.js +++ b/app/engine/utils/WLSLinearSeries.js @@ -73,8 +73,8 @@ export function createWLSLinearSeries (maxSeriesLength = 0) { // Calculate weighted R^2 const yMean = WY.sum() / weight.sum() const ss_res = WYY.sum() - (2 * _intercept * WY.sum()) - (2 * _slope * WXY.sum()) + - (_intercept * _intercept * weight.sum()) + (2 * _slope * _intercept * WX.sum()) + - (_slope * _slope * WXX.sum()) + (_intercept * _intercept * weight.sum()) + (2 * _slope * _intercept * WX.sum()) + + (_slope * _slope * WXX.sum()) const ss_tot = WYY.sum() - (yMean * yMean * weight.sum()) _goodnessOfFit = (ss_tot !== 0) ? 1 - (ss_res / ss_tot) : 0 @@ -137,7 +137,7 @@ export function createWLSLinearSeries (maxSeriesLength = 0) { if (X.length() >= 2 && _slope !== 0) { return ((y - _intercept) / _slope) } else { - log. error('WLS Regressor, attempted a Y-projection while slope was zero!') + log.error('WLS Regressor, attempted a Y-projection while slope was zero!') return 0 } } @@ -152,7 +152,7 @@ export function createWLSLinearSeries (maxSeriesLength = 0) { function reset () { X.reset() Y.reset() - W.reset() + weight.reset() WX.reset() WY.reset() WXX.reset() From 895427774204e56ed3e1936b40687524c8baef5c Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Thu, 25 Dec 2025 21:12:59 +0100 Subject: [PATCH 006/118] Fixed import bug --- app/engine/RowingStatistics.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/engine/RowingStatistics.js b/app/engine/RowingStatistics.js index 98dfd0deee..a443d0b0c6 100644 --- a/app/engine/RowingStatistics.js +++ b/app/engine/RowingStatistics.js @@ -7,7 +7,7 @@ * @see {@link https://github.com/JaapvanEkris/openrowingmonitor/blob/main/docs/Architecture.md#rowingstatisticsjs|the architecture description} */ import { createRower } from './Rower.js' -import { createWLSLinearSeries } from './utils/OLSLinearSeries.js' +import { createWLSLinearSeries } from './utils/WLSLinearSeries.js' import { createStreamFilter } from './utils/StreamFilter.js' import { createCurveAligner } from './utils/CurveAligner.js' From 587c1afc0f00175228f66b9b80e586574c8ade4f Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Thu, 25 Dec 2025 21:15:15 +0100 Subject: [PATCH 007/118] Fix import path for createWLSLinearSeries function --- app/engine/utils/WLSLinearSeries.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/engine/utils/WLSLinearSeries.test.js b/app/engine/utils/WLSLinearSeries.test.js index 5575f5ec7b..33209d0a66 100644 --- a/app/engine/utils/WLSLinearSeries.test.js +++ b/app/engine/utils/WLSLinearSeries.test.js @@ -5,7 +5,7 @@ import { test } from 'uvu' import * as assert from 'uvu/assert' -import { createWLSLinearSeries } from './OLSLinearSeries.js' +import { createWLSLinearSeries } from './WLSLinearSeries.js' test('Correct behaviour of a series after initialisation', () => { const dataSeries = createWLSLinearSeries(3) From 29b6ff9912cd151e4549d370e3509f0ce8281037 Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Thu, 25 Dec 2025 21:38:41 +0100 Subject: [PATCH 008/118] Based the CyclicErrorFilter based on WLS --- app/engine/utils/CyclicErrorFilter.js | 197 +++++++++++++------------- 1 file changed, 99 insertions(+), 98 deletions(-) diff --git a/app/engine/utils/CyclicErrorFilter.js b/app/engine/utils/CyclicErrorFilter.js index b61c5ffa5d..1841e28f5e 100644 --- a/app/engine/utils/CyclicErrorFilter.js +++ b/app/engine/utils/CyclicErrorFilter.js @@ -4,155 +4,156 @@ */ /** * This implements a cyclic error filter. This is used to create a profile - * The filterArray does the calculation, the filterConfig contains the results for easy retrieval - * the weightCorrection ensures that the sum of corrections will converge to an average of 1 (thus preventing time dilation) + * The filterArray does the calculation, the slope and intercept arrays contain the results for easy retrieval + * the slopeCorrection and interceptCorrection ensure preventing time dilation due to excessive corrections */ import loglevel from 'loglevel' import { createSeries } from './Series.js' -import { createWeighedMedianSeries } from './WeighedMedianSeries.js' +import { createWLSLinearSeries } from './WLSLinearSeries.js' const log = loglevel.getLogger('RowingEngine') +/** + * @param {{numOfImpulsesPerRevolution: integer, flankLength: integer, systematicErrorAgressiveness: float, minimumTimeBetweenImpulses: float, maximumTimeBetweenImpulses: float}}the rower settings configuration object + * @param {integer} the number of expected dragfactor samples + * @param the linear regression function for the drag calculation + */ export function createCyclicErrorFilter (rowerSettings, minimumDragFactorSamples, deltaTime) { - const upperBound = 1 + rowerSettings.systematicErrorMaximumChange - const lowerBound = 1 - rowerSettings.systematicErrorMaximumChange const _numberOfMagnets = rowerSettings.numOfImpulsesPerRevolution const _flankLength = rowerSettings.flankLength - const _agressiveness = rowerSettings.systematicErrorAgressiveness + const _agressiveness = Math.min(Math.max(rowerSettings.systematicErrorAgressiveness, 0), 1) + const _invAgressiveness = Math.min(Math.max(1 - _agressiveness, 0), 1) const _numberOfFilterSamples = Math.max((minimumDragFactorSamples / _numberOfMagnets) * 2.5, 5) const _minimumTimeBetweenImpulses = rowerSettings.minimumTimeBetweenImpulses const _maximumTimeBetweenImpulses = rowerSettings.maximumTimeBetweenImpulses const raw = createSeries(_flankLength) const clean = createSeries(_flankLength) const linearRegressor = deltaTime - let filterArray = [] - let filterConfig = [] let recordedRelativePosition = [] let recordedAbsolutePosition = [] let recordedRawValue = [] + let filterArray = [] + let slope = [] + let intercept = [] let startPosition - let cursor - let totalNumberOfDatapointsProcessed - let filterSum = _numberOfMagnets - let weightCorrection = 1 - let offset = 0 + let lowerCursor + let upperCursor + let slopeSum = _numberOfMagnets + let interceptSum = 0 + let slopeCorrection = 1 + let interceptCorrection = 0 reset() + /** + * @param {integer} the maximum length of the linear series, 0 for unlimited + */ function applyFilter (rawValue, position) { if (startPosition === undefined) { startPosition = position + _flankLength } raw.push(rawValue) - clean.push(rawValue * filterConfig[position % _numberOfMagnets] * weightCorrection) + clean.push(projectX(position % _numberOfMagnets, rawValue)) + } + + /** + * @param {integer} the magnet number + * @param {float} the raw value to be projected by the function for that magnet + * @returns {float} projected result + */ + function projectX (magnet, rawValue) { + return (rawValue * slope[magnet] * slopeCorrection) + (intercept[magnet] - interceptCorrection) } + /** + * @param {integer} the position of the recorded datapoint (i.e the sequence number of the datapoint) + * @param {float} the total spinning time of the flywheel + * @param {float} the raw value + */ function recordRawDatapoint (relativePosition, absolutePosition, rawValue) { - if (rawValue >= _minimumTimeBetweenImpulses && _maximumTimeBetweenImpulses >= rawValue) { + if (_agressiveness > 0 && rawValue >= _minimumTimeBetweenImpulses && _maximumTimeBetweenImpulses >= rawValue) { recordedRelativePosition.push(relativePosition) recordedAbsolutePosition.push(absolutePosition) recordedRawValue.push(rawValue) } } + /** + * This processes a next datapoint from the queue + */ function processNextRawDatapoint () { - if (cursor === undefined) { cursor = Math.ceil(recordedRelativePosition.length * 0.25) } - if (cursor < Math.floor(recordedRelativePosition.length * 0.75)) { - const perfectCurrentDt = linearRegressor.projectX(recordedAbsolutePosition[cursor]) - const correctionFactor = (perfectCurrentDt / recordedRawValue[cursor]) - const weightCorrectedCorrectionFactor = ((correctionFactor - 1) * _agressiveness) + 1 - const nextPerfectCurrentDt = linearRegressor.projectX(recordedAbsolutePosition[cursor + 1]) - const nextCorrectionFactor = (nextPerfectCurrentDt / recordedRawValue[cursor + 1]) - const nextWeightCorrectedCorrectionFactor = ((nextCorrectionFactor - 1) * _agressiveness) + 1 - const GoF = linearRegressor.goodnessOfFit() * linearRegressor.localGoodnessOfFit(cursor) - updateFilter(recordedRelativePosition[cursor], weightCorrectedCorrectionFactor, nextWeightCorrectedCorrectionFactor, GoF) - cursor++ + let perfectCurrentDt + let weightCorrectedCorrectedDatapoint + let GoF + if (lowerCursor === undefined || upperCursor === undefined) { + lowerCursor = Math.ceil(recordedRelativePosition.length * 0.1) + upperCursor = Math.floor(recordedRelativePosition.length * 0.9) + } + + if (lowerCursor < upperCursor && recordedRelativePosition[lowerCursor] > startPosition) { + perfectCurrentDt = linearRegressor.projectX(recordedAbsolutePosition[lowerCursor]) + weightCorrectedCorrectedDatapoint = (_invAgressiveness * recordedRawValue[lowerCursor]) + (_agressiveness * perfectCurrentDt) + GoF = linearRegressor.goodnessOfFit() * linearRegressor.localGoodnessOfFit(lowerCursor) + updateFilter(recordedRelativePosition[lowerCursor] % _numberOfMagnets, recordedRawValue[lowerCursor], weightCorrectedCorrectedDatapoint, GoF) + } + lowerCursor++ + + if (lowerCursor < upperCursor && recordedRelativePosition[upperCursor] > startPosition) { + perfectCurrentDt = linearRegressor.projectX(recordedAbsolutePosition[upperCursor]) + weightCorrectedCorrectedDatapoint = (_invAgressiveness * recordedRawValue[upperCursor]) + (_agressiveness * perfectCurrentDt) + GoF = linearRegressor.goodnessOfFit() * linearRegressor.localGoodnessOfFit(upperCursor) + updateFilter(recordedRelativePosition[upperCursor] % _numberOfMagnets, recordedRawValue[upperCursor], weightCorrectedCorrectedDatapoint, GoF) } + upperCursor-- } - function updateFilter (position, weightCorrectedCorrectionFactor, nextWeightCorrectedCorrectionFactor, goodnessOfFit) { - if (position > startPosition) { - let workPosition = (position + offset) % _numberOfMagnets - const leftDistance = Math.abs(filterConfig[(workPosition - 1) % _numberOfMagnets] - weightCorrectedCorrectionFactor) - const middleDistance = Math.abs(filterConfig[workPosition] - weightCorrectedCorrectionFactor) - const rightDistance = Math.abs(filterConfig[(workPosition + 1) % _numberOfMagnets] - weightCorrectedCorrectionFactor) - const nextLeftDistance = Math.abs(filterConfig[(workPosition) % _numberOfMagnets] - nextWeightCorrectedCorrectionFactor) - const nextMiddleDistance = Math.abs(filterConfig[workPosition + 1] - nextWeightCorrectedCorrectionFactor) - const nextRightDistance = Math.abs(filterConfig[(workPosition + 2) % _numberOfMagnets] - nextWeightCorrectedCorrectionFactor) - const aboveUpperBound = (weightCorrectedCorrectionFactor > (filterConfig[workPosition] * upperBound)) - const belowLowerBound = (weightCorrectedCorrectionFactor < (filterConfig[workPosition] * lowerBound)) - const outsideBounds = (aboveUpperBound || belowLowerBound) - switch (true) { - // Prevent a single measurement to add radical change (measurement error), offsetting the entire filter - case (totalNumberOfDatapointsProcessed < (_numberOfFilterSamples * _numberOfMagnets)): - // We are still at filter startup - filterArray[position % _numberOfMagnets].push(position, weightCorrectedCorrectionFactor, goodnessOfFit) - break - case (!outsideBounds): - filterArray[workPosition].push(position, weightCorrectedCorrectionFactor, goodnessOfFit) - break - case (outsideBounds && leftDistance < middleDistance && leftDistance < rightDistance && nextLeftDistance < nextMiddleDistance && nextLeftDistance < nextRightDistance): - // We're outside the usual boundaries, and it seems that the previous point is a better match than the current one, potentially due to a missing datapoint - log.debug(`*** WARNING: cyclic error filter detected a positive shift at magnet ${workPosition}, most likely due to an unhandled switch bounce`) - offset-- - workPosition = (position + offset) % _numberOfMagnets - filterArray[workPosition].push(position, weightCorrectedCorrectionFactor, goodnessOfFit) - break - case (outsideBounds && rightDistance < middleDistance && rightDistance < leftDistance && nextRightDistance < nextMiddleDistance && nextRightDistance < nextLeftDistance): - // We're outside the usual boundaries, and it seems that the next datapoint is a better match than the current one, potentially due to a switch bounce - log.debug(`*** WARNING: cyclic error filter detected a negative shift at magnet ${workPosition}, most likely due to a missing datapoint`) - offset++ - workPosition = (position + offset) % _numberOfMagnets - filterArray[workPosition].push(position, weightCorrectedCorrectionFactor, goodnessOfFit) - break - case (belowLowerBound): - log.debug(`*** WARNING: cyclic error filter detected a too rapid decrease from ${filterConfig[workPosition]} to ${weightCorrectedCorrectionFactor} at magnet ${workPosition}, clipping`) - filterArray[workPosition].push(position, filterConfig[workPosition] * lowerBound, goodnessOfFit) - break - case (aboveUpperBound): - log.debug(`*** WARNING: cyclic error filter detected a too rapid increase from ${filterConfig[workPosition]} to ${weightCorrectedCorrectionFactor} at magnet ${workPosition}, clipping`) - filterArray[workPosition].push(position, filterConfig[workPosition] * upperBound, goodnessOfFit) - break - default: - filterArray[workPosition].push(position, weightCorrectedCorrectionFactor, goodnessOfFit) - } - filterSum -= filterConfig[position % _numberOfMagnets] - filterConfig[position % _numberOfMagnets] = filterArray[position % _numberOfMagnets].weighedMedian() - filterSum += filterConfig[position % _numberOfMagnets] - if (filterSum !== 0) { weightCorrection = _numberOfMagnets / filterSum } - totalNumberOfDatapointsProcessed++ - } + function updateFilter (magnet, rawDatapoint, correctedDatapoint, goodnessOfFit) { + slopeSum -= slope[magnet] + interceptSum -= intercept[magnet] + filterArray[magnet].push(rawDatapoint, correctedDatapoint, goodnessOfFit) + slope[magnet] = filterArray[magnet].slope() + slopeSum += slope[magnet] + if (slopeSum !== 0) { slopeCorrection = _numberOfMagnets / slopeSum } + intercept[magnet] = filterArray[magnet].intercept() + interceptSum += intercept[magnet] + interceptCorrection = interceptSum / _numberOfMagnets } function restart () { - if (!isNaN(cursor)) { log.debug('*** WARNING: cyclic error filter has forcefully been restarted') } + if (!isNaN(lowerCursor)) { log.debug('*** WARNING: cyclic error filter has forcefully been restarted') } recordedRelativePosition = [] recordedAbsolutePosition = [] recordedRawValue = [] - cursor = undefined + lowerCursor = undefined + upperCursor = undefined } function reset () { - if (totalNumberOfDatapointsProcessed > 0) { log.debug('*** WARNING: cyclic error filter is reset') } - restart() - startPosition = undefined + if (slopeSum !== _numberOfMagnets || interceptSum !== 0) { log.debug('*** WARNING: cyclic error filter is reset') } + const noIncrements = Math.max(Math.ceil(_numberOfFilterSamples / 4), 5) + const increment = (_maximumTimeBetweenImpulses - _minimumTimeBetweenImpulses) / noIncrements + + lowerCursor = undefined + restart() + let i = 0 + let j = 0 + let datapoint = 0 while (i < _numberOfMagnets) { filterArray[i] = {} - filterArray[i] = createWeighedMedianSeries(_numberOfFilterSamples) - filterArray[i].push(-9, 1, 0.5) - filterArray[i].push(-8, 0.90, 0.5) - filterArray[i].push(-7, 1.10, 0.5) - filterArray[i].push(-6, 0.95, 0.5) - filterArray[i].push(-5, 1.05, 0.5) - filterArray[i].push(-4, 0.925, 0.5) - filterArray[i].push(-3, 1.075, 0.5) - filterArray[i].push(-2, 0.975, 0.5) - filterArray[i].push(-1, 1.025, 0.5) - filterConfig[i] = 1 + filterArray[i] = createWLSLinearSeries(_numberOfFilterSamples) + j = 0 + while (j <= noIncrements) { + datapoint = _maximumTimeBetweenImpulses - (j * increment) + filterArray[i].push(datapoint, datapoint, 0.5) + j++ + } + slope[i] = 1 + intercept[i] = 0 i++ } - filterSum = _numberOfMagnets - weightCorrection = 1 - totalNumberOfDatapointsProcessed = 0 - offset = 0 + slopeSum = _numberOfMagnets + interceptSum = 0 + slopeCorrection = 1 + interceptCorrection = 0 + startPosition = undefined } return { From eed1f6b8c73c03ffa7b4166c84959dcc18f96cc3 Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Thu, 25 Dec 2025 21:46:15 +0100 Subject: [PATCH 009/118] Fix Lint errors --- app/engine/utils/WLSLinearSeries.js | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/app/engine/utils/WLSLinearSeries.js b/app/engine/utils/WLSLinearSeries.js index 955a6fe598..5f43063e39 100644 --- a/app/engine/utils/WLSLinearSeries.js +++ b/app/engine/utils/WLSLinearSeries.js @@ -11,7 +11,7 @@ * At creation it can be determined that the Series is limited (i.e. after it * is filled, the oldest will be pushed out of the queue) or that the series * is unlimited (will only expand). The latter is activated by calling the creation with - * an empty argument. + * an empty argument. * * please note that for unlimited series it is up to the calling function to handle resetting * the Linear Series when needed through the reset() call. @@ -19,7 +19,7 @@ * This implementation uses concepts that are described here: * https://www.colorado.edu/amath/sites/default/files/attached-files/ch12_0.pdf * - * For weighted least squares: + * For weighted least squares: * https://en.wikipedia.org/wiki/Weighted_least_squares */ @@ -51,10 +51,10 @@ export function createWLSLinearSeries (maxSeriesLength = 0) { */ function push (x, y, w = 1) { if (x === undefined || isNaN(x) || y === undefined || isNaN(y)) { return } - + // Ensure weight is valid and positive const _weight = (w === undefined || isNaN(w) || w <= 0) ? 1 : w - + X.push(x) Y.push(y) weight.push(_weight) @@ -65,19 +65,19 @@ export function createWLSLinearSeries (maxSeriesLength = 0) { WXY.push(_weight * x * y) // Calculate regression parameters using Weighted Least Squares - const denominator = (weight.sum() * WXX.sum()) - (WX.sum() * WX.sum()) + const denominator = (weight.sum() * WXX.sum()) - (WX.sum() * WX.sum()) if (X.length() >= 2 && denominator !== 0) { _slope = (weight.sum() * WXY.sum() - WX.sum() * WY.sum()) / denominator _intercept = (WY.sum() - _slope * WX.sum()) / weight.sum() // Calculate weighted R^2 const yMean = WY.sum() / weight.sum() - const ss_res = WYY.sum() - (2 * _intercept * WY.sum()) - (2 * _slope * WXY.sum()) + - (_intercept * _intercept * weight.sum()) + (2 * _slope * _intercept * WX.sum()) + + const ssRes = WYY.sum() - (2 * _intercept * WY.sum()) - (2 * _slope * WXY.sum()) + + (_intercept * _intercept * weight.sum()) + (2 * _slope * _intercept * WX.sum()) + (_slope * _slope * WXX.sum()) - const ss_tot = WYY.sum() - (yMean * yMean * weight.sum()) - - _goodnessOfFit = (ss_tot !== 0) ? 1 - (ss_res / ss_tot) : 0 + const ssTot = WYY.sum() - (yMean * yMean * weight.sum()) + + _goodnessOfFit = (ssTot !== 0) ? 1 - (ssRes / ssTot) : 0 } else { _slope = 0 _intercept = 0 From 1571ea4bcc559076c3d928f86e3130fbeafa6c7b Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Thu, 25 Dec 2025 21:52:39 +0100 Subject: [PATCH 010/118] Fixes ESlint errors --- app/engine/utils/CyclicErrorFilter.js | 44 +++++++++++++-------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/app/engine/utils/CyclicErrorFilter.js b/app/engine/utils/CyclicErrorFilter.js index 1841e28f5e..716adc4e26 100644 --- a/app/engine/utils/CyclicErrorFilter.js +++ b/app/engine/utils/CyclicErrorFilter.js @@ -56,7 +56,7 @@ export function createCyclicErrorFilter (rowerSettings, minimumDragFactorSamples /** * @param {integer} the magnet number * @param {float} the raw value to be projected by the function for that magnet - * @returns {float} projected result + * @returns {float} projected result */ function projectX (magnet, rawValue) { return (rawValue * slope[magnet] * slopeCorrection) + (intercept[magnet] - interceptCorrection) @@ -81,27 +81,27 @@ export function createCyclicErrorFilter (rowerSettings, minimumDragFactorSamples function processNextRawDatapoint () { let perfectCurrentDt let weightCorrectedCorrectedDatapoint - let GoF + let GoF if (lowerCursor === undefined || upperCursor === undefined) { - lowerCursor = Math.ceil(recordedRelativePosition.length * 0.1) - upperCursor = Math.floor(recordedRelativePosition.length * 0.9) - } + lowerCursor = Math.ceil(recordedRelativePosition.length * 0.1) + upperCursor = Math.floor(recordedRelativePosition.length * 0.9) + } if (lowerCursor < upperCursor && recordedRelativePosition[lowerCursor] > startPosition) { perfectCurrentDt = linearRegressor.projectX(recordedAbsolutePosition[lowerCursor]) - weightCorrectedCorrectedDatapoint = (_invAgressiveness * recordedRawValue[lowerCursor]) + (_agressiveness * perfectCurrentDt) + weightCorrectedCorrectedDatapoint = (_invAgressiveness * recordedRawValue[lowerCursor]) + (_agressiveness * perfectCurrentDt) GoF = linearRegressor.goodnessOfFit() * linearRegressor.localGoodnessOfFit(lowerCursor) updateFilter(recordedRelativePosition[lowerCursor] % _numberOfMagnets, recordedRawValue[lowerCursor], weightCorrectedCorrectedDatapoint, GoF) - } + } lowerCursor++ if (lowerCursor < upperCursor && recordedRelativePosition[upperCursor] > startPosition) { perfectCurrentDt = linearRegressor.projectX(recordedAbsolutePosition[upperCursor]) - weightCorrectedCorrectedDatapoint = (_invAgressiveness * recordedRawValue[upperCursor]) + (_agressiveness * perfectCurrentDt) + weightCorrectedCorrectedDatapoint = (_invAgressiveness * recordedRawValue[upperCursor]) + (_agressiveness * perfectCurrentDt) GoF = linearRegressor.goodnessOfFit() * linearRegressor.localGoodnessOfFit(upperCursor) updateFilter(recordedRelativePosition[upperCursor] % _numberOfMagnets, recordedRawValue[upperCursor], weightCorrectedCorrectedDatapoint, GoF) } - upperCursor-- + upperCursor-- } function updateFilter (magnet, rawDatapoint, correctedDatapoint, goodnessOfFit) { @@ -122,30 +122,30 @@ export function createCyclicErrorFilter (rowerSettings, minimumDragFactorSamples recordedAbsolutePosition = [] recordedRawValue = [] lowerCursor = undefined - upperCursor = undefined + upperCursor = undefined } function reset () { if (slopeSum !== _numberOfMagnets || interceptSum !== 0) { log.debug('*** WARNING: cyclic error filter is reset') } - const noIncrements = Math.max(Math.ceil(_numberOfFilterSamples / 4), 5) - const increment = (_maximumTimeBetweenImpulses - _minimumTimeBetweenImpulses) / noIncrements + const noIncrements = Math.max(Math.ceil(_numberOfFilterSamples / 4), 5) + const increment = (_maximumTimeBetweenImpulses - _minimumTimeBetweenImpulses) / noIncrements lowerCursor = undefined - restart() + restart() let i = 0 - let j = 0 - let datapoint = 0 + let j = 0 + let datapoint = 0 while (i < _numberOfMagnets) { filterArray[i] = {} filterArray[i] = createWLSLinearSeries(_numberOfFilterSamples) - j = 0 - while (j <= noIncrements) { + j = 0 + while (j <= noIncrements) { datapoint = _maximumTimeBetweenImpulses - (j * increment) - filterArray[i].push(datapoint, datapoint, 0.5) - j++ - } - slope[i] = 1 + filterArray[i].push(datapoint, datapoint, 0.8) + j++ + } + slope[i] = 1 intercept[i] = 0 i++ } @@ -153,7 +153,7 @@ export function createCyclicErrorFilter (rowerSettings, minimumDragFactorSamples interceptSum = 0 slopeCorrection = 1 interceptCorrection = 0 - startPosition = undefined + startPosition = undefined } return { From a3a577220866e4432b7ad9cf80fa68c675508b73 Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Fri, 26 Dec 2025 00:00:54 +0100 Subject: [PATCH 011/118] Removed unneeded function --- app/engine/utils/MovingWindowRegressor.js | 53 +++++++++++++++-------- 1 file changed, 34 insertions(+), 19 deletions(-) diff --git a/app/engine/utils/MovingWindowRegressor.js b/app/engine/utils/MovingWindowRegressor.js index 29d543ad2b..c2fc1e9c11 100644 --- a/app/engine/utils/MovingWindowRegressor.js +++ b/app/engine/utils/MovingWindowRegressor.js @@ -18,6 +18,10 @@ export function createMovingRegressor (bandwith) { let bMatrix = [] let cMatrix = [] + /** + * @param {float} the x value of the datapoint + * @param {float} the y value of the datapoint + */ function push (x, y) { quadraticTheilSenRegressor.push(x, y) @@ -55,6 +59,10 @@ export function createMovingRegressor (bandwith) { } } + /** + * @param {integer} the position in the flank of the requested value (default = 0) + * @returns {float} the coefficient a of the quadratic function y = a x^2 + b x + c + */ function coefficientA (position = 0) { if (aMatrix.length === flankLength && position < aMatrix.length) { return aMatrix[position].weighedAverage() @@ -63,6 +71,10 @@ export function createMovingRegressor (bandwith) { } } + /** + * @param {integer} the position in the flank of the requested value (default = 0) + * @returns {float} the coefficient b of the quadratic function y = a x^2 + b x + c + */ function coefficientB (position = 0) { if (bMatrix.length === flankLength && position < aMatrix.length) { return bMatrix[position].weighedAverage() @@ -71,6 +83,10 @@ export function createMovingRegressor (bandwith) { } } + /** + * @param {integer} the position in the flank of the requested value (default = 0) + * @returns {float} the coefficient c of the quadratic function y = a x^2 + b x + c + */ function coefficientC (position = 0) { if (cMatrix.length === flankLength && position < aMatrix.length) { return cMatrix[position].weighedAverage() @@ -79,6 +95,10 @@ export function createMovingRegressor (bandwith) { } } + /** + * @param {integer} the position in the flank of the requested value (default = 0) + * @returns {float} the firdt derivative of the quadratic function y = a x^2 + b x + c + */ function firstDerivative (position = 0) { if (aMatrix.length === flankLength && position < aMatrix.length) { return ((aMatrix[position].weighedAverage() * 2 * quadraticTheilSenRegressor.X.get(position)) + bMatrix[position].weighedAverage()) @@ -87,6 +107,10 @@ export function createMovingRegressor (bandwith) { } } + /** + * @param {integer} the position in the flank of the requested value (default = 0) + * @returns {float} the second derivative of the quadratic function y = a x^2 + b x + c + */ function secondDerivative (position = 0) { if (aMatrix.length === flankLength && position < aMatrix.length) { return (aMatrix[position].weighedAverage() * 2) @@ -95,6 +119,11 @@ export function createMovingRegressor (bandwith) { } } + /** + * @param {integer} the position in the flank of the requested value (default = 0) + * @param {float} the x to project onto the function + * @returns {float} the resulting y from the projection + */ function projectX (position, x) { if (aMatrix[position].length() >= 3) { return ((aMatrix[position].weighedAverage() * Math.pow(x, 2)) + (bMatrix[position].weighedAverage() * x) + cMatrix[position].weighedAverage()) @@ -103,6 +132,11 @@ export function createMovingRegressor (bandwith) { } } +/** + * @param {integer} the position in the flank of the requested value (default = 0) + * @param {float} the y to project onto the function + * @returns {array} the resulting x's from the projection + */ function projectY (position, y) { // Calculate the discriminant const discriminant = Math.pow(bMatrix[position].weighedAverage(), 2) - (4 * aMatrix[position].weighedAverage() * (cMatrix[position].weighedAverage() - y)) @@ -131,24 +165,6 @@ export function createMovingRegressor (bandwith) { } } - function expectedX (position = 0) { - const solutions = projectY(position, quadraticTheilSenRegressor.Y.get(position)) - switch (true) { - case (solutions.length === 0): - return quadraticTheilSenRegressor.X.get(position) - case (solutions.length === 1): - return solutions[0] - case (solutions.length === 2): - if (Math.abs(solutions[0] - quadraticTheilSenRegressor.X.get(position)) < Math.abs(solutions[1] - quadraticTheilSenRegressor.X.get(position))) { - return solutions[0] - } else { - return solutions[1] - } - default: - return quadraticTheilSenRegressor.X.get(position) - } - } - function reset () { quadraticTheilSenRegressor.reset() let i = aMatrix.length @@ -213,7 +229,6 @@ export function createMovingRegressor (bandwith) { secondDerivative, projectX, projectY, - expectedX, reset } } From 31020a2941596c4b90b4ade6bb7a566f70a4b47b Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Fri, 26 Dec 2025 00:03:33 +0100 Subject: [PATCH 012/118] Fixed ESLint error --- app/engine/utils/MovingWindowRegressor.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/engine/utils/MovingWindowRegressor.js b/app/engine/utils/MovingWindowRegressor.js index c2fc1e9c11..c723917cf6 100644 --- a/app/engine/utils/MovingWindowRegressor.js +++ b/app/engine/utils/MovingWindowRegressor.js @@ -132,7 +132,7 @@ export function createMovingRegressor (bandwith) { } } -/** + /** * @param {integer} the position in the flank of the requested value (default = 0) * @param {float} the y to project onto the function * @returns {array} the resulting x's from the projection From 8447dd148f513867e61ee20825d99200edb4b2ec Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Fri, 26 Dec 2025 21:00:26 +0100 Subject: [PATCH 013/118] Rename of file --- app/engine/utils/{FullTSLinearSeries.js => TSLinearSeries.js} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename app/engine/utils/{FullTSLinearSeries.js => TSLinearSeries.js} (100%) diff --git a/app/engine/utils/FullTSLinearSeries.js b/app/engine/utils/TSLinearSeries.js similarity index 100% rename from app/engine/utils/FullTSLinearSeries.js rename to app/engine/utils/TSLinearSeries.js From dc9f754b3d493163c119dbe092d2293c62867613 Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Fri, 26 Dec 2025 21:00:57 +0100 Subject: [PATCH 014/118] Update import path for createTSLinearSeries --- app/recorders/utils/BucketedLinearSeries.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/recorders/utils/BucketedLinearSeries.js b/app/recorders/utils/BucketedLinearSeries.js index fd72a62ea4..77939502ee 100644 --- a/app/recorders/utils/BucketedLinearSeries.js +++ b/app/recorders/utils/BucketedLinearSeries.js @@ -5,7 +5,7 @@ This Module calculates a bucketed Linear Regression. It assumes a rising line. */ -import { createTSLinearSeries } from '../../engine/utils/FullTSLinearSeries.js' +import { createTSLinearSeries } from '../../engine/utils/TSLinearSeries.js' /** * @param {number} xCutOffInterval From 44d8cddf487ab6b6fb26ab52d85d317f90eca5e6 Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Fri, 26 Dec 2025 21:02:34 +0100 Subject: [PATCH 015/118] Rename FullTSLinearSeries.test.js to TSLinearSeries.test.js --- .../utils/{FullTSLinearSeries.test.js => TSLinearSeries.test.js} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename app/engine/utils/{FullTSLinearSeries.test.js => TSLinearSeries.test.js} (100%) diff --git a/app/engine/utils/FullTSLinearSeries.test.js b/app/engine/utils/TSLinearSeries.test.js similarity index 100% rename from app/engine/utils/FullTSLinearSeries.test.js rename to app/engine/utils/TSLinearSeries.test.js From 1aac84b81505774ed79166a48cdc6065820a9765 Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Fri, 26 Dec 2025 21:05:04 +0100 Subject: [PATCH 016/118] Update import path for createTSLinearSeries --- app/engine/utils/TSLinearSeries.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/engine/utils/TSLinearSeries.test.js b/app/engine/utils/TSLinearSeries.test.js index b0c29955c2..fd35e06554 100644 --- a/app/engine/utils/TSLinearSeries.test.js +++ b/app/engine/utils/TSLinearSeries.test.js @@ -5,7 +5,7 @@ import { test } from 'uvu' import * as assert from 'uvu/assert' -import { createTSLinearSeries } from './FullTSLinearSeries.js' +import { createTSLinearSeries } from './TSLinearSeries.js' test('Correct behaviour of a series after initialisation', () => { const dataSeries = createTSLinearSeries(3) From c7fad91ee84de90807d86bf48b23506e5b5c5828 Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Fri, 26 Dec 2025 21:05:47 +0100 Subject: [PATCH 017/118] Update import path for createTSLinearSeries --- app/engine/Flywheel.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/engine/Flywheel.js b/app/engine/Flywheel.js index 3b4406558e..95ae72744a 100644 --- a/app/engine/Flywheel.js +++ b/app/engine/Flywheel.js @@ -23,7 +23,7 @@ */ import loglevel from 'loglevel' import { createCyclicErrorFilter } from './utils/CyclicErrorFilter.js' -import { createTSLinearSeries } from './utils/FullTSLinearSeries.js' +import { createTSLinearSeries } from './utils/TSLinearSeries.js' import { createWeighedSeries } from './utils/WeighedSeries.js' import { createMovingRegressor } from './utils/MovingWindowRegressor.js' From d64ea49b8fcec0ac76e6831060aa02776b12b19e Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Fri, 26 Dec 2025 21:06:27 +0100 Subject: [PATCH 018/118] Update import path for createTSQuadraticSeries --- app/engine/utils/MovingWindowRegressor.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/engine/utils/MovingWindowRegressor.js b/app/engine/utils/MovingWindowRegressor.js index c723917cf6..bb138fec34 100644 --- a/app/engine/utils/MovingWindowRegressor.js +++ b/app/engine/utils/MovingWindowRegressor.js @@ -6,7 +6,7 @@ * This implements a Moving Regression Algorithm to obtain a coefficients, first (angular velocity) and * second derivative (angular acceleration) at the front of the flank */ -import { createTSQuadraticSeries } from './FullTSQuadraticSeries.js' +import { createTSQuadraticSeries } from './TSQuadraticSeries.js' import { createWeighedSeries } from './WeighedSeries.js' import { createGaussianWeightFunction } from './Gaussian.js' From 22a2f0a54da92c33617de319818c272988861e49 Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Fri, 26 Dec 2025 21:07:51 +0100 Subject: [PATCH 019/118] Rename FullTSQuadraticSeries.test.js to TSQuadraticSeries.test.js --- ...{FullTSQuadraticSeries.test.js => TSQuadraticSeries.test.js} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename app/engine/utils/{FullTSQuadraticSeries.test.js => TSQuadraticSeries.test.js} (99%) diff --git a/app/engine/utils/FullTSQuadraticSeries.test.js b/app/engine/utils/TSQuadraticSeries.test.js similarity index 99% rename from app/engine/utils/FullTSQuadraticSeries.test.js rename to app/engine/utils/TSQuadraticSeries.test.js index d222e2aebc..5ab31e5724 100644 --- a/app/engine/utils/FullTSQuadraticSeries.test.js +++ b/app/engine/utils/TSQuadraticSeries.test.js @@ -9,7 +9,7 @@ import { test } from 'uvu' import * as assert from 'uvu/assert' -import { createTSQuadraticSeries } from './FullTSQuadraticSeries.js' +import { createTSQuadraticSeries } from './TSQuadraticSeries.js' /** * This series of tests focusses on testing the reliability of the quadratic estimator algorithm From 460fe006bb37d6cec0a7033f58199d146b361dce Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Fri, 26 Dec 2025 21:23:13 +0100 Subject: [PATCH 020/118] Refactor TSQuadraticSeries with improved documentation Updated imports and added JSDoc comments for clarity. --- ...uadraticSeries.js => TSQuadraticSeries.js} | 61 ++++++++++++++++--- 1 file changed, 52 insertions(+), 9 deletions(-) rename app/engine/utils/{FullTSQuadraticSeries.js => TSQuadraticSeries.js} (81%) diff --git a/app/engine/utils/FullTSQuadraticSeries.js b/app/engine/utils/TSQuadraticSeries.js similarity index 81% rename from app/engine/utils/FullTSQuadraticSeries.js rename to app/engine/utils/TSQuadraticSeries.js index ebf57686f2..bf411b268e 100644 --- a/app/engine/utils/FullTSQuadraticSeries.js +++ b/app/engine/utils/TSQuadraticSeries.js @@ -26,12 +26,15 @@ */ import { createSeries } from './Series.js' -import { createTSLinearSeries } from './FullTSLinearSeries.js' +import { createTSLinearSeries } from './TSLinearSeries.js' import { createLabelledBinarySearchTree } from './BinarySearchTree.js' import loglevel from 'loglevel' const log = loglevel.getLogger('RowingEngine') +/** + * @param {integer} the maximum length of the quadratic series, 0 for unlimited + */ export function createTSQuadraticSeries (maxSeriesLength = 0) { const X = createSeries(maxSeriesLength) const Y = createSeries(maxSeriesLength) @@ -43,9 +46,13 @@ export function createTSQuadraticSeries (maxSeriesLength = 0) { let _sst = 0 let _goodnessOfFit = 0 + /** + * @param {float} the x value of the datapoint + * @param {float} the y value of the datapoint + * Invariant: BinrySearchTree A contains all calculated a's (as in the general formula y = a * x^2 + b * x + c), + * where the a's are labeled in the BinarySearchTree with their Xi when they BEGIN in the point (Xi, Yi) + */ function push (x, y) { - // Invariant: A contains all a's (as in the general formula y = a * x^2 + b * x + c) - // Where the a's are labeled in the Binary Search Tree with their Xi when they BEGIN in the point (Xi, Yi) if (x === undefined || isNaN(x) || y === undefined || isNaN(y)) { return } if (maxSeriesLength > 0 && X.length() >= maxSeriesLength) { @@ -91,7 +98,11 @@ export function createTSQuadraticSeries (maxSeriesLength = 0) { } } - function firstDerivativeAtPosition (position) { + /** + * @param {integer} the position in the flank of the requested value (default = 0) + * @returns {float} the firdt derivative of the quadratic function y = a x^2 + b x + c + */ + function firstDerivativeAtPosition (position = 0) { if (X.length() >= 3 && position < X.length()) { calculateB() return ((_A * 2 * X.get(position)) + _B) @@ -100,7 +111,11 @@ export function createTSQuadraticSeries (maxSeriesLength = 0) { } } - function secondDerivativeAtPosition (position) { + /** + * @param {integer} the position in the flank of the requested value (default = 0) + * @returns {float} the second derivative of the quadratic function y = a x^2 + b x + c + */ + function secondDerivativeAtPosition (position = 0) { if (X.length() >= 3 && position < X.length()) { return (_A * 2) } else { @@ -108,6 +123,10 @@ export function createTSQuadraticSeries (maxSeriesLength = 0) { } } + /** + * @param {float} the x value of the requested value + * @returns {float} the slope of the linear function + */ function slope (x) { if (X.length() >= 3) { calculateB() @@ -117,36 +136,50 @@ export function createTSQuadraticSeries (maxSeriesLength = 0) { } } + /** + * @returns {float} the coefficient a of the quadratic function y = a x^2 + b x + c + */ function coefficientA () { - // For testing purposses only! return _A } + /** + * @returns {float} the coefficient b of the quadratic function y = a x^2 + b x + c + */ function coefficientB () { - // For testing purposses only! calculateB() return _B } + /** + * @returns {float} the coefficient c of the quadratic function y = a x^2 + b x + c + */ function coefficientC () { - // For testing purposses only! calculateB() calculateC() return _C } + /** + * @returns {float} the intercept of the quadratic function + */ function intercept () { calculateB() calculateC() return _C } + /** + * @returns {integer} the lenght of the stored series + */ function length () { return X.length() } + /** + * @returns {float} the R^2 as a goodness of fit indicator + */ function goodnessOfFit () { - // This function returns the R^2 as a goodness of fit indicator let i = 0 let sse = 0 if (_goodnessOfFit === null) { @@ -179,6 +212,9 @@ export function createTSQuadraticSeries (maxSeriesLength = 0) { return _goodnessOfFit } + /** + * @returns {float} the local R^2 as a local goodness of fit indicator + */ function localGoodnessOfFit (position) { if (_sst === null) { // Force the recalculation of the _sst @@ -208,6 +244,10 @@ export function createTSQuadraticSeries (maxSeriesLength = 0) { } } + /** + * @param {float} the x value to be projected + * @returns {float} the resulting y value when projected via the linear function + */ function projectX (x) { if (X.length() >= 3) { calculateB() @@ -267,6 +307,9 @@ export function createTSQuadraticSeries (maxSeriesLength = 0) { } } + /** + * @returns {boolean} whether the quadratic regression should be considered reliable to produce results + */ function reliable () { return (X.length() >= 3) } From 6064c6b275fd322c820501b31f23cce01e2db18e Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Fri, 26 Dec 2025 21:33:58 +0100 Subject: [PATCH 021/118] Enhance documentation with JSDoc comments Added JSDoc comments for better documentation of functions and parameters. --- app/engine/utils/TSLinearSeries.js | 51 ++++++++++++++++++++++++++---- 1 file changed, 44 insertions(+), 7 deletions(-) diff --git a/app/engine/utils/TSLinearSeries.js b/app/engine/utils/TSLinearSeries.js index 1895346b03..4804f89c09 100644 --- a/app/engine/utils/TSLinearSeries.js +++ b/app/engine/utils/TSLinearSeries.js @@ -29,6 +29,9 @@ import { createLabelledBinarySearchTree } from './BinarySearchTree.js' import loglevel from 'loglevel' const log = loglevel.getLogger('RowingEngine') +/** + * @param {integer} the maximum length of the quadratic series, 0 for unlimited + */ export function createTSLinearSeries (maxSeriesLength = 0) { const X = createSeries(maxSeriesLength) const Y = createSeries(maxSeriesLength) @@ -39,9 +42,13 @@ export function createTSLinearSeries (maxSeriesLength = 0) { let _sst = 0 let _goodnessOfFit = 0 + /** + * @param {float} the x value of the datapoint + * @param {float} the y value of the datapoint + * Invariant: BinarySearchTree A contains all calculated a's (as in the general formula y = a * x + b), + * where the a's are labeled in the BinarySearchTree with their Xi when they BEGIN in the point (Xi, Yi) + */ function push (x, y) { - // Invariant: A contains all a's (as in the general formula y = a * x + b) - // Where the a's are labeled in the Binary Search Tree with their xi when they BEGIN in the point (xi, yi) if (x === undefined || isNaN(x) || y === undefined || isNaN(y)) { return } if (maxSeriesLength > 0 && X.length() >= maxSeriesLength) { @@ -77,34 +84,50 @@ export function createTSLinearSeries (maxSeriesLength = 0) { _goodnessOfFit = null } + /** + * @returns {float} the slope of the linear function + */ function slope () { return _A } + /** + * @returns {float} the intercept of the linear function + */ function intercept () { calculateIntercept() return _B } + /** + * @returns {float} the coefficient a of the linear function y = a * x + b + */ function coefficientA () { - // For testing purposses only! return _A } + /** + * @returns {float} the coefficient b of the linear function y = a * x + b + */ function coefficientB () { - // For testing purposses only! calculateIntercept() return _B } + /** + * @returns {integer} the lenght of the stored series + */ function length () { return X.length() } + /** + * @returns {float} the R^2 as a goodness of fit indicator + * It will automatically recalculate the _goodnessOfFit when it isn't defined + * This lazy approach is intended to prevent unneccesary calculations, especially when there is a batch of datapoint pushes + * from the TSQuadratic regressor processing its linear residu + */ function goodnessOfFit () { - // This function returns the R^2 as a goodness of fit indicator - // It will automatically recalculate the _goodnessOfFit when it isn't defined - // This lazy approach is intended to prevent unneccesary calculations let i = 0 let sse = 0 if (_goodnessOfFit === null) { @@ -137,6 +160,9 @@ export function createTSLinearSeries (maxSeriesLength = 0) { return _goodnessOfFit } + /** + * @returns {float} the local R^2 as a local goodness of fit indicator + */ function localGoodnessOfFit (position) { if (_sst === null) { // Force the recalculation of the _sst @@ -166,6 +192,10 @@ export function createTSLinearSeries (maxSeriesLength = 0) { } } + /** + * @param {float} the x value to be projected + * @returns {float} the resulting y value when projected via the linear function + */ function projectX (x) { if (X.length() >= 2) { calculateIntercept() @@ -175,6 +205,10 @@ export function createTSLinearSeries (maxSeriesLength = 0) { } } + /** + * @param {float} the y value to be projected + * @returns {float} the resulting x value when projected via the linear function + */ function projectY (y) { if (X.length() >= 2 && _A !== 0) { calculateIntercept() @@ -215,6 +249,9 @@ export function createTSLinearSeries (maxSeriesLength = 0) { B.reset() } + /** + * @returns {boolean} whether the linear regression should be considered reliable to produce results + */ function reliable () { return (X.length() >= 2) } From 9d94341fbc67293e47f410706867312c32603a11 Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Fri, 26 Dec 2025 21:36:23 +0100 Subject: [PATCH 022/118] Fix comment formatting in intercept function --- app/engine/utils/TSLinearSeries.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/engine/utils/TSLinearSeries.js b/app/engine/utils/TSLinearSeries.js index 4804f89c09..5aa8de2885 100644 --- a/app/engine/utils/TSLinearSeries.js +++ b/app/engine/utils/TSLinearSeries.js @@ -91,7 +91,7 @@ export function createTSLinearSeries (maxSeriesLength = 0) { return _A } - /** + /** * @returns {float} the intercept of the linear function */ function intercept () { From 41b77870433f9fff42f2d7b98b137aaa440ba208 Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Fri, 26 Dec 2025 21:38:19 +0100 Subject: [PATCH 023/118] Fix total moving time in Rower test --- app/engine/Rower.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/engine/Rower.test.js b/app/engine/Rower.test.js index df4730b396..e5be29b373 100644 --- a/app/engine/Rower.test.js +++ b/app/engine/Rower.test.js @@ -430,7 +430,7 @@ 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.039795913105) + testTotalMovingTimeSinceStart(rower, 590.0294331572366) testTotalLinearDistanceSinceStart(rower, 2027.8318516062236) testTotalNumberOfStrokes(rower, 206) // As dragFactor isn't static, it should have changed From 94c9dcef44016aa98f80fc8c48da2586d63431c2 Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Fri, 26 Dec 2025 21:39:32 +0100 Subject: [PATCH 024/118] Fix total moving time in rowing statistics test --- app/engine/RowingStatistics.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/engine/RowingStatistics.test.js b/app/engine/RowingStatistics.test.js index a548518711..058c9aaef9 100644 --- a/app/engine/RowingStatistics.test.js +++ b/app/engine/RowingStatistics.test.js @@ -490,7 +490,7 @@ test('A full session for a Concept2 RowErg should produce plausible results', as await replayRowingSession(rowingStatistics.handleRotationImpulse, { filename: 'recordings/Concept2_RowErg_Session_2000meters.csv', realtime: false, loop: false }) - testTotalMovingTime(rowingStatistics, 590.039795913105) + testTotalMovingTime(rowingStatistics, 590.0294331572366) testTotalLinearDistance(rowingStatistics, 2027.8318516062236) testTotalNumberOfStrokes(rowingStatistics, 205) // As dragFactor isn't static, it should have changed From 952a3ffcf27125ecf62dd3bbf4838dc187df5fbe Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Fri, 26 Dec 2025 21:42:11 +0100 Subject: [PATCH 025/118] Fix expected total linear distance in Rower tests --- app/engine/Rower.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/engine/Rower.test.js b/app/engine/Rower.test.js index e5be29b373..44e44083d4 100644 --- a/app/engine/Rower.test.js +++ b/app/engine/Rower.test.js @@ -431,7 +431,7 @@ 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.0294331572366) - testTotalLinearDistanceSinceStart(rower, 2027.8318516062236) + testTotalLinearDistanceSinceStart(rower, 2027.8951016561075) testTotalNumberOfStrokes(rower, 206) // As dragFactor isn't static, it should have changed testRecoveryDragFactor(rower, 80.68166392487412) From 400ee3b76fe2e9593fd3262055e1cc9bb79611e4 Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Fri, 26 Dec 2025 21:46:24 +0100 Subject: [PATCH 026/118] Fix total moving time assertions in tests --- app/engine/SessionManager.test.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/engine/SessionManager.test.js b/app/engine/SessionManager.test.js index 297593c9a8..74381a11a9 100644 --- a/app/engine/SessionManager.test.js +++ b/app/engine/SessionManager.test.js @@ -652,7 +652,7 @@ test('A full session for a Concept2 RowErg should produce plausible results', as await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/Concept2_RowErg_Session_2000meters.csv', realtime: false, loop: false }) - testTotalMovingTime(sessionManager, 590.039795913105) + testTotalMovingTime(sessionManager, 590.0294331572366) testTotalLinearDistance(sessionManager, 2027.8318516062236) testTotalCalories(sessionManager, 113.56819627152989) testTotalNumberOfStrokes(sessionManager, 205) @@ -691,7 +691,7 @@ test('A 2000 meter session for a Concept2 RowErg should produce plausible result await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/Concept2_RowErg_Session_2000meters.csv', realtime: false, loop: false }) - testTotalMovingTime(sessionManager, 582.0334511569066) + testTotalMovingTime(sessionManager, 582.0058299961318) testTotalLinearDistance(sessionManager, 2000.025219680081) testTotalCalories(sessionManager, 112.18682528064748) testTotalNumberOfStrokes(sessionManager, 203) @@ -730,7 +730,7 @@ test('A 580 seconds session for a Concept2 RowErg should produce plausible resul await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/Concept2_RowErg_Session_2000meters.csv', realtime: false, loop: false }) - testTotalMovingTime(sessionManager, 580.002830265074) + testTotalMovingTime(sessionManager, 580.0016078988951) testTotalLinearDistance(sessionManager, 1993.1883167437431) testTotalCalories(sessionManager, 111.77483058027613) testTotalNumberOfStrokes(sessionManager, 202) @@ -768,7 +768,7 @@ test('A 100 calories session for a Concept2 RowErg should produce plausible resu await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/Concept2_RowErg_Session_2000meters.csv', realtime: false, loop: false }) - testTotalMovingTime(sessionManager, 520.2844995790927) + testTotalMovingTime(sessionManager, 520.3824691827283) testTotalLinearDistance(sessionManager, 1785.8189419422438) testTotalCalories(sessionManager, 100.00001288159193) testTotalNumberOfStrokes(sessionManager, 181) From 800a3a70e484bbaa6f8c4f3becc4b3d2f4cc7df3 Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Fri, 26 Dec 2025 21:51:10 +0100 Subject: [PATCH 027/118] Update expected recovery drag factor in tests --- app/engine/Rower.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/engine/Rower.test.js b/app/engine/Rower.test.js index 44e44083d4..03018bb3d2 100644 --- a/app/engine/Rower.test.js +++ b/app/engine/Rower.test.js @@ -434,7 +434,7 @@ test('A full session for a Concept2 RowErg should produce plausible results', as testTotalLinearDistanceSinceStart(rower, 2027.8951016561075) testTotalNumberOfStrokes(rower, 206) // As dragFactor isn't static, it should have changed - testRecoveryDragFactor(rower, 80.68166392487412) + testRecoveryDragFactor(rower, 80.70650785533269) }) function testStrokeState (rower, expectedValue) { From 6ef62f6470fe0f6e8f0b3bf643beb8a5e489d366 Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Fri, 26 Dec 2025 21:52:44 +0100 Subject: [PATCH 028/118] Fix test values for total distance and drag factor --- app/engine/RowingStatistics.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/engine/RowingStatistics.test.js b/app/engine/RowingStatistics.test.js index 058c9aaef9..841c9c6f6a 100644 --- a/app/engine/RowingStatistics.test.js +++ b/app/engine/RowingStatistics.test.js @@ -491,10 +491,10 @@ test('A full session for a Concept2 RowErg should produce plausible results', as await replayRowingSession(rowingStatistics.handleRotationImpulse, { filename: 'recordings/Concept2_RowErg_Session_2000meters.csv', realtime: false, loop: false }) testTotalMovingTime(rowingStatistics, 590.0294331572366) - testTotalLinearDistance(rowingStatistics, 2027.8318516062236) + testTotalLinearDistance(rowingStatistics, 2027.8951016561075) testTotalNumberOfStrokes(rowingStatistics, 205) // As dragFactor isn't static, it should have changed - testDragFactor(rowingStatistics, 80.68166392487412) + testDragFactor(rowingStatistics, 80.70650785533269) }) function testStrokeState (rowingStatistics, expectedValue) { From dc9caeafbb825d7330b28210849caa34c4778713 Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Fri, 26 Dec 2025 21:58:59 +0100 Subject: [PATCH 029/118] Fix linear distance assertions in tests --- app/engine/SessionManager.test.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/engine/SessionManager.test.js b/app/engine/SessionManager.test.js index 74381a11a9..b45459e003 100644 --- a/app/engine/SessionManager.test.js +++ b/app/engine/SessionManager.test.js @@ -653,7 +653,7 @@ test('A full session for a Concept2 RowErg should produce plausible results', as await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/Concept2_RowErg_Session_2000meters.csv', realtime: false, loop: false }) testTotalMovingTime(sessionManager, 590.0294331572366) - testTotalLinearDistance(sessionManager, 2027.8318516062236) + testTotalLinearDistance(sessionManager, 2027.8951016561075) testTotalCalories(sessionManager, 113.56819627152989) testTotalNumberOfStrokes(sessionManager, 205) // As dragFactor isn't static, it should have changed @@ -692,7 +692,7 @@ test('A 2000 meter session for a Concept2 RowErg should produce plausible result await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/Concept2_RowErg_Session_2000meters.csv', realtime: false, loop: false }) testTotalMovingTime(sessionManager, 582.0058299961318) - testTotalLinearDistance(sessionManager, 2000.025219680081) + testTotalLinearDistance(sessionManager, 2000.0206027129661) testTotalCalories(sessionManager, 112.18682528064748) testTotalNumberOfStrokes(sessionManager, 203) // As dragFactor isn't static, it should have changed @@ -731,7 +731,7 @@ test('A 580 seconds session for a Concept2 RowErg should produce plausible resul await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/Concept2_RowErg_Session_2000meters.csv', realtime: false, loop: false }) testTotalMovingTime(sessionManager, 580.0016078988951) - testTotalLinearDistance(sessionManager, 1993.1883167437431) + testTotalLinearDistance(sessionManager, 1993.2788181883743) testTotalCalories(sessionManager, 111.77483058027613) testTotalNumberOfStrokes(sessionManager, 202) // As dragFactor isn't static, it should have changed @@ -769,7 +769,7 @@ test('A 100 calories session for a Concept2 RowErg should produce plausible resu await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/Concept2_RowErg_Session_2000meters.csv', realtime: false, loop: false }) testTotalMovingTime(sessionManager, 520.3824691827283) - testTotalLinearDistance(sessionManager, 1785.8189419422438) + testTotalLinearDistance(sessionManager, 1786.2212497568994) testTotalCalories(sessionManager, 100.00001288159193) testTotalNumberOfStrokes(sessionManager, 181) // As dragFactor isn't static, it should have changed From 4cc8c46fc3f67ae78b822f5e02ba7ca16d474dc7 Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Fri, 26 Dec 2025 22:06:17 +0100 Subject: [PATCH 030/118] Fix calorie test values in SessionManager tests --- app/engine/SessionManager.test.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/engine/SessionManager.test.js b/app/engine/SessionManager.test.js index b45459e003..ffee392cd3 100644 --- a/app/engine/SessionManager.test.js +++ b/app/engine/SessionManager.test.js @@ -654,7 +654,7 @@ test('A full session for a Concept2 RowErg should produce plausible results', as testTotalMovingTime(sessionManager, 590.0294331572366) testTotalLinearDistance(sessionManager, 2027.8951016561075) - testTotalCalories(sessionManager, 113.56819627152989) + testTotalCalories(sessionManager, 113.55660950119214) testTotalNumberOfStrokes(sessionManager, 205) // As dragFactor isn't static, it should have changed testDragFactor(sessionManager, 80.68166392487412) @@ -693,7 +693,7 @@ test('A 2000 meter session for a Concept2 RowErg should produce plausible result testTotalMovingTime(sessionManager, 582.0058299961318) testTotalLinearDistance(sessionManager, 2000.0206027129661) - testTotalCalories(sessionManager, 112.18682528064748) + testTotalCalories(sessionManager, 112.16536746119625) testTotalNumberOfStrokes(sessionManager, 203) // As dragFactor isn't static, it should have changed testDragFactor(sessionManager, 80.64401882558202) @@ -732,7 +732,7 @@ test('A 580 seconds session for a Concept2 RowErg should produce plausible resul testTotalMovingTime(sessionManager, 580.0016078988951) testTotalLinearDistance(sessionManager, 1993.2788181883743) - testTotalCalories(sessionManager, 111.77483058027613) + testTotalCalories(sessionManager, 111.76461106588519) testTotalNumberOfStrokes(sessionManager, 202) // As dragFactor isn't static, it should have changed testDragFactor(sessionManager, 80.67823666359594) @@ -770,7 +770,7 @@ test('A 100 calories session for a Concept2 RowErg should produce plausible resu testTotalMovingTime(sessionManager, 520.3824691827283) testTotalLinearDistance(sessionManager, 1786.2212497568994) - testTotalCalories(sessionManager, 100.00001288159193) + testTotalCalories(sessionManager, 100.00025111255141) testTotalNumberOfStrokes(sessionManager, 181) // As dragFactor isn't static, it should have changed testDragFactor(sessionManager, 80.66801954484566) From f3d54eb4760663590ed156ec421277d57415c97a Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Fri, 26 Dec 2025 22:12:35 +0100 Subject: [PATCH 031/118] Update dragFactor test values in SessionManager tests --- app/engine/SessionManager.test.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/engine/SessionManager.test.js b/app/engine/SessionManager.test.js index ffee392cd3..5b505f03bc 100644 --- a/app/engine/SessionManager.test.js +++ b/app/engine/SessionManager.test.js @@ -657,7 +657,7 @@ test('A full session for a Concept2 RowErg should produce plausible results', as testTotalCalories(sessionManager, 113.55660950119214) testTotalNumberOfStrokes(sessionManager, 205) // As dragFactor isn't static, it should have changed - testDragFactor(sessionManager, 80.68166392487412) + testDragFactor(sessionManager, 80.70650785533269) }) test('A 2000 meter session for a Concept2 RowErg should produce plausible results', async () => { @@ -696,7 +696,7 @@ test('A 2000 meter session for a Concept2 RowErg should produce plausible result testTotalCalories(sessionManager, 112.16536746119625) testTotalNumberOfStrokes(sessionManager, 203) // As dragFactor isn't static, it should have changed - testDragFactor(sessionManager, 80.64401882558202) + testDragFactor(sessionManager, 80.68314716929032) }) test('A 580 seconds session for a Concept2 RowErg should produce plausible results', async () => { @@ -735,7 +735,7 @@ test('A 580 seconds session for a Concept2 RowErg should produce plausible resul testTotalCalories(sessionManager, 111.76461106588519) testTotalNumberOfStrokes(sessionManager, 202) // As dragFactor isn't static, it should have changed - testDragFactor(sessionManager, 80.67823666359594) + testDragFactor(sessionManager, 80.70729014258711) }) test('A 100 calories session for a Concept2 RowErg should produce plausible results', async () => { @@ -773,7 +773,7 @@ test('A 100 calories session for a Concept2 RowErg should produce plausible resu testTotalCalories(sessionManager, 100.00025111255141) testTotalNumberOfStrokes(sessionManager, 181) // As dragFactor isn't static, it should have changed - testDragFactor(sessionManager, 80.66801954484566) + testDragFactor(sessionManager, 80.69402503758549) }) function testTotalMovingTime (sessionManager, expectedValue) { From bb5d9f7e4f6cb97c18aa17bb20974e8ffdc94514 Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Sun, 28 Dec 2025 17:43:23 +0100 Subject: [PATCH 032/118] Enhance rationale and error handling in algorithms Expanded the rationale for design decisions in mathematical algorithms, emphasizing the importance of accuracy and CPU load reduction. Added details on systematic error handling and the performance testing methods used. --- docs/Mathematical_Foundations.md | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/docs/Mathematical_Foundations.md b/docs/Mathematical_Foundations.md index c081a0f8a5..813597029d 100644 --- a/docs/Mathematical_Foundations.md +++ b/docs/Mathematical_Foundations.md @@ -3,7 +3,7 @@ In this document we explain the math behind the OpenRowingMonitor, to allow for independent review and software maintenance. It should be read in conjunction with [our desciption of OpenRowingMonitor's physics](./physics_openrowingmonitor.md), as these interact. When possible, we link to the source code to allow further investigation and keep the link with the actual implementation. -Please note that this text is used as a rationale for design decissions of the mathematical algorithms used in OpenRowingMonitor. So it is of interest for people maintaining the code (as it explains why we do things the way we do) and for academics to verify or improve our solution. For these academics, we conclude with a section of open design issues as they might provide avenues of future research. If you are interested in just using OpenRowingMonitor as-is, this might not be the text you are looking for. +Please note that this text is used as a rationale for design decissions of the mathematical algorithms used in OpenRowingMonitor. So it is of interest for people maintaining the code (as it explains why we do things the way we do) and for academics to verify or improve our solution. For these academics, we conclude with a section of open design issues as they might provide avenues of future research and improvement. As the algorithms described here are essential to OpenRowingMonitor's performance, we are actively looking for improvements in accuracy and reduction of cpu-load. If you are interested in just using OpenRowingMonitor as-is, this might not be the text you are looking for. This document consists out of four sections: @@ -16,17 +16,35 @@ This document consists out of four sections: In our design of the physics engine, we obey the following principles (see also [the architecture document](Architecture.md)): -* all calculations should be performed in real-time in a stream of datapoints, even on data intensive machines, to allow decent feedback to the user. The losd on the CPU is to be limited as some rowing machines are data intensive and the app's CPU load interferes with the accurate measurement of time between pulses by the responsible kernel functions; +* all calculations should be performed in real-time in a stream of datapoints, even on data intensive machines, to allow decent feedback to the user. The load on the CPU thus has to be limited as some rowing machines are data intensive, and a Raspberry Pi Zero 2W is used, and the app's CPU load interferes with the accurate measurement of time between pulses by the responsible kernel functions; * stay as close to the original data as possible (thus depend on direct measurements as much as possible) instead of heavily depend on derived data. This means that there are two absolute values we try to stay close to as much as possible: the **time between an impulse** and the **Number of Impulses**, where we consider **Number of Impulses** most reliable, and **time between an impulse** reliable but containing noise (the origin and meaning of these metrics, as well the effects of this approach are explained later); -* use robust calculations wherever possible (i.e. not depend on a single measurements, extrapolations, derivation, etc.) to reduce effects of measurement errors. A typical issue is the role of *CurrentDt*, which is often used as a divisor with small numers as Δt, increasing the effect of measurement errors in most metrics. When we do need to calculate a derived function, we choose to use a robust linear regression method to reduce the impact of noise and than use the function to calculate the derived function; +* use robust calculations wherever possible (i.e. not depend on a single measurements, extrapolations, derivation, etc.) to reduce effects of measurement errors. A typical issue is the role of *CurrentDt* (essentially Δt), which is often used as a divisor with small numers, increasing the effect of measurement errors in most metrics. When we do need to calculate a derived function, we choose to use a linear regression method to reduce the impact of noise and than use the function to calculate the derived function; + +* A key element of the analysis of the algorithm performance, we use two way of testing performance: a **synthetic approach** which uses a known polynomial (sometimes with injected noise) to see how well a certain algorithm behaves, and an **organic approach**, where previously recorded data is replayed. A key indicator for the organic approach in the Goodness Of Fit of the drag calculation: [as theory prescribes](./physics_openrowingmonitor.md#determining-the-drag-factor-of-the-flywheel), the progression of Δt throughout the recovery time should be a perfect straight line, thus any deviation from that line is most likely due to measurement noise. ## Overview of algorithms used -### Noise filtering on CurrentDt +### Filtering on systematic noise on *CurrentDt* + +Several machines, including the Concept2 RowErg, [are known to have small errors in their magnet placement](./rower_settings.md#fixing-magnet-placement-errors). The systematic error filter is designed to reduce the effects of these systematic errors. Although the subsequent calcuations are designed to be robust against noise, the repeating nature has tendency to + +As the synchronisation with the actual flywheel position is essential (as somehow it should be known which specific misplaced magnet produces which error) and quite hard to solve, we've chosen the approach to continuously dynamically calculate the error correction value, rather than provide a static value beforehand that has to be synchronised. + +A key assumption is that these structural errors will always be present as part of the random noise, and by comparing multiple observations across time, systematic errors can be identified. In essence, the residual between the raw input and the regression corrected projection is used as a basis for future corrections. These concepts are shared with [Kalman filters](https://en.wikipedia.org/wiki/Kalman_filter). Here, as the error is specific for each magnet, we need to maintain such a filter per magnet. + +Here, we use a linear regressor to determine the relation between the raw value and the 'perfect values' (i.e. noise free) per individual magnet. By maintaining a function per magnet, we can calculate the error per magnet as a function of the raw value, allowing for an effective error correction of systematic placement errors in the magnet array. + +[`Flywheel.js`](../app/engine/Flywheel.js) both feeds and uses the [`CyclicErrorFilter.js`](../app/engine/utils/CyclicErrorFilter.js). Given its dynamic approach, there is a need for a source of *perfect values*. In [`Flywheel.js`](../app/engine/Flywheel.js) there are two potential sources for estimating the perfect value of a given *CurrentDt*: + +* The Quadratic regression used for the estimation of ω and α. This is a continuous stream of data as each *CurrentDt* will be processed via this algorithm, allowing the algorithm to adjust quickly to changes. The key issue is that this quadratic regression is an approximation at best: especially in the drive the true movement behaves like much higher polynomial, so errors found can also originate from the deviation between the quadratic estimator and the true data. Using this as a basis for noise reduction would be theoretically unsound. An additional issue is that, due to its design, it requires solving a the local quadratic equation to find the total time given a specific angular distance θ, which might result in two solutions. A minor issue is that this regression is done on relatively small *flankLenght* sized intervals (typically two full rotations), making it a narrow basis for error correction, especially for systematic errors. These issues combined makes this the far less desireable choice. + +* The linear regression used for the drag calculation. This is a discontinuous stream, as data is only collected during the recovery, and can only be processed after the recovery is complete (as only then the regression completes to deliver a completed estimate of the drag slope). The big benefit is that this regression analysis is theoretically sound: [the physics model prescribes a straight line](./physics_openrowingmonitor.md#determining-the-drag-factor-of-the-flywheel) and linear regression is used to calculate it. It has a much wider base, as the regression is conducted over the entirety of the recovery (often dozens of full rotations), making the regression also less vulnerable to systematic errors. Additional benefit is that the regression already is arranged optimally to calculate *perfect values of currentDt* as the dragslope measures the decay of *currentDt* through time. Disadvantage is that shifts (i.e. misalignment of the physical flywheel and the corrections due to missed datapoints or switch bounces) and changes in magnet error are detected relatively late: it will take at least a recovery and part of the drive to detect and correct these issues. + +Practical experiments support the above argument: where feeding the algorithm using the Quadratic regression for systematic error correction results in a Goodness Of Fit for the recovery slope of 0.9990, feeding from the linear regression results in a Goodness Of Fit often exceeding 0.9996. -In [`Flywheel.js`](..//app/engine/Flywheel.js) there is a noise filter present, essentially to handle edge cases and for legacy purposses. In theory this should be removed as the subsequent handling by robust algorithms is actually disturbed when this filter is applied. +A key issue is preventing time contraction/delution: maintaining a correction factor per magnet will not guarantee that all corrections over all magnets across a session will not cause a drift in time (as the corrected *currentDt* is also used for calculating the moving time since start [`Flywheel.js`](../app/engine/Flywheel.js)). To keep time drift under controll, the [`CyclicErrorFilter.js`](../app/engine/utils/CyclicErrorFilter.js) will guarantee that the average of all slopes will be 1 at all times, and the slopes of all intercepts be 0. This is a bit crude, but it is shown to be effective. ### Linear regression algorithm for dragfactor calculation based on *CurrentDt* and time From 5a924e0007cacbbfdb9ca407cb58881decdebd02 Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Sun, 28 Dec 2025 23:41:19 +0100 Subject: [PATCH 033/118] Enhance regression analysis section with weighted methods Added a section on weighted linear regression for drag slope correction and its impact on systematic error filtering. Included details on the use of the Linear Theil-Sen estimator and cyclic error correction. --- docs/Mathematical_Foundations.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/Mathematical_Foundations.md b/docs/Mathematical_Foundations.md index 813597029d..fa648666f0 100644 --- a/docs/Mathematical_Foundations.md +++ b/docs/Mathematical_Foundations.md @@ -44,6 +44,8 @@ Here, we use a linear regressor to determine the relation between the raw value Practical experiments support the above argument: where feeding the algorithm using the Quadratic regression for systematic error correction results in a Goodness Of Fit for the recovery slope of 0.9990, feeding from the linear regression results in a Goodness Of Fit often exceeding 0.9996. +A key element is the use of a weighed linear regression method, where the weight is based on the Goodness of Fit of the datapoint to prevent badly fitted drag slopes or badly fitting specific datapoints from throwing off the systematic error correction filter too much. + A key issue is preventing time contraction/delution: maintaining a correction factor per magnet will not guarantee that all corrections over all magnets across a session will not cause a drift in time (as the corrected *currentDt* is also used for calculating the moving time since start [`Flywheel.js`](../app/engine/Flywheel.js)). To keep time drift under controll, the [`CyclicErrorFilter.js`](../app/engine/utils/CyclicErrorFilter.js) will guarantee that the average of all slopes will be 1 at all times, and the slopes of all intercepts be 0. This is a bit crude, but it is shown to be effective. ### Linear regression algorithm for dragfactor calculation based on *CurrentDt* and time @@ -66,6 +68,8 @@ This is expected to be a straight line, where its slope is essentially the dragf Therefore, we choose to apply the Linear Theil-Sen estimator for the calculation of the dragfactor and the closely related recovery slope. +As the cyclic error correction filter can provide an indication (i.e. the general Goodness of Fit of the relation between raw values and perfect values, essentially representing the variance), we also include that as weight. This prevents results from badly fitting error corrections of individual magnets to pollute the drag calculation too much, stabilising its slope calculation. At first glance, this might lead to feedback loops, as the systematic error correction is fed by the drag calculation, and the systematic error correction will influence drag calculation. The feeding algorithm of the systematic error correction indeed uses a goodness of fit that is both global and local, where a specific badly fitted magnet might further loose its influence (especially via the local fit) as it local fit will deviate further and further from the regression line as it looses influence. As the Goodness of Fit reported by the systematic error correction focusses on the variance in the relation between raw and perfect values, a enduring low Goodness of Fit for a specific error correction of a magnet indicates that new updates provide a different relation between raw and perfect values each recovery. As this relation in a large part is determined by the slope of the drag calculation (as that determines the perfect value) we consider this unlikely as other magnets tend to stabilise this effect. In practice, we haven't seen this happen yet. + ### Linear regression algorithms applied for recovery detection based on *CurrentDt* and time We use OLS for the stroke detection. From 7d3d43e6f492fa0a6f53f05f44e6acaf91e6c32f Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Sun, 28 Dec 2025 23:45:29 +0100 Subject: [PATCH 034/118] Fixes ESlint errors Corrected typos and improved clarity in the explanation of systematic error filtering related to magnet placement. --- docs/Mathematical_Foundations.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/Mathematical_Foundations.md b/docs/Mathematical_Foundations.md index fa648666f0..a1cecbf27a 100644 --- a/docs/Mathematical_Foundations.md +++ b/docs/Mathematical_Foundations.md @@ -28,11 +28,11 @@ In our design of the physics engine, we obey the following principles (see also ### Filtering on systematic noise on *CurrentDt* -Several machines, including the Concept2 RowErg, [are known to have small errors in their magnet placement](./rower_settings.md#fixing-magnet-placement-errors). The systematic error filter is designed to reduce the effects of these systematic errors. Although the subsequent calcuations are designed to be robust against noise, the repeating nature has tendency to +Several machines, including the Concept2 RowErg, [are known to have small errors in their magnet placement](./rower_settings.md#fixing-magnet-placement-errors). The systematic error filter is designed to reduce the effects of these systematic errors. Although the subsequent calcuations are designed to be robust against noise, the repeating nature has tendency to still disturbe measurements, requiring a different approach to noise supression. As the synchronisation with the actual flywheel position is essential (as somehow it should be known which specific misplaced magnet produces which error) and quite hard to solve, we've chosen the approach to continuously dynamically calculate the error correction value, rather than provide a static value beforehand that has to be synchronised. -A key assumption is that these structural errors will always be present as part of the random noise, and by comparing multiple observations across time, systematic errors can be identified. In essence, the residual between the raw input and the regression corrected projection is used as a basis for future corrections. These concepts are shared with [Kalman filters](https://en.wikipedia.org/wiki/Kalman_filter). Here, as the error is specific for each magnet, we need to maintain such a filter per magnet. +A key assumption is that these structural errors will always be present as part of the random noise, and by comparing multiple observations across time, systematic errors can be identified. In essence, the residual between the raw input and the regression corrected projection is used as a basis for future corrections. These concepts are shared with [Kalman filters](https://en.wikipedia.org/wiki/Kalman_filter). Here, as the error is specific for each magnet, we need to maintain such a filter per magnet. Here, we use a linear regressor to determine the relation between the raw value and the 'perfect values' (i.e. noise free) per individual magnet. By maintaining a function per magnet, we can calculate the error per magnet as a function of the raw value, allowing for an effective error correction of systematic placement errors in the magnet array. From 91820708b94236485d2f4e93ab39ea3c1fcc1185 Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Tue, 30 Dec 2025 17:24:37 +0100 Subject: [PATCH 035/118] Add tests for OLS and WLS regression using Galton dataset --- app/engine/utils/WLSLinearSeries.test.js | 32 ++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/app/engine/utils/WLSLinearSeries.test.js b/app/engine/utils/WLSLinearSeries.test.js index 33209d0a66..c8db2186c2 100644 --- a/app/engine/utils/WLSLinearSeries.test.js +++ b/app/engine/utils/WLSLinearSeries.test.js @@ -194,6 +194,38 @@ test('Series with 5 elements, with 2 noisy datapoints', () => { testGoodnessOfFitBetween(dataSeries, 0.9, 1.0) }) +// Test based on the Galton dataset, using unweighted (=OLS) regression +// Example found at https://online.stat.psu.edu/stat501/lesson/13/13.1 +test('Unweighted series with 7 elements based on Galton dataset (OLS)', () => { + const dataSeries = createWLSLinearSeries(7) + dataSeries.push(0.21, 0.1726, 1) + dataSeries.push(0.2, 0.1707, 1) + dataSeries.push(0.19, 0.1637, 1) + dataSeries.push(0.18, 0.164, 1) + dataSeries.push(0.17, 0.1613, 1) + dataSeries.push(0.16, 0.1617, 1) + dataSeries.push(0.15, 0.1598, 1) + testSlopeEquals(dataSeries, 0.2100) + testInterceptEquals(dataSeries, 0.12703) + testGoodnessOfFitBetween(dataSeries, 0.9, 1.0) +}) + +// Test based on the Galton dataset, using weighted (=WLS) regression +// Example found at https://online.stat.psu.edu/stat501/lesson/13/13.1 +test('Weighted series with 7 elements based on Galton dataset (WLS)', () => { + const dataSeries = createWLSLinearSeries(7) + dataSeries.push(0.21, 0.1726, 2530.272176) + dataSeries.push(0.2, 0.1707, 2662.5174) + dataSeries.push(0.19, 0.1637, 2781.783546) + dataSeries.push(0.18, 0.164, 2410.004991) + dataSeries.push(0.17, 0.1613, 3655.35019) + dataSeries.push(0.16, 0.1617, 3935.712498) + dataSeries.push(0.15, 0.1598, 3217.328273) + testSlopeEquals(dataSeries, 0.2048) + testInterceptEquals(dataSeries, 0.12796) + testGoodnessOfFitBetween(dataSeries, 0.9, 1.0) +}) + function testLength (series, expectedValue) { assert.ok(series.length() === expectedValue, `Expected length should be ${expectedValue}, encountered a ${series.length()}`) } From c116c93c6c5feb65c042bc369b09a8a4eaa80455 Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Tue, 30 Dec 2025 19:57:02 +0100 Subject: [PATCH 036/118] Update slope test values for precision --- app/engine/utils/WLSLinearSeries.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/engine/utils/WLSLinearSeries.test.js b/app/engine/utils/WLSLinearSeries.test.js index c8db2186c2..f3e3440357 100644 --- a/app/engine/utils/WLSLinearSeries.test.js +++ b/app/engine/utils/WLSLinearSeries.test.js @@ -205,7 +205,7 @@ test('Unweighted series with 7 elements based on Galton dataset (OLS)', () => { dataSeries.push(0.17, 0.1613, 1) dataSeries.push(0.16, 0.1617, 1) dataSeries.push(0.15, 0.1598, 1) - testSlopeEquals(dataSeries, 0.2100) + testSlopeEquals(dataSeries, 0.2100000000000111) testInterceptEquals(dataSeries, 0.12703) testGoodnessOfFitBetween(dataSeries, 0.9, 1.0) }) @@ -221,7 +221,7 @@ test('Weighted series with 7 elements based on Galton dataset (WLS)', () => { dataSeries.push(0.17, 0.1613, 3655.35019) dataSeries.push(0.16, 0.1617, 3935.712498) dataSeries.push(0.15, 0.1598, 3217.328273) - testSlopeEquals(dataSeries, 0.2048) + testSlopeEquals(dataSeries, 0.20480116324222641) testInterceptEquals(dataSeries, 0.12796) testGoodnessOfFitBetween(dataSeries, 0.9, 1.0) }) From 2567aa6b62a8563b0ad1fd1e4b3ec5582bedefab Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Tue, 30 Dec 2025 20:03:49 +0100 Subject: [PATCH 037/118] Update intercept values in WLSLinearSeries tests --- app/engine/utils/WLSLinearSeries.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/engine/utils/WLSLinearSeries.test.js b/app/engine/utils/WLSLinearSeries.test.js index f3e3440357..d59142acb4 100644 --- a/app/engine/utils/WLSLinearSeries.test.js +++ b/app/engine/utils/WLSLinearSeries.test.js @@ -206,7 +206,7 @@ test('Unweighted series with 7 elements based on Galton dataset (OLS)', () => { dataSeries.push(0.16, 0.1617, 1) dataSeries.push(0.15, 0.1598, 1) testSlopeEquals(dataSeries, 0.2100000000000111) - testInterceptEquals(dataSeries, 0.12703) + testInterceptEquals(dataSeries, 0.12702857142856944) testGoodnessOfFitBetween(dataSeries, 0.9, 1.0) }) @@ -222,7 +222,7 @@ test('Weighted series with 7 elements based on Galton dataset (WLS)', () => { dataSeries.push(0.16, 0.1617, 3935.712498) dataSeries.push(0.15, 0.1598, 3217.328273) testSlopeEquals(dataSeries, 0.20480116324222641) - testInterceptEquals(dataSeries, 0.12796) + testInterceptEquals(dataSeries, 0.12796416521509518) testGoodnessOfFitBetween(dataSeries, 0.9, 1.0) }) From b19e88ec348e16259a7c8c3b85c9d5c4c39967fe Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Tue, 30 Dec 2025 20:08:50 +0100 Subject: [PATCH 038/118] Update goodness of fit tests in WLSLinearSeries --- app/engine/utils/WLSLinearSeries.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/engine/utils/WLSLinearSeries.test.js b/app/engine/utils/WLSLinearSeries.test.js index d59142acb4..0328faeef8 100644 --- a/app/engine/utils/WLSLinearSeries.test.js +++ b/app/engine/utils/WLSLinearSeries.test.js @@ -207,7 +207,7 @@ test('Unweighted series with 7 elements based on Galton dataset (OLS)', () => { dataSeries.push(0.15, 0.1598, 1) testSlopeEquals(dataSeries, 0.2100000000000111) testInterceptEquals(dataSeries, 0.12702857142856944) - testGoodnessOfFitBetween(dataSeries, 0.9, 1.0) + testGoodnessOfFitEquals(dataSeries, 0.8553954556248868) }) // Test based on the Galton dataset, using weighted (=WLS) regression @@ -223,7 +223,7 @@ test('Weighted series with 7 elements based on Galton dataset (WLS)', () => { dataSeries.push(0.15, 0.1598, 3217.328273) testSlopeEquals(dataSeries, 0.20480116324222641) testInterceptEquals(dataSeries, 0.12796416521509518) - testGoodnessOfFitBetween(dataSeries, 0.9, 1.0) + testGoodnessOfFitEquals(dataSeries, 0.8521213232768868) }) function testLength (series, expectedValue) { From e58b2c0cb6d7c2ca198fd98ce9910f9ca2938c01 Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Tue, 30 Dec 2025 23:58:34 +0100 Subject: [PATCH 039/118] Fixed a bug where LocalGoodnessOfFit wasn;t defined for 2 datapoints --- app/engine/utils/TSLinearSeries.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/engine/utils/TSLinearSeries.js b/app/engine/utils/TSLinearSeries.js index 5aa8de2885..e8dd460477 100644 --- a/app/engine/utils/TSLinearSeries.js +++ b/app/engine/utils/TSLinearSeries.js @@ -168,7 +168,7 @@ export function createTSLinearSeries (maxSeriesLength = 0) { // Force the recalculation of the _sst goodnessOfFit() } - if (X.length() >= 3 && position < X.length()) { + if (X.length() >= 2 && position < X.length()) { const squaredError = Math.pow((Y.get(position) - projectX(X.get(position))), 2) /* eslint-disable no-unreachable -- rather be systematic and add a break in all case statements */ switch (true) { From bd4543fb53a3456b6cc2d363f615266dc28228d5 Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Tue, 30 Dec 2025 23:59:27 +0100 Subject: [PATCH 040/118] Added tests for LocalGoodnessOfFit --- app/engine/utils/TSLinearSeries.test.js | 26 +++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/app/engine/utils/TSLinearSeries.test.js b/app/engine/utils/TSLinearSeries.test.js index fd35e06554..508b870ec8 100644 --- a/app/engine/utils/TSLinearSeries.test.js +++ b/app/engine/utils/TSLinearSeries.test.js @@ -27,6 +27,7 @@ test('Correct behaviour of a series after initialisation', () => { testSlopeEquals(dataSeries, 0) testInterceptEquals(dataSeries, 0) testGoodnessOfFitEquals(dataSeries, 0) + testLocalGoodnessOfFitEquals(dataSeries, 0, 0) }) test('Correct behaviour of a series after several puhed values, function y = 3x + 6, noisefree, 1 datapoint', () => { @@ -51,6 +52,7 @@ test('Correct behaviour of a series after several puhed values, function y = 3x testSlopeEquals(dataSeries, 0) testInterceptEquals(dataSeries, 0) testGoodnessOfFitEquals(dataSeries, 0) + testLocalGoodnessOfFitEquals(dataSeries, 0, 0) }) test('Correct behaviour of a series after several puhed values, function y = 3x + 6, noisefree, 2 datapoints', () => { @@ -75,6 +77,8 @@ test('Correct behaviour of a series after several puhed values, function y = 3x testSlopeEquals(dataSeries, 3) testInterceptEquals(dataSeries, -6) testGoodnessOfFitEquals(dataSeries, 1) + testLocalGoodnessOfFitEquals(dataSeries, 0, 1) + testLocalGoodnessOfFitEquals(dataSeries, 1, 1) }) test('Correct behaviour of a series after several puhed values, function y = 3x + 6, noisefree, 3 datapoints', () => { @@ -100,6 +104,9 @@ test('Correct behaviour of a series after several puhed values, function y = 3x testSlopeEquals(dataSeries, 3) testInterceptEquals(dataSeries, -6) testGoodnessOfFitEquals(dataSeries, 1) + testLocalGoodnessOfFitEquals(dataSeries, 0, 1) + testLocalGoodnessOfFitEquals(dataSeries, 1, 1) + testLocalGoodnessOfFitEquals(dataSeries, 2, 1) }) test('Correct behaviour of a series after several puhed values, function y = 3x + 6, noisefree, 4 datapoints', () => { @@ -126,6 +133,10 @@ test('Correct behaviour of a series after several puhed values, function y = 3x testSlopeEquals(dataSeries, 3) testInterceptEquals(dataSeries, -6) testGoodnessOfFitEquals(dataSeries, 1) + testLocalGoodnessOfFitEquals(dataSeries, 0, 1) + testLocalGoodnessOfFitEquals(dataSeries, 1, 1) + testLocalGoodnessOfFitEquals(dataSeries, 2, 1) + testLocalGoodnessOfFitEquals(dataSeries, 3, 1) }) test('Correct behaviour of a series after several puhed values, function y = 3x + 6, noisefree, 5 datapoints', () => { @@ -153,6 +164,11 @@ test('Correct behaviour of a series after several puhed values, function y = 3x testSlopeEquals(dataSeries, 3) testInterceptEquals(dataSeries, -6) testGoodnessOfFitEquals(dataSeries, 1) + testLocalGoodnessOfFitEquals(dataSeries, 0, 1) + testLocalGoodnessOfFitEquals(dataSeries, 1, 1) + testLocalGoodnessOfFitEquals(dataSeries, 2, 1) + testLocalGoodnessOfFitEquals(dataSeries, 3, 1) + testLocalGoodnessOfFitEquals(dataSeries, 4, 1) }) test('Correct behaviour of a series after several puhed values, function y = 3x + 6, noisefree, 4 datapoints and a reset', () => { @@ -180,6 +196,7 @@ test('Correct behaviour of a series after several puhed values, function y = 3x testSlopeEquals(dataSeries, 0) testInterceptEquals(dataSeries, 0) testGoodnessOfFitEquals(dataSeries, 0) + testLocalGoodnessOfFitEquals(dataSeries, 0, 0) }) test('Series with 5 elements, with 2 noisy datapoints', () => { @@ -192,6 +209,11 @@ test('Series with 5 elements, with 2 noisy datapoints', () => { testSlopeBetween(dataSeries, 2.9, 3.1) testInterceptBetween(dataSeries, -6.3, -5.8) testGoodnessOfFitBetween(dataSeries, 0.9, 1.0) + testLocalGoodnessOfFitEquals(dataSeries, 0, 1) + testLocalGoodnessOfFitEquals(dataSeries, 1, 1) + testLocalGoodnessOfFitEquals(dataSeries, 2, 1) + testLocalGoodnessOfFitEquals(dataSeries, 3, 1) + testLocalGoodnessOfFitEquals(dataSeries, 4, 1) }) function testLength (series, expectedValue) { @@ -265,4 +287,8 @@ function testGoodnessOfFitBetween (series, expectedValueAbove, expectedValueBelo assert.ok(series.goodnessOfFit() < expectedValueBelow, `Expected goodnessOfFit to be below ${expectedValueBelow}, encountered ${series.goodnessOfFit()}`) } +function testLocalGoodnessOfFitEquals (series, position, expectedValue) { + assert.ok(series.localGoodnessOfFit(position) === expectedValue, `Expected localGoodnessOfFit at position ${position}to be ${expectedValue}, encountered ${series.localGoodnessOfFit(position)}`) +} + test.run() From 8ffe6d54a92321598f825c75dee9cb95601abaca Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Wed, 31 Dec 2025 00:12:18 +0100 Subject: [PATCH 041/118] Update TSLinearSeries.js --- app/engine/utils/TSLinearSeries.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/engine/utils/TSLinearSeries.js b/app/engine/utils/TSLinearSeries.js index e8dd460477..08efd3c4f4 100644 --- a/app/engine/utils/TSLinearSeries.js +++ b/app/engine/utils/TSLinearSeries.js @@ -177,6 +177,7 @@ export function createTSLinearSeries (maxSeriesLength = 0) { break case (squaredError > _sst): // This is a pretty bad fit as the error is bigger than just using the line for the average y as intercept + console.log('squaredError > _sst') return 0 break case (_sst !== 0): @@ -184,6 +185,7 @@ export function createTSLinearSeries (maxSeriesLength = 0) { break default: // When _SST = 0, localGoodnessOfFit isn't defined + console.log('_sst === 0') return 0 } /* eslint-enable no-unreachable */ From fb1d14ba9695cc9cee111d0082f0b0de5ee234a3 Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Wed, 31 Dec 2025 00:12:27 +0100 Subject: [PATCH 042/118] Update TSLinearSeries.test.js --- app/engine/utils/TSLinearSeries.test.js | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/app/engine/utils/TSLinearSeries.test.js b/app/engine/utils/TSLinearSeries.test.js index 508b870ec8..e537f19b45 100644 --- a/app/engine/utils/TSLinearSeries.test.js +++ b/app/engine/utils/TSLinearSeries.test.js @@ -30,7 +30,7 @@ test('Correct behaviour of a series after initialisation', () => { testLocalGoodnessOfFitEquals(dataSeries, 0, 0) }) -test('Correct behaviour of a series after several puhed values, function y = 3x + 6, noisefree, 1 datapoint', () => { +test('Correct behaviour of a series after several puhed values, function y = 3x - 6, noisefree, 1 datapoint', () => { const dataSeries = createTSLinearSeries(3) testLength(dataSeries, 0) dataSeries.push(5, 9) @@ -55,7 +55,7 @@ test('Correct behaviour of a series after several puhed values, function y = 3x testLocalGoodnessOfFitEquals(dataSeries, 0, 0) }) -test('Correct behaviour of a series after several puhed values, function y = 3x + 6, noisefree, 2 datapoints', () => { +test('Correct behaviour of a series after several puhed values, function y = 3x - 6, noisefree, 2 datapoints', () => { const dataSeries = createTSLinearSeries(3) dataSeries.push(5, 9) dataSeries.push(3, 3) @@ -81,7 +81,7 @@ test('Correct behaviour of a series after several puhed values, function y = 3x testLocalGoodnessOfFitEquals(dataSeries, 1, 1) }) -test('Correct behaviour of a series after several puhed values, function y = 3x + 6, noisefree, 3 datapoints', () => { +test('Correct behaviour of a series after several puhed values, function y = 3x - 6, noisefree, 3 datapoints', () => { const dataSeries = createTSLinearSeries(3) dataSeries.push(5, 9) dataSeries.push(3, 3) @@ -109,7 +109,7 @@ test('Correct behaviour of a series after several puhed values, function y = 3x testLocalGoodnessOfFitEquals(dataSeries, 2, 1) }) -test('Correct behaviour of a series after several puhed values, function y = 3x + 6, noisefree, 4 datapoints', () => { +test('Correct behaviour of a series after several puhed values, function y = 3x - 6, noisefree, 4 datapoints', () => { const dataSeries = createTSLinearSeries(3) dataSeries.push(5, 9) dataSeries.push(3, 3) @@ -139,7 +139,7 @@ test('Correct behaviour of a series after several puhed values, function y = 3x testLocalGoodnessOfFitEquals(dataSeries, 3, 1) }) -test('Correct behaviour of a series after several puhed values, function y = 3x + 6, noisefree, 5 datapoints', () => { +test('Correct behaviour of a series after several puhed values, function y = 3x - 6, noisefree, 5 datapoints', () => { const dataSeries = createTSLinearSeries(3) dataSeries.push(5, 9) dataSeries.push(3, 3) @@ -171,7 +171,7 @@ test('Correct behaviour of a series after several puhed values, function y = 3x testLocalGoodnessOfFitEquals(dataSeries, 4, 1) }) -test('Correct behaviour of a series after several puhed values, function y = 3x + 6, noisefree, 4 datapoints and a reset', () => { +test('Correct behaviour of a series after several puhed values, function y = 3x - 6, noisefree, 4 datapoints and a reset', () => { const dataSeries = createTSLinearSeries(3) dataSeries.push(5, 9) dataSeries.push(3, 3) @@ -199,7 +199,7 @@ test('Correct behaviour of a series after several puhed values, function y = 3x testLocalGoodnessOfFitEquals(dataSeries, 0, 0) }) -test('Series with 5 elements, with 2 noisy datapoints', () => { +test('Series for function y = 3x - 6, with 5 elements, with 2 noisy datapoints', () => { const dataSeries = createTSLinearSeries(5) dataSeries.push(5, 9) dataSeries.push(3, 2) @@ -209,7 +209,7 @@ test('Series with 5 elements, with 2 noisy datapoints', () => { testSlopeBetween(dataSeries, 2.9, 3.1) testInterceptBetween(dataSeries, -6.3, -5.8) testGoodnessOfFitBetween(dataSeries, 0.9, 1.0) - testLocalGoodnessOfFitEquals(dataSeries, 0, 1) + testLocalGoodnessOfFitEquals(dataSeries, 0, 0.9645892351274787) testLocalGoodnessOfFitEquals(dataSeries, 1, 1) testLocalGoodnessOfFitEquals(dataSeries, 2, 1) testLocalGoodnessOfFitEquals(dataSeries, 3, 1) @@ -288,7 +288,7 @@ function testGoodnessOfFitBetween (series, expectedValueAbove, expectedValueBelo } function testLocalGoodnessOfFitEquals (series, position, expectedValue) { - assert.ok(series.localGoodnessOfFit(position) === expectedValue, `Expected localGoodnessOfFit at position ${position}to be ${expectedValue}, encountered ${series.localGoodnessOfFit(position)}`) + assert.ok(series.localGoodnessOfFit(position) === expectedValue, `Expected localGoodnessOfFit at position ${position} to be ${expectedValue}, encountered ${series.localGoodnessOfFit(position)}`) } test.run() From 6c70ee6f832734a111d36ec7a9b940ea47cce54a Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Wed, 31 Dec 2025 00:15:05 +0100 Subject: [PATCH 043/118] Update TSLinearSeries.js --- app/engine/utils/TSLinearSeries.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/engine/utils/TSLinearSeries.js b/app/engine/utils/TSLinearSeries.js index 08efd3c4f4..7f797e5609 100644 --- a/app/engine/utils/TSLinearSeries.js +++ b/app/engine/utils/TSLinearSeries.js @@ -177,6 +177,7 @@ export function createTSLinearSeries (maxSeriesLength = 0) { break case (squaredError > _sst): // This is a pretty bad fit as the error is bigger than just using the line for the average y as intercept + // eslint-disable-next-line no-console console.log('squaredError > _sst') return 0 break @@ -185,6 +186,7 @@ export function createTSLinearSeries (maxSeriesLength = 0) { break default: // When _SST = 0, localGoodnessOfFit isn't defined + // eslint-disable-next-line no-console console.log('_sst === 0') return 0 } From 9745b91e870d33c9f43f7c1fb367d3117e480d7c Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Wed, 31 Dec 2025 00:21:35 +0100 Subject: [PATCH 044/118] Update TSLinearSeries.test.js --- app/engine/utils/TSLinearSeries.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/engine/utils/TSLinearSeries.test.js b/app/engine/utils/TSLinearSeries.test.js index e537f19b45..ba81a7185c 100644 --- a/app/engine/utils/TSLinearSeries.test.js +++ b/app/engine/utils/TSLinearSeries.test.js @@ -209,8 +209,8 @@ test('Series for function y = 3x - 6, with 5 elements, with 2 noisy datapoints', testSlopeBetween(dataSeries, 2.9, 3.1) testInterceptBetween(dataSeries, -6.3, -5.8) testGoodnessOfFitBetween(dataSeries, 0.9, 1.0) - testLocalGoodnessOfFitEquals(dataSeries, 0, 0.9645892351274787) - testLocalGoodnessOfFitEquals(dataSeries, 1, 1) + testLocalGoodnessOfFitEquals(dataSeries, 0, 1) + testLocalGoodnessOfFitEquals(dataSeries, 1, 0.9645892351274787) testLocalGoodnessOfFitEquals(dataSeries, 2, 1) testLocalGoodnessOfFitEquals(dataSeries, 3, 1) testLocalGoodnessOfFitEquals(dataSeries, 4, 1) From 86dedc7029246c1c05169eff0daa3cea5e5801ec Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Wed, 31 Dec 2025 00:21:46 +0100 Subject: [PATCH 045/118] Update TSLinearSeries.js --- app/engine/utils/TSLinearSeries.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/engine/utils/TSLinearSeries.js b/app/engine/utils/TSLinearSeries.js index 7f797e5609..2d71bb4ea3 100644 --- a/app/engine/utils/TSLinearSeries.js +++ b/app/engine/utils/TSLinearSeries.js @@ -192,6 +192,8 @@ export function createTSLinearSeries (maxSeriesLength = 0) { } /* eslint-enable no-unreachable */ } else { + // eslint-disable-next-line no-console + console.log('_sst === 0') return 0 } } From 6f539c6f5e514474cc0a76badf1f93ba6f1164e6 Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Wed, 31 Dec 2025 00:26:08 +0100 Subject: [PATCH 046/118] Fixed testing errors --- app/engine/utils/TSLinearSeries.test.js | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/app/engine/utils/TSLinearSeries.test.js b/app/engine/utils/TSLinearSeries.test.js index ba81a7185c..1fc8881c09 100644 --- a/app/engine/utils/TSLinearSeries.test.js +++ b/app/engine/utils/TSLinearSeries.test.js @@ -107,6 +107,7 @@ test('Correct behaviour of a series after several puhed values, function y = 3x testLocalGoodnessOfFitEquals(dataSeries, 0, 1) testLocalGoodnessOfFitEquals(dataSeries, 1, 1) testLocalGoodnessOfFitEquals(dataSeries, 2, 1) + testLocalGoodnessOfFitEquals(dataSeries, 3, 0) // Overshooting the length of the series }) test('Correct behaviour of a series after several puhed values, function y = 3x - 6, noisefree, 4 datapoints', () => { @@ -136,7 +137,6 @@ test('Correct behaviour of a series after several puhed values, function y = 3x testLocalGoodnessOfFitEquals(dataSeries, 0, 1) testLocalGoodnessOfFitEquals(dataSeries, 1, 1) testLocalGoodnessOfFitEquals(dataSeries, 2, 1) - testLocalGoodnessOfFitEquals(dataSeries, 3, 1) }) test('Correct behaviour of a series after several puhed values, function y = 3x - 6, noisefree, 5 datapoints', () => { @@ -167,8 +167,6 @@ test('Correct behaviour of a series after several puhed values, function y = 3x testLocalGoodnessOfFitEquals(dataSeries, 0, 1) testLocalGoodnessOfFitEquals(dataSeries, 1, 1) testLocalGoodnessOfFitEquals(dataSeries, 2, 1) - testLocalGoodnessOfFitEquals(dataSeries, 3, 1) - testLocalGoodnessOfFitEquals(dataSeries, 4, 1) }) test('Correct behaviour of a series after several puhed values, function y = 3x - 6, noisefree, 4 datapoints and a reset', () => { @@ -211,9 +209,7 @@ test('Series for function y = 3x - 6, with 5 elements, with 2 noisy datapoints', testGoodnessOfFitBetween(dataSeries, 0.9, 1.0) testLocalGoodnessOfFitEquals(dataSeries, 0, 1) testLocalGoodnessOfFitEquals(dataSeries, 1, 0.9645892351274787) - testLocalGoodnessOfFitEquals(dataSeries, 2, 1) - testLocalGoodnessOfFitEquals(dataSeries, 3, 1) - testLocalGoodnessOfFitEquals(dataSeries, 4, 1) + testLocalGoodnessOfFitEquals(dataSeries, 2, 0.9645892351274787) }) function testLength (series, expectedValue) { From bb0d1c6b3206bafefea56d98da4773cfc95b702e Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Wed, 31 Dec 2025 00:27:05 +0100 Subject: [PATCH 047/118] Removed scaffold code --- app/engine/utils/TSLinearSeries.js | 6 ------ 1 file changed, 6 deletions(-) diff --git a/app/engine/utils/TSLinearSeries.js b/app/engine/utils/TSLinearSeries.js index 2d71bb4ea3..e8dd460477 100644 --- a/app/engine/utils/TSLinearSeries.js +++ b/app/engine/utils/TSLinearSeries.js @@ -177,8 +177,6 @@ export function createTSLinearSeries (maxSeriesLength = 0) { break case (squaredError > _sst): // This is a pretty bad fit as the error is bigger than just using the line for the average y as intercept - // eslint-disable-next-line no-console - console.log('squaredError > _sst') return 0 break case (_sst !== 0): @@ -186,14 +184,10 @@ export function createTSLinearSeries (maxSeriesLength = 0) { break default: // When _SST = 0, localGoodnessOfFit isn't defined - // eslint-disable-next-line no-console - console.log('_sst === 0') return 0 } /* eslint-enable no-unreachable */ } else { - // eslint-disable-next-line no-console - console.log('_sst === 0') return 0 } } From 5a6f13ca5efafaaaa23ef61bdc7b19bfb7626e43 Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Wed, 31 Dec 2025 00:32:11 +0100 Subject: [PATCH 048/118] Added additional tests --- app/engine/utils/TSLinearSeries.test.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/engine/utils/TSLinearSeries.test.js b/app/engine/utils/TSLinearSeries.test.js index 1fc8881c09..7169262b18 100644 --- a/app/engine/utils/TSLinearSeries.test.js +++ b/app/engine/utils/TSLinearSeries.test.js @@ -210,6 +210,8 @@ test('Series for function y = 3x - 6, with 5 elements, with 2 noisy datapoints', testLocalGoodnessOfFitEquals(dataSeries, 0, 1) testLocalGoodnessOfFitEquals(dataSeries, 1, 0.9645892351274787) testLocalGoodnessOfFitEquals(dataSeries, 2, 0.9645892351274787) + testLocalGoodnessOfFitEquals(dataSeries, 3, 1) + testLocalGoodnessOfFitEquals(dataSeries, 4, 1) }) function testLength (series, expectedValue) { From e78c9e7454a5f6b89170adde358fd24e4e686e07 Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Wed, 31 Dec 2025 22:23:24 +0100 Subject: [PATCH 049/118] Refactor applyFilter() function to make interface cleaner --- app/engine/utils/CyclicErrorFilter.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/app/engine/utils/CyclicErrorFilter.js b/app/engine/utils/CyclicErrorFilter.js index 716adc4e26..402909aa42 100644 --- a/app/engine/utils/CyclicErrorFilter.js +++ b/app/engine/utils/CyclicErrorFilter.js @@ -28,6 +28,7 @@ export function createCyclicErrorFilter (rowerSettings, minimumDragFactorSamples const _maximumTimeBetweenImpulses = rowerSettings.maximumTimeBetweenImpulses const raw = createSeries(_flankLength) const clean = createSeries(_flankLength) + const goodnessOfFit = createSeries(_flankLength) const linearRegressor = deltaTime let recordedRelativePosition = [] let recordedAbsolutePosition = [] @@ -46,11 +47,18 @@ export function createCyclicErrorFilter (rowerSettings, minimumDragFactorSamples /** * @param {integer} the maximum length of the linear series, 0 for unlimited + * @returns {{value: float, goodnessOfFit: float}} clean value and goodness of fit indication */ function applyFilter (rawValue, position) { if (startPosition === undefined) { startPosition = position + _flankLength } + const magnet = position % _numberOfMagnets raw.push(rawValue) - clean.push(projectX(position % _numberOfMagnets, rawValue)) + clean.push(projectX(magnet, rawValue)) + goodnessOfFit.push(filterArray[magnet].goodnessOfFit()) + return { + value: clean.atSeriesEnd(), + goodnessOfFit: goodnessOfFit.atSeriesEnd() + } } /** From becac8081d401e0eac7d1ab197c80ef6894621f8 Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Wed, 31 Dec 2025 22:27:22 +0100 Subject: [PATCH 050/118] Refactor applyFilter() function to make interface cleaner --- app/engine/Flywheel.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/engine/Flywheel.js b/app/engine/Flywheel.js index 95ae72744a..915b220a06 100644 --- a/app/engine/Flywheel.js +++ b/app/engine/Flywheel.js @@ -113,12 +113,12 @@ export function createFlywheel (rowerSettings) { _torqueBeforeFlank = 0 } - currentDt.applyFilter(dataPoint, totalNumberOfImpulses + flankLength) - totalTime += currentDt.clean.atSeriesEnd() + const cleanCurrentDt = currentDt.applyFilter(dataPoint, totalNumberOfImpulses + flankLength) + totalTime += cleanCurrentDt.value currentAngularDistance += angularDisplacementPerImpulse // Let's feed the stroke detection algorithm - _deltaTime.push(totalTime, currentDt.clean.atSeriesEnd()) + _deltaTime.push(totalTime, cleanCurrentDt.value) // Calculate the metrics that are needed for more advanced metrics, like the foce curve _angularDistance.push(totalTime, currentAngularDistance) From 86dec42c7d53e01c817f8b13c1d4ed647915dffc Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Wed, 31 Dec 2025 22:31:35 +0100 Subject: [PATCH 051/118] Improved comments --- app/engine/utils/CyclicErrorFilter.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/engine/utils/CyclicErrorFilter.js b/app/engine/utils/CyclicErrorFilter.js index 402909aa42..36c005e658 100644 --- a/app/engine/utils/CyclicErrorFilter.js +++ b/app/engine/utils/CyclicErrorFilter.js @@ -46,8 +46,10 @@ export function createCyclicErrorFilter (rowerSettings, minimumDragFactorSamples reset() /** - * @param {integer} the maximum length of the linear series, 0 for unlimited + * @param {float} the raw recorded value to be cleaned up + * @param {integer} the position of the flywheel * @returns {{value: float, goodnessOfFit: float}} clean value and goodness of fit indication + * @description Applies the filter on the raw value for the given position (i.e. magnet). Please note: this function is NOT stateless, it also fills a hystoric buffer of raw and clean values */ function applyFilter (rawValue, position) { if (startPosition === undefined) { startPosition = position + _flankLength } From 5ddeb3500ea2282e8514caf0e3e0a2463a834386 Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Wed, 31 Dec 2025 22:36:53 +0100 Subject: [PATCH 052/118] Renamed cyclicErrorFilter --- app/engine/Flywheel.js | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/app/engine/Flywheel.js b/app/engine/Flywheel.js index 915b220a06..ed522d39d2 100644 --- a/app/engine/Flywheel.js +++ b/app/engine/Flywheel.js @@ -39,7 +39,7 @@ export function createFlywheel (rowerSettings) { const _deltaTime = createTSLinearSeries(flankLength) const drag = createWeighedSeries(rowerSettings.dragFactorSmoothing, (rowerSettings.dragFactor / 1000000)) const recoveryDeltaTime = createTSLinearSeries() - const currentDt = createCyclicErrorFilter(rowerSettings, minimumDragFactorSamples, recoveryDeltaTime) + const cyclicErrorFilter = createCyclicErrorFilter(rowerSettings, minimumDragFactorSamples, recoveryDeltaTime) const strokedetectionMinimalGoodnessOfFit = rowerSettings.minimumStrokeQuality const minimumRecoverySlope = createWeighedSeries(rowerSettings.dragFactorSmoothing, rowerSettings.minimumRecoverySlope) let totalTime @@ -88,7 +88,7 @@ export function createFlywheel (rowerSettings) { // 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 = currentDt.clean.atSeriesBegin() + _deltaTimeBeforeFlank = cyclicErrorFilter.clean.atSeriesBegin() totalTimeSpinning += _deltaTimeBeforeFlank _angularVelocityBeforeFlank = _angularVelocityAtBeginFlank _angularAccelerationBeforeFlank = _angularAccelerationAtBeginFlank @@ -99,12 +99,12 @@ export function createFlywheel (rowerSettings) { // Feed the drag calculation, as we didn't reset the Semaphore in the previous cycle based on the current flank recoveryDeltaTime.push(totalTimeSpinning, _deltaTimeBeforeFlank) // Feed the systematic error filter buffer - if (rowerSettings.autoAdjustDragFactor) { currentDt.recordRawDatapoint(totalNumberOfImpulses, totalTimeSpinning, currentDt.raw.atSeriesBegin()) } + if (rowerSettings.autoAdjustDragFactor) { cyclicErrorFilter.recordRawDatapoint(totalNumberOfImpulses, totalTimeSpinning, cyclicErrorFilter.raw.atSeriesBegin()) } } else { // Accumulate the energy total as we are in the drive phase _totalWork += Math.max(_torqueBeforeFlank * angularDisplacementPerImpulse, 0) // Process a value in the systematic error filter buffer. We need to do this slowly to prevent radical changes which might disturbe the force curve etc. - currentDt.processNextRawDatapoint() + cyclicErrorFilter.processNextRawDatapoint() } } else { _deltaTimeBeforeFlank = 0 @@ -113,7 +113,7 @@ export function createFlywheel (rowerSettings) { _torqueBeforeFlank = 0 } - const cleanCurrentDt = currentDt.applyFilter(dataPoint, totalNumberOfImpulses + flankLength) + const cleanCurrentDt = cyclicErrorFilter.applyFilter(dataPoint, totalNumberOfImpulses + flankLength) totalTime += cleanCurrentDt.value currentAngularDistance += angularDisplacementPerImpulse @@ -134,13 +134,13 @@ export function createFlywheel (rowerSettings) { function maintainStateAndMetrics () { maintainMetrics = true - currentDt.reset() + cyclicErrorFilter.reset() } function markRecoveryPhaseStart () { inRecoveryPhase = true recoveryDeltaTime.reset() - currentDt.restart() + cyclicErrorFilter.restart() } /** @@ -169,7 +169,7 @@ export function createFlywheel (rowerSettings) { 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)}`) - currentDt.restart() + cyclicErrorFilter.restart() } } } @@ -364,8 +364,8 @@ export function createFlywheel (rowerSettings) { maintainMetrics = false inRecoveryPhase = false drag.reset() - currentDt.reset() - currentDt.applyFilter(0, flankLength - 1) + cyclicErrorFilter.reset() + cyclicErrorFilter.applyFilter(0, flankLength - 1) recoveryDeltaTime.reset() _deltaTime.reset() _angularDistance.reset() From 870878b9f4571b562c29296958f7d5eb7511ad5a Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Wed, 31 Dec 2025 23:28:46 +0100 Subject: [PATCH 053/118] Added bugfix --- docs/Release_Notes.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/Release_Notes.md b/docs/Release_Notes.md index bd7261f9ac..7a1f54e01b 100644 --- a/docs/Release_Notes.md +++ b/docs/Release_Notes.md @@ -16,9 +16,10 @@ Main contributors: [Jaap van Ekris](https://github.com/JaapvanEkris), with suppo - Introduced the 'Local Goodness of Fit' function to improve the robustness against noise. This reduces the effect of outliers on stroke detection, the Force curve, Power curve and Handle speed curve - Introduction of a 'Gaussian Weight' filter to reduce the effects of flanks on the regression in a specific datapoint - Added documentation about the mathematical foundations of the algorithms used -- Upgrade of the flywheel position pre-filter, which now can handle systematic errors of flywheel positioning. This is more effective at reducing measurement noise and allows a reduction of the code complexity in `Flyhweel.js` as all dependent algorithms can use the same datastream again. +- Upgrade of the flywheel position pre-filter, which now can handle systematic errors of magnet positioning on the flywheel. This is more effective at reducing structural measurement noise and allows a reduction of the code complexity in `Flyhweel.js` as all dependent algorithms can use the same datastream again. - Fixed a bug in the initialisation of the `Flywheel.js` - Improved logging in the Strava uploader for better troubleshooting (see [issue 145](https://github.com/JaapvanEkris/openrowingmonitor/issues/145)) +- Fixed a bug where VO2Max calculation missed heartrate data (see [this discussion](https://github.com/JaapvanEkris/openrowingmonitor/discussions/156)) - Increased the test coverage of key algorithms ## Version 0.9.6 (June 2025) From bd869bbb60c50b6688c7a34bf40b69e6cda7a957 Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Wed, 31 Dec 2025 23:32:04 +0100 Subject: [PATCH 054/118] Improved wording --- docs/Release_Notes.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/Release_Notes.md b/docs/Release_Notes.md index 7a1f54e01b..f89fd91ad7 100644 --- a/docs/Release_Notes.md +++ b/docs/Release_Notes.md @@ -11,16 +11,16 @@ Main contributors: [Jaap van Ekris](https://github.com/JaapvanEkris), with suppo ### Bugfixes and robustness improvements in 0.9.7 -- Improvement of the Moving Least Squares regressor: +- **Improvement of the Moving Least Squares regressor**: - Code refactoring to isolate this function from `Flywheel.js`, allowing a more thorough testing of this function's behaviour - Introduced the 'Local Goodness of Fit' function to improve the robustness against noise. This reduces the effect of outliers on stroke detection, the Force curve, Power curve and Handle speed curve - Introduction of a 'Gaussian Weight' filter to reduce the effects of flanks on the regression in a specific datapoint - Added documentation about the mathematical foundations of the algorithms used -- Upgrade of the flywheel position pre-filter, which now can handle systematic errors of magnet positioning on the flywheel. This is more effective at reducing structural measurement noise and allows a reduction of the code complexity in `Flyhweel.js` as all dependent algorithms can use the same datastream again. -- Fixed a bug in the initialisation of the `Flywheel.js` -- Improved logging in the Strava uploader for better troubleshooting (see [issue 145](https://github.com/JaapvanEkris/openrowingmonitor/issues/145)) -- Fixed a bug where VO2Max calculation missed heartrate data (see [this discussion](https://github.com/JaapvanEkris/openrowingmonitor/discussions/156)) -- Increased the test coverage of key algorithms +- **Upgrade of the flywheel systematic error filter**, which now can handle systematic errors of magnet positioning on the flywheel. This is more effective at reducing structural measurement noise and allows a reduction of the code complexity in `Flyhweel.js` as all dependent algorithms can use the same datastream again. +- **Fixed a bug in the initialisation of the `Flywheel.js`** +- **Improved logging in the Strava uploader** for better troubleshooting (see [issue 145](https://github.com/JaapvanEkris/openrowingmonitor/issues/145)) +- **Fixed a bug where VO2Max calculation missed heartrate data** (see [this discussion](https://github.com/JaapvanEkris/openrowingmonitor/discussions/156)) +- **Increased the test coverage of key algorithms** ## Version 0.9.6 (June 2025) From faa1f105c687e4d43508e9833e5e0d5f0ef94e57 Mon Sep 17 00:00:00 2001 From: "David C." Date: Wed, 31 Dec 2025 14:52:19 -0800 Subject: [PATCH 055/118] Small Update to Installation Docs (#161) * Updated installation docs to require rPi OS Legacy (Bookworm) Due to pigpio compatibility issues in Trixie * Fixed typos in README documentation. Corrected spelling errors throughout the README. --------- Co-authored-by: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> --- docs/README.md | 16 ++++++++-------- docs/installation.md | 3 ++- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/docs/README.md b/docs/README.md index 1aac2c524b..4b5aa8b17e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -23,7 +23,7 @@ The following items describe most of the current features in more detail. ### Rowing Metrics -OpenRowingMonitor calculates the typical metrics of a rowing machine, where the underlying parameters can be tuned to the specifics of a rower machine via the configuration file. We maintain [settings for machines alrrady known to us](Supported_Rowers.md). The underlying software is structurally validated against a Concept2 PM5 in over 300 sessions (totalling over 3 million meters), and results deviate less than 0.1% for each individual rowing session. +OpenRowingMonitor calculates the typical metrics of a rowing machine, where the parameters can be tuned to the specifics of a rower machine by changing the configuration file. We maintain [settings for machines already known to us](Supported_Rowers.md). The underlying software is structurally validated against a Concept2 PM5 in over 300 sessions (totalling over 3 million meters), and results deviate less than 0.1% for every individual rowing session. OpenRowingMonitor can display the following key metrics on the user interface: @@ -61,7 +61,7 @@ OpenRowingMonitor can recieve heartrate data via Bluetooth Low Energy (BLE) and * **FTMS Rower**: This is the FTMS profile for rowing machines and supports all key rowing specific metrics (such as pace and stroke rate). We've successfully tested it with [EXR](https://www.exrgame.com), [Peleton](https://www.onepeloton.com/app), [MyHomeFit](https://myhomefit.de) and [Kinomap](https://www.kinomap.com). -* **ANT+ FE-C**: OpenRowingMonitor can broadcast rowing metrics via ANT+ FE-C, which can be recieved by several series of Garmin smartwatches like the Epix/Fenix series, which then can calculate metrics like training load etc.. +* **ANT+ FE-C**: OpenRowingMonitor can broadcast rowing metrics via ANT+ FE-C, which can be received by several series of Garmin smartwatches like the Epix/Fenix series, which then can calculate metrics like training load etc.. * **FTMS Indoor Bike**: This FTMS profile is used by Smart Bike Trainers and widely adopted by bike training apps. It does not support rowing specific metrics, but it can present metrics such as power and distance to the biking application and use cadence for stroke rate. So why not use your virtual rowing bike to row up a mountain in [Zwift](https://www.zwift.com), [Bkool](https://www.bkool.com), [The Sufferfest](https://thesufferfest.com) or similar :-) @@ -76,19 +76,19 @@ OpenRowingMonitor can recieve heartrate data via Bluetooth Low Energy (BLE) and ### Export of Training Sessions -OpenRowingMonitor is based on the idea your metrics should be easily accessible for further analysis on data platforms. Automatic uploading your sessions to [RowsAndAll](https://rowsandall.com/), [Intervals.icu](https://intervals.icu/) and [Strava](https://www.strava.com) is an integrated feature. For other platforms this is a manual step, see [the integration manual](Integrations.md). To allow the data upload, OpenRowingMonitor can create the following file types: +OpenRowingMonitor is based on the idea your metrics should be easily accessible for further analysis on data platforms. Automatic uploading your sessions to [RowsAndAll](https://rowsandall.com/), [Intervals.icu](https://intervals.icu/) and [Strava](https://www.strava.com) is an integrated feature. For other platforms this is currently a manual step, see [the integration manual](Integrations.md). To allow the data upload, OpenRowingMonitor can create the following file types: -* **RowingData** files, which are comma-seperated files with all metrics OpenRowingMonitor can produce. These can be used with [RowingData](https://pypi.org/project/rowingdata/) to display your results locally, or uploaded to [RowsAndAll](https://rowsandall.com/) for a webbased analysis (including dynamic in-stroke metrics). The csv-files can also be processed manually in Excel, allowing your own custom analysis; +* **RowingData** files, which are comma-separated files with all metrics OpenRowingMonitor can produce. These can be used with [RowingData](https://pypi.org/project/rowingdata/) to display your results locally, or uploaded to [RowsAndAll](https://rowsandall.com/) for a webbased analysis (including dynamic in-stroke metrics). The csv-files can also be processed manually in Excel, allowing your own custom analysis; -* **Garmin FIT files**: These are binairy files that contain the most interesting metrics of a rowing session. Most modern training analysis tools will accept a FIT-file. You can upload these files to training platforms like [Strava](https://www.strava.com), [Garmin Connect](https://connect.garmin.com), [Intervals.icu](https://intervals.icu/), [RowsAndAll](https://rowsandall.com/) or [Trainingpeaks](https://trainingpeaks.com) to track your training sessions; +* **Garmin FIT files**: These are binary files that contain the most interesting metrics of a rowing session. Most modern training analysis tools will accept a FIT-file. You can upload these files to training platforms like [Strava](https://www.strava.com), [Garmin Connect](https://connect.garmin.com), [Intervals.icu](https://intervals.icu/), [RowsAndAll](https://rowsandall.com/) or [Trainingpeaks](https://trainingpeaks.com) to track your training sessions; -* **Training Center XML files (TCX)**: These are legacy XML-files that contain the most essential metrics of a rowing session. Most training analysis tools will still accept a tcx-file (although FIT usually is recomended). You can upload these files to training platforms like [Strava](https://www.strava.com), [Garmin Connect](https://connect.garmin.com), [Intervals.icu](https://intervals.icu/), [RowsAndAll](https://rowsandall.com/) or [Trainingpeaks](https://trainingpeaks.com) to track your training sessions; +* **Training Center XML files (TCX)**: These are legacy XML files that contain the most essential metrics of a rowing session. Most training analysis tools will still accept a tcx-file (although FIT usually is recommended). You can upload these files to training platforms like [Strava](https://www.strava.com), [Garmin Connect](https://connect.garmin.com), [Intervals.icu](https://intervals.icu/), [RowsAndAll](https://rowsandall.com/) or [Trainingpeaks](https://trainingpeaks.com) to track your training sessions; The OpenRowingMonitor installer can also set up a network share that contains all training data so it is easy to grab the files from there and manually upload them to the training platform of your choice. ## Installation -You will need a Raspberry Pi Zero 2 W, Raspberry Pi 3, Raspberry Pi 4 with a fresh installation of Raspberry Pi OS Lite for this (the 64Bit kernel is recomended). Connect to the device with SSH and just follow the [Detailed Installation Instructions](installation.md) and you'll get a working monitor. This guide will help you install the software and explain how to connect the rowing machine. If you can follow the guide, it will work. If you run into issues, you can always [drop a question in the GitHub Discussions](https://github.com/JaapvanEkris/openrowingmonitor/discussions), and there always is someone to help you. +You will need a Raspberry Pi Zero 2 W, Raspberry Pi 3, Raspberry Pi 4 with a fresh installation of Raspberry Pi OS Lite for this (the 64Bit kernel is recommended). Connect to the device with SSH and just follow the [Detailed Installation Instructions](installation.md) and you'll get a working monitor. This guide will help you install the software and explain how to connect the rowing machine. If you can follow the guide, it will work. If you run into issues, you can always [drop a question in the GitHub Discussions](https://github.com/JaapvanEkris/openrowingmonitor/discussions), and there always is someone to help you. > [!IMPORTANT] > Due to architecture differences, both the Raspberry Pi Zero W (see [this discussion for more information](https://github.com/JaapvanEkris/openrowingmonitor/discussions/33)) and Raspberry Pi 5 (see [this discussion for more information](https://github.com/JaapvanEkris/openrowingmonitor/issues/52)) will **not** work. @@ -102,6 +102,6 @@ This project is in a very stable stage, as it is used daily by many rowers. The This is a larger team effort and OpenRowingMonitor had much direct and indirect support by many people during the years, see the [Attribution to these people here](attribution.md). You can see its development throughout the years [here in the Release notes](Release_Notes.md). Our work is never done, so more functionality will be added in the future, so check the [Development Roadmap](backlog.md) if you are curious. -Contributions to improve OpenRowingMonitor further are always welcome! To get an idea how this all works, you can read the [Archtecture description](Architecture.md), the [Physics of OpenRowingMonitor (for advanced readers)](physics_openrowingmonitor.md) and [Contributing Guidelines](CONTRIBUTING.md) how you can help us improve this project. +Contributions to improve OpenRowingMonitor further are always welcome! To get an idea how this all works, you can read the [Architecture description](Architecture.md), the [Physics of OpenRowingMonitor (for advanced readers)](physics_openrowingmonitor.md) and [Contributing Guidelines](CONTRIBUTING.md) how you can help us improve this project. Feel free to leave a message in the [GitHub Discussions](https://github.com/JaapvanEkris/openrowingmonitor/discussions) if you have any questions or ideas related to this project. diff --git a/docs/installation.md b/docs/installation.md index 935a0b4d55..b8597704af 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -33,7 +33,8 @@ The cheapest solution is a headless Raspberry Pi Zero 2W (roughly $15), the most ### Initialization of the Raspberry Pi -- Install **Raspberry Pi OS Lite** on the SD Card i.e. with the [Raspberry Pi Imager](https://www.raspberrypi.org/software). Here, Raspberry Pi OS Lite 64 Bit is recommended as it is better suited for real-time environments. This can be done by selecting "other" Raspberry Pi OS in the imager and select OS Lite 64 Bit. We typically support the current and previous (Legacy) version of Raspberry Pi OS. + +- Install **Raspberry Pi OS Lite (Legacy 64-bit)** on the SD Card i.e. with the [Raspberry Pi Imager](https://www.raspberrypi.org/software). This can be done by selecting "Raspberry Pi OS (other)" in the imager and then selecting "Raspberry Pi OS Lite (Legacy) 64-bit". The Legacy version is based on Debian 12 (Bookworm) and is required for compatibility - the current version of Raspberry Pi OS (based on Debian 13 Trixie) is not yet supported. - In the Raspbverry Pi Imager, configure the network connection and enable SSH. In the Raspberry Pi Imager, you can automatically do this while writing the SD Card, just press `Ctrl-Shift-X`(see [here](https://www.raspberrypi.org/blog/raspberry-pi-imager-update-to-v1-6/) for a description), otherwise follow the instructions below - Connect the device to your network ([headless](https://www.raspberrypi.org/documentation/configuration/wireless/headless.md) or via [command line](https://www.raspberrypi.org/documentation/configuration/wireless/wireless-cli.md)) - Enable [SSH](https://www.raspberrypi.org/documentation/remote-access/ssh/README.md) From 5e3f9a2bad6905ca97950789902f310be9c5918f Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Thu, 1 Jan 2026 16:24:23 +0100 Subject: [PATCH 056/118] Improvement of restart/reset behaviour of the cyclicErrorFilter --- app/engine/Flywheel.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/engine/Flywheel.js b/app/engine/Flywheel.js index ed522d39d2..3611cb81a4 100644 --- a/app/engine/Flywheel.js +++ b/app/engine/Flywheel.js @@ -134,13 +134,13 @@ export function createFlywheel (rowerSettings) { function maintainStateAndMetrics () { maintainMetrics = true - cyclicErrorFilter.reset() + cyclicErrorFilter.coldRestart() } function markRecoveryPhaseStart () { inRecoveryPhase = true recoveryDeltaTime.reset() - cyclicErrorFilter.restart() + cyclicErrorFilter.warmRestart() } /** @@ -169,7 +169,7 @@ export function createFlywheel (rowerSettings) { 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)}`) - cyclicErrorFilter.restart() + cyclicErrorFilter.warmRestart() } } } From 62880fae69b0ca3ea4af60f7899cbc8e61ea45ed Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Thu, 1 Jan 2026 16:28:06 +0100 Subject: [PATCH 057/118] Improvement of the restart/reset behaviour --- app/engine/utils/CyclicErrorFilter.js | 35 +++++++++++++++++++++------ 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/app/engine/utils/CyclicErrorFilter.js b/app/engine/utils/CyclicErrorFilter.js index 36c005e658..0061f2af2b 100644 --- a/app/engine/utils/CyclicErrorFilter.js +++ b/app/engine/utils/CyclicErrorFilter.js @@ -43,7 +43,7 @@ export function createCyclicErrorFilter (rowerSettings, minimumDragFactorSamples let interceptSum = 0 let slopeCorrection = 1 let interceptCorrection = 0 - reset() + coldRestart() /** * @param {float} the raw recorded value to be cleaned up @@ -126,8 +126,11 @@ export function createCyclicErrorFilter (rowerSettings, minimumDragFactorSamples interceptCorrection = interceptSum / _numberOfMagnets } - function restart () { - if (!isNaN(lowerCursor)) { log.debug('*** WARNING: cyclic error filter has forcefully been restarted') } + /** + * @description This is used for clearing the buffers in order to prepare to record for a new set of datapoints, or clear it when the buffer is filled with a recovery with too weak GoF + */ + function warmRestart () { + if (!isNaN(lowerCursor)) { log.debug('*** WARNING: cyclic error filter has forcefully been restarted (warm)') } recordedRelativePosition = [] recordedAbsolutePosition = [] recordedRawValue = [] @@ -135,18 +138,22 @@ export function createCyclicErrorFilter (rowerSettings, minimumDragFactorSamples upperCursor = undefined } - function reset () { - if (slopeSum !== _numberOfMagnets || interceptSum !== 0) { log.debug('*** WARNING: cyclic error filter is reset') } + /** + * @description This is used for clearing the predictive buffers as the flywheel seems to have stopped + */ + function coldRestart () { + if (slopeSum !== _numberOfMagnets || interceptSum !== 0) { log.debug('*** WARNING: cyclic error filter has forcefully been restarted (cold)') } const noIncrements = Math.max(Math.ceil(_numberOfFilterSamples / 4), 5) const increment = (_maximumTimeBetweenImpulses - _minimumTimeBetweenImpulses) / noIncrements lowerCursor = undefined - restart() + warmRestart() let i = 0 let j = 0 let datapoint = 0 while (i < _numberOfMagnets) { + if (!!filterArray[i].slope()) {filterArray[i].reset()} filterArray[i] = {} filterArray[i] = createWLSLinearSeries(_numberOfFilterSamples) j = 0 @@ -166,6 +173,19 @@ export function createCyclicErrorFilter (rowerSettings, minimumDragFactorSamples startPosition = undefined } + /** + * @description This is used for clearing all buffers (i.e. the currentDt's maintained in the flank and the predictive buffers) when the flywheel is completely reset + */ + function reset () { + log.debug('*** WARNING: cyclic error filter is reset') + slopeSum = _numberOfMagnets + interceptSum = 0 + coldRestart() + raw.reset() + clean.reset() + goodnessOfFit.reset() + } + return { applyFilter, recordRawDatapoint, @@ -173,7 +193,8 @@ export function createCyclicErrorFilter (rowerSettings, minimumDragFactorSamples updateFilter, raw, clean, - restart, + warmRestart, + coldRestart, reset } } From 4a10b489e752807ed29a869a49982e3f06fa7e5b Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Thu, 1 Jan 2026 16:46:37 +0100 Subject: [PATCH 058/118] Improved handling of reset --- app/engine/utils/CyclicErrorFilter.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/app/engine/utils/CyclicErrorFilter.js b/app/engine/utils/CyclicErrorFilter.js index 0061f2af2b..9795bbed64 100644 --- a/app/engine/utils/CyclicErrorFilter.js +++ b/app/engine/utils/CyclicErrorFilter.js @@ -153,9 +153,11 @@ export function createCyclicErrorFilter (rowerSettings, minimumDragFactorSamples let j = 0 let datapoint = 0 while (i < _numberOfMagnets) { - if (!!filterArray[i].slope()) {filterArray[i].reset()} - filterArray[i] = {} - filterArray[i] = createWLSLinearSeries(_numberOfFilterSamples) + if (i < filterArray.length) { + filterArray[i]?.reset() + } else { + filterArray[i] = createWLSLinearSeries(_numberOfFilterSamples) + } j = 0 while (j <= noIncrements) { datapoint = _maximumTimeBetweenImpulses - (j * increment) From 5ea5a73d51ebfdd3b91ecddff82418714c9bfe93 Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Thu, 1 Jan 2026 20:05:02 +0100 Subject: [PATCH 059/118] Improved JSDoc comments --- app/engine/utils/Series.js | 42 +++++++++++++++++++------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/app/engine/utils/Series.js b/app/engine/utils/Series.js index 15a67fd9d0..7cdf6ca2ce 100644 --- a/app/engine/utils/Series.js +++ b/app/engine/utils/Series.js @@ -1,11 +1,10 @@ 'use strict' -/* - Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor -*/ /** - * This creates a series with a maximum number of values. It allows for determining the Average, Median, Number of Positive, number of Negative - * @remark BE AWARE: The median function is extremely CPU intensive for larger series. Use the BinarySearchTree for that situation instead! - * + * @copyright [OpenRowingMonitor]{@link https://github.com/JaapvanEkris/openrowingmonitor} + * + * @file This creates a series with a maximum number of values. It allows for determining the Average, Median, Number of Positive, number of Negative BE AWARE: The median function is extremely CPU intensive for larger series. Use the BinarySearchTree for that situation instead! + */ +/** * @param {number} [maxSeriesLength] The maximum length of the series (0 for unlimited) */ export function createSeries (maxSeriesLength = 0) { @@ -55,14 +54,14 @@ export function createSeries (maxSeriesLength = 0) { } /** - * @output {number} length of the series + * @returns {number} length of the series */ function length () { return seriesArray.length } /** - * @output {float} value at the head of the series (i.e. the one first added) + * @returns {float} the oldest value of the series (i.e. the one first added) */ function atSeriesBegin () { if (seriesArray.length > 0) { @@ -73,7 +72,7 @@ export function createSeries (maxSeriesLength = 0) { } /** - * @output {float} value at the tail of the series (i.e. the one last added) + * @returns {float} the youngest value of the series (i.e. the one last added) */ function atSeriesEnd () { if (seriesArray.length > 0) { @@ -84,8 +83,8 @@ export function createSeries (maxSeriesLength = 0) { } /** - * @param {number} position - * @output {float} value at a specific postion, starting at 0 + * @param {integer} position to be retrieved, starting at 0 + * @returns {float} value at that specific postion in the series */ function get (position) { if (position >= 0 && position < seriesArray.length) { @@ -96,8 +95,8 @@ export function createSeries (maxSeriesLength = 0) { } /** - * @param {number} testedValue - * @output {number} number of values in the series above the tested value + * @param {float} tested value + * @returns {integer} count of values in the series above the tested value */ function numberOfValuesAbove (testedValue) { if (testedValue === 0) { @@ -116,8 +115,8 @@ export function createSeries (maxSeriesLength = 0) { } /** - * @param {number} testedValue - * @output {number} number of values in the series below or equal to the tested value + * @param {float} tested value + * @returns {integer} number of values in the series below or equal to the tested value */ function numberOfValuesEqualOrBelow (testedValue) { if (testedValue === 0) { @@ -136,14 +135,14 @@ export function createSeries (maxSeriesLength = 0) { } /** - * @output {float} sum of the entire series + * @returns {float} sum of the entire series */ function sum () { return seriesSum } /** - * @output {float} average of the entire series + * @returns {float} average of the entire series */ function average () { if (seriesArray.length > 0) { @@ -154,7 +153,7 @@ export function createSeries (maxSeriesLength = 0) { } /** - * @output {float} smallest element in the series + * @returns {float} smallest element in the series */ function minimum () { if (seriesArray.length > 0) { @@ -166,7 +165,7 @@ export function createSeries (maxSeriesLength = 0) { } /** - * @output {float} largest value in the series + * @returns {float} largest value in the series */ function maximum () { if (seriesArray.length > 0) { @@ -178,7 +177,8 @@ export function createSeries (maxSeriesLength = 0) { } /** - * @output {float} median of the series (DO NOT USE FOR LARGE SERIES!) + * @returns {float} median of the series + * @description returns the median of the series. As this is a CPU intensive approach, DO NOT USE FOR LARGE SERIES!. For larger series, use the BinarySearchTree.js instead */ function median () { if (seriesArray.length > 0) { @@ -191,7 +191,7 @@ export function createSeries (maxSeriesLength = 0) { } /** - * @output {array} returns the entire series + * @returns {array} returns the entire series */ function series () { if (seriesArray.length > 0) { From acd79da613fc3eb357d3d3ccdec19bdf18267024 Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Thu, 1 Jan 2026 20:07:36 +0100 Subject: [PATCH 060/118] Added totalWeight() function --- app/engine/utils/WeighedSeries.js | 78 +++++++++++++++++++++++++++---- 1 file changed, 70 insertions(+), 8 deletions(-) diff --git a/app/engine/utils/WeighedSeries.js b/app/engine/utils/WeighedSeries.js index 8581597f5b..3f7e015a1e 100644 --- a/app/engine/utils/WeighedSeries.js +++ b/app/engine/utils/WeighedSeries.js @@ -1,18 +1,23 @@ 'use strict' -/* - Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor - - This creates a series with a maximum number of values - It allows for determining the Average, Median, Number of Positive, number of Negative -*/ - +/** + * @copyright [OpenRowingMonitor]{@link https://github.com/JaapvanEkris/openrowingmonitor} + * + * @file This creates a weighed series with a maximum number of values. It allows for determining the Average, Weighed Averge, Median, Number of Positive, number of Negative. DO NOT USE MEDIAN ON LARGE SERIES! + */ import { createSeries } from './Series.js' -export function createWeighedSeries (maxSeriesLength, defaultValue) { +/** + * @param {integer} the maximum length of the weighed series, 0 for unlimited + */ +export function createWeighedSeries (maxSeriesLength = 0, defaultValue) { const dataArray = createSeries(maxSeriesLength) const weightArray = createSeries(maxSeriesLength) const weightedArray = createSeries(maxSeriesLength) + /** + * @param {float} the value of the datapoint + * @param {float} the weight of the datapoint + */ function push (value, weight) { if (value === undefined || isNaN(value) || weight === undefined || isNaN(weight)) { return } dataArray.push(value) @@ -20,34 +25,61 @@ export function createWeighedSeries (maxSeriesLength, defaultValue) { weightedArray.push(value * weight) } + /** + * @returns {integer} the lenght of the stored series + */ function length () { return dataArray.length() } + /** + * @returns {float} the oldest value of the series (i.e. the one first added) + */ function atSeriesBegin () { return dataArray.atSeriesBegin() } + /** + * @returns {float} the youngest value of the series (i.e. the one last added) + */ function atSeriesEnd () { return dataArray.atSeriesEnd() } + /** + * @param {integer} position to be retrieved, starting at 0 + * @returns {float} value at that specific postion in the series + */ function get (position) { return dataArray.get(position) } + /** + * @param {float} tested value + * @returns {integer} count of values in the series above the tested value + */ function numberOfValuesAbove (testedValue) { return dataArray.numberOfValuesAbove(testedValue) } + /** + * @param {float} tested value + * @returns {integer} number of values in the series below or equal to the tested value + */ function numberOfValuesEqualOrBelow (testedValue) { return dataArray.numberOfValuesEqualOrBelow(testedValue) } + /** + * @returns {float} sum of the entire series + */ function sum () { return dataArray.sum() } + /** + * @returns {float} average of the entire series + */ function average () { if (dataArray.length() > 0) { // The series contains sufficient values to be valid @@ -58,6 +90,9 @@ export function createWeighedSeries (maxSeriesLength, defaultValue) { } } + /** + * @returns {float} the weighed average of the series + */ function weighedAverage () { if (dataArray.length() > 0 && weightArray.sum() !== 0) { return (weightedArray.sum() / weightArray.sum()) @@ -66,26 +101,52 @@ export function createWeighedSeries (maxSeriesLength, defaultValue) { } } + /** + * @returns {float} the total weight the series + */ + function totalWeight () { + return weightArray.sum() + } + + /** + * @returns {float} smallest element in the series + */ function minimum () { return dataArray.minimum() } + /** + * @returns {float} largest value in the series + */ function maximum () { return dataArray.maximum() } + /** + * @returns {float} median of the series + * @description returns the median of the series. As this is a CPU intensive approach, DO NOT USE FOR LARGE SERIES!. For larger series, use the BinarySearchTree.js instead + */ function median () { return dataArray.median() } + /** + * @returns {boolean} if the weighed series results are to be considered reliable + */ function reliable () { return dataArray.length() > 0 } + /** + * @returns {array} returns the entire series of datapoints + */ function series () { return dataArray.series() } + /** + * Resets the series to its initial state + */ function reset () { dataArray.reset() weightArray.reset() @@ -103,6 +164,7 @@ export function createWeighedSeries (maxSeriesLength, defaultValue) { sum, average, weighedAverage, + totalWeight, minimum, maximum, median, From 3f325816b7ce2a5034b536f420aa369b94c46ef3 Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Thu, 1 Jan 2026 20:11:39 +0100 Subject: [PATCH 061/118] Improved JSDoc comments --- app/engine/utils/WeighedSeries.js | 1 + 1 file changed, 1 insertion(+) diff --git a/app/engine/utils/WeighedSeries.js b/app/engine/utils/WeighedSeries.js index 3f7e015a1e..8538191304 100644 --- a/app/engine/utils/WeighedSeries.js +++ b/app/engine/utils/WeighedSeries.js @@ -8,6 +8,7 @@ import { createSeries } from './Series.js' /** * @param {integer} the maximum length of the weighed series, 0 for unlimited + * @param {float|undefined} the default value to return if a function can't calculate a value */ export function createWeighedSeries (maxSeriesLength = 0, defaultValue) { const dataArray = createSeries(maxSeriesLength) From 139477fefcbc0155dc5daac1e3a26deadeda63b4 Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Thu, 1 Jan 2026 20:38:44 +0100 Subject: [PATCH 062/118] Removed totalWeight function --- app/engine/utils/WeighedSeries.js | 8 -------- 1 file changed, 8 deletions(-) diff --git a/app/engine/utils/WeighedSeries.js b/app/engine/utils/WeighedSeries.js index 8538191304..0d5aaddad9 100644 --- a/app/engine/utils/WeighedSeries.js +++ b/app/engine/utils/WeighedSeries.js @@ -102,13 +102,6 @@ export function createWeighedSeries (maxSeriesLength = 0, defaultValue) { } } - /** - * @returns {float} the total weight the series - */ - function totalWeight () { - return weightArray.sum() - } - /** * @returns {float} smallest element in the series */ @@ -165,7 +158,6 @@ export function createWeighedSeries (maxSeriesLength = 0, defaultValue) { sum, average, weighedAverage, - totalWeight, minimum, maximum, median, From ef99480b4c7e021372dd979dd0ac30b0aab4a577 Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Thu, 1 Jan 2026 20:47:27 +0100 Subject: [PATCH 063/118] Added a new test --- app/engine/utils/WLSLinearSeries.test.js | 83 ++++++++++++++++-------- 1 file changed, 56 insertions(+), 27 deletions(-) diff --git a/app/engine/utils/WLSLinearSeries.test.js b/app/engine/utils/WLSLinearSeries.test.js index 0328faeef8..91cc0c867e 100644 --- a/app/engine/utils/WLSLinearSeries.test.js +++ b/app/engine/utils/WLSLinearSeries.test.js @@ -1,7 +1,9 @@ 'use strict' -/* - Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor -*/ +/** + * @copyright [OpenRowingMonitor]{@link https://github.com/JaapvanEkris/openrowingmonitor} + * + * @file This constains all tests for the WLS Linear Series + */ import { test } from 'uvu' import * as assert from 'uvu/assert' @@ -128,7 +130,7 @@ test('Correct behaviour of a series after several puhed values, function y = 3x testGoodnessOfFitEquals(dataSeries, 1) }) -test('Correct behaviour of a series after several puhed values, function y = 3x + 6, noisefree, 5 datapoints', () => { +test('Correct behaviour of an unweighted series after several puhed values, function y = 3x + 6, noisefree, 5 datapoints', () => { const dataSeries = createWLSLinearSeries(3) dataSeries.push(5, 9, 1) dataSeries.push(3, 3, 1) @@ -155,31 +157,31 @@ test('Correct behaviour of a series after several puhed values, function y = 3x testGoodnessOfFitEquals(dataSeries, 1) }) -test('Correct behaviour of a series after several puhed values, function y = 3x + 6, noisefree, 4 datapoints and a reset', () => { +test('Correct behaviour of a uniformly weighted series after several puhed values, function y = 3x + 6, noisefree, 5 datapoints', () => { const dataSeries = createWLSLinearSeries(3) - dataSeries.push(5, 9, 1) - dataSeries.push(3, 3, 1) - dataSeries.push(4, 6, 1) - dataSeries.push(6, 12, 1) - dataSeries.reset() - testLength(dataSeries, 0) - testXAtSeriesBegin(dataSeries, 0) - testYAtSeriesBegin(dataSeries, 0) - testXAtSeriesEnd(dataSeries, 0) - testYAtSeriesEnd(dataSeries, 0) - testNumberOfXValuesAbove(dataSeries, 0, 0) - testNumberOfYValuesAbove(dataSeries, 0, 0) + dataSeries.push(5, 9, 0.5) + dataSeries.push(3, 3, 0.5) + dataSeries.push(4, 6, 0.5) + dataSeries.push(6, 12, 0.5) + dataSeries.push(1, -3, 0.5) + testLength(dataSeries, 3) + testXAtSeriesBegin(dataSeries, 4) + testYAtSeriesBegin(dataSeries, 6) + testXAtSeriesEnd(dataSeries, 1) + testYAtSeriesEnd(dataSeries, -3) + testNumberOfXValuesAbove(dataSeries, 0, 3) + testNumberOfYValuesAbove(dataSeries, 0, 2) testNumberOfXValuesEqualOrBelow(dataSeries, 0, 0) - testNumberOfYValuesEqualOrBelow(dataSeries, 0, 0) + testNumberOfYValuesEqualOrBelow(dataSeries, 0, 1) testNumberOfXValuesAbove(dataSeries, 10, 0) - testNumberOfYValuesAbove(dataSeries, 10, 0) - testNumberOfXValuesEqualOrBelow(dataSeries, 10, 0) - testNumberOfYValuesEqualOrBelow(dataSeries, 10, 0) - testXSum(dataSeries, 0) - testYSum(dataSeries, 0) - testSlopeEquals(dataSeries, 0) - testInterceptEquals(dataSeries, 0) - testGoodnessOfFitEquals(dataSeries, 0) + testNumberOfYValuesAbove(dataSeries, 10, 1) + testNumberOfXValuesEqualOrBelow(dataSeries, 10, 3) + testNumberOfYValuesEqualOrBelow(dataSeries, 10, 2) + testXSum(dataSeries, 11) + testYSum(dataSeries, 15) + testSlopeEquals(dataSeries, 3) + testInterceptEquals(dataSeries, -6) + testGoodnessOfFitEquals(dataSeries, 1) }) test('Series with 5 elements, with 2 noisy datapoints', () => { @@ -212,7 +214,7 @@ test('Unweighted series with 7 elements based on Galton dataset (OLS)', () => { // Test based on the Galton dataset, using weighted (=WLS) regression // Example found at https://online.stat.psu.edu/stat501/lesson/13/13.1 -test('Weighted series with 7 elements based on Galton dataset (WLS)', () => { +test('Non-uniformly weighted series with 7 elements based on Galton dataset (WLS)', () => { const dataSeries = createWLSLinearSeries(7) dataSeries.push(0.21, 0.1726, 2530.272176) dataSeries.push(0.2, 0.1707, 2662.5174) @@ -226,6 +228,33 @@ test('Weighted series with 7 elements based on Galton dataset (WLS)', () => { testGoodnessOfFitEquals(dataSeries, 0.8521213232768868) }) +test('Correct reset behaviour. Series with 4 datapoints and a reset', () => { + const dataSeries = createWLSLinearSeries(3) + dataSeries.push(5, 9, 1) + dataSeries.push(3, 3, 1) + dataSeries.push(4, 6, 1) + dataSeries.push(6, 12, 1) + dataSeries.reset() + testLength(dataSeries, 0) + testXAtSeriesBegin(dataSeries, 0) + testYAtSeriesBegin(dataSeries, 0) + testXAtSeriesEnd(dataSeries, 0) + testYAtSeriesEnd(dataSeries, 0) + testNumberOfXValuesAbove(dataSeries, 0, 0) + testNumberOfYValuesAbove(dataSeries, 0, 0) + testNumberOfXValuesEqualOrBelow(dataSeries, 0, 0) + testNumberOfYValuesEqualOrBelow(dataSeries, 0, 0) + testNumberOfXValuesAbove(dataSeries, 10, 0) + testNumberOfYValuesAbove(dataSeries, 10, 0) + testNumberOfXValuesEqualOrBelow(dataSeries, 10, 0) + testNumberOfYValuesEqualOrBelow(dataSeries, 10, 0) + testXSum(dataSeries, 0) + testYSum(dataSeries, 0) + testSlopeEquals(dataSeries, 0) + testInterceptEquals(dataSeries, 0) + testGoodnessOfFitEquals(dataSeries, 0) +}) + function testLength (series, expectedValue) { assert.ok(series.length() === expectedValue, `Expected length should be ${expectedValue}, encountered a ${series.length()}`) } From 58557f07ccd47be775c6a44ef3ced8cdd3cf5b93 Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Thu, 1 Jan 2026 22:25:11 +0100 Subject: [PATCH 064/118] Improved JSDoc comments --- app/engine/utils/CyclicErrorFilter.js | 30 +++++++++++++++++---------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/app/engine/utils/CyclicErrorFilter.js b/app/engine/utils/CyclicErrorFilter.js index 9795bbed64..3c5dc2bef3 100644 --- a/app/engine/utils/CyclicErrorFilter.js +++ b/app/engine/utils/CyclicErrorFilter.js @@ -1,9 +1,8 @@ 'use strict' -/* - Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor -*/ /** - * This implements a cyclic error filter. This is used to create a profile + * @copyright [OpenRowingMonitor]{@link https://github.com/JaapvanEkris/openrowingmonitor} + * + * @file This implements a cyclic error filter. This is used to create a profile * The filterArray does the calculation, the slope and intercept arrays contain the results for easy retrieval * the slopeCorrection and interceptCorrection ensure preventing time dilation due to excessive corrections */ @@ -16,7 +15,7 @@ const log = loglevel.getLogger('RowingEngine') /** * @param {{numOfImpulsesPerRevolution: integer, flankLength: integer, systematicErrorAgressiveness: float, minimumTimeBetweenImpulses: float, maximumTimeBetweenImpulses: float}}the rower settings configuration object * @param {integer} the number of expected dragfactor samples - * @param the linear regression function for the drag calculation + * @param {function} the linear regression function for the drag calculation */ export function createCyclicErrorFilter (rowerSettings, minimumDragFactorSamples, deltaTime) { const _numberOfMagnets = rowerSettings.numOfImpulsesPerRevolution @@ -55,8 +54,14 @@ export function createCyclicErrorFilter (rowerSettings, minimumDragFactorSamples if (startPosition === undefined) { startPosition = position + _flankLength } const magnet = position % _numberOfMagnets raw.push(rawValue) - clean.push(projectX(magnet, rawValue)) - goodnessOfFit.push(filterArray[magnet].goodnessOfFit()) + if (_agressiveness > 0) { + clean.push(projectX(magnet, rawValue)) + goodnessOfFit.push(filterArray[magnet].goodnessOfFit()) + } else { + // In essence, the filter is turned off + clean.push(rawValue) + goodnessOfFit.push(1) + } return { value: clean.atSeriesEnd(), goodnessOfFit: goodnessOfFit.atSeriesEnd() @@ -86,7 +91,7 @@ export function createCyclicErrorFilter (rowerSettings, minimumDragFactorSamples } /** - * This processes a next datapoint from the queue + * @description This processes a next two datapoints from the queue */ function processNextRawDatapoint () { let perfectCurrentDt @@ -114,6 +119,9 @@ export function createCyclicErrorFilter (rowerSettings, minimumDragFactorSamples upperCursor-- } + /** + * @description Helper function to actually update the filter and calculate all dependent parameters + */ function updateFilter (magnet, rawDatapoint, correctedDatapoint, goodnessOfFit) { slopeSum -= slope[magnet] interceptSum -= intercept[magnet] @@ -127,7 +135,7 @@ export function createCyclicErrorFilter (rowerSettings, minimumDragFactorSamples } /** - * @description This is used for clearing the buffers in order to prepare to record for a new set of datapoints, or clear it when the buffer is filled with a recovery with too weak GoF + * @description This function is used for clearing the buffers in order to prepare to record for a new set of datapoints, or clear it when the buffer is filled with a recovery with too weak GoF */ function warmRestart () { if (!isNaN(lowerCursor)) { log.debug('*** WARNING: cyclic error filter has forcefully been restarted (warm)') } @@ -139,7 +147,7 @@ export function createCyclicErrorFilter (rowerSettings, minimumDragFactorSamples } /** - * @description This is used for clearing the predictive buffers as the flywheel seems to have stopped + * @description This function is used for clearing the predictive buffers as the flywheel seems to have stopped */ function coldRestart () { if (slopeSum !== _numberOfMagnets || interceptSum !== 0) { log.debug('*** WARNING: cyclic error filter has forcefully been restarted (cold)') } @@ -176,7 +184,7 @@ export function createCyclicErrorFilter (rowerSettings, minimumDragFactorSamples } /** - * @description This is used for clearing all buffers (i.e. the currentDt's maintained in the flank and the predictive buffers) when the flywheel is completely reset + * @description This function is used for clearing all buffers (i.e. the currentDt's maintained in the flank and the predictive buffers) when the flywheel is completely reset */ function reset () { log.debug('*** WARNING: cyclic error filter is reset') From 296fb9311701bcf7a92cf51cc1a399527fc30563 Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Thu, 1 Jan 2026 22:39:24 +0100 Subject: [PATCH 065/118] Improved JSDoc comments --- app/engine/utils/CyclicErrorFilter.js | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/app/engine/utils/CyclicErrorFilter.js b/app/engine/utils/CyclicErrorFilter.js index 3c5dc2bef3..4f890c10c9 100644 --- a/app/engine/utils/CyclicErrorFilter.js +++ b/app/engine/utils/CyclicErrorFilter.js @@ -13,9 +13,14 @@ import { createWLSLinearSeries } from './WLSLinearSeries.js' const log = loglevel.getLogger('RowingEngine') /** - * @param {{numOfImpulsesPerRevolution: integer, flankLength: integer, systematicErrorAgressiveness: float, minimumTimeBetweenImpulses: float, maximumTimeBetweenImpulses: float}}the rower settings configuration object - * @param {integer} the number of expected dragfactor samples - * @param {function} the linear regression function for the drag calculation + * @param {object} rowerSettings - The rower settings configuration object + * @param {integer} rowerSettings.numOfImpulsesPerRevolution - Number of impulses per flywheel revolution + * @param {integer} rowerSettings.flankLength - Length of the flank used + * @param {float} rowerSettings.systematicErrorAgressiveness - Agressiveness of the systematic error correction algorithm (0 turns it off) + * @param {float} rowerSettings.minimumTimeBetweenImpulses - minimum expected time between impulses (in seconds) + * @param {float} rowerSettings.maximumTimeBetweenImpulses - maximum expected time between impulses (in seconds) + * @param {integer} minimumDragFactorSamples - the number of expected dragfactor samples + * @param {function} deltaTime - injection of the linear regression function used for the drag calculation */ export function createCyclicErrorFilter (rowerSettings, minimumDragFactorSamples, deltaTime) { const _numberOfMagnets = rowerSettings.numOfImpulsesPerRevolution @@ -47,7 +52,9 @@ export function createCyclicErrorFilter (rowerSettings, minimumDragFactorSamples /** * @param {float} the raw recorded value to be cleaned up * @param {integer} the position of the flywheel - * @returns {{value: float, goodnessOfFit: float}} clean value and goodness of fit indication + * @returns {object} result + * @returns {float} result.value - the resulting clean value + * @returns {float} result.goodnessOfFit - The goodness of fit indication for the specific datapoint * @description Applies the filter on the raw value for the given position (i.e. magnet). Please note: this function is NOT stateless, it also fills a hystoric buffer of raw and clean values */ function applyFilter (rawValue, position) { @@ -69,8 +76,8 @@ export function createCyclicErrorFilter (rowerSettings, minimumDragFactorSamples } /** - * @param {integer} the magnet number - * @param {float} the raw value to be projected by the function for that magnet + * @param {integer} magnet - the magnet number + * @param {float} rawValue - the raw value to be projected by the function for that magnet * @returns {float} projected result */ function projectX (magnet, rawValue) { @@ -78,9 +85,9 @@ export function createCyclicErrorFilter (rowerSettings, minimumDragFactorSamples } /** - * @param {integer} the position of the recorded datapoint (i.e the sequence number of the datapoint) - * @param {float} the total spinning time of the flywheel - * @param {float} the raw value + * @param {integer} relativePosition - the position of the recorded datapoint (i.e the sequence number of the datapoint) + * @param {float} absolutePosition - the total spinning time of the flywheel + * @param {float} rawValue - the raw value */ function recordRawDatapoint (relativePosition, absolutePosition, rawValue) { if (_agressiveness > 0 && rawValue >= _minimumTimeBetweenImpulses && _maximumTimeBetweenImpulses >= rawValue) { From 28415068a87689d5098246b8ab0e6ac493b2958e Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Thu, 1 Jan 2026 23:03:03 +0100 Subject: [PATCH 066/118] Improved JSDoc comments --- app/engine/utils/WLSLinearSeries.js | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/app/engine/utils/WLSLinearSeries.js b/app/engine/utils/WLSLinearSeries.js index 5f43063e39..255ad93dac 100644 --- a/app/engine/utils/WLSLinearSeries.js +++ b/app/engine/utils/WLSLinearSeries.js @@ -1,11 +1,10 @@ 'use strict' -/* - Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor -*/ /** - * The WLSLinearSeries is a datatype that represents a Linear Series. It allows + * @copyright [OpenRowingMonitor]{@link https://github.com/JaapvanEkris/openrowingmonitor} + * + * @file The WLSLinearSeries is a datatype that represents a Linear Series. It allows * values to be retrieved (like a FiFo buffer, or Queue) but it also includes - * a Weighted Linear Regressor to determine the slope, intercept and R^2 of this timeseries + * a Weighted Linear Regressor to determine the slope, intercept and R^2 of this series * of x and y coordinates through Weighted Least Squares Regression. * * At creation it can be determined that the Series is limited (i.e. after it @@ -22,14 +21,13 @@ * For weighted least squares: * https://en.wikipedia.org/wiki/Weighted_least_squares */ - import { createSeries } from './Series.js' import loglevel from 'loglevel' const log = loglevel.getLogger('RowingEngine') /** - * @param {integer} the maximum length of the linear series, 0 for unlimited + * @param {integer} maxSeriesLength - the maximum length of the linear series, default = 0 for unlimited */ export function createWLSLinearSeries (maxSeriesLength = 0) { const X = createSeries(maxSeriesLength) @@ -45,9 +43,9 @@ export function createWLSLinearSeries (maxSeriesLength = 0) { let _goodnessOfFit = 0 /** - * @param {float} the x value of the datapoint - * @param {float} the y value of the datapoint - * @param {float} the observation weight of the datapoint + * @param {float} x - the x value of the datapoint + * @param {float} y - the y value of the datapoint + * @param {float} w - the observation weight of the datapoint, default = 1 */ function push (x, y, w = 1) { if (x === undefined || isNaN(x) || y === undefined || isNaN(y)) { return } @@ -118,7 +116,7 @@ export function createWLSLinearSeries (maxSeriesLength = 0) { } /** - * @param {float} the x value to be projected + * @param {float} x - the x value to be projected * @returns {float} the resulting y value when projected via the linear function */ function projectX (x) { @@ -130,8 +128,8 @@ export function createWLSLinearSeries (maxSeriesLength = 0) { } /** - * @param {float} the y value to be (reverse) projected - * @returns {float} the resulting x value when projected via the linear function + * @param {float} y - the y value to be solved + * @returns {float} the resulting x value when solved via the linear function */ function projectY (y) { if (X.length() >= 2 && _slope !== 0) { From 507098af92ca34689d944942ecae672f2029f45b Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Fri, 2 Jan 2026 00:05:09 +0100 Subject: [PATCH 067/118] Added test for Goodness Of Fit and weights --- app/engine/utils/WLSLinearSeries.test.js | 45 +++++++++++++++--------- 1 file changed, 28 insertions(+), 17 deletions(-) diff --git a/app/engine/utils/WLSLinearSeries.test.js b/app/engine/utils/WLSLinearSeries.test.js index 91cc0c867e..5379a5dc48 100644 --- a/app/engine/utils/WLSLinearSeries.test.js +++ b/app/engine/utils/WLSLinearSeries.test.js @@ -184,16 +184,38 @@ test('Correct behaviour of a uniformly weighted series after several puhed value testGoodnessOfFitEquals(dataSeries, 1) }) -test('Series with 5 elements, with 2 noisy datapoints', () => { +test('Series with 5 elements, with 2 noisy datapoints, uniform weights', () => { const dataSeries = createWLSLinearSeries(5) dataSeries.push(5, 9, 1) dataSeries.push(3, 2, 1) dataSeries.push(4, 7, 1) dataSeries.push(6, 12, 1) dataSeries.push(1, -3, 1) - testSlopeBetween(dataSeries, 2.9, 3.1) - testInterceptBetween(dataSeries, -6.3, -5.8) - testGoodnessOfFitBetween(dataSeries, 0.9, 1.0) + testSlopeEquals(dataSeries, 3) + testInterceptEquals(dataSeries, -6) + testGoodnessOfFitEquals(dataSeries, 1) + testLocalGoodnessOfFitEquals(dataSeries, 0, 1) + testLocalGoodnessOfFitEquals(dataSeries, 1, 0.9645892351274787) + testLocalGoodnessOfFitEquals(dataSeries, 2, 0.9645892351274787) + testLocalGoodnessOfFitEquals(dataSeries, 3, 1) + testLocalGoodnessOfFitEquals(dataSeries, 4, 1) +}) + +test('Series with 5 elements, with 2 noisy datapoints, non-uniform weights', () => { + const dataSeries = createWLSLinearSeries(5) + dataSeries.push(5, 9, 1) + dataSeries.push(3, 2, 0.5) + dataSeries.push(4, 7, 0.5) + dataSeries.push(6, 12, 1) + dataSeries.push(1, -3, 1) + testSlopeEquals(dataSeries, 3) + testInterceptEquals(dataSeries, -6) + testGoodnessOfFitEquals(dataSeries, 1) + testLocalGoodnessOfFitEquals(dataSeries, 0, 1) + testLocalGoodnessOfFitEquals(dataSeries, 1, 0.9645892351274787) + testLocalGoodnessOfFitEquals(dataSeries, 2, 0.9645892351274787) + testLocalGoodnessOfFitEquals(dataSeries, 3, 1) + testLocalGoodnessOfFitEquals(dataSeries, 4, 1) }) // Test based on the Galton dataset, using unweighted (=OLS) regression @@ -303,27 +325,16 @@ function testSlopeEquals (series, expectedValue) { assert.ok(series.slope() === expectedValue, `Expected slope to be ${expectedValue}, encountered a ${series.slope()}`) } -function testSlopeBetween (series, expectedValueAbove, expectedValueBelow) { - assert.ok(series.slope() > expectedValueAbove, `Expected slope to be above ${expectedValueAbove}, encountered a ${series.slope()}`) - assert.ok(series.slope() < expectedValueBelow, `Expected slope to be below ${expectedValueBelow}, encountered a ${series.slope()}`) -} - function testInterceptEquals (series, expectedValue) { assert.ok(series.intercept() === expectedValue, `Expected intercept to be ${expectedValue}, encountered ${series.intercept()}`) } -function testInterceptBetween (series, expectedValueAbove, expectedValueBelow) { - assert.ok(series.intercept() > expectedValueAbove, `Expected intercept to be above ${expectedValueAbove}, encountered ${series.intercept()}`) - assert.ok(series.intercept() < expectedValueBelow, `Expected intercept to be below ${expectedValueBelow}, encountered ${series.intercept()}`) -} - function testGoodnessOfFitEquals (series, expectedValue) { assert.ok(series.goodnessOfFit() === expectedValue, `Expected goodnessOfFit to be ${expectedValue}, encountered ${series.goodnessOfFit()}`) } -function testGoodnessOfFitBetween (series, expectedValueAbove, expectedValueBelow) { - assert.ok(series.goodnessOfFit() > expectedValueAbove, `Expected goodnessOfFit to be above ${expectedValueAbove}, encountered ${series.goodnessOfFit()}`) - assert.ok(series.goodnessOfFit() < expectedValueBelow, `Expected goodnessOfFit to be below ${expectedValueBelow}, encountered ${series.goodnessOfFit()}`) +function testLocalGoodnessOfFitEquals (series, position, expectedValue) { + assert.ok(series.localGoodnessOfFit(position) === expectedValue, `Expected localGoodnessOfFit at position ${position} to be ${expectedValue}, encountered ${series.localGoodnessOfFit(position)}`) } test.run() From 1b8eb2d9528ed27e81a1470b77eef0b430fd401a Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Fri, 2 Jan 2026 00:08:21 +0100 Subject: [PATCH 068/118] Update WLSLinearSeries.test.js --- app/engine/utils/WLSLinearSeries.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/engine/utils/WLSLinearSeries.test.js b/app/engine/utils/WLSLinearSeries.test.js index 5379a5dc48..ec0b115457 100644 --- a/app/engine/utils/WLSLinearSeries.test.js +++ b/app/engine/utils/WLSLinearSeries.test.js @@ -191,7 +191,7 @@ test('Series with 5 elements, with 2 noisy datapoints, uniform weights', () => { dataSeries.push(4, 7, 1) dataSeries.push(6, 12, 1) dataSeries.push(1, -3, 1) - testSlopeEquals(dataSeries, 3) + testSlopeEquals(dataSeries, 3.0675675675675675) testInterceptEquals(dataSeries, -6) testGoodnessOfFitEquals(dataSeries, 1) testLocalGoodnessOfFitEquals(dataSeries, 0, 1) @@ -208,7 +208,7 @@ test('Series with 5 elements, with 2 noisy datapoints, non-uniform weights', () dataSeries.push(4, 7, 0.5) dataSeries.push(6, 12, 1) dataSeries.push(1, -3, 1) - testSlopeEquals(dataSeries, 3) + testSlopeEquals(dataSeries, 3.034632034632035) testInterceptEquals(dataSeries, -6) testGoodnessOfFitEquals(dataSeries, 1) testLocalGoodnessOfFitEquals(dataSeries, 0, 1) From b0397b9c7497edc6847cd3adb40f2521e2f8769e Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Fri, 2 Jan 2026 00:11:18 +0100 Subject: [PATCH 069/118] Update WLSLinearSeries.test.js --- app/engine/utils/WLSLinearSeries.test.js | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/app/engine/utils/WLSLinearSeries.test.js b/app/engine/utils/WLSLinearSeries.test.js index ec0b115457..f710e7317b 100644 --- a/app/engine/utils/WLSLinearSeries.test.js +++ b/app/engine/utils/WLSLinearSeries.test.js @@ -31,7 +31,7 @@ test('Correct behaviour of a series after initialisation', () => { testGoodnessOfFitEquals(dataSeries, 0) }) -test('Correct behaviour of a series after several puhed values, function y = 3x + 6, noisefree, 1 datapoint', () => { +test('Correct behaviour of a series after several puhed values, function y = 3x - 6, noisefree, 1 datapoint', () => { const dataSeries = createWLSLinearSeries(3) testLength(dataSeries, 0) dataSeries.push(5, 9, 1) @@ -55,7 +55,7 @@ test('Correct behaviour of a series after several puhed values, function y = 3x testGoodnessOfFitEquals(dataSeries, 0) }) -test('Correct behaviour of a series after several puhed values, function y = 3x + 6, noisefree, 2 datapoints', () => { +test('Correct behaviour of a series after several puhed values, function y = 3x - 6, noisefree, 2 datapoints', () => { const dataSeries = createWLSLinearSeries(3) dataSeries.push(5, 9, 1) dataSeries.push(3, 3, 1) @@ -79,7 +79,7 @@ test('Correct behaviour of a series after several puhed values, function y = 3x testGoodnessOfFitEquals(dataSeries, 1) }) -test('Correct behaviour of a series after several puhed values, function y = 3x + 6, noisefree, 3 datapoints', () => { +test('Correct behaviour of a series after several puhed values, function y = 3x - 6, noisefree, 3 datapoints', () => { const dataSeries = createWLSLinearSeries(3) dataSeries.push(5, 9, 1) dataSeries.push(3, 3, 1) @@ -104,7 +104,7 @@ test('Correct behaviour of a series after several puhed values, function y = 3x testGoodnessOfFitEquals(dataSeries, 1) }) -test('Correct behaviour of a series after several puhed values, function y = 3x + 6, noisefree, 4 datapoints', () => { +test('Correct behaviour of a series after several puhed values, function y = 3x - 6, noisefree, 4 datapoints', () => { const dataSeries = createWLSLinearSeries(3) dataSeries.push(5, 9, 1) dataSeries.push(3, 3, 1) @@ -130,7 +130,7 @@ test('Correct behaviour of a series after several puhed values, function y = 3x testGoodnessOfFitEquals(dataSeries, 1) }) -test('Correct behaviour of an unweighted series after several puhed values, function y = 3x + 6, noisefree, 5 datapoints', () => { +test('Correct behaviour of an unweighted series after several puhed values, function y = 3x - 6, noisefree, 5 datapoints', () => { const dataSeries = createWLSLinearSeries(3) dataSeries.push(5, 9, 1) dataSeries.push(3, 3, 1) @@ -157,7 +157,7 @@ test('Correct behaviour of an unweighted series after several puhed values, func testGoodnessOfFitEquals(dataSeries, 1) }) -test('Correct behaviour of a uniformly weighted series after several puhed values, function y = 3x + 6, noisefree, 5 datapoints', () => { +test('Correct behaviour of a uniformly weighted series after several puhed values, function y = 3x - 6, noisefree, 5 datapoints', () => { const dataSeries = createWLSLinearSeries(3) dataSeries.push(5, 9, 0.5) dataSeries.push(3, 3, 0.5) @@ -184,7 +184,7 @@ test('Correct behaviour of a uniformly weighted series after several puhed value testGoodnessOfFitEquals(dataSeries, 1) }) -test('Series with 5 elements, with 2 noisy datapoints, uniform weights', () => { +test('Series with 5 elements, with 2 noisy datapoints, ideal function y = 3x - 6, uniform weights', () => { const dataSeries = createWLSLinearSeries(5) dataSeries.push(5, 9, 1) dataSeries.push(3, 2, 1) @@ -192,7 +192,7 @@ test('Series with 5 elements, with 2 noisy datapoints, uniform weights', () => { dataSeries.push(6, 12, 1) dataSeries.push(1, -3, 1) testSlopeEquals(dataSeries, 3.0675675675675675) - testInterceptEquals(dataSeries, -6) + testInterceptEquals(dataSeries, -6.256756756756756) testGoodnessOfFitEquals(dataSeries, 1) testLocalGoodnessOfFitEquals(dataSeries, 0, 1) testLocalGoodnessOfFitEquals(dataSeries, 1, 0.9645892351274787) @@ -201,7 +201,7 @@ test('Series with 5 elements, with 2 noisy datapoints, uniform weights', () => { testLocalGoodnessOfFitEquals(dataSeries, 4, 1) }) -test('Series with 5 elements, with 2 noisy datapoints, non-uniform weights', () => { +test('Series with 5 elements, with 2 noisy datapoints, ideal function y = 3x - 6, non-uniform weights', () => { const dataSeries = createWLSLinearSeries(5) dataSeries.push(5, 9, 1) dataSeries.push(3, 2, 0.5) @@ -209,7 +209,7 @@ test('Series with 5 elements, with 2 noisy datapoints, non-uniform weights', () dataSeries.push(6, 12, 1) dataSeries.push(1, -3, 1) testSlopeEquals(dataSeries, 3.034632034632035) - testInterceptEquals(dataSeries, -6) + testInterceptEquals(dataSeries, -6.134199134199134) testGoodnessOfFitEquals(dataSeries, 1) testLocalGoodnessOfFitEquals(dataSeries, 0, 1) testLocalGoodnessOfFitEquals(dataSeries, 1, 0.9645892351274787) From 42e1e7a4744f3f1248a10bb91fd4be9ffdc1fc85 Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Fri, 2 Jan 2026 00:14:01 +0100 Subject: [PATCH 070/118] Update WLSLinearSeries.test.js --- app/engine/utils/WLSLinearSeries.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/engine/utils/WLSLinearSeries.test.js b/app/engine/utils/WLSLinearSeries.test.js index f710e7317b..70c550834f 100644 --- a/app/engine/utils/WLSLinearSeries.test.js +++ b/app/engine/utils/WLSLinearSeries.test.js @@ -193,7 +193,7 @@ test('Series with 5 elements, with 2 noisy datapoints, ideal function y = 3x - 6 dataSeries.push(1, -3, 1) testSlopeEquals(dataSeries, 3.0675675675675675) testInterceptEquals(dataSeries, -6.256756756756756) - testGoodnessOfFitEquals(dataSeries, 1) + testGoodnessOfFitEquals(dataSeries, 0.9863142179006205) testLocalGoodnessOfFitEquals(dataSeries, 0, 1) testLocalGoodnessOfFitEquals(dataSeries, 1, 0.9645892351274787) testLocalGoodnessOfFitEquals(dataSeries, 2, 0.9645892351274787) @@ -210,7 +210,7 @@ test('Series with 5 elements, with 2 noisy datapoints, ideal function y = 3x - 6 dataSeries.push(1, -3, 1) testSlopeEquals(dataSeries, 3.034632034632035) testInterceptEquals(dataSeries, -6.134199134199134) - testGoodnessOfFitEquals(dataSeries, 1) + testGoodnessOfFitEquals(dataSeries, 0.9926631153882663) testLocalGoodnessOfFitEquals(dataSeries, 0, 1) testLocalGoodnessOfFitEquals(dataSeries, 1, 0.9645892351274787) testLocalGoodnessOfFitEquals(dataSeries, 2, 0.9645892351274787) From e20bb279877b050229d75d87916715fc987ac605 Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Fri, 2 Jan 2026 00:20:29 +0100 Subject: [PATCH 071/118] Update WLSLinearSeries.test.js --- app/engine/utils/WLSLinearSeries.test.js | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/app/engine/utils/WLSLinearSeries.test.js b/app/engine/utils/WLSLinearSeries.test.js index 70c550834f..61f09bc04c 100644 --- a/app/engine/utils/WLSLinearSeries.test.js +++ b/app/engine/utils/WLSLinearSeries.test.js @@ -194,11 +194,11 @@ test('Series with 5 elements, with 2 noisy datapoints, ideal function y = 3x - 6 testSlopeEquals(dataSeries, 3.0675675675675675) testInterceptEquals(dataSeries, -6.256756756756756) testGoodnessOfFitEquals(dataSeries, 0.9863142179006205) - testLocalGoodnessOfFitEquals(dataSeries, 0, 1) - testLocalGoodnessOfFitEquals(dataSeries, 1, 0.9645892351274787) - testLocalGoodnessOfFitEquals(dataSeries, 2, 0.9645892351274787) - testLocalGoodnessOfFitEquals(dataSeries, 3, 1) - testLocalGoodnessOfFitEquals(dataSeries, 4, 1) + testXProjectionEquals(dataSeries, 1, -3) + testXProjectionEquals(dataSeries, 3, 2) + testXProjectionEquals(dataSeries, 4, 7) + testXProjectionEquals(dataSeries, 5, 9) + testXProjectionEquals(dataSeries, 6, 12) }) test('Series with 5 elements, with 2 noisy datapoints, ideal function y = 3x - 6, non-uniform weights', () => { @@ -211,11 +211,11 @@ test('Series with 5 elements, with 2 noisy datapoints, ideal function y = 3x - 6 testSlopeEquals(dataSeries, 3.034632034632035) testInterceptEquals(dataSeries, -6.134199134199134) testGoodnessOfFitEquals(dataSeries, 0.9926631153882663) - testLocalGoodnessOfFitEquals(dataSeries, 0, 1) - testLocalGoodnessOfFitEquals(dataSeries, 1, 0.9645892351274787) - testLocalGoodnessOfFitEquals(dataSeries, 2, 0.9645892351274787) - testLocalGoodnessOfFitEquals(dataSeries, 3, 1) - testLocalGoodnessOfFitEquals(dataSeries, 4, 1) + testXProjectionEquals(dataSeries, 1, -3) + testXProjectionEquals(dataSeries, 3, 2) + testXProjectionEquals(dataSeries, 4, 7) + testXProjectionEquals(dataSeries, 5, 9) + testXProjectionEquals(dataSeries, 6, 12) }) // Test based on the Galton dataset, using unweighted (=OLS) regression @@ -333,8 +333,8 @@ function testGoodnessOfFitEquals (series, expectedValue) { assert.ok(series.goodnessOfFit() === expectedValue, `Expected goodnessOfFit to be ${expectedValue}, encountered ${series.goodnessOfFit()}`) } -function testLocalGoodnessOfFitEquals (series, position, expectedValue) { - assert.ok(series.localGoodnessOfFit(position) === expectedValue, `Expected localGoodnessOfFit at position ${position} to be ${expectedValue}, encountered ${series.localGoodnessOfFit(position)}`) +function testXProjectionEquals (series, value, expectedValue) { + assert.ok(series.projectX(value) === expectedValue, `Expected projectX at value ${value} to be ${expectedValue}, encountered ${series.projectX(value)}`) } test.run() From 978f06aa11dff8e53466ca06372dd4ba956038ed Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Fri, 2 Jan 2026 00:23:33 +0100 Subject: [PATCH 072/118] Update WLSLinearSeries.test.js --- app/engine/utils/WLSLinearSeries.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/engine/utils/WLSLinearSeries.test.js b/app/engine/utils/WLSLinearSeries.test.js index 61f09bc04c..8b35695fa1 100644 --- a/app/engine/utils/WLSLinearSeries.test.js +++ b/app/engine/utils/WLSLinearSeries.test.js @@ -194,7 +194,7 @@ test('Series with 5 elements, with 2 noisy datapoints, ideal function y = 3x - 6 testSlopeEquals(dataSeries, 3.0675675675675675) testInterceptEquals(dataSeries, -6.256756756756756) testGoodnessOfFitEquals(dataSeries, 0.9863142179006205) - testXProjectionEquals(dataSeries, 1, -3) + testXProjectionEquals(dataSeries, 1, -3.1891891891891886) testXProjectionEquals(dataSeries, 3, 2) testXProjectionEquals(dataSeries, 4, 7) testXProjectionEquals(dataSeries, 5, 9) @@ -211,7 +211,7 @@ test('Series with 5 elements, with 2 noisy datapoints, ideal function y = 3x - 6 testSlopeEquals(dataSeries, 3.034632034632035) testInterceptEquals(dataSeries, -6.134199134199134) testGoodnessOfFitEquals(dataSeries, 0.9926631153882663) - testXProjectionEquals(dataSeries, 1, -3) + testXProjectionEquals(dataSeries, 1, -3.0995670995670994) testXProjectionEquals(dataSeries, 3, 2) testXProjectionEquals(dataSeries, 4, 7) testXProjectionEquals(dataSeries, 5, 9) From 40372325a6ba997c08ac0fe4dd546129b7e3b8df Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Fri, 2 Jan 2026 00:30:32 +0100 Subject: [PATCH 073/118] Update WLSLinearSeries.test.js --- app/engine/utils/WLSLinearSeries.test.js | 32 ++++++++++++------------ 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/app/engine/utils/WLSLinearSeries.test.js b/app/engine/utils/WLSLinearSeries.test.js index 8b35695fa1..be39106a57 100644 --- a/app/engine/utils/WLSLinearSeries.test.js +++ b/app/engine/utils/WLSLinearSeries.test.js @@ -191,14 +191,14 @@ test('Series with 5 elements, with 2 noisy datapoints, ideal function y = 3x - 6 dataSeries.push(4, 7, 1) dataSeries.push(6, 12, 1) dataSeries.push(1, -3, 1) - testSlopeEquals(dataSeries, 3.0675675675675675) - testInterceptEquals(dataSeries, -6.256756756756756) - testGoodnessOfFitEquals(dataSeries, 0.9863142179006205) - testXProjectionEquals(dataSeries, 1, -3.1891891891891886) - testXProjectionEquals(dataSeries, 3, 2) - testXProjectionEquals(dataSeries, 4, 7) - testXProjectionEquals(dataSeries, 5, 9) - testXProjectionEquals(dataSeries, 6, 12) + testSlopeEquals(dataSeries, 3.0675675675675675) // Theoretical value 3 + testInterceptEquals(dataSeries, -6.256756756756756) // Theoretical value -6 + testGoodnessOfFitEquals(dataSeries, 0.9863142179006205) // Ideal value 1 + testXProjectionEquals(dataSeries, 1, -3.1891891891891886) // Theoretical value -3 + testXProjectionEquals(dataSeries, 3, 2.9459459459459456) // Theoretical value 3 + testXProjectionEquals(dataSeries, 4, 7) // Theoretical value 6 + testXProjectionEquals(dataSeries, 5, 9) // Theoretical value 9 + testXProjectionEquals(dataSeries, 6, 12) // Theoretical value 12 }) test('Series with 5 elements, with 2 noisy datapoints, ideal function y = 3x - 6, non-uniform weights', () => { @@ -208,14 +208,14 @@ test('Series with 5 elements, with 2 noisy datapoints, ideal function y = 3x - 6 dataSeries.push(4, 7, 0.5) dataSeries.push(6, 12, 1) dataSeries.push(1, -3, 1) - testSlopeEquals(dataSeries, 3.034632034632035) - testInterceptEquals(dataSeries, -6.134199134199134) - testGoodnessOfFitEquals(dataSeries, 0.9926631153882663) - testXProjectionEquals(dataSeries, 1, -3.0995670995670994) - testXProjectionEquals(dataSeries, 3, 2) - testXProjectionEquals(dataSeries, 4, 7) - testXProjectionEquals(dataSeries, 5, 9) - testXProjectionEquals(dataSeries, 6, 12) + testSlopeEquals(dataSeries, 3.034632034632035) // Theoretical value 3 + testInterceptEquals(dataSeries, -6.134199134199134) // Theoretical value -6 + testGoodnessOfFitEquals(dataSeries, 0.9926631153882663) // Ideal value 1 + testXProjectionEquals(dataSeries, 1, -3.0995670995670994) // Theoretical value -3 + testXProjectionEquals(dataSeries, 3, 2.9696969696969706) // Theoretical value 3 + testXProjectionEquals(dataSeries, 4, 7) // Theoretical value 6 + testXProjectionEquals(dataSeries, 5, 9) // Theoretical value 9 + testXProjectionEquals(dataSeries, 6, 12) // Theoretical value 12 }) // Test based on the Galton dataset, using unweighted (=OLS) regression From 75ac0bc0723ebd63d042109bad7e5482516eaccc Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Fri, 2 Jan 2026 00:33:16 +0100 Subject: [PATCH 074/118] Update WLSLinearSeries.test.js --- app/engine/utils/WLSLinearSeries.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/engine/utils/WLSLinearSeries.test.js b/app/engine/utils/WLSLinearSeries.test.js index be39106a57..3d5b909344 100644 --- a/app/engine/utils/WLSLinearSeries.test.js +++ b/app/engine/utils/WLSLinearSeries.test.js @@ -196,7 +196,7 @@ test('Series with 5 elements, with 2 noisy datapoints, ideal function y = 3x - 6 testGoodnessOfFitEquals(dataSeries, 0.9863142179006205) // Ideal value 1 testXProjectionEquals(dataSeries, 1, -3.1891891891891886) // Theoretical value -3 testXProjectionEquals(dataSeries, 3, 2.9459459459459456) // Theoretical value 3 - testXProjectionEquals(dataSeries, 4, 7) // Theoretical value 6 + testXProjectionEquals(dataSeries, 4, 6.013513513513514) // Theoretical value 6 testXProjectionEquals(dataSeries, 5, 9) // Theoretical value 9 testXProjectionEquals(dataSeries, 6, 12) // Theoretical value 12 }) @@ -213,7 +213,7 @@ test('Series with 5 elements, with 2 noisy datapoints, ideal function y = 3x - 6 testGoodnessOfFitEquals(dataSeries, 0.9926631153882663) // Ideal value 1 testXProjectionEquals(dataSeries, 1, -3.0995670995670994) // Theoretical value -3 testXProjectionEquals(dataSeries, 3, 2.9696969696969706) // Theoretical value 3 - testXProjectionEquals(dataSeries, 4, 7) // Theoretical value 6 + testXProjectionEquals(dataSeries, 4, 6.004329004329005) // Theoretical value 6 testXProjectionEquals(dataSeries, 5, 9) // Theoretical value 9 testXProjectionEquals(dataSeries, 6, 12) // Theoretical value 12 }) From 3c8dfc2b613a5b07955951239e45f711bd4705c2 Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Fri, 2 Jan 2026 07:46:05 +0100 Subject: [PATCH 075/118] Update WLSLinearSeries.test.js --- app/engine/utils/WLSLinearSeries.test.js | 28 ++++++++++++------------ 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/app/engine/utils/WLSLinearSeries.test.js b/app/engine/utils/WLSLinearSeries.test.js index 3d5b909344..112c427845 100644 --- a/app/engine/utils/WLSLinearSeries.test.js +++ b/app/engine/utils/WLSLinearSeries.test.js @@ -191,14 +191,14 @@ test('Series with 5 elements, with 2 noisy datapoints, ideal function y = 3x - 6 dataSeries.push(4, 7, 1) dataSeries.push(6, 12, 1) dataSeries.push(1, -3, 1) - testSlopeEquals(dataSeries, 3.0675675675675675) // Theoretical value 3 - testInterceptEquals(dataSeries, -6.256756756756756) // Theoretical value -6 + testSlopeEquals(dataSeries, 3.0675675675675675) // Theoretical noisefree value 3 + testInterceptEquals(dataSeries, -6.256756756756756) // Theoretical noisefree value -6 testGoodnessOfFitEquals(dataSeries, 0.9863142179006205) // Ideal value 1 - testXProjectionEquals(dataSeries, 1, -3.1891891891891886) // Theoretical value -3 - testXProjectionEquals(dataSeries, 3, 2.9459459459459456) // Theoretical value 3 - testXProjectionEquals(dataSeries, 4, 6.013513513513514) // Theoretical value 6 - testXProjectionEquals(dataSeries, 5, 9) // Theoretical value 9 - testXProjectionEquals(dataSeries, 6, 12) // Theoretical value 12 + testXProjectionEquals(dataSeries, 1, -3.1891891891891886) // Theoretical noisefree value -3 + testXProjectionEquals(dataSeries, 3, 2.9459459459459456) // Theoretical noisefree value 3 + testXProjectionEquals(dataSeries, 4, 6.013513513513514) // Theoretical noisefree value 6 + testXProjectionEquals(dataSeries, 5, 9.081081081081082) // Theoretical noisefree value 9 + testXProjectionEquals(dataSeries, 6, 12) // Theoretical noisefree value 12 }) test('Series with 5 elements, with 2 noisy datapoints, ideal function y = 3x - 6, non-uniform weights', () => { @@ -208,14 +208,14 @@ test('Series with 5 elements, with 2 noisy datapoints, ideal function y = 3x - 6 dataSeries.push(4, 7, 0.5) dataSeries.push(6, 12, 1) dataSeries.push(1, -3, 1) - testSlopeEquals(dataSeries, 3.034632034632035) // Theoretical value 3 - testInterceptEquals(dataSeries, -6.134199134199134) // Theoretical value -6 + testSlopeEquals(dataSeries, 3.034632034632035) // Theoretical noisefree value 3 + testInterceptEquals(dataSeries, -6.134199134199134) // Theoretical noisefree value -6 testGoodnessOfFitEquals(dataSeries, 0.9926631153882663) // Ideal value 1 - testXProjectionEquals(dataSeries, 1, -3.0995670995670994) // Theoretical value -3 - testXProjectionEquals(dataSeries, 3, 2.9696969696969706) // Theoretical value 3 - testXProjectionEquals(dataSeries, 4, 6.004329004329005) // Theoretical value 6 - testXProjectionEquals(dataSeries, 5, 9) // Theoretical value 9 - testXProjectionEquals(dataSeries, 6, 12) // Theoretical value 12 + testXProjectionEquals(dataSeries, 1, -3.0995670995670994) // Theoretical noisefree value -3 + testXProjectionEquals(dataSeries, 3, 2.9696969696969706) // Theoretical noisefree value 3 + testXProjectionEquals(dataSeries, 4, 6.004329004329005) // Theoretical noisefree value 6 + testXProjectionEquals(dataSeries, 5, 9.03896103896104) // Theoretical noisefree value 9 + testXProjectionEquals(dataSeries, 6, 12) // Theoretical noisefree value 12 }) // Test based on the Galton dataset, using unweighted (=OLS) regression From 9d063efb04d50bebfd08747603f1b3c729eedd60 Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Fri, 2 Jan 2026 07:49:53 +0100 Subject: [PATCH 076/118] Additional tests added for WLSLinearSeries --- app/engine/utils/WLSLinearSeries.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/engine/utils/WLSLinearSeries.test.js b/app/engine/utils/WLSLinearSeries.test.js index 112c427845..c51bd35d0f 100644 --- a/app/engine/utils/WLSLinearSeries.test.js +++ b/app/engine/utils/WLSLinearSeries.test.js @@ -198,7 +198,7 @@ test('Series with 5 elements, with 2 noisy datapoints, ideal function y = 3x - 6 testXProjectionEquals(dataSeries, 3, 2.9459459459459456) // Theoretical noisefree value 3 testXProjectionEquals(dataSeries, 4, 6.013513513513514) // Theoretical noisefree value 6 testXProjectionEquals(dataSeries, 5, 9.081081081081082) // Theoretical noisefree value 9 - testXProjectionEquals(dataSeries, 6, 12) // Theoretical noisefree value 12 + testXProjectionEquals(dataSeries, 6, 12.148648648648647) // Theoretical noisefree value 12 }) test('Series with 5 elements, with 2 noisy datapoints, ideal function y = 3x - 6, non-uniform weights', () => { @@ -215,7 +215,7 @@ test('Series with 5 elements, with 2 noisy datapoints, ideal function y = 3x - 6 testXProjectionEquals(dataSeries, 3, 2.9696969696969706) // Theoretical noisefree value 3 testXProjectionEquals(dataSeries, 4, 6.004329004329005) // Theoretical noisefree value 6 testXProjectionEquals(dataSeries, 5, 9.03896103896104) // Theoretical noisefree value 9 - testXProjectionEquals(dataSeries, 6, 12) // Theoretical noisefree value 12 + testXProjectionEquals(dataSeries, 6, 12.073593073593075) // Theoretical noisefree value 12 }) // Test based on the Galton dataset, using unweighted (=OLS) regression From 8fcd3071c16e36106e8c768ab6455d9c25f43460 Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Fri, 2 Jan 2026 15:39:23 +0100 Subject: [PATCH 077/118] Moved responsibility to become active from Flywheel.js to CEC Filter --- app/engine/utils/CyclicErrorFilter.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/engine/utils/CyclicErrorFilter.js b/app/engine/utils/CyclicErrorFilter.js index 4f890c10c9..64b4679700 100644 --- a/app/engine/utils/CyclicErrorFilter.js +++ b/app/engine/utils/CyclicErrorFilter.js @@ -16,7 +16,8 @@ const log = loglevel.getLogger('RowingEngine') * @param {object} rowerSettings - The rower settings configuration object * @param {integer} rowerSettings.numOfImpulsesPerRevolution - Number of impulses per flywheel revolution * @param {integer} rowerSettings.flankLength - Length of the flank used - * @param {float} rowerSettings.systematicErrorAgressiveness - Agressiveness of the systematic error correction algorithm (0 turns it off) + * @param {boolean} rowerSettings.autoAdjustDragFactor - Indicates if the Flywheel.js is allowed to automatically adjust dragfactor (false turns the filter off) + * @param {float} rowerSettings.systematicErrorAgressiveness - Agressiveness of the systematic error correction algorithm (0 turns the filter off) * @param {float} rowerSettings.minimumTimeBetweenImpulses - minimum expected time between impulses (in seconds) * @param {float} rowerSettings.maximumTimeBetweenImpulses - maximum expected time between impulses (in seconds) * @param {integer} minimumDragFactorSamples - the number of expected dragfactor samples @@ -61,7 +62,7 @@ export function createCyclicErrorFilter (rowerSettings, minimumDragFactorSamples if (startPosition === undefined) { startPosition = position + _flankLength } const magnet = position % _numberOfMagnets raw.push(rawValue) - if (_agressiveness > 0) { + if (rowerSettings.autoAdjustDragFactor && _agressiveness > 0) { clean.push(projectX(magnet, rawValue)) goodnessOfFit.push(filterArray[magnet].goodnessOfFit()) } else { @@ -90,7 +91,7 @@ export function createCyclicErrorFilter (rowerSettings, minimumDragFactorSamples * @param {float} rawValue - the raw value */ function recordRawDatapoint (relativePosition, absolutePosition, rawValue) { - if (_agressiveness > 0 && rawValue >= _minimumTimeBetweenImpulses && _maximumTimeBetweenImpulses >= rawValue) { + if (rowerSettings.autoAdjustDragFactor && _agressiveness > 0 && rawValue >= _minimumTimeBetweenImpulses && _maximumTimeBetweenImpulses >= rawValue) { recordedRelativePosition.push(relativePosition) recordedAbsolutePosition.push(absolutePosition) recordedRawValue.push(rawValue) From 40bc057acc4f471d135a8e8dd54bf0f16045ab0c Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Fri, 2 Jan 2026 19:41:53 +0100 Subject: [PATCH 078/118] Moved responsibility to become active from Flywheel.js to CEC Filter --- app/engine/Flywheel.js | 59 +++++++++++++++++++++++++++++++++++------- 1 file changed, 50 insertions(+), 9 deletions(-) diff --git a/app/engine/Flywheel.js b/app/engine/Flywheel.js index 3611cb81a4..072088cf8a 100644 --- a/app/engine/Flywheel.js +++ b/app/engine/Flywheel.js @@ -1,9 +1,8 @@ 'use strict' -/* - 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 + * @copyright [OpenRowingMonitor]{@link https://github.com/JaapvanEkris/openrowingmonitor} + * + * @file This models the flywheel with all of its attributes, which we can also test for being powered * * All times and distances are defined as being before the beginning of the flank, as RowingEngine's metrics * solely depend on times and angular positions before the flank (as they are to be certain to belong to a specific @@ -29,6 +28,26 @@ import { createMovingRegressor } from './utils/MovingWindowRegressor.js' const log = loglevel.getLogger('RowingEngine') +/** + * @param {object} rowerSettings - The rower settings configuration object + * @param {integer} rowerSettings.numOfImpulsesPerRevolution - Number of impulses per flywheel revolution + * @param {integer} rowerSettings.flankLength - Length of the flank used + * @param {float} rowerSettings.minimumRecoveryTime - Minimum time a recovery should last (seconds) + * @param {float} rowerSettings.maximumStrokeTimeBeforePause - Minimum time that has to pass after the last drive for a pause to kick in (seconds) + * @param {float} rowerSettings.flywheelInertia - Inertia of the flywheel + * @param {float} rowerSettings.dragFactor - (initial) Dragfactor + * @param {boolean} rowerSettings.autoAdjustDragFactor - Indicates if the Flywheel.js is allowed to automatically adjust dragfactor (false turns the filter off) + * @param {integer} rowerSettings.dragFactorSmoothing - Number of recoveries to be weighed in the current dragfactor + * @param {float} rowerSettings.minimumRecoverySlope - (initial) recpvery slope + * @param {boolean} rowerSettings.autoAdjustRecoverySlope - Allow OpenRowingMonitor to adjust the recoverySlope based on the previous recoveries (and thus dragfactor) + * @param {float} rowerSettings.autoAdjustRecoverySlopeMargin - Margin to be maintained for the automatically adjusted recovery slope + * @param {float} rowerSettings.minimumStrokeQuality - Minimum Goodness Of Fit for a slope to be considered reliable for stroke detection + * @param {float} rowerSettings.sprocketRadius - Radius of the driving sprocket (centimeters) + * @param {float} rowerSettings.minimumForceBeforeStroke - Minimum force for the flywheel to be considered powered (Newton) + * @param {float} rowerSettings.systematicErrorAgressiveness - Agressiveness of the systematic error correction algorithm (0 turns the filter off) + * @param {float} rowerSettings.minimumTimeBetweenImpulses - minimum expected time between impulses (in seconds) + * @param {float} rowerSettings.maximumTimeBetweenImpulses - maximum expected time between impulses (in seconds) + */ export function createFlywheel (rowerSettings) { const angularDisplacementPerImpulse = (2.0 * Math.PI) / rowerSettings.numOfImpulsesPerRevolution const flankLength = rowerSettings.flankLength @@ -57,7 +76,12 @@ export function createFlywheel (rowerSettings) { let totalTimeSpinning let _totalWork reset() - + + /** + * @param {float} dataPoint - The lenght of the impuls (currentDt) in seconds + * @description This function is called from Rower.js each time the sensor detected an impulse. It transforms this (via the buffers) into a robust flywheel position, speed and acceleration. + * It also calculates dragfactor and provides the indicators for stroke detection. + */ /* eslint-disable max-statements -- we need to maintain a lot of metrics in the main loop, nothing we can do about that */ function pushValue (dataPoint) { if (isNaN(dataPoint) || dataPoint < 0 || dataPoint > rowerSettings.maximumStrokeTimeBeforePause) { @@ -99,7 +123,7 @@ export function createFlywheel (rowerSettings) { // Feed the drag calculation, as we didn't reset the Semaphore in the previous cycle based on the current flank recoveryDeltaTime.push(totalTimeSpinning, _deltaTimeBeforeFlank) // Feed the systematic error filter buffer - if (rowerSettings.autoAdjustDragFactor) { cyclicErrorFilter.recordRawDatapoint(totalNumberOfImpulses, totalTimeSpinning, cyclicErrorFilter.raw.atSeriesBegin()) } + cyclicErrorFilter.recordRawDatapoint(totalNumberOfImpulses, totalTimeSpinning, cyclicErrorFilter.raw.atSeriesBegin()) } else { // Accumulate the energy total as we are in the drive phase _totalWork += Math.max(_torqueBeforeFlank * angularDisplacementPerImpulse, 0) @@ -128,15 +152,24 @@ export function createFlywheel (rowerSettings) { } /* eslint-enable max-statements */ + /** + * @description Function to handle the start of a pause/stop based on a trigger from Rower.js + */ function maintainStateOnly () { maintainMetrics = false } + /** + * @description Function to handle the end of a pause/stop based on a trigger from Rower.js + */ function maintainStateAndMetrics () { maintainMetrics = true cyclicErrorFilter.coldRestart() } + /** + * @description Function to handle the start of the recovery phase based on a trigger from Rower.js + */ function markRecoveryPhaseStart () { inRecoveryPhase = true recoveryDeltaTime.reset() @@ -144,7 +177,7 @@ export function createFlywheel (rowerSettings) { } /** - * Function to handle ompletion of the recovery phase + * @description Function to handle the completion of the recovery phase based on a trigger from Rower.js */ function markRecoveryPhaseCompleted () { inRecoveryPhase = false @@ -181,10 +214,16 @@ export function createFlywheel (rowerSettings) { return totalTimeSpinning } + /** + * @returns {float} the total energy produced onto the flywheel in Joules BEFORE the beginning of the flank + */ function totalWork () { return Math.max(_totalWork, 0) } + /** + * @returns {float} the current DeltaTime BEFORE the flank + */ function deltaTime () { return _deltaTimeBeforeFlank } @@ -300,7 +339,8 @@ export function createFlywheel (rowerSettings) { } /** - * @returns {boolean} indicator if the currentDt slope is below a certain slope + * @param {float} threshold - Maximum slope + * @returns {boolean} indicator if the currentDt slope is below the specified slope * 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 @@ -315,7 +355,8 @@ export function createFlywheel (rowerSettings) { } /** - * @returns {boolean} indicator if the currentDt slope is above a certain slope + * @param {float} threshold - Maximum slope + * @returns {boolean} indicator if the currentDt slope is above the specified slope * This is a typical indication that the flywheel is deccelerating. We use the slope of successive currentDt's * A (more) positive slope indicates a unpowered flywheel. When set to 0, it determines whether the DeltaT's are increasing * When set to a value below 0, it will become more stringent as it will detect a power inconsistent with the drag From 174512c9dc333f00339744ea7e7d084a3c8ded59 Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Fri, 2 Jan 2026 19:53:36 +0100 Subject: [PATCH 079/118] Introduction of Probobalistic drag calculation --- app/engine/utils/TSLinearSeries.js | 78 ++++++++++++++++++++---------- 1 file changed, 52 insertions(+), 26 deletions(-) diff --git a/app/engine/utils/TSLinearSeries.js b/app/engine/utils/TSLinearSeries.js index e8dd460477..b4dfbbab28 100644 --- a/app/engine/utils/TSLinearSeries.js +++ b/app/engine/utils/TSLinearSeries.js @@ -1,11 +1,10 @@ 'use strict' -/* - Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor -*/ /** - * The TSLinearSeries is a datatype that represents a Linear Series. It allows + * @copyright [OpenRowingMonitor]{@link https://github.com/JaapvanEkris/openrowingmonitor} + * + * @file The TSLinearSeries is a datatype that represents a Weighted Linear Series. It allows * values to be retrieved (like a FiFo buffer, or Queue) but it also includes - * a Theil-Sen estimator Linear Regressor to determine the slope of this timeseries. + * a Weighted Theil-Sen estimator Linear Regressor to determine the slope of this timeseries. * * At creation its length is determined. After it is filled, the oldest will be pushed * out of the queue) automatically. This is a property of the Series object @@ -24,17 +23,20 @@ */ import { createSeries } from './Series.js' +import { createWeighedSeries } from './WeighedSeries.js' import { createLabelledBinarySearchTree } from './BinarySearchTree.js' import loglevel from 'loglevel' const log = loglevel.getLogger('RowingEngine') /** - * @param {integer} the maximum length of the quadratic series, 0 for unlimited + * @param {integer} maxSeriesLength - the maximum length of the quadratic series, default = 0 for unlimited */ export function createTSLinearSeries (maxSeriesLength = 0) { const X = createSeries(maxSeriesLength) const Y = createSeries(maxSeriesLength) + const weight = createSeries(maxSeriesLength) + const WY = createWeighedSeries(maxSeriesLength, undefined) const A = createLabelledBinarySearchTree() let _A = 0 @@ -43,37 +45,44 @@ export function createTSLinearSeries (maxSeriesLength = 0) { let _goodnessOfFit = 0 /** - * @param {float} the x value of the datapoint - * @param {float} the y value of the datapoint + * @param {float} x - the x value of the datapoint + * @param {float} y - the y value of the datapoint + * @param {float} w - the weight of the datapoint (optional, defaults to 1 for unweighted regression) * Invariant: BinarySearchTree A contains all calculated a's (as in the general formula y = a * x + b), * where the a's are labeled in the BinarySearchTree with their Xi when they BEGIN in the point (Xi, Yi) */ - function push (x, y) { + function push (x, y, w = 1) { if (x === undefined || isNaN(x) || y === undefined || isNaN(y)) { return } if (maxSeriesLength > 0 && X.length() >= maxSeriesLength) { // The maximum of the array has been reached, so when pushing the x,y the array gets shifted, - // thus we have to remove the a's belonging to the current position X0 as well before this value is trashed + // thus we have to remove the a's belonging to the current position X[0] as well before this value is trashed A.remove(X.get(0)) } X.push(x) Y.push(y) + weight.push(w) + WY.push(y, w) // Calculate all the slopes of the newly added point if (X.length() > 1) { // There are at least two points in the X and Y arrays, so let's add the new datapoint let i = 0 + let slope + let combinedweight while (i < X.length() - 1) { // Calculate the slope with all preceeding datapoints and X.length() - 1'th datapoint (as the array starts at zero) - A.push(X.get(i), calculateSlope(i, X.length() - 1), 1) + slope = calculateSlope(i, X.length() - 1) + combinedweight = weight.get(i) * w + A.push(X.get(i), slope, combinedweight) i++ } } // Calculate the median of the slopes if (X.length() > 1) { - _A = A.median() + _A = A.weightedMedian() } else { _A = 0 } @@ -124,8 +133,9 @@ export function createTSLinearSeries (maxSeriesLength = 0) { /** * @returns {float} the R^2 as a goodness of fit indicator * It will automatically recalculate the _goodnessOfFit when it isn't defined - * This lazy approach is intended to prevent unneccesary calculations, especially when there is a batch of datapoint pushes - * from the TSQuadratic regressor processing its linear residu + * This lazy approach is intended to prevent unneccesary calculations, especially when there is a batch of datapoints + * pushes from the TSQuadratic regressor processing its linear residu + * @see [Goodness-of-Fit Statistics] {@link https://web.maths.unsw.edu.au/~adelle/Garvan/Assays/GoodnessOfFit.html} */ function goodnessOfFit () { let i = 0 @@ -133,11 +143,13 @@ export function createTSLinearSeries (maxSeriesLength = 0) { if (_goodnessOfFit === null) { if (X.length() >= 2) { _sst = 0 + while (i < X.length()) { - sse += Math.pow((Y.get(i) - projectX(X.get(i))), 2) - _sst += Math.pow((Y.get(i) - Y.average()), 2) + sse += weight.get(i) * Math.pow(Y.get(i) - projectX(X.get(i)), 2) + _sst += weight.get(i) * Math.pow(Y.get(i) - WY.weighedAverage(), 2) i++ } + switch (true) { case (sse === 0): _goodnessOfFit = 1 @@ -161,6 +173,7 @@ export function createTSLinearSeries (maxSeriesLength = 0) { } /** + * @param {integer} position - The position in the series for which the Local Goodness Of Fit has to be calcuated * @returns {float} the local R^2 as a local goodness of fit indicator */ function localGoodnessOfFit (position) { @@ -169,18 +182,18 @@ export function createTSLinearSeries (maxSeriesLength = 0) { goodnessOfFit() } if (X.length() >= 2 && position < X.length()) { - const squaredError = Math.pow((Y.get(position) - projectX(X.get(position))), 2) + const weightedSquaredError = weight.get(position) * Math.pow((Y.get(position) - projectX(X.get(position))), 2) /* eslint-disable no-unreachable -- rather be systematic and add a break in all case statements */ switch (true) { - case (squaredError === 0): + case (weightedSquaredError === 0): return 1 break - case (squaredError > _sst): + case (weightedSquaredError > _sst): // This is a pretty bad fit as the error is bigger than just using the line for the average y as intercept return 0 break case (_sst !== 0): - return Math.min(Math.max(1 - ((squaredError * X.length()) / _sst), 0), 1) + return Math.min(Math.max(1 - ((weightedSquaredError * X.length()) / _sst), 0), 1) break default: // When _SST = 0, localGoodnessOfFit isn't defined @@ -193,7 +206,7 @@ export function createTSLinearSeries (maxSeriesLength = 0) { } /** - * @param {float} the x value to be projected + * @param {float} x - the x value to be projected * @returns {float} the resulting y value when projected via the linear function */ function projectX (x) { @@ -206,8 +219,8 @@ export function createTSLinearSeries (maxSeriesLength = 0) { } /** - * @param {float} the y value to be projected - * @returns {float} the resulting x value when projected via the linear function + * @param {float} y - the y value to be solved + * @returns {float} the resulting x value when solved via the linear function */ function projectY (y) { if (X.length() >= 2 && _A !== 0) { @@ -219,6 +232,11 @@ export function createTSLinearSeries (maxSeriesLength = 0) { } } + /** + * @param {integer} pointOne - The position in the series of the first datapoint used for the slope calculation + * @param {integer} pointTwo - The position in the series of the second datapoint used for the slope calculation + * @returns {float} the slope of the linear function + */ function calculateSlope (pointOne, pointTwo) { if (pointOne !== pointTwo && X.get(pointOne) !== X.get(pointTwo)) { return ((Y.get(pointTwo) - Y.get(pointOne)) / (X.get(pointTwo) - X.get(pointOne))) @@ -228,6 +246,9 @@ export function createTSLinearSeries (maxSeriesLength = 0) { } } + /** + * @description This helper function calculates the intercept and stores it in _B + */ function calculateIntercept () { // Calculate all the intercepts for the newly added point and the newly calculated A, when needed // This function is only called when an intercept is really needed, as this saves a lot of CPU cycles when only a slope suffices @@ -237,11 +258,11 @@ export function createTSLinearSeries (maxSeriesLength = 0) { // There are at least two points in the X and Y arrays, so let's calculate the intercept let i = 0 while (i < X.length()) { - // Please note , as we need to recreate the B-tree for each newly added datapoint anyway, the label i isn't relevant - B.push(i, (Y.get(i) - (_A * X.get(i)))) + // Please note, we recreate the B-tree for each newly added datapoint anyway, so the label i isn't relevant + B.push(i, (Y.get(i) - (_A * X.get(i))), weight.get(i)) i++ } - _B = B.median() + _B = B.weightedMedian() } else { _B = 0 } @@ -256,11 +277,16 @@ export function createTSLinearSeries (maxSeriesLength = 0) { return (X.length() >= 2) } + /** + * @description This function is used for clearing data and state + */ function reset () { if (X.length() > 0) { // There is something to reset X.reset() Y.reset() + weight.reset() + WY.reset() A.reset() _A = 0 _B = 0 From 43f508b654454b67f38c24f07fad9ae301013bdd Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Fri, 2 Jan 2026 19:57:28 +0100 Subject: [PATCH 080/118] Fixed lint errors --- app/engine/utils/TSLinearSeries.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/engine/utils/TSLinearSeries.js b/app/engine/utils/TSLinearSeries.js index b4dfbbab28..d7148191c9 100644 --- a/app/engine/utils/TSLinearSeries.js +++ b/app/engine/utils/TSLinearSeries.js @@ -1,7 +1,7 @@ 'use strict' /** * @copyright [OpenRowingMonitor]{@link https://github.com/JaapvanEkris/openrowingmonitor} - * + * * @file The TSLinearSeries is a datatype that represents a Weighted Linear Series. It allows * values to be retrieved (like a FiFo buffer, or Queue) but it also includes * a Weighted Theil-Sen estimator Linear Regressor to determine the slope of this timeseries. @@ -63,7 +63,7 @@ export function createTSLinearSeries (maxSeriesLength = 0) { X.push(x) Y.push(y) weight.push(w) - WY.push(y, w) + WY.push(y, w) // Calculate all the slopes of the newly added point if (X.length() > 1) { From bb25988d97184ce88f869e22f5b05931d12f520f Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Fri, 2 Jan 2026 20:08:21 +0100 Subject: [PATCH 081/118] Added tests to test effect of weights --- app/engine/utils/TSLinearSeries.test.js | 66 ++++++++++++++++++------- 1 file changed, 49 insertions(+), 17 deletions(-) diff --git a/app/engine/utils/TSLinearSeries.test.js b/app/engine/utils/TSLinearSeries.test.js index 7169262b18..5ed0b29964 100644 --- a/app/engine/utils/TSLinearSeries.test.js +++ b/app/engine/utils/TSLinearSeries.test.js @@ -169,6 +169,51 @@ test('Correct behaviour of a series after several puhed values, function y = 3x testLocalGoodnessOfFitEquals(dataSeries, 2, 1) }) +test('Series with 5 elements, with 2 noisy datapoints, ideal function y = 3x - 6, uniform weights', () => { + const dataSeries = createTSLinearSeries(5) + dataSeries.push(5, 9) + dataSeries.push(3, 2) + dataSeries.push(4, 7) + dataSeries.push(6, 12) + dataSeries.push(1, -3) + testSlopeEquals(dataSeries, 3) // Theoretical noisefree value 3 + testInterceptEquals(dataSeries, -6) // Theoretical noisefree value -6 + testGoodnessOfFitEquals(dataSeries, 0.9863142179006205) // Ideal value 1 + testLocalGoodnessOfFitEquals(dataSeries, 0, 1) + testXProjectionEquals(dataSeries, 1, -3) // Theoretical noisefree value -3 + testLocalGoodnessOfFitEquals(dataSeries, 1, 0.9645892351274787) + testXProjectionEquals(dataSeries, 3, 3) // Theoretical noisefree value 3 + testLocalGoodnessOfFitEquals(dataSeries, 2, 0.9645892351274787) + testXProjectionEquals(dataSeries, 4, 6) // Theoretical noisefree value 6 + testLocalGoodnessOfFitEquals(dataSeries, 3, 1) + testXProjectionEquals(dataSeries, 5, 9) // Theoretical noisefree value 9 + testLocalGoodnessOfFitEquals(dataSeries, 4, 1) + testXProjectionEquals(dataSeries, 6, 12) // Theoretical noisefree value 12 +}) + +test('Series with 5 elements, with 2 noisy datapoints, ideal function y = 3x - 6, non-uniform weights', () => { + const dataSeries = createTSLinearSeries(5) + dataSeries.push(5, 9, 1) + dataSeries.push(3, 2, 0.5) + dataSeries.push(4, 7, 0.5) + dataSeries.push(6, 12, 1) + dataSeries.push(1, -3, 1) + testSlopeEquals(dataSeries, 3) // Theoretical noisefree value 3 + testInterceptEquals(dataSeries, -6) // Theoretical noisefree value -6 + testGoodnessOfFitEquals(dataSeries, 0.9863142179006205) // Ideal value 1 + testLocalGoodnessOfFitEquals(dataSeries, 0, 1) + testXProjectionEquals(dataSeries, 1, -3) // Theoretical noisefree value -3 + testLocalGoodnessOfFitEquals(dataSeries, 1, 0.9645892351274787) + testXProjectionEquals(dataSeries, 3, 3) // Theoretical noisefree value 3 + testLocalGoodnessOfFitEquals(dataSeries, 2, 0.9645892351274787) + testXProjectionEquals(dataSeries, 4, 6) // Theoretical noisefree value 6 + testLocalGoodnessOfFitEquals(dataSeries, 3, 1) + testXProjectionEquals(dataSeries, 5, 9) // Theoretical noisefree value 9 + testLocalGoodnessOfFitEquals(dataSeries, 4, 1) + testXProjectionEquals(dataSeries, 6, 12) // Theoretical noisefree value 12 +}) + + test('Correct behaviour of a series after several puhed values, function y = 3x - 6, noisefree, 4 datapoints and a reset', () => { const dataSeries = createTSLinearSeries(3) dataSeries.push(5, 9) @@ -197,23 +242,6 @@ test('Correct behaviour of a series after several puhed values, function y = 3x testLocalGoodnessOfFitEquals(dataSeries, 0, 0) }) -test('Series for function y = 3x - 6, with 5 elements, with 2 noisy datapoints', () => { - const dataSeries = createTSLinearSeries(5) - dataSeries.push(5, 9) - dataSeries.push(3, 2) - dataSeries.push(4, 7) - dataSeries.push(6, 12) - dataSeries.push(1, -3) - testSlopeBetween(dataSeries, 2.9, 3.1) - testInterceptBetween(dataSeries, -6.3, -5.8) - testGoodnessOfFitBetween(dataSeries, 0.9, 1.0) - testLocalGoodnessOfFitEquals(dataSeries, 0, 1) - testLocalGoodnessOfFitEquals(dataSeries, 1, 0.9645892351274787) - testLocalGoodnessOfFitEquals(dataSeries, 2, 0.9645892351274787) - testLocalGoodnessOfFitEquals(dataSeries, 3, 1) - testLocalGoodnessOfFitEquals(dataSeries, 4, 1) -}) - function testLength (series, expectedValue) { assert.ok(series.length() === expectedValue, `Expected length should be ${expectedValue}, encountered a ${series.length()}`) } @@ -289,4 +317,8 @@ function testLocalGoodnessOfFitEquals (series, position, expectedValue) { assert.ok(series.localGoodnessOfFit(position) === expectedValue, `Expected localGoodnessOfFit at position ${position} to be ${expectedValue}, encountered ${series.localGoodnessOfFit(position)}`) } +function testXProjectionEquals (series, value, expectedValue) { + assert.ok(series.projectX(value) === expectedValue, `Expected projectX at value ${value} to be ${expectedValue}, encountered ${series.projectX(value)}`) +} + test.run() From a0d481674a5d350633d0717883f1a647c04f6873 Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Fri, 2 Jan 2026 20:12:21 +0100 Subject: [PATCH 082/118] Fixed lint errors --- app/engine/utils/TSLinearSeries.test.js | 44 ++++++++++++++++--------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/app/engine/utils/TSLinearSeries.test.js b/app/engine/utils/TSLinearSeries.test.js index 5ed0b29964..76886dea9b 100644 --- a/app/engine/utils/TSLinearSeries.test.js +++ b/app/engine/utils/TSLinearSeries.test.js @@ -110,6 +110,34 @@ test('Correct behaviour of a series after several puhed values, function y = 3x testLocalGoodnessOfFitEquals(dataSeries, 3, 0) // Overshooting the length of the series }) +test('Correct behaviour of a series after several puhed values, function y = 3x - 6, noisefree, 3 datapoints, uniform (halved) weights', () => { + const dataSeries = createTSLinearSeries(3) + dataSeries.push(5, 9, 0.5) + dataSeries.push(3, 3, 0.5) + dataSeries.push(4, 6, 0.5) + testLength(dataSeries, 3) + testXAtSeriesBegin(dataSeries, 5) + testYAtSeriesBegin(dataSeries, 9) + testXAtSeriesEnd(dataSeries, 4) + testYAtSeriesEnd(dataSeries, 6) + testNumberOfXValuesAbove(dataSeries, 0, 3) + testNumberOfYValuesAbove(dataSeries, 0, 3) + testNumberOfXValuesEqualOrBelow(dataSeries, 0, 0) + testNumberOfYValuesEqualOrBelow(dataSeries, 0, 0) + testNumberOfXValuesAbove(dataSeries, 10, 0) + testNumberOfYValuesAbove(dataSeries, 10, 0) + testNumberOfXValuesEqualOrBelow(dataSeries, 10, 3) + testNumberOfYValuesEqualOrBelow(dataSeries, 10, 3) + testXSum(dataSeries, 12) + testYSum(dataSeries, 18) + testSlopeEquals(dataSeries, 3) + testInterceptEquals(dataSeries, -6) + testGoodnessOfFitEquals(dataSeries, 1) + testLocalGoodnessOfFitEquals(dataSeries, 0, 1) + testLocalGoodnessOfFitEquals(dataSeries, 1, 1) + testLocalGoodnessOfFitEquals(dataSeries, 2, 1) +}) + test('Correct behaviour of a series after several puhed values, function y = 3x - 6, noisefree, 4 datapoints', () => { const dataSeries = createTSLinearSeries(3) dataSeries.push(5, 9) @@ -213,7 +241,6 @@ test('Series with 5 elements, with 2 noisy datapoints, ideal function y = 3x - 6 testXProjectionEquals(dataSeries, 6, 12) // Theoretical noisefree value 12 }) - test('Correct behaviour of a series after several puhed values, function y = 3x - 6, noisefree, 4 datapoints and a reset', () => { const dataSeries = createTSLinearSeries(3) dataSeries.push(5, 9) @@ -290,29 +317,14 @@ function testSlopeEquals (series, expectedValue) { assert.ok(series.slope() === expectedValue, `Expected slope to be ${expectedValue}, encountered a ${series.slope()}`) } -function testSlopeBetween (series, expectedValueAbove, expectedValueBelow) { - assert.ok(series.slope() > expectedValueAbove, `Expected slope to be above ${expectedValueAbove}, encountered a ${series.slope()}`) - assert.ok(series.slope() < expectedValueBelow, `Expected slope to be below ${expectedValueBelow}, encountered a ${series.slope()}`) -} - function testInterceptEquals (series, expectedValue) { assert.ok(series.intercept() === expectedValue, `Expected intercept to be ${expectedValue}, encountered ${series.intercept()}`) } -function testInterceptBetween (series, expectedValueAbove, expectedValueBelow) { - assert.ok(series.intercept() > expectedValueAbove, `Expected intercept to be above ${expectedValueAbove}, encountered ${series.intercept()}`) - assert.ok(series.intercept() < expectedValueBelow, `Expected intercept to be below ${expectedValueBelow}, encountered ${series.intercept()}`) -} - function testGoodnessOfFitEquals (series, expectedValue) { assert.ok(series.goodnessOfFit() === expectedValue, `Expected goodnessOfFit to be ${expectedValue}, encountered ${series.goodnessOfFit()}`) } -function testGoodnessOfFitBetween (series, expectedValueAbove, expectedValueBelow) { - assert.ok(series.goodnessOfFit() > expectedValueAbove, `Expected goodnessOfFit to be above ${expectedValueAbove}, encountered ${series.goodnessOfFit()}`) - assert.ok(series.goodnessOfFit() < expectedValueBelow, `Expected goodnessOfFit to be below ${expectedValueBelow}, encountered ${series.goodnessOfFit()}`) -} - function testLocalGoodnessOfFitEquals (series, position, expectedValue) { assert.ok(series.localGoodnessOfFit(position) === expectedValue, `Expected localGoodnessOfFit at position ${position} to be ${expectedValue}, encountered ${series.localGoodnessOfFit(position)}`) } From 3090b56a9a01ccb305d33e462293ee8f63c464fc Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Fri, 2 Jan 2026 20:15:48 +0100 Subject: [PATCH 083/118] Update TSLinearSeries.test.js --- app/engine/utils/TSLinearSeries.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/engine/utils/TSLinearSeries.test.js b/app/engine/utils/TSLinearSeries.test.js index 76886dea9b..a1d9641243 100644 --- a/app/engine/utils/TSLinearSeries.test.js +++ b/app/engine/utils/TSLinearSeries.test.js @@ -206,7 +206,7 @@ test('Series with 5 elements, with 2 noisy datapoints, ideal function y = 3x - 6 dataSeries.push(1, -3) testSlopeEquals(dataSeries, 3) // Theoretical noisefree value 3 testInterceptEquals(dataSeries, -6) // Theoretical noisefree value -6 - testGoodnessOfFitEquals(dataSeries, 0.9863142179006205) // Ideal value 1 + testGoodnessOfFitEquals(dataSeries, 0.9858356940509915) // Ideal value 1 testLocalGoodnessOfFitEquals(dataSeries, 0, 1) testXProjectionEquals(dataSeries, 1, -3) // Theoretical noisefree value -3 testLocalGoodnessOfFitEquals(dataSeries, 1, 0.9645892351274787) @@ -228,7 +228,7 @@ test('Series with 5 elements, with 2 noisy datapoints, ideal function y = 3x - 6 dataSeries.push(1, -3, 1) testSlopeEquals(dataSeries, 3) // Theoretical noisefree value 3 testInterceptEquals(dataSeries, -6) // Theoretical noisefree value -6 - testGoodnessOfFitEquals(dataSeries, 0.9863142179006205) // Ideal value 1 + testGoodnessOfFitEquals(dataSeries, 0.9925338310779281) // Ideal value 1 testLocalGoodnessOfFitEquals(dataSeries, 0, 1) testXProjectionEquals(dataSeries, 1, -3) // Theoretical noisefree value -3 testLocalGoodnessOfFitEquals(dataSeries, 1, 0.9645892351274787) From ec08f367088045f0ce264103c02e5bfc604e699c Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Fri, 2 Jan 2026 20:18:26 +0100 Subject: [PATCH 084/118] Update TSLinearSeries.test.js --- app/engine/utils/TSLinearSeries.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/engine/utils/TSLinearSeries.test.js b/app/engine/utils/TSLinearSeries.test.js index a1d9641243..d5268428b4 100644 --- a/app/engine/utils/TSLinearSeries.test.js +++ b/app/engine/utils/TSLinearSeries.test.js @@ -231,7 +231,7 @@ test('Series with 5 elements, with 2 noisy datapoints, ideal function y = 3x - 6 testGoodnessOfFitEquals(dataSeries, 0.9925338310779281) // Ideal value 1 testLocalGoodnessOfFitEquals(dataSeries, 0, 1) testXProjectionEquals(dataSeries, 1, -3) // Theoretical noisefree value -3 - testLocalGoodnessOfFitEquals(dataSeries, 1, 0.9645892351274787) + testLocalGoodnessOfFitEquals(dataSeries, 1, 0.9813345776948204) testXProjectionEquals(dataSeries, 3, 3) // Theoretical noisefree value 3 testLocalGoodnessOfFitEquals(dataSeries, 2, 0.9645892351274787) testXProjectionEquals(dataSeries, 4, 6) // Theoretical noisefree value 6 From e3f2690741b50dda5806f7cefb8bb78e89cb0d90 Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Fri, 2 Jan 2026 20:22:23 +0100 Subject: [PATCH 085/118] Added tests to test effect of weights --- app/engine/utils/TSLinearSeries.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/engine/utils/TSLinearSeries.test.js b/app/engine/utils/TSLinearSeries.test.js index d5268428b4..e6aec4f722 100644 --- a/app/engine/utils/TSLinearSeries.test.js +++ b/app/engine/utils/TSLinearSeries.test.js @@ -233,7 +233,7 @@ test('Series with 5 elements, with 2 noisy datapoints, ideal function y = 3x - 6 testXProjectionEquals(dataSeries, 1, -3) // Theoretical noisefree value -3 testLocalGoodnessOfFitEquals(dataSeries, 1, 0.9813345776948204) testXProjectionEquals(dataSeries, 3, 3) // Theoretical noisefree value 3 - testLocalGoodnessOfFitEquals(dataSeries, 2, 0.9645892351274787) + testLocalGoodnessOfFitEquals(dataSeries, 2, 0.9813345776948204) testXProjectionEquals(dataSeries, 4, 6) // Theoretical noisefree value 6 testLocalGoodnessOfFitEquals(dataSeries, 3, 1) testXProjectionEquals(dataSeries, 5, 9) // Theoretical noisefree value 9 From 13928be8ed7e4d825cdef54817596f88c1253d6d Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Fri, 2 Jan 2026 20:30:19 +0100 Subject: [PATCH 086/118] Exposed GoodnessOfFit series --- app/engine/utils/CyclicErrorFilter.js | 1 + 1 file changed, 1 insertion(+) diff --git a/app/engine/utils/CyclicErrorFilter.js b/app/engine/utils/CyclicErrorFilter.js index 64b4679700..ef5d113dab 100644 --- a/app/engine/utils/CyclicErrorFilter.js +++ b/app/engine/utils/CyclicErrorFilter.js @@ -211,6 +211,7 @@ export function createCyclicErrorFilter (rowerSettings, minimumDragFactorSamples updateFilter, raw, clean, + goodnessOfFit, warmRestart, coldRestart, reset From 69f1150866f73dbbbac310fbccc4d49d072385d4 Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Fri, 2 Jan 2026 20:33:00 +0100 Subject: [PATCH 087/118] Added cyclicErrorFilter GoF as Weight for the drag calculation --- app/engine/Flywheel.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/engine/Flywheel.js b/app/engine/Flywheel.js index 072088cf8a..456077073e 100644 --- a/app/engine/Flywheel.js +++ b/app/engine/Flywheel.js @@ -121,7 +121,7 @@ export function createFlywheel (rowerSettings) { if (inRecoveryPhase) { // Feed the drag calculation, as we didn't reset the Semaphore in the previous cycle based on the current flank - recoveryDeltaTime.push(totalTimeSpinning, _deltaTimeBeforeFlank) + recoveryDeltaTime.push(totalTimeSpinning, _deltaTimeBeforeFlank, cyclicErrorFilter.goodnessOfFit.atSeriesBegin()) // Feed the systematic error filter buffer cyclicErrorFilter.recordRawDatapoint(totalNumberOfImpulses, totalTimeSpinning, cyclicErrorFilter.raw.atSeriesBegin()) } else { From 4bfd36fbab49bb3a43ecbc340b2ecde046fe0ebf Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Fri, 2 Jan 2026 20:50:29 +0100 Subject: [PATCH 088/118] Refactoring code to make interface less stateless --- app/engine/utils/CyclicErrorFilter.js | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/app/engine/utils/CyclicErrorFilter.js b/app/engine/utils/CyclicErrorFilter.js index ef5d113dab..f1d000313a 100644 --- a/app/engine/utils/CyclicErrorFilter.js +++ b/app/engine/utils/CyclicErrorFilter.js @@ -85,6 +85,28 @@ export function createCyclicErrorFilter (rowerSettings, minimumDragFactorSamples return (rawValue * slope[magnet] * slopeCorrection) + (intercept[magnet] - interceptCorrection) } + /** + * @returns {object} result - provides the (oldest) object at the head of the FiFo buffer, as once returned as a repsonse to the 'applyFilter()' function + * @returns {float} result.clean - the resulting clean value as once returned + * @returns {float} result.raw - the initial (raw) datapoint before applying the filter + * @returns {float} result.goodnessOfFit - The goodness of fit indication for the specific datapoint + */ + function atSeriesBegin () { + if (clean.length() > 0) { + return { + clean: clean.atSeriesBegin(), + raw: raw.atSeriesBegin(), + goodnessOfFit: goodnessOfFit.atSeriesBegin() + } + } else { + return { + clean: undefined, + raw: undefined, + goodnessOfFit: 0 + } + } + } + /** * @param {integer} relativePosition - the position of the recorded datapoint (i.e the sequence number of the datapoint) * @param {float} absolutePosition - the total spinning time of the flywheel @@ -209,9 +231,7 @@ export function createCyclicErrorFilter (rowerSettings, minimumDragFactorSamples recordRawDatapoint, processNextRawDatapoint, updateFilter, - raw, - clean, - goodnessOfFit, + atSeriesBegin, warmRestart, coldRestart, reset From 4ae358b2edab8fb591e48881e0d61ef19771ce03 Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Fri, 2 Jan 2026 20:54:11 +0100 Subject: [PATCH 089/118] Refactoring code to make interface less stateless --- app/engine/Flywheel.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/app/engine/Flywheel.js b/app/engine/Flywheel.js index 456077073e..ee7d808a70 100644 --- a/app/engine/Flywheel.js +++ b/app/engine/Flywheel.js @@ -63,7 +63,7 @@ export function createFlywheel (rowerSettings) { const minimumRecoverySlope = createWeighedSeries(rowerSettings.dragFactorSmoothing, rowerSettings.minimumRecoverySlope) let totalTime let currentAngularDistance - let _deltaTimeBeforeFlank + let _deltaTimeBeforeFlank = {} let _angularVelocityAtBeginFlank let _angularVelocityBeforeFlank let _angularAccelerationAtBeginFlank @@ -112,8 +112,8 @@ export function createFlywheel (rowerSettings) { // 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 = cyclicErrorFilter.clean.atSeriesBegin() - totalTimeSpinning += _deltaTimeBeforeFlank + _deltaTimeBeforeFlank = cyclicErrorFilter.atSeriesBegin() + totalTimeSpinning += _deltaTimeBeforeFlank.clean _angularVelocityBeforeFlank = _angularVelocityAtBeginFlank _angularAccelerationBeforeFlank = _angularAccelerationAtBeginFlank // As drag is recalculated at the begin of the drive, we need to recalculate the torque @@ -121,9 +121,9 @@ export function createFlywheel (rowerSettings) { if (inRecoveryPhase) { // Feed the drag calculation, as we didn't reset the Semaphore in the previous cycle based on the current flank - recoveryDeltaTime.push(totalTimeSpinning, _deltaTimeBeforeFlank, cyclicErrorFilter.goodnessOfFit.atSeriesBegin()) + recoveryDeltaTime.push(totalTimeSpinning, _deltaTimeBeforeFlank.clean, _deltaTimeBeforeFlank.goodnessOfFit) // Feed the systematic error filter buffer - cyclicErrorFilter.recordRawDatapoint(totalNumberOfImpulses, totalTimeSpinning, cyclicErrorFilter.raw.atSeriesBegin()) + cyclicErrorFilter.recordRawDatapoint(totalNumberOfImpulses, totalTimeSpinning, _deltaTimeBeforeFlank.raw) } else { // Accumulate the energy total as we are in the drive phase _totalWork += Math.max(_torqueBeforeFlank * angularDisplacementPerImpulse, 0) @@ -131,7 +131,7 @@ export function createFlywheel (rowerSettings) { cyclicErrorFilter.processNextRawDatapoint() } } else { - _deltaTimeBeforeFlank = 0 + _deltaTimeBeforeFlank.clean = 0 _angularVelocityBeforeFlank = 0 _angularAccelerationBeforeFlank = 0 _torqueBeforeFlank = 0 @@ -225,7 +225,7 @@ export function createFlywheel (rowerSettings) { * @returns {float} the current DeltaTime BEFORE the flank */ function deltaTime () { - return _deltaTimeBeforeFlank + return _deltaTimeBeforeFlank.clean } /** @@ -417,7 +417,7 @@ export function createFlywheel (rowerSettings) { _totalWork = 0 _deltaTime.push(0, 0) _angularDistance.push(0, 0) - _deltaTimeBeforeFlank = 0 + _deltaTimeBeforeFlank.clean = 0 _angularVelocityBeforeFlank = 0 _angularAccelerationBeforeFlank = 0 _torqueAtBeginFlank = 0 From 34f0ba995646e58751891c3fb30c4cddfcc9f920 Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Fri, 2 Jan 2026 20:55:20 +0100 Subject: [PATCH 090/118] Fixes ESlint errors --- app/engine/Flywheel.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/engine/Flywheel.js b/app/engine/Flywheel.js index ee7d808a70..e902f2d4cb 100644 --- a/app/engine/Flywheel.js +++ b/app/engine/Flywheel.js @@ -1,7 +1,7 @@ 'use strict' /** * @copyright [OpenRowingMonitor]{@link https://github.com/JaapvanEkris/openrowingmonitor} - * + * * @file This models the flywheel with all of its attributes, which we can also test for being powered * * All times and distances are defined as being before the beginning of the flank, as RowingEngine's metrics @@ -76,7 +76,7 @@ export function createFlywheel (rowerSettings) { let totalTimeSpinning let _totalWork reset() - + /** * @param {float} dataPoint - The lenght of the impuls (currentDt) in seconds * @description This function is called from Rower.js each time the sensor detected an impulse. It transforms this (via the buffers) into a robust flywheel position, speed and acceleration. From 4dd70efca4390902ed105fd0d7a18c3db6c2adce Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Fri, 2 Jan 2026 21:02:48 +0100 Subject: [PATCH 091/118] Improved JSDoc comments --- app/engine/utils/WLSLinearSeries.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/engine/utils/WLSLinearSeries.js b/app/engine/utils/WLSLinearSeries.js index 255ad93dac..6aa16e2d54 100644 --- a/app/engine/utils/WLSLinearSeries.js +++ b/app/engine/utils/WLSLinearSeries.js @@ -1,7 +1,7 @@ 'use strict' /** * @copyright [OpenRowingMonitor]{@link https://github.com/JaapvanEkris/openrowingmonitor} - * + * * @file The WLSLinearSeries is a datatype that represents a Linear Series. It allows * values to be retrieved (like a FiFo buffer, or Queue) but it also includes * a Weighted Linear Regressor to determine the slope, intercept and R^2 of this series @@ -147,6 +147,9 @@ export function createWLSLinearSeries (maxSeriesLength = 0) { return (X.length() >= 2 && _slope !== 0) } + /** + * @description This function is used for clearing all data, typically when flywheel.js is completely reset + */ function reset () { X.reset() Y.reset() From db85e5d3e587157c0f82639c82d93c6db8e491e2 Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Fri, 2 Jan 2026 21:06:37 +0100 Subject: [PATCH 092/118] Fixes ESlint errors --- app/engine/utils/CyclicErrorFilter.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/engine/utils/CyclicErrorFilter.js b/app/engine/utils/CyclicErrorFilter.js index f1d000313a..6e7e173835 100644 --- a/app/engine/utils/CyclicErrorFilter.js +++ b/app/engine/utils/CyclicErrorFilter.js @@ -1,7 +1,7 @@ 'use strict' /** * @copyright [OpenRowingMonitor]{@link https://github.com/JaapvanEkris/openrowingmonitor} - * + * * @file This implements a cyclic error filter. This is used to create a profile * The filterArray does the calculation, the slope and intercept arrays contain the results for easy retrieval * the slopeCorrection and interceptCorrection ensure preventing time dilation due to excessive corrections @@ -54,7 +54,7 @@ export function createCyclicErrorFilter (rowerSettings, minimumDragFactorSamples * @param {float} the raw recorded value to be cleaned up * @param {integer} the position of the flywheel * @returns {object} result - * @returns {float} result.value - the resulting clean value + * @returns {float} result.value - the resulting clean value * @returns {float} result.goodnessOfFit - The goodness of fit indication for the specific datapoint * @description Applies the filter on the raw value for the given position (i.e. magnet). Please note: this function is NOT stateless, it also fills a hystoric buffer of raw and clean values */ From d8c759c584a2ee4fe385215e21b87e74903db04f Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Fri, 2 Jan 2026 21:09:57 +0100 Subject: [PATCH 093/118] Added link to documentation --- app/engine/utils/CyclicErrorFilter.js | 1 + 1 file changed, 1 insertion(+) diff --git a/app/engine/utils/CyclicErrorFilter.js b/app/engine/utils/CyclicErrorFilter.js index 6e7e173835..924b72ddb7 100644 --- a/app/engine/utils/CyclicErrorFilter.js +++ b/app/engine/utils/CyclicErrorFilter.js @@ -5,6 +5,7 @@ * @file This implements a cyclic error filter. This is used to create a profile * The filterArray does the calculation, the slope and intercept arrays contain the results for easy retrieval * the slopeCorrection and interceptCorrection ensure preventing time dilation due to excessive corrections + * @see {@link https://github.com/JaapvanEkris/openrowingmonitor/blob/main/docs/Mathematical_Foundations.md|for the underlying math description) */ import loglevel from 'loglevel' import { createSeries } from './Series.js' From ba8439f608dcda0bfe2b5b90e5767ab34b20c0e8 Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Sun, 4 Jan 2026 12:45:07 +0100 Subject: [PATCH 094/118] Changed loglevel to more sane approach --- app/engine/utils/CyclicErrorFilter.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/engine/utils/CyclicErrorFilter.js b/app/engine/utils/CyclicErrorFilter.js index 924b72ddb7..5f83e441cf 100644 --- a/app/engine/utils/CyclicErrorFilter.js +++ b/app/engine/utils/CyclicErrorFilter.js @@ -169,7 +169,7 @@ export function createCyclicErrorFilter (rowerSettings, minimumDragFactorSamples * @description This function is used for clearing the buffers in order to prepare to record for a new set of datapoints, or clear it when the buffer is filled with a recovery with too weak GoF */ function warmRestart () { - if (!isNaN(lowerCursor)) { log.debug('*** WARNING: cyclic error filter has forcefully been restarted (warm)') } + if (!isNaN(lowerCursor)) { log.trace('*** Cyclic error filter had a warm restart for the next recovery') } recordedRelativePosition = [] recordedAbsolutePosition = [] recordedRawValue = [] From da1704e0de379bc02ea4cbc2a2c76e4bcae5991f Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Sun, 4 Jan 2026 22:24:43 +0100 Subject: [PATCH 095/118] Changed loglevel to more sane approach --- .../ForceCurveCharacteristic.js | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/app/peripherals/ble/pm5/rowing-service/other-characteristics/ForceCurveCharacteristic.js b/app/peripherals/ble/pm5/rowing-service/other-characteristics/ForceCurveCharacteristic.js index 14076ac786..3cd22ba81f 100644 --- a/app/peripherals/ble/pm5/rowing-service/other-characteristics/ForceCurveCharacteristic.js +++ b/app/peripherals/ble/pm5/rowing-service/other-characteristics/ForceCurveCharacteristic.js @@ -1,11 +1,10 @@ 'use strict' -/* - Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor -*/ /** - * Implementation of the Force Curve Data as defined in: - * https://www.concept2.co.uk/files/pdf/us/monitors/PM5_BluetoothSmartInterfaceDefinition.pdf - * https://www.concept2.co.uk/files/pdf/us/monitors/PM5_CSAFECommunicationDefinition.pdf + * @copyright [OpenRowingMonitor]{@link https://github.com/JaapvanEkris/openrowingmonitor} + * + * @file Implementation of the Force Curve Data as defined in: + * - @see {@link https://www.concept2.co.uk/files/pdf/us/monitors/PM5_BluetoothSmartInterfaceDefinition.pdf|The PM5 Bluetooth Smart Interface Definition} + * - @see {@link https://www.concept2.co.uk/files/pdf/us/monitors/PM5_CSAFECommunicationDefinition.pdf|The PM5 CSAFE Communication Definition} */ import loglevel from 'loglevel' @@ -51,7 +50,7 @@ export class ForceCurveCharacteristic extends GattNotifyCharacteristic { const split = Math.floor(data.driveHandleForceCurve.length / chunkSize + (data.driveHandleForceCurve.length % chunkSize === 0 ? 0 : 1)) let i = 0 - log.debug(`Force curve data count: ${data.driveHandleForceCurve.length} chunk size(number of values): ${chunkSize}, number of chunks: ${split}`) + log.trace(`Force curve data count: ${data.driveHandleForceCurve.length} chunk size(number of values): ${chunkSize}, number of chunks: ${split}`) while (i < split) { const end = (i + 1) * chunkSize < data.driveHandleForceCurve.length ? chunkSize * (i + 1) : data.driveHandleForceCurve.length From 46ee353b9af0c028a737c07c3ab56e9529320b08 Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Wed, 7 Jan 2026 11:14:42 +0100 Subject: [PATCH 096/118] Changed CEC filter size to be a setting --- app/engine/utils/CyclicErrorFilter.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/engine/utils/CyclicErrorFilter.js b/app/engine/utils/CyclicErrorFilter.js index 5f83e441cf..b9decf730c 100644 --- a/app/engine/utils/CyclicErrorFilter.js +++ b/app/engine/utils/CyclicErrorFilter.js @@ -29,7 +29,7 @@ export function createCyclicErrorFilter (rowerSettings, minimumDragFactorSamples const _flankLength = rowerSettings.flankLength const _agressiveness = Math.min(Math.max(rowerSettings.systematicErrorAgressiveness, 0), 1) const _invAgressiveness = Math.min(Math.max(1 - _agressiveness, 0), 1) - const _numberOfFilterSamples = Math.max((minimumDragFactorSamples / _numberOfMagnets) * 2.5, 5) + const _numberOfFilterSamples = Math.max(Math.round((rowerSettings.systematicErrorNumberOfDatapoints / _numberOfMagnets)), 5) const _minimumTimeBetweenImpulses = rowerSettings.minimumTimeBetweenImpulses const _maximumTimeBetweenImpulses = rowerSettings.maximumTimeBetweenImpulses const raw = createSeries(_flankLength) From 4866b521dad48ac1615a18739e28a1d2e9444962 Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Wed, 7 Jan 2026 11:23:10 +0100 Subject: [PATCH 097/118] Added systematicErrorNumberOfDatapoints setting --- config/rowerProfiles.js | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/config/rowerProfiles.js b/config/rowerProfiles.js index 18b1db87e8..32b4077d93 100644 --- a/config/rowerProfiles.js +++ b/config/rowerProfiles.js @@ -30,13 +30,12 @@ export default { // NOISE FILTER SETTINGS // Filter Settings to reduce noise in the measured data - // Systematic error agressibveness determines the strength of the systematic error filter. 0 turns it off, 1 turns it to its maximum. - // A value of 0.10 is known to work well, but some machines can handle 0.90. + // Systematic error agressiveness determines the strength of the systematic error filter. 0 turns it off (default), 1 turns it to its maximum. + // A value of 0.10 is known to bring some benefits, but some machines can handle 0.90 to 1.0. Don't set too high if the machine has a lot of signal bounce. systematicErrorAgressiveness: 0, - // Systematic error maximum change determines the maximum change the systematic error filter will allow by a single datapoint. - // This is a percentage, with a minimum of 0, and a maximum of 1. Values closer to 0 will make the filter behave more smoothly, but react slower to changes - systematicErrorMaximumChange: 1, + // Size of the total buffer for the systematic error filter. We recomend to use at least use the length of the longest recovery here (logs can tell this). + systematicErrorNumberOfDatapoints: 1, // Flank length determines the number of measuments that are used for determining the angular velocity and angular acceleration flankLength: 3, @@ -160,8 +159,8 @@ export default { minimumTimeBetweenImpulses: 0.005, maximumTimeBetweenImpulses: 0.01375, flankLength: 12, - systematicErrorAgressiveness: 0.85, - systematicErrorMaximumChange: 0.0065, + systematicErrorAgressiveness: 0.95, + systematicErrorNumberOfDatapoints: 240, minimumStrokeQuality: 0.26, minimumForceBeforeStroke: 22, minimumRecoverySlope: 0.00070, From cc6f040445eea437e344b6234eb92d43dd0da943 Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Wed, 7 Jan 2026 11:35:18 +0100 Subject: [PATCH 098/118] Added weights to the MovingWindowRegressor --- app/engine/utils/MovingWindowRegressor.js | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/app/engine/utils/MovingWindowRegressor.js b/app/engine/utils/MovingWindowRegressor.js index bb138fec34..2f5f325088 100644 --- a/app/engine/utils/MovingWindowRegressor.js +++ b/app/engine/utils/MovingWindowRegressor.js @@ -1,9 +1,8 @@ 'use strict' -/* - Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor -*/ /** - * This implements a Moving Regression Algorithm to obtain a coefficients, first (angular velocity) and + * @copyright [OpenRowingMonitor]{@link https://github.com/JaapvanEkris/openrowingmonitor} + * + * @file This implements a Moving Regression Algorithm to obtain a coefficients, first (angular velocity) and * second derivative (angular acceleration) at the front of the flank */ import { createTSQuadraticSeries } from './TSQuadraticSeries.js' @@ -22,8 +21,8 @@ export function createMovingRegressor (bandwith) { * @param {float} the x value of the datapoint * @param {float} the y value of the datapoint */ - function push (x, y) { - quadraticTheilSenRegressor.push(x, y) + function push (x, y, w = 1) { + quadraticTheilSenRegressor.push(x, y, w) // Let's shift the matrix to make room for a new datapoint if (aMatrix.length >= flankLength) { @@ -165,6 +164,9 @@ export function createMovingRegressor (bandwith) { } } + /** + * Resets the series to its initial state + */ function reset () { quadraticTheilSenRegressor.reset() let i = aMatrix.length @@ -198,6 +200,10 @@ export function createMovingRegressor (bandwith) { cMatrix = [] } + /** + * @param {integer} position - position to be retrieved, starting at 0 + * @returns {float} X value at that specific postion in the series + */ function Xget (position = 0) { if (position < quadraticTheilSenRegressor.length()) { return quadraticTheilSenRegressor.X.get(position) @@ -206,6 +212,10 @@ export function createMovingRegressor (bandwith) { } } + /** + * @param {integer} position - position to be retrieved, starting at 0 + * @returns {float} Y value at that specific postion in the series + */ function Yget (position = 0) { if (position < quadraticTheilSenRegressor.length()) { return quadraticTheilSenRegressor.Y.get(position) From 07c37e725d87b16aa570b4515bfb80529d779e7c Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Wed, 7 Jan 2026 11:43:12 +0100 Subject: [PATCH 099/118] Changed series.sum() implementation due to numerical instability --- app/engine/utils/Series.js | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/app/engine/utils/Series.js b/app/engine/utils/Series.js index 7cdf6ca2ce..0f655765db 100644 --- a/app/engine/utils/Series.js +++ b/app/engine/utils/Series.js @@ -1,25 +1,25 @@ 'use strict' /** * @copyright [OpenRowingMonitor]{@link https://github.com/JaapvanEkris/openrowingmonitor} - * - * @file This creates a series with a maximum number of values. It allows for determining the Average, Median, Number of Positive, number of Negative BE AWARE: The median function is extremely CPU intensive for larger series. Use the BinarySearchTree for that situation instead! + * + * @file This creates a series with a maximum number of values. It allows for determining the Average, Median, Number of Positive, number of Negative + * BE AWARE: The median function is extremely CPU intensive for larger series. Use the BinarySearchTree for that situation instead! */ /** - * @param {number} [maxSeriesLength] The maximum length of the series (0 for unlimited) + * @param {number} maxSeriesLength - The maximum length of the series (0 for unlimited) */ export function createSeries (maxSeriesLength = 0) { /** * @type {Array} */ let seriesArray = [] - let seriesSum = 0 let numPos = 0 let numNeg = 0 let min = undefined let max = undefined /** - * @param {float} value to be added to the series + * @param {float} value - value to be added to the series */ function push (value) { if (value === undefined || isNaN(value)) { return } @@ -28,9 +28,7 @@ export function createSeries (maxSeriesLength = 0) { if (max !== undefined) { max = Math.max(max, value) } if (maxSeriesLength > 0 && seriesArray.length >= maxSeriesLength) { - // The maximum of the array has been reached, we have to create room by removing the first - // value from the array - seriesSum -= seriesArray[0] + // The maximum of the array has been reached, we have to create room by removing the first value from the array if (seriesArray[0] > 0) { numPos-- } else { @@ -45,7 +43,7 @@ export function createSeries (maxSeriesLength = 0) { seriesArray.shift() } seriesArray.push(value) - seriesSum += value + if (value > 0) { numPos++ } else { @@ -95,7 +93,7 @@ export function createSeries (maxSeriesLength = 0) { } /** - * @param {float} tested value + * @param {float} testedValue - tested value * @returns {integer} count of values in the series above the tested value */ function numberOfValuesAbove (testedValue) { @@ -115,7 +113,7 @@ export function createSeries (maxSeriesLength = 0) { } /** - * @param {float} tested value + * @param {float} testedValue - tested value * @returns {integer} number of values in the series below or equal to the tested value */ function numberOfValuesEqualOrBelow (testedValue) { @@ -136,9 +134,10 @@ export function createSeries (maxSeriesLength = 0) { /** * @returns {float} sum of the entire series + * @description This determines the total sum of the series. As a running sum becomes unstable after longer running sums, we need to summarise this via a reduce */ function sum () { - return seriesSum + return (seriesArray.length > 0 ? seriesArray.reduce((total, item) => total + item) : 0) } /** @@ -146,7 +145,7 @@ export function createSeries (maxSeriesLength = 0) { */ function average () { if (seriesArray.length > 0) { - return seriesSum / seriesArray.length + return sum() / seriesArray.length } else { return 0 } @@ -207,7 +206,6 @@ export function createSeries (maxSeriesLength = 0) { function reset () { seriesArray = /** @type {Array} */(/** @type {unknown} */(null)) seriesArray = [] - seriesSum = 0 numPos = 0 numNeg = 0 min = undefined From b42620dd0fa68a296e736eb32a0206194a6d723d Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Wed, 7 Jan 2026 11:48:10 +0100 Subject: [PATCH 100/118] Added weights to the TSLinearSeries --- app/engine/utils/TSLinearSeries.js | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/app/engine/utils/TSLinearSeries.js b/app/engine/utils/TSLinearSeries.js index d7148191c9..17b8f8099e 100644 --- a/app/engine/utils/TSLinearSeries.js +++ b/app/engine/utils/TSLinearSeries.js @@ -23,7 +23,6 @@ */ import { createSeries } from './Series.js' -import { createWeighedSeries } from './WeighedSeries.js' import { createLabelledBinarySearchTree } from './BinarySearchTree.js' import loglevel from 'loglevel' @@ -36,7 +35,7 @@ export function createTSLinearSeries (maxSeriesLength = 0) { const X = createSeries(maxSeriesLength) const Y = createSeries(maxSeriesLength) const weight = createSeries(maxSeriesLength) - const WY = createWeighedSeries(maxSeriesLength, undefined) + const WY = createSeries(maxSeriesLength) const A = createLabelledBinarySearchTree() let _A = 0 @@ -63,7 +62,7 @@ export function createTSLinearSeries (maxSeriesLength = 0) { X.push(x) Y.push(y) weight.push(w) - WY.push(y, w) + WY.push(w * y) // Calculate all the slopes of the newly added point if (X.length() > 1) { @@ -131,7 +130,7 @@ export function createTSLinearSeries (maxSeriesLength = 0) { } /** - * @returns {float} the R^2 as a goodness of fit indicator + * @returns {float} the R^2 as a global goodness of fit indicator * It will automatically recalculate the _goodnessOfFit when it isn't defined * This lazy approach is intended to prevent unneccesary calculations, especially when there is a batch of datapoints * pushes from the TSQuadratic regressor processing its linear residu @@ -140,13 +139,17 @@ export function createTSLinearSeries (maxSeriesLength = 0) { function goodnessOfFit () { let i = 0 let sse = 0 + calculateIntercept() if (_goodnessOfFit === null) { if (X.length() >= 2) { _sst = 0 + // Calculate weighted R^2 + const weightedAverageY = WY.sum() / weight.sum() + while (i < X.length()) { sse += weight.get(i) * Math.pow(Y.get(i) - projectX(X.get(i)), 2) - _sst += weight.get(i) * Math.pow(Y.get(i) - WY.weighedAverage(), 2) + _sst += weight.get(i) * Math.pow(Y.get(i) - weightedAverageY, 2) i++ } @@ -156,14 +159,14 @@ export function createTSLinearSeries (maxSeriesLength = 0) { break case (sse > _sst): // This is a pretty bad fit as the error is bigger than just using the line for the average y as intercept - _goodnessOfFit = 0 + _goodnessOfFit = 0.01 break case (_sst !== 0): _goodnessOfFit = 1 - (sse / _sst) break default: // When SST = 0, R2 isn't defined - _goodnessOfFit = 0 + _goodnessOfFit = 0.01 } } else { _goodnessOfFit = 0 @@ -190,14 +193,14 @@ export function createTSLinearSeries (maxSeriesLength = 0) { break case (weightedSquaredError > _sst): // This is a pretty bad fit as the error is bigger than just using the line for the average y as intercept - return 0 + return 0.01 break case (_sst !== 0): return Math.min(Math.max(1 - ((weightedSquaredError * X.length()) / _sst), 0), 1) break default: // When _SST = 0, localGoodnessOfFit isn't defined - return 0 + return 0.01 } /* eslint-enable no-unreachable */ } else { @@ -278,7 +281,7 @@ export function createTSLinearSeries (maxSeriesLength = 0) { } /** - * @description This function is used for clearing data and state + * @description This function is used for clearing data and state, bringing it back to its original state */ function reset () { if (X.length() > 0) { From 027afa037f946b5e7eceed4d980dffa8e3045a52 Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Wed, 7 Jan 2026 11:58:54 +0100 Subject: [PATCH 101/118] Added weights to TSQuadraticSeries --- app/engine/utils/TSQuadraticSeries.js | 83 +++++++++++++++++++-------- 1 file changed, 58 insertions(+), 25 deletions(-) diff --git a/app/engine/utils/TSQuadraticSeries.js b/app/engine/utils/TSQuadraticSeries.js index bf411b268e..514cf3bd11 100644 --- a/app/engine/utils/TSQuadraticSeries.js +++ b/app/engine/utils/TSQuadraticSeries.js @@ -1,9 +1,8 @@ 'use strict' -/* - Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor -*/ /** - * The FullTSQuadraticSeries is a datatype that represents a Quadratic Series. It allows + * @copyright [OpenRowingMonitor]{@link https://github.com/JaapvanEkris/openrowingmonitor} + * + * @file The FullTSQuadraticSeries is a datatype that represents a Quadratic Series. It allows * values to be retrieved (like a FiFo buffer, or Queue) but it also includes * a Theil-Sen Quadratic Regressor to determine the coefficients of this dataseries. * @@ -33,11 +32,13 @@ import loglevel from 'loglevel' const log = loglevel.getLogger('RowingEngine') /** - * @param {integer} the maximum length of the quadratic series, 0 for unlimited + * @param {integer} maxSeriesLength - the maximum length of the quadratic series, 0 for unlimited */ export function createTSQuadraticSeries (maxSeriesLength = 0) { const X = createSeries(maxSeriesLength) const Y = createSeries(maxSeriesLength) + const weight = createSeries(maxSeriesLength) + const WY = createSeries(maxSeriesLength) const A = createLabelledBinarySearchTree() const linearResidu = createTSLinearSeries(maxSeriesLength) let _A = 0 @@ -47,12 +48,13 @@ export function createTSQuadraticSeries (maxSeriesLength = 0) { let _goodnessOfFit = 0 /** - * @param {float} the x value of the datapoint - * @param {float} the y value of the datapoint + * @param {float} x - the x value of the datapoint + * @param {float} y - the y value of the datapoint + * @param {float} w - the weight of the datapoint (defaults to 1) * Invariant: BinrySearchTree A contains all calculated a's (as in the general formula y = a * x^2 + b * x + c), * where the a's are labeled in the BinarySearchTree with their Xi when they BEGIN in the point (Xi, Yi) */ - function push (x, y) { + function push (x, y, w = 1) { if (x === undefined || isNaN(x) || y === undefined || isNaN(y)) { return } if (maxSeriesLength > 0 && X.length() >= maxSeriesLength) { @@ -63,6 +65,8 @@ export function createTSQuadraticSeries (maxSeriesLength = 0) { X.push(x) Y.push(y) + weight.push(w) + WY.push(w * y) // Calculate the coefficient a for the new interval by adding the newly added datapoint let i = 0 @@ -72,15 +76,19 @@ export function createTSQuadraticSeries (maxSeriesLength = 0) { case (X.length() >= 3): // There are now at least three datapoints in the X and Y arrays, so let's calculate the A portion belonging for the new datapoint via Quadratic Theil-Sen regression // First we calculate the A for the formula + let combinedweight = 0 + let coeffA = 1 while (i < X.length() - 2) { j = i + 1 while (j < X.length() - 1) { - A.push(X.get(i), calculateA(i, j, X.length() - 1), 1) + combinedweight = weight.get(i) * weight.get(j) * w + coeffA = calculateA(i, j, X.length() - 1) + A.push(X.get(i), coeffA, combinedweight) j++ } i++ } - _A = A.median() + _A = A.weightedMedian() // We invalidate the linearResidu, B, C, and goodnessOfFit, as this will trigger a recalculate when they are needed linearResidu.reset() @@ -99,7 +107,7 @@ export function createTSQuadraticSeries (maxSeriesLength = 0) { } /** - * @param {integer} the position in the flank of the requested value (default = 0) + * @param {integer} position - the position in the flank of the requested value (default = 0) * @returns {float} the firdt derivative of the quadratic function y = a x^2 + b x + c */ function firstDerivativeAtPosition (position = 0) { @@ -112,7 +120,7 @@ export function createTSQuadraticSeries (maxSeriesLength = 0) { } /** - * @param {integer} the position in the flank of the requested value (default = 0) + * @param {integer} position - the position in the flank of the requested value (default = 0) * @returns {float} the second derivative of the quadratic function y = a x^2 + b x + c */ function secondDerivativeAtPosition (position = 0) { @@ -124,7 +132,7 @@ export function createTSQuadraticSeries (maxSeriesLength = 0) { } /** - * @param {float} the x value of the requested value + * @param {float} x - the x value of the requested value * @returns {float} the slope of the linear function */ function slope (x) { @@ -137,14 +145,14 @@ export function createTSQuadraticSeries (maxSeriesLength = 0) { } /** - * @returns {float} the coefficient a of the quadratic function y = a x^2 + b x + c + * @returns {float} the (quadratic) coefficient a of the quadratic function y = a x^2 + b x + c */ function coefficientA () { return _A } /** - * @returns {float} the coefficient b of the quadratic function y = a x^2 + b x + c + * @returns {float} the (linear) coefficient b of the quadratic function y = a x^2 + b x + c */ function coefficientB () { calculateB() @@ -152,7 +160,7 @@ export function createTSQuadraticSeries (maxSeriesLength = 0) { } /** - * @returns {float} the coefficient c of the quadratic function y = a x^2 + b x + c + * @returns {float} the (intercept) coefficient c of the quadratic function y = a x^2 + b x + c */ function coefficientC () { calculateB() @@ -177,33 +185,38 @@ export function createTSQuadraticSeries (maxSeriesLength = 0) { } /** - * @returns {float} the R^2 as a goodness of fit indicator + * @returns {float} the R^2 as a global goodness of fit indicator */ function goodnessOfFit () { let i = 0 let sse = 0 if (_goodnessOfFit === null) { + calculateB() + calculateC() if (X.length() >= 3) { _sst = 0 + const weightedAverageY = WY.sum() / weight.sum() + while (i < X.length()) { - sse += Math.pow((Y.get(i) - projectX(X.get(i))), 2) - _sst += Math.pow((Y.get(i) - Y.average()), 2) + sse += weight.get(i) * Math.pow(Y.get(i) - projectX(X.get(i)), 2) + _sst += weight.get(i) * Math.pow(Y.get(i) - weightedAverageY, 2) i++ } + switch (true) { case (sse === 0): _goodnessOfFit = 1 break case (sse > _sst): // This is a pretty bad fit as the error is bigger than just using the line for the average y as intercept - _goodnessOfFit = 0 + _goodnessOfFit = 0.01 break case (_sst !== 0): _goodnessOfFit = 1 - (sse / _sst) break default: // When _SST = 0, R2 isn't defined - _goodnessOfFit = 0 + _goodnessOfFit = 0.01 } } else { _goodnessOfFit = 0 @@ -229,14 +242,14 @@ export function createTSQuadraticSeries (maxSeriesLength = 0) { break case (squaredError > _sst): // This is a pretty bad fit as the error is bigger than just using the line for the average y as intercept - return 0 + return 0.01 break case (_sst !== 0): return Math.min(Math.max(1 - ((squaredError * X.length()) / _sst), 0), 1) break default: // When _SST = 0, localGoodnessOfFit isn't defined - return 0 + return 0.01 } /* eslint-enable no-unreachable */ } else { @@ -245,7 +258,7 @@ export function createTSQuadraticSeries (maxSeriesLength = 0) { } /** - * @param {float} the x value to be projected + * @param {float} x - the x value to be projected * @returns {float} the resulting y value when projected via the linear function */ function projectX (x) { @@ -258,6 +271,12 @@ export function createTSQuadraticSeries (maxSeriesLength = 0) { } } + /** + * @param {integer} pointOne - The position in the series of the first datapoint used for the quadratic coefficient calculation + * @param {integer} pointTwo - The position in the series of the second datapoint used for the quadratic coefficient calculation + * @param {integer} pointThree - The position in the series of the third datapoint used for the quadratic coefficient calculation + * @returns {float} the coefficient A of the linear function + */ function calculateA (pointOne, pointTwo, pointThree) { let result = 0 if (X.get(pointOne) !== X.get(pointTwo) && X.get(pointOne) !== X.get(pointThree) && X.get(pointTwo) !== X.get(pointThree)) { @@ -270,6 +289,9 @@ export function createTSQuadraticSeries (maxSeriesLength = 0) { } } + /** + * @description This helper function calculates the slope of the linear residu and stores it in _B + */ function calculateB () { // Calculate all the linear slope for the newly added point and the newly calculated A // This function is only called when a linear slope is really needed, as this saves a lot of CPU cycles when only a slope suffices @@ -283,6 +305,9 @@ export function createTSQuadraticSeries (maxSeriesLength = 0) { } } + /** + * @description This helper function calculates the intercept of the linear residu and stores it in _C + */ function calculateC () { // Calculate all the intercept for the newly added point and the newly calculated A // This function is only called when a linear intercept is really needed, as this saves a lot of CPU cycles when only a slope suffices @@ -296,12 +321,15 @@ export function createTSQuadraticSeries (maxSeriesLength = 0) { } } + /** + * @description This helper function fills the linear residu + */ function fillLinearResidu () { // To calculate the B and C via Linear regression over the residu, we need to fill it if empty if (linearResidu.length() === 0) { let i = 0 while (i < X.length()) { - linearResidu.push(X.get(i), Y.get(i) - (_A * Math.pow(X.get(i), 2))) + linearResidu.push(X.get(i), Y.get(i) - (_A * Math.pow(X.get(i), 2)), weight.get(i)) i++ } } @@ -314,11 +342,16 @@ export function createTSQuadraticSeries (maxSeriesLength = 0) { return (X.length() >= 3) } + /** + * @description This function is used for clearing data and state + */ function reset () { if (X.length() > 0) { // There is something to reset X.reset() Y.reset() + weight.reset() + WY.reset() A.reset() linearResidu.reset() _A = 0 From 51a227846f9bb42f520a32b8b7beff859ccfb9b8 Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Wed, 7 Jan 2026 12:01:53 +0100 Subject: [PATCH 102/118] Improved variable naming consistency --- app/engine/utils/WLSLinearSeries.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/engine/utils/WLSLinearSeries.js b/app/engine/utils/WLSLinearSeries.js index 6aa16e2d54..b596616ea3 100644 --- a/app/engine/utils/WLSLinearSeries.js +++ b/app/engine/utils/WLSLinearSeries.js @@ -45,7 +45,7 @@ export function createWLSLinearSeries (maxSeriesLength = 0) { /** * @param {float} x - the x value of the datapoint * @param {float} y - the y value of the datapoint - * @param {float} w - the observation weight of the datapoint, default = 1 + * @param {float} w - the weight of the datapoint, default = 1 */ function push (x, y, w = 1) { if (x === undefined || isNaN(x) || y === undefined || isNaN(y)) { return } @@ -69,13 +69,13 @@ export function createWLSLinearSeries (maxSeriesLength = 0) { _intercept = (WY.sum() - _slope * WX.sum()) / weight.sum() // Calculate weighted R^2 - const yMean = WY.sum() / weight.sum() - const ssRes = WYY.sum() - (2 * _intercept * WY.sum()) - (2 * _slope * WXY.sum()) + + const weighedAverageY = WY.sum() / weight.sum() + const sse = WYY.sum() - (2 * _intercept * WY.sum()) - (2 * _slope * WXY.sum()) + (_intercept * _intercept * weight.sum()) + (2 * _slope * _intercept * WX.sum()) + (_slope * _slope * WXX.sum()) - const ssTot = WYY.sum() - (yMean * yMean * weight.sum()) + const sst = WYY.sum() - (weighedAverageY * weighedAverageY * weight.sum()) - _goodnessOfFit = (ssTot !== 0) ? 1 - (ssRes / ssTot) : 0 + _goodnessOfFit = (sst !== 0) ? 1 - (sse / sst) : 0 } else { _slope = 0 _intercept = 0 From 469c5d8fd7f62581a5a7b01f6e471181016400f7 Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Wed, 7 Jan 2026 12:10:05 +0100 Subject: [PATCH 103/118] Improved numeric robustness of angular distance calculation As angular distance is composed of many floating point additions, it is changed in a increment counter followed by a multiplication to reduce floating point errors from escalating. --- app/engine/Flywheel.js | 41 +++++++++++++++++++++++++---------------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/app/engine/Flywheel.js b/app/engine/Flywheel.js index e902f2d4cb..bb4a2f87ce 100644 --- a/app/engine/Flywheel.js +++ b/app/engine/Flywheel.js @@ -61,8 +61,11 @@ export function createFlywheel (rowerSettings) { const cyclicErrorFilter = createCyclicErrorFilter(rowerSettings, minimumDragFactorSamples, recoveryDeltaTime) const strokedetectionMinimalGoodnessOfFit = rowerSettings.minimumStrokeQuality const minimumRecoverySlope = createWeighedSeries(rowerSettings.dragFactorSmoothing, rowerSettings.minimumRecoverySlope) - let totalTime - let currentAngularDistance + let rawTime = 0 + let rawNumberOfImpulses = 0 + let totalTimeSpinning = 0 + let totalNumberOfImpulses = 0 + let _totalWork = 0 let _deltaTimeBeforeFlank = {} let _angularVelocityAtBeginFlank let _angularVelocityBeforeFlank @@ -72,9 +75,6 @@ export function createFlywheel (rowerSettings) { let _torqueBeforeFlank let inRecoveryPhase let maintainMetrics - let totalNumberOfImpulses - let totalTimeSpinning - let _totalWork reset() /** @@ -138,14 +138,15 @@ export function createFlywheel (rowerSettings) { } const cleanCurrentDt = cyclicErrorFilter.applyFilter(dataPoint, totalNumberOfImpulses + flankLength) - totalTime += cleanCurrentDt.value - currentAngularDistance += angularDisplacementPerImpulse + rawTime += cleanCurrentDt.value + rawNumberOfImpulses++ + const currentAngularDistance = rawNumberOfImpulses * angularDisplacementPerImpulse // Let's feed the stroke detection algorithm - _deltaTime.push(totalTime, cleanCurrentDt.value) + _deltaTime.push(rawTime, cleanCurrentDt.value, cleanCurrentDt.goodnessOfFit) // Calculate the metrics that are needed for more advanced metrics, like the foce curve - _angularDistance.push(totalTime, currentAngularDistance) + _angularDistance.push(rawTime, currentAngularDistance, cleanCurrentDt.goodnessOfFit) _angularVelocityAtBeginFlank = _angularDistance.firstDerivative(0) _angularAccelerationAtBeginFlank = _angularDistance.secondDerivative(0) _torqueAtBeginFlank = (rowerSettings.flywheelInertia * _angularAccelerationAtBeginFlank + drag.weighedAverage() * Math.pow(_angularVelocityAtBeginFlank, 2)) @@ -190,10 +191,10 @@ export function createFlywheel (rowerSettings) { if (rowerSettings.autoAdjustRecoverySlope) { // We are allowed to autoadjust stroke detection slope as well, so let's do that minimumRecoverySlope.push((1 - rowerSettings.autoAdjustRecoverySlopeMargin) * recoveryDeltaTime.slope(), recoveryDeltaTime.goodnessOfFit()) - log.debug(`*** Calculated recovery slope: ${recoveryDeltaTime.slope().toFixed(6)}, Goodness of Fit: ${recoveryDeltaTime.goodnessOfFit().toFixed(4)}`) + log.trace(`*** Calculated recovery slope: ${recoveryDeltaTime.slope().toFixed(6)}, Goodness of Fit: ${recoveryDeltaTime.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.trace(`*** Calculated recovery slope: ${recoveryDeltaTime.slope().toFixed(6)}, Goodness of Fit: ${recoveryDeltaTime.goodnessOfFit().toFixed(4)}, not used as autoAdjustRecoverySlope isn't set to true`) } } else { // As the drag calculation is considered unreliable, we must skip updating the systematic error filter that depends on it @@ -397,24 +398,32 @@ export function createFlywheel (rowerSettings) { } } + /** + * @param {float} slope - Recovery slope to be converted + * @returns {float} Dragfactor to be used in all calculations + * @description Helper function to convert a recovery slope into a dragfactor + */ function slopeToDrag (slope) { return ((slope * rowerSettings.flywheelInertia) / angularDisplacementPerImpulse) } + /** + * @description This function is used for clearing all data, returning the flywheel.js to its initial state + */ function reset () { maintainMetrics = false inRecoveryPhase = false + rawTime = 0 + rawNumberOfImpulses = 0 + totalTimeSpinning = 0 + totalNumberOfImpulses = -1 + _totalWork = 0 drag.reset() cyclicErrorFilter.reset() cyclicErrorFilter.applyFilter(0, flankLength - 1) recoveryDeltaTime.reset() _deltaTime.reset() _angularDistance.reset() - totalTime = 0 - currentAngularDistance = 0 - totalNumberOfImpulses = -1 - totalTimeSpinning = 0 - _totalWork = 0 _deltaTime.push(0, 0) _angularDistance.push(0, 0) _deltaTimeBeforeFlank.clean = 0 From 9577b7fc3dcf44117482b9e644c31d5fab51e8b3 Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Wed, 7 Jan 2026 12:16:09 +0100 Subject: [PATCH 104/118] Fixed Lint errors --- app/engine/utils/TSQuadraticSeries.js | 61 +++++++++++++-------------- 1 file changed, 29 insertions(+), 32 deletions(-) diff --git a/app/engine/utils/TSQuadraticSeries.js b/app/engine/utils/TSQuadraticSeries.js index 514cf3bd11..95863a56c5 100644 --- a/app/engine/utils/TSQuadraticSeries.js +++ b/app/engine/utils/TSQuadraticSeries.js @@ -68,41 +68,38 @@ export function createTSQuadraticSeries (maxSeriesLength = 0) { weight.push(w) WY.push(w * y) - // Calculate the coefficient a for the new interval by adding the newly added datapoint - let i = 0 - let j = 0 + if (X.length() >= 3): + // There are now at least three datapoints in the X and Y arrays, so let's calculate the A portion belonging for the new datapoint via Quadratic Theil-Sen regression + let i = 0 + let j = 0 - switch (true) { - case (X.length() >= 3): - // There are now at least three datapoints in the X and Y arrays, so let's calculate the A portion belonging for the new datapoint via Quadratic Theil-Sen regression - // First we calculate the A for the formula - let combinedweight = 0 - let coeffA = 1 - while (i < X.length() - 2) { - j = i + 1 - while (j < X.length() - 1) { - combinedweight = weight.get(i) * weight.get(j) * w - coeffA = calculateA(i, j, X.length() - 1) - A.push(X.get(i), coeffA, combinedweight) - j++ - } - i++ + // First we calculate the A for the formula + let combinedweight = 0 + let coeffA = 1 + while (i < X.length() - 2) { + j = i + 1 + while (j < X.length() - 1) { + combinedweight = weight.get(i) * weight.get(j) * w + coeffA = calculateA(i, j, X.length() - 1) + A.push(X.get(i), coeffA, combinedweight) + j++ } - _A = A.weightedMedian() + i++ + } + _A = A.weightedMedian() - // We invalidate the linearResidu, B, C, and goodnessOfFit, as this will trigger a recalculate when they are needed - linearResidu.reset() - _B = null - _C = null - _sst = null - _goodnessOfFit = null - break - default: - _A = 0 - _B = 0 - _C = 0 - _sst = 0 - _goodnessOfFit = 0 + // We invalidate the linearResidu, B, C, and goodnessOfFit, as this will trigger a recalculate when they are needed + linearResidu.reset() + _B = null + _C = null + _sst = null + _goodnessOfFit = null + } else { + _A = 0 + _B = 0 + _C = 0 + _sst = 0 + _goodnessOfFit = 0 } } From f355feef14c9b79b55a542078c333fbdea5ea174 Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Wed, 7 Jan 2026 12:19:31 +0100 Subject: [PATCH 105/118] Fixed Lint errors --- app/engine/utils/TSQuadraticSeries.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/engine/utils/TSQuadraticSeries.js b/app/engine/utils/TSQuadraticSeries.js index 95863a56c5..5b62416e1f 100644 --- a/app/engine/utils/TSQuadraticSeries.js +++ b/app/engine/utils/TSQuadraticSeries.js @@ -68,7 +68,7 @@ export function createTSQuadraticSeries (maxSeriesLength = 0) { weight.push(w) WY.push(w * y) - if (X.length() >= 3): + if (X.length() >= 3) { // There are now at least three datapoints in the X and Y arrays, so let's calculate the A portion belonging for the new datapoint via Quadratic Theil-Sen regression let i = 0 let j = 0 From f64878af46d7af801606edf6995e6f5384120e74 Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Wed, 7 Jan 2026 12:20:33 +0100 Subject: [PATCH 106/118] Fixed lint errors --- app/engine/utils/WLSLinearSeries.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/engine/utils/WLSLinearSeries.test.js b/app/engine/utils/WLSLinearSeries.test.js index c51bd35d0f..2d7ee4692b 100644 --- a/app/engine/utils/WLSLinearSeries.test.js +++ b/app/engine/utils/WLSLinearSeries.test.js @@ -1,7 +1,7 @@ 'use strict' /** * @copyright [OpenRowingMonitor]{@link https://github.com/JaapvanEkris/openrowingmonitor} - * + * * @file This constains all tests for the WLS Linear Series */ import { test } from 'uvu' From b2f1ed45b77128fed5302e0a7c7acd39faea10e7 Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Wed, 7 Jan 2026 12:21:37 +0100 Subject: [PATCH 107/118] Fixed lint errors --- app/engine/utils/WeighedSeries.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/engine/utils/WeighedSeries.js b/app/engine/utils/WeighedSeries.js index 0d5aaddad9..e5568ca1b7 100644 --- a/app/engine/utils/WeighedSeries.js +++ b/app/engine/utils/WeighedSeries.js @@ -1,7 +1,7 @@ 'use strict' /** * @copyright [OpenRowingMonitor]{@link https://github.com/JaapvanEkris/openrowingmonitor} - * + * * @file This creates a weighed series with a maximum number of values. It allows for determining the Average, Weighed Averge, Median, Number of Positive, number of Negative. DO NOT USE MEDIAN ON LARGE SERIES! */ import { createSeries } from './Series.js' @@ -117,7 +117,7 @@ export function createWeighedSeries (maxSeriesLength = 0, defaultValue) { } /** - * @returns {float} median of the series + * @returns {float} median of the series * @description returns the median of the series. As this is a CPU intensive approach, DO NOT USE FOR LARGE SERIES!. For larger series, use the BinarySearchTree.js instead */ function median () { @@ -126,7 +126,7 @@ export function createWeighedSeries (maxSeriesLength = 0, defaultValue) { /** * @returns {boolean} if the weighed series results are to be considered reliable - */ + */ function reliable () { return dataArray.length() > 0 } From 6cebde725b789efe6c364f5380d976d1f8cad612 Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Wed, 7 Jan 2026 12:23:47 +0100 Subject: [PATCH 108/118] Fixed lint errors --- app/engine/utils/Series.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/engine/utils/Series.js b/app/engine/utils/Series.js index 0f655765db..217c48861a 100644 --- a/app/engine/utils/Series.js +++ b/app/engine/utils/Series.js @@ -81,7 +81,7 @@ export function createSeries (maxSeriesLength = 0) { } /** - * @param {integer} position to be retrieved, starting at 0 + * @param {integer} position - position to be retrieved, starting at 0 * @returns {float} value at that specific postion in the series */ function get (position) { @@ -176,7 +176,7 @@ export function createSeries (maxSeriesLength = 0) { } /** - * @returns {float} median of the series + * @returns {float} median of the series * @description returns the median of the series. As this is a CPU intensive approach, DO NOT USE FOR LARGE SERIES!. For larger series, use the BinarySearchTree.js instead */ function median () { From f7dbe8ef7ac7b4052eca8fba0babda3feafba3f1 Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Wed, 7 Jan 2026 14:20:17 +0100 Subject: [PATCH 109/118] Performance improvement --- app/engine/utils/Series.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/engine/utils/Series.js b/app/engine/utils/Series.js index 217c48861a..f0bd0057d1 100644 --- a/app/engine/utils/Series.js +++ b/app/engine/utils/Series.js @@ -4,6 +4,7 @@ * * @file This creates a series with a maximum number of values. It allows for determining the Average, Median, Number of Positive, number of Negative * BE AWARE: The median function is extremely CPU intensive for larger series. Use the BinarySearchTree for that situation instead! + * BE AWARE: Accumulators (seriesSum especially) are vulnerable to floating point rounding errors causing drift. Special tests are present in the unit-tests, which should be run manually when this module is changed */ /** * @param {number} maxSeriesLength - The maximum length of the series (0 for unlimited) @@ -17,6 +18,7 @@ export function createSeries (maxSeriesLength = 0) { let numNeg = 0 let min = undefined let max = undefined + let seriesSum = null /** * @param {float} value - value to be added to the series @@ -43,6 +45,7 @@ export function createSeries (maxSeriesLength = 0) { seriesArray.shift() } seriesArray.push(value) + seriesSum = null if (value > 0) { numPos++ @@ -137,7 +140,10 @@ export function createSeries (maxSeriesLength = 0) { * @description This determines the total sum of the series. As a running sum becomes unstable after longer running sums, we need to summarise this via a reduce */ function sum () { - return (seriesArray.length > 0 ? seriesArray.reduce((total, item) => total + item) : 0) + if (seriesSum === null) { + seriesSum = (seriesArray.length > 0 ? seriesArray.reduce((total, item) => total + item) : 0) + } + return seriesSum } /** From 2719e39f9e84809974771b448f30d30093824136 Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Wed, 7 Jan 2026 17:10:02 +0100 Subject: [PATCH 110/118] Update tests to reflect changed behaviour --- .../utils/MovingWindowRegressor.test.js | 360 +++++++++--------- 1 file changed, 186 insertions(+), 174 deletions(-) diff --git a/app/engine/utils/MovingWindowRegressor.test.js b/app/engine/utils/MovingWindowRegressor.test.js index 4c7e0a6c97..985c7656d4 100644 --- a/app/engine/utils/MovingWindowRegressor.test.js +++ b/app/engine/utils/MovingWindowRegressor.test.js @@ -1,8 +1,8 @@ -'use strict' /** - * Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor - */ +'use strict' /** - * Tests of the movingRegressor object + * @copyright [OpenRowingMonitor]{@link https://github.com/JaapvanEkris/openrowingmonitor} + * + * @file Tests of the movingRegressor object */ import { test } from 'uvu' import * as assert from 'uvu/assert' @@ -12,7 +12,9 @@ function flywheelPosition (position) { return ((position * Math.PI) / 3) } -// Test behaviour for no datapoints +/** + * @description Test behaviour for no datapoints + */ test('Correct movingRegressor behaviour at initialisation', () => { const flankLength = 12 const movingRegressor = createMovingRegressor(flankLength) @@ -20,14 +22,20 @@ test('Correct movingRegressor behaviour at initialisation', () => { testSecondDerivative(movingRegressor, undefined) }) -// Test behaviour for one datapoint +/** + * @todo Test behaviour for one datapoint + */ -// Test behaviour for perfect upgoing flank +/** + * @todo Test behaviour for perfect upgoing flank + */ -// Test behaviour for perfect downgoing flank +/** + * @todo Test behaviour for perfect downgoing flank + */ /** - * Test of the integration of the underlying FullTSQuadraticEstimator object + * @description Test of the integration of the underlying FullTSQuadraticEstimator object * This uses the same data as the function y = 2 x^2 + 4 * x */ test('Test of correct algorithmic integration of FullTSQuadraticEstimator and movingRegressor object for quadratic function f(x) = 2 * x^2 + 4 * x', () => { @@ -133,7 +141,7 @@ test('Test of correct algorithmic integration of FullTSQuadraticEstimator and mo }) /** - * Test of the integration of the underlying FullTSQuadraticEstimator object + * @description Test of the integration of the underlying FullTSQuadraticEstimator object * The data follows the function y = X^3 + 2 * x^2 + 4 * x * To test if multiple quadratic regressions can decently approximate a cubic function */ @@ -240,10 +248,10 @@ test('Test of correct algorithmic integration of FullTSQuadraticEstimator and mo }) /** - * Test of the integration of the underlying FullTSQuadraticEstimator object + * @description Test of the integration of the underlying FullTSQuadraticEstimator object * The data follows the function y = X^3 + 2 * x^2 + 4 * x with a +/-0.0001 sec injected noise in currentDt * To test if multiple quadratic regressions can decently approximate a cubic function with noise - * Please note: theoretical values are based on the perfect function (i.e. without noise) + * Please note: theoretical values are based on the perfect function (i.e. without noise) */ test('Test of correct algorithmic integration of FullTSQuadraticEstimator and movingRegressor object for cubic function f(x) = X^3 + 2 * x^2 + 4 * x with +/- 0.0001 error', () => { const flankLength = 12 @@ -347,8 +355,12 @@ test('Test of correct algorithmic integration of FullTSQuadraticEstimator and mo testSecondDerivative(movingRegressor, 15.113402988997308) // Datapoint 20, Theoretical value: 14.90963951, error: -1.05% }) -// Test behaviour for no datapoints -test('Test of correct algorithmic behaviourof FullTSQuadraticEstimator in movingRegressor object for function f(x) = = (x + 3,22398390803294)^3 + 33,5103216382911', () => { +/** + * @description Test of the integration of the underlying FullTSQuadraticEstimator object + * The data follows the function y = (x + 3,22398390803294)^3 + 33,5103216382911 + * To test if multiple quadratic regressions can decently approximate a cubic function + */ +test('Test of correct algorithmic behaviourof FullTSQuadraticEstimator in movingRegressor object for function f(x) = (x + 3,22398390803294)^3 + 33,5103216382911', () => { const flankLength = 11 const movingRegressor = createMovingRegressor(flankLength) @@ -432,62 +444,62 @@ test('Test of correct algorithmic behaviourof FullTSQuadraticEstimator in moving testFirstDerivative(movingRegressor, 14.287862296451902) // Datapoint: 22, Theoretical value: 14.3595335732101, Error: -0.4991% testSecondDerivative(movingRegressor, -12.86939888687925) // Datapoint: 22, Theoretical value: -13.1268580733747, Error: -1.9613% movingRegressor.push(4.2394752055962, flywheelPosition(33)) // Datapoint 33 - testFirstDerivative(movingRegressor, 13.344069231831451) // Datapoint: 23, Theoretical value: 13.3855228467043, Error: -0.3097% - testSecondDerivative(movingRegressor, -12.596943554868657) // Datapoint: 23, Theoretical value: -12.6738421230679, Error: -0.6068% + testFirstDerivative(movingRegressor, 13.343446992890124) // Datapoint: 23, Theoretical value: 13.3855228467043, Error: -0.3097% + testSecondDerivative(movingRegressor, -12.593658621633756) // Datapoint: 23, Theoretical value: -12.6738421230679, Error: -0.6068% movingRegressor.push(4.50342276981795, flywheelPosition(34)) // Datapoint 34 - testFirstDerivative(movingRegressor, 12.27277433707641) // Datapoint: 24, Theoretical value: 12.3746709051205, Error: -0.8234% - testSecondDerivative(movingRegressor, -11.94611298378567) // Datapoint: 24, Theoretical value: -12.1858955707591, Error: -1.9677% + testFirstDerivative(movingRegressor, 12.272424425339942) // Datapoint: 24, Theoretical value: 12.3746709051205, Error: -0.8234% + testSecondDerivative(movingRegressor, -11.943049564910012) // Datapoint: 24, Theoretical value: -12.1858955707591, Error: -1.9677% movingRegressor.push(4.68857579559446, flywheelPosition(35)) // Datapoint 35 - testFirstDerivative(movingRegressor, 11.111270142918675) // Datapoint: 25, Theoretical value: 11.3206759756907, Error: -1.8498% - testSecondDerivative(movingRegressor, -11.104255084717169) // Datapoint: 25, Theoretical value: -11.6553898136565, Error: -4.7286% + testFirstDerivative(movingRegressor, 11.111275577176187) // Datapoint: 25, Theoretical value: 11.3206759756907, Error: -1.8498% + testSecondDerivative(movingRegressor, -11.10165547134061) // Datapoint: 25, Theoretical value: -11.6553898136565, Error: -4.7286% movingRegressor.push(4.83597586204941, flywheelPosition(36)) // Datapoint 36 - testFirstDerivative(movingRegressor, 9.856257396415623) // Datapoint: 26, Theoretical value: 10.2150657644303, Error: -3.5125% - testSecondDerivative(movingRegressor, -10.133558490306132) // Datapoint: 26, Theoretical value: -11.0716208918642, Error: -8.4727% + testFirstDerivative(movingRegressor, 9.85670546231101) // Datapoint: 26, Theoretical value: 10.2150657644303, Error: -3.5125% + testSecondDerivative(movingRegressor, -10.131638260894363) // Datapoint: 26, Theoretical value: -11.0716208918642, Error: -8.4727% movingRegressor.push(4.96044960092562, flywheelPosition(37)) // Datapoint 37 - testFirstDerivative(movingRegressor, 8.33034413131051) // Datapoint: 27, Theoretical value: 9.04593930777978, Error: -7.9107% - testSecondDerivative(movingRegressor, -8.90342412705638) // Datapoint: 27, Theoretical value: -10.4187941573561, Error: -14.5446% + testFirstDerivative(movingRegressor, 8.33150256290571) // Datapoint: 27, Theoretical value: 9.04593930777978, Error: -7.9107% + testSecondDerivative(movingRegressor, -8.90260010296994) // Datapoint: 27, Theoretical value: -10.4187941573561, Error: -14.5446% movingRegressor.push(5.06925405667697, flywheelPosition(38)) // Datapoint 38 - testFirstDerivative(movingRegressor, 6.614017373941227) // Datapoint: 28, Theoretical value: 7.79555417944151, Error: -15.1565% - testSecondDerivative(movingRegressor, -7.485201796668778) // Datapoint: 28, Theoretical value: -9.67195172409882, Error: -22.6092% + testFirstDerivative(movingRegressor, 6.616103614905059) // Datapoint: 28, Theoretical value: 7.79555417944151, Error: -15.1565% + testSecondDerivative(movingRegressor, -7.485916659905859) // Datapoint: 28, Theoretical value: -9.67195172409882, Error: -22.6092% movingRegressor.push(5.16654887697569, flywheelPosition(39)) // Datapoint 39 - testFirstDerivative(movingRegressor, 4.885955137652653) // Datapoint: 29, Theoretical value: 6.43508819133308, Error: -24.0732% - testSecondDerivative(movingRegressor, -5.954069768024937) // Datapoint: 29, Theoretical value: -8.78755132536914, Error: -32.2443% + testFirstDerivative(movingRegressor, 4.888940778715439) // Datapoint: 29, Theoretical value: 6.43508819133308, Error: -24.0732% + testSecondDerivative(movingRegressor, -5.956802316997369) // Datapoint: 29, Theoretical value: -8.78755132536914, Error: -32.2443% movingRegressor.push(5.25496650315946, flywheelPosition(40)) // Datapoint 40 - testFirstDerivative(movingRegressor, 3.6285904570805947) // Datapoint: 30, Theoretical value: 4.91089140313715, Error: -26.1114% - testSecondDerivative(movingRegressor, -4.766667985585422) // Datapoint: 30, Theoretical value: -7.67663317071005, Error: -37.9068% + testFirstDerivative(movingRegressor, 3.6316979581923494) // Datapoint: 30, Theoretical value: 4.91089140313715, Error: -26.1114% + testSecondDerivative(movingRegressor, -4.771588801465927) // Datapoint: 30, Theoretical value: -7.67663317071005, Error: -37.9068% movingRegressor.push(5.33629092854426, flywheelPosition(41)) // Datapoint 41 - testFirstDerivative(movingRegressor, 2.305636451416337) // Datapoint: 31, Theoretical value: 3.09366772628014, Error: -25.4724% - testSecondDerivative(movingRegressor, -3.5514156470006624) // Datapoint: 31, Theoretical value: -6.09294778537956, Error: -41.7127% + testFirstDerivative(movingRegressor, 2.3077423939611448) // Datapoint: 31, Theoretical value: 3.09366772628014, Error: -25.4724% + testSecondDerivative(movingRegressor, -3.5593152612469012) // Datapoint: 31, Theoretical value: -6.09294778537956, Error: -41.7127% movingRegressor.push(5.41179358692871, flywheelPosition(42)) // Datapoint 42 - testFirstDerivative(movingRegressor, 1.5762722749892073) // Datapoint: 32, Theoretical value: 0 - testSecondDerivative(movingRegressor, 3.845172493155469e-14) // Datapoint: 32, Theoretical value: 0 + testFirstDerivative(movingRegressor, 1.5335044322403928) // Datapoint: 32, Theoretical value: 0 + testSecondDerivative(movingRegressor, 3.8379764035844055e-14) // Datapoint: 32, Theoretical value: 0 movingRegressor.push(5.48241633596003, flywheelPosition(43)) // Datapoint 43 - testFirstDerivative(movingRegressor, 2.3056364514163405) // Datapoint: 33, Theoretical value: 3.09366772628014, Error: -25.4724% - testSecondDerivative(movingRegressor, 3.551415647000658) // Datapoint: 33, Theoretical value: 6.09294778537956, Error: -41.7127% + testFirstDerivative(movingRegressor, 2.3077423939611457) // Datapoint: 33, Theoretical value: 3.09366772628014, Error: -25.4724% + testSecondDerivative(movingRegressor, 3.5593152612468977) // Datapoint: 33, Theoretical value: 6.09294778537956, Error: -41.7127% movingRegressor.push(5.54887861105219, flywheelPosition(44)) // Datapoint 44 - testFirstDerivative(movingRegressor, 3.6285904570805094) // Datapoint: 34, Theoretical value: 4.91089140313715, Error: -26.1114% - testSecondDerivative(movingRegressor, 4.766667985585649) // Datapoint: 34, Theoretical value: 7.67663317071005, Error: -37.9068% + testFirstDerivative(movingRegressor, 3.6316979581922624) // Datapoint: 34, Theoretical value: 4.91089140313715, Error: -26.1114% + testSecondDerivative(movingRegressor, 4.771588801466153) // Datapoint: 34, Theoretical value: 7.67663317071005, Error: -37.9068% movingRegressor.push(5.61174382107199, flywheelPosition(45)) // Datapoint 45 - testFirstDerivative(movingRegressor, 4.885955137652726) // Datapoint: 35, Theoretical value: 6.43508819133308, Error: -24.0732% - testSecondDerivative(movingRegressor, 5.954069768025052) // Datapoint: 35, Theoretical value: 8.78755132536914, Error: -32.2443% + testFirstDerivative(movingRegressor, 4.888940778715508) // Datapoint: 35, Theoretical value: 6.43508819133308, Error: -24.0732% + testSecondDerivative(movingRegressor, 5.956802316997485) // Datapoint: 35, Theoretical value: 8.78755132536914, Error: -32.2443% movingRegressor.push(5.6714624031923, flywheelPosition(46)) // Datapoint 46 - testFirstDerivative(movingRegressor, 6.614017373941454) // Datapoint: 36, Theoretical value: 7.79555417944151, Error: -15.1565% - testSecondDerivative(movingRegressor, 7.4852017966686715) // Datapoint: 36, Theoretical value: 9.67195172409882, Error: -22.6092% + testFirstDerivative(movingRegressor, 6.616103614905288) // Datapoint: 36, Theoretical value: 7.79555417944151, Error: -15.1565% + testSecondDerivative(movingRegressor, 7.485916659905754) // Datapoint: 36, Theoretical value: 9.67195172409882, Error: -22.6092% movingRegressor.push(5.72840080746097, flywheelPosition(47)) // Datapoint 47 - testFirstDerivative(movingRegressor, 8.330344131310724) // Datapoint: 37, Theoretical value: 9.04593930777979, Error: -7.9107% - testSecondDerivative(movingRegressor, 8.903424127056287) // Datapoint: 37, Theoretical value: 10.4187941573561, Error: -14.5446% + testFirstDerivative(movingRegressor, 8.331502562905932) // Datapoint: 37, Theoretical value: 9.04593930777979, Error: -7.9107% + testSecondDerivative(movingRegressor, 8.902600102969847) // Datapoint: 37, Theoretical value: 10.4187941573561, Error: -14.5446% movingRegressor.push(5.78286163160296, flywheelPosition(48)) // Datapoint 48 - testFirstDerivative(movingRegressor, 9.856257396415842) // Datapoint: 38, Theoretical value: 10.2150657644303, Error: -3.5125% - testSecondDerivative(movingRegressor, 10.133558490305996) // Datapoint: 38, Theoretical value: 11.0716208918642, Error: -8.4727% + testFirstDerivative(movingRegressor, 9.856705462311233) // Datapoint: 38, Theoretical value: 10.2150657644303, Error: -3.5125% + testSecondDerivative(movingRegressor, 10.131638260894228) // Datapoint: 38, Theoretical value: 11.0716208918642, Error: -8.4727% movingRegressor.push(5.83509798693099, flywheelPosition(49)) // Datapoint 49 - testFirstDerivative(movingRegressor, 11.11127014291916) // Datapoint: 39, Theoretical value: 11.3206759756907, Error: -1.8498% - testSecondDerivative(movingRegressor, 11.104255084716218) // Datapoint: 39, Theoretical value: 11.6553898136565, Error: -4.7286% + testFirstDerivative(movingRegressor, 11.111275577176677) // Datapoint: 39, Theoretical value: 11.3206759756907, Error: -1.8498% + testSecondDerivative(movingRegressor, 11.101655471339662) // Datapoint: 39, Theoretical value: 11.6553898136565, Error: -4.7286% movingRegressor.push(5.88532398701588, flywheelPosition(50)) // Datapoint 50 - testFirstDerivative(movingRegressor, 12.272774337076662) // Datapoint: 40, Theoretical value: 12.3746709051205, Error: -0.8234% - testSecondDerivative(movingRegressor, 11.946112983784932) // Datapoint: 40, Theoretical value: 12.1858955707591, Error: -1.9677% + testFirstDerivative(movingRegressor, 12.272424425340205) // Datapoint: 40, Theoretical value: 12.3746709051205, Error: -0.8234% + testSecondDerivative(movingRegressor, 11.943049564909279) // Datapoint: 40, Theoretical value: 12.1858955707591, Error: -1.9677% movingRegressor.push(5.93372256071353, flywheelPosition(51)) // Datapoint 51 - testFirstDerivative(movingRegressor, 13.34406923183132) // Datapoint: 41, Theoretical value: 13.3855228467043, Error: -0.3097% - testSecondDerivative(movingRegressor, 12.59694355486861) // Datapoint: 41, Theoretical value: 12.6738421230679, Error: -0.6068% + testFirstDerivative(movingRegressor, 13.343446992889987) // Datapoint: 41, Theoretical value: 13.3855228467043, Error: -0.3097% + testSecondDerivative(movingRegressor, 12.593658621633706) // Datapoint: 41, Theoretical value: 12.6738421230679, Error: -0.6068% movingRegressor.push(5.98045137563747, flywheelPosition(52)) // Datapoint 52 testFirstDerivative(movingRegressor, 14.287862296451628) // Datapoint: 42, Theoretical value: 14.3595335732101, Error: -0.4991% testSecondDerivative(movingRegressor, 12.869398886878852) // Datapoint: 42, Theoretical value: 13.1268580733747, Error: -1.9613% @@ -624,62 +636,62 @@ test('Test of correct algorithmic behaviourof FullTSQuadraticEstimator in moving testFirstDerivative(movingRegressor, 14.287862296451593) // Datapoint: 86, Theoretical value: 14.3595335732101, Error: -0.4991% testSecondDerivative(movingRegressor, -12.86939888687885) // Datapoint: 86, Theoretical value: -13.1268580733747, Error: -1.9613% movingRegressor.push(10.6535038284662, flywheelPosition(97)) // Datapoint 97 - testFirstDerivative(movingRegressor, 13.344069231831583) // Datapoint: 87, Theoretical value: 13.3855228467043, Error: -0.3097% - testSecondDerivative(movingRegressor, -12.596943554867401) // Datapoint: 87, Theoretical value: -12.6738421230679, Error: -0.6068% + testFirstDerivative(movingRegressor, 13.343446992890279) // Datapoint: 87, Theoretical value: 13.3855228467043, Error: -0.3097% + testSecondDerivative(movingRegressor, -12.5936586216325) // Datapoint: 87, Theoretical value: -12.6738421230679, Error: -0.6068% movingRegressor.push(10.9174513926879, flywheelPosition(98)) // Datapoint 98 - testFirstDerivative(movingRegressor, 12.272774337077237) // Datapoint: 88, Theoretical value: 12.3746709051205, Error: -0.8234% - testSecondDerivative(movingRegressor, -11.94611298378346) // Datapoint: 88, Theoretical value: -12.1858955707591, Error: -1.9677% + testFirstDerivative(movingRegressor, 12.272424425340773) // Datapoint: 88, Theoretical value: 12.3746709051205, Error: -0.8234% + testSecondDerivative(movingRegressor, -11.943049564907804) // Datapoint: 88, Theoretical value: -12.1858955707591, Error: -1.9677% movingRegressor.push(11.1026044184645, flywheelPosition(99)) // Datapoint 99 - testFirstDerivative(movingRegressor, 11.11127014291931) // Datapoint: 89, Theoretical value: 11.3206759756907, Error: -1.8498% - testSecondDerivative(movingRegressor, -11.10425508471583) // Datapoint: 89, Theoretical value: -11.6553898136565, Error: -4.7286% + testFirstDerivative(movingRegressor, 11.111275577176826) // Datapoint: 89, Theoretical value: 11.3206759756907, Error: -1.8498% + testSecondDerivative(movingRegressor, -11.10165547133927) // Datapoint: 89, Theoretical value: -11.6553898136565, Error: -4.7286% movingRegressor.push(11.2500044849194, flywheelPosition(100)) // Datapoint 100 - testFirstDerivative(movingRegressor, 9.856257396415984) // Datapoint: 90, Theoretical value: 10.2150657644303, Error: -3.5125% - testSecondDerivative(movingRegressor, -10.133558490305735) // Datapoint: 90, Theoretical value: -11.0716208918642, Error: -8.4727% + testFirstDerivative(movingRegressor, 9.856705462311382) // Datapoint: 90, Theoretical value: 10.2150657644303, Error: -3.5125% + testSecondDerivative(movingRegressor, -10.131638260893967) // Datapoint: 90, Theoretical value: -11.0716208918642, Error: -8.4727% movingRegressor.push(11.3744782237956, flywheelPosition(101)) // Datapoint 101 - testFirstDerivative(movingRegressor, 8.330344131310596) // Datapoint: 91, Theoretical value: 9.04593930777978, Error: -7.9107% - testSecondDerivative(movingRegressor, -8.903424127056597) // Datapoint: 91, Theoretical value: -10.4187941573561, Error: -14.5446% + testFirstDerivative(movingRegressor, 8.331502562905783) // Datapoint: 91, Theoretical value: 9.04593930777978, Error: -7.9107% + testSecondDerivative(movingRegressor, -8.902600102970158) // Datapoint: 91, Theoretical value: -10.4187941573561, Error: -14.5446% movingRegressor.push(11.483282679547, flywheelPosition(102)) // Datapoint 102 - testFirstDerivative(movingRegressor, 6.614017373941209) // Datapoint: 92, Theoretical value: 7.79555417944151, Error: -15.1565% - testSecondDerivative(movingRegressor, -7.485201796668469) // Datapoint: 92, Theoretical value: -9.67195172409882, Error: -22.6092% + testFirstDerivative(movingRegressor, 6.616103614905029) // Datapoint: 92, Theoretical value: 7.79555417944151, Error: -15.1565% + testSecondDerivative(movingRegressor, -7.485916659905551) // Datapoint: 92, Theoretical value: -9.67195172409882, Error: -22.6092% movingRegressor.push(11.5805774998457, flywheelPosition(103)) // Datapoint 103 - testFirstDerivative(movingRegressor, 4.885955137652751) // Datapoint: 93, Theoretical value: 6.43508819133308, Error: -24.0732% - testSecondDerivative(movingRegressor, -5.95406976802524) // Datapoint: 93, Theoretical value: -8.78755132536914, Error: -32.2443% + testFirstDerivative(movingRegressor, 4.888940778715536) // Datapoint: 93, Theoretical value: 6.43508819133308, Error: -24.0732% + testSecondDerivative(movingRegressor, -5.956802316997673) // Datapoint: 93, Theoretical value: -8.78755132536914, Error: -32.2443% movingRegressor.push(11.6689951260294, flywheelPosition(104)) // Datapoint 104 - testFirstDerivative(movingRegressor, 3.628590457080918) // Datapoint: 94, Theoretical value: 4.91089140313715, Error: -26.1114% - testSecondDerivative(movingRegressor, -4.766667985586247) // Datapoint: 94, Theoretical value: -7.67663317071005, Error: -37.9068% + testFirstDerivative(movingRegressor, 3.6316979581926674) // Datapoint: 94, Theoretical value: 4.91089140313715, Error: -26.1114% + testSecondDerivative(movingRegressor, -4.771588801466752) // Datapoint: 94, Theoretical value: -7.67663317071005, Error: -37.9068% movingRegressor.push(11.7503195514143, flywheelPosition(105)) // Datapoint 105 - testFirstDerivative(movingRegressor, 2.3056364514165004) // Datapoint: 95, Theoretical value: 3.09366772628014, Error: -25.4724% - testSecondDerivative(movingRegressor, -3.5514156470012384) // Datapoint: 95, Theoretical value: -6.09294778537956, Error: -41.7127% + testFirstDerivative(movingRegressor, 2.3077423939613055) // Datapoint: 95, Theoretical value: 3.09366772628014, Error: -25.4724% + testSecondDerivative(movingRegressor, -3.559315261247479) // Datapoint: 95, Theoretical value: -6.09294778537956, Error: -41.7127% movingRegressor.push(11.8258222097987, flywheelPosition(106)) // Datapoint 1066 - testFirstDerivative(movingRegressor, 1.5762722749896647) // Datapoint: 96, Theoretical value: 0 - testSecondDerivative(movingRegressor, -2.0664377115840596e-12) // Datapoint: 96, Theoretical value: 0 + testFirstDerivative(movingRegressor, 1.5335044322408324) // Datapoint: 96, Theoretical value: 0 + testSecondDerivative(movingRegressor, -2.0596270771553654e-12) // Datapoint: 96, Theoretical value: 0 movingRegressor.push(11.89644495883, flywheelPosition(107)) // Datapoint 107 - testFirstDerivative(movingRegressor, 2.3056364514154026) // Datapoint: 97, Theoretical value: 3.09366772628014, Error: -25.4724% - testSecondDerivative(movingRegressor, 3.551415647001751) // Datapoint: 97, Theoretical value: 6.09294778537956, Error: -41.7127% + testFirstDerivative(movingRegressor, 2.307742393960204) // Datapoint: 97, Theoretical value: 3.09366772628014, Error: -25.4724% + testSecondDerivative(movingRegressor, 3.559315261247989) // Datapoint: 97, Theoretical value: 6.09294778537956, Error: -41.7127% movingRegressor.push(11.9629072339222, flywheelPosition(108)) // Datapoint 108 - testFirstDerivative(movingRegressor, 3.62859045708084) // Datapoint: 98, Theoretical value: 4.91089140313715, Error: -26.1114% - testSecondDerivative(movingRegressor, 4.766667985584684) // Datapoint: 98, Theoretical value: 7.67663317071005, Error: -37.9068% + testFirstDerivative(movingRegressor, 3.6316979581925963) // Datapoint: 98, Theoretical value: 4.91089140313715, Error: -26.1114% + testSecondDerivative(movingRegressor, 4.771588801465188) // Datapoint: 98, Theoretical value: 7.67663317071005, Error: -37.9068% movingRegressor.push(12.025772443942, flywheelPosition(109)) // Datapoint 109 - testFirstDerivative(movingRegressor, 4.885955137653781) // Datapoint: 99, Theoretical value: 6.43508819133308, Error: -24.0732% - testSecondDerivative(movingRegressor, 5.954069768023376) // Datapoint: 99, Theoretical value: 8.78755132536914, Error: -32.2443% + testFirstDerivative(movingRegressor, 4.888940778716552) // Datapoint: 99, Theoretical value: 6.43508819133308, Error: -24.0732% + testSecondDerivative(movingRegressor, 5.956802316995809) // Datapoint: 99, Theoretical value: 8.78755132536914, Error: -32.2443% movingRegressor.push(12.0854910260623, flywheelPosition(110)) // Datapoint 110 - testFirstDerivative(movingRegressor, 6.614017373942133) // Datapoint: 100, Theoretical value: 7.79555417944151, Error: -15.1565% - testSecondDerivative(movingRegressor, 7.485201796666093) // Datapoint: 100, Theoretical value: 9.67195172409882, Error: -22.6092% + testFirstDerivative(movingRegressor, 6.616103614905953) // Datapoint: 100, Theoretical value: 7.79555417944151, Error: -15.1565% + testSecondDerivative(movingRegressor, 7.485916659903175) // Datapoint: 100, Theoretical value: 9.67195172409882, Error: -22.6092% movingRegressor.push(12.142429430331, flywheelPosition(111)) // Datapoint 111 - testFirstDerivative(movingRegressor, 8.330344131312316) // Datapoint: 101, Theoretical value: 9.04593930777979, Error: -7.9107% - testSecondDerivative(movingRegressor, 8.903424127051075) // Datapoint: 101, Theoretical value: 10.4187941573561, Error: -14.5446% + testFirstDerivative(movingRegressor, 8.331502562907502) // Datapoint: 101, Theoretical value: 9.04593930777979, Error: -7.9107% + testSecondDerivative(movingRegressor, 8.902600102964639) // Datapoint: 101, Theoretical value: 10.4187941573561, Error: -14.5446% movingRegressor.push(12.1968902544729, flywheelPosition(112)) // Datapoint 112 - testFirstDerivative(movingRegressor, 9.856257396417504) // Datapoint: 102, Theoretical value: 10.2150657644303, Error: -3.5125% - testSecondDerivative(movingRegressor, 10.133558490300592) // Datapoint: 102, Theoretical value: 11.0716208918642, Error: -8.4727% + testFirstDerivative(movingRegressor, 9.856705462312902) // Datapoint: 102, Theoretical value: 10.2150657644303, Error: -3.5125% + testSecondDerivative(movingRegressor, 10.131638260888828) // Datapoint: 102, Theoretical value: 11.0716208918642, Error: -8.4727% movingRegressor.push(12.249126609801, flywheelPosition(113)) // Datapoint 113 - testFirstDerivative(movingRegressor, 11.111270142920347) // Datapoint: 103, Theoretical value: 11.3206759756907, Error: -1.8498% - testSecondDerivative(movingRegressor, 11.104255084710369) // Datapoint: 103, Theoretical value: 11.6553898136565, Error: -4.7286% + testFirstDerivative(movingRegressor, 11.111275577177864) // Datapoint: 103, Theoretical value: 11.3206759756907, Error: -1.8498% + testSecondDerivative(movingRegressor, 11.101655471333814) // Datapoint: 103, Theoretical value: 11.6553898136565, Error: -4.7286% movingRegressor.push(12.2993526098859, flywheelPosition(114)) // Datapoint 114 - testFirstDerivative(movingRegressor, 12.272774337075006) // Datapoint: 104, Theoretical value: 12.3746709051205, Error: -0.8234% - testSecondDerivative(movingRegressor, 11.946112983782152) // Datapoint: 104, Theoretical value: 12.1858955707591, Error: -1.9677% + testFirstDerivative(movingRegressor, 12.272424425338542) // Datapoint: 104, Theoretical value: 12.3746709051205, Error: -0.8234% + testSecondDerivative(movingRegressor, 11.943049564906497) // Datapoint: 104, Theoretical value: 12.1858955707591, Error: -1.9677% movingRegressor.push(12.3477511835835, flywheelPosition(115)) // Datapoint 115 - testFirstDerivative(movingRegressor, 13.344069231830105) // Datapoint: 105, Theoretical value: 13.3855228467043, Error: -0.3097% - testSecondDerivative(movingRegressor, 12.596943554867831) // Datapoint: 105, Theoretical value: 12.6738421230679, Error: -0.6068% + testFirstDerivative(movingRegressor, 13.343446992888772) // Datapoint: 105, Theoretical value: 13.3855228467043, Error: -0.3097% + testSecondDerivative(movingRegressor, 12.59365862163293) // Datapoint: 105, Theoretical value: 12.6738421230679, Error: -0.6068% movingRegressor.push(12.3944799985075, flywheelPosition(116)) // Datapoint 116 testFirstDerivative(movingRegressor, 14.28786229645084) // Datapoint: 106, Theoretical value: 14.3595335732101, Error: -0.4991% testSecondDerivative(movingRegressor, 12.86939888687176) // Datapoint: 106, Theoretical value: 13.1268580733747, Error: -1.9613% @@ -821,68 +833,68 @@ test('Test of correct algorithmic behaviourof FullTSQuadraticEstimator in moving testFirstDerivative(movingRegressor, 35.03463828636044) // Datapoint: 21, Theoretical value: 35.3270234685551, Error: -0.8277% testSecondDerivative(movingRegressor, -84.72131405455713) // Datapoint: 21, Theoretical value: -86.6727901598317, Error: -2.2515% movingRegressor.push(2.01853237434599, flywheelPosition(32)) // Datapoint 32 - testFirstDerivative(movingRegressor, 32.556646337181334) // Datapoint: 22, Theoretical value: 32.7335342472893, Error: -0.5404% - testSecondDerivative(movingRegressor, -81.74563687145616) // Datapoint: 22, Theoretical value: -81.8553682135038, Error: -0.1341% + testFirstDerivative(movingRegressor, 32.554704278009275) // Datapoint: 22, Theoretical value: 32.7335342472893, Error: -0.5404% + testSecondDerivative(movingRegressor, -81.7211071442608) // Datapoint: 22, Theoretical value: -81.8553682135038, Error: -0.1341% movingRegressor.push(3.02779856151898, flywheelPosition(33)) // Datapoint 33 - testFirstDerivative(movingRegressor, 30.0053262177018) // Datapoint: 23, Theoretical value: 30.0875556300011, Error: -0.2733% - testSecondDerivative(movingRegressor, -78.8103638663143) // Datapoint: 23, Theoretical value: -76.8409405553369, Error: 2.563% + testFirstDerivative(movingRegressor, 30.000816353086208) // Datapoint: 23, Theoretical value: 30.0875556300011, Error: -0.2733% + testSecondDerivative(movingRegressor, -78.74803078952486) // Datapoint: 23, Theoretical value: -76.8409405553369, Error: 2.563% movingRegressor.push(3.17787478330574, flywheelPosition(34)) // Datapoint 34 - testFirstDerivative(movingRegressor, 27.3590650062608) // Datapoint: 24, Theoretical value: 27.3819824840534, Error: -0.0837% - testSecondDerivative(movingRegressor, -75.91047202675192) // Datapoint: 24, Theoretical value: -71.5980441226457, Error: 6.0231% + testFirstDerivative(movingRegressor, 27.35085176667772) // Datapoint: 24, Theoretical value: 27.3819824840534, Error: -0.0837% + testSecondDerivative(movingRegressor, -75.79177233150754) // Datapoint: 24, Theoretical value: -71.5980441226457, Error: 6.0231% movingRegressor.push(3.27580649001517, flywheelPosition(35)) // Datapoint 35 - testFirstDerivative(movingRegressor, 24.26443935991906) // Datapoint: 25, Theoretical value: 24.6077174058151, Error: -1.395% - testSecondDerivative(movingRegressor, -70.22915254416483) // Datapoint: 25, Theoretical value: -66.0854711273398, Error: 6.2702% + testFirstDerivative(movingRegressor, 24.26071071895941) // Datapoint: 25, Theoretical value: 24.6077174058151, Error: -1.395% + testSecondDerivative(movingRegressor, -70.11339732080359) // Datapoint: 25, Theoretical value: -66.0854711273398, Error: 6.2702% movingRegressor.push(3.35026709239635, flywheelPosition(36)) // Datapoint 36 - testFirstDerivative(movingRegressor, 20.83493250613911) // Datapoint: 26, Theoretical value: 21.7527364967177, Error: -4.2193% - testSecondDerivative(movingRegressor, -63.20422464017087) // Datapoint: 26, Theoretical value: -60.2473455054648, Error: 4.9079% + testFirstDerivative(movingRegressor, 20.837518238274917) // Datapoint: 26, Theoretical value: 21.7527364967177, Error: -4.2193% + testSecondDerivative(movingRegressor, -63.09857048895648) // Datapoint: 26, Theoretical value: -60.2473455054648, Error: 4.9079% movingRegressor.push(3.41104686909844, flywheelPosition(37)) // Datapoint 37 - testFirstDerivative(movingRegressor, 16.540358644462728) // Datapoint: 27, Theoretical value: 18.8004784715502, Error: -12.0216% - testSecondDerivative(movingRegressor, -53.9026545272266) // Datapoint: 27, Theoretical value: -54.0044029484732, Error: -0.1884% + testFirstDerivative(movingRegressor, 16.553286547446575) // Datapoint: 27, Theoretical value: 18.8004784715502, Error: -12.0216% + testSecondDerivative(movingRegressor, -53.82083440126937) // Datapoint: 27, Theoretical value: -54.0044029484732, Error: -0.1884% movingRegressor.push(3.46276108279553, flywheelPosition(38)) // Datapoint 38 - testFirstDerivative(movingRegressor, 12.023279845406904) // Datapoint: 28, Theoretical value: 15.7268191179949, Error: -23.5492% - testSecondDerivative(movingRegressor, -44.515559757405676) // Datapoint: 28, Theoretical value: -47.2370928078489, Error: -5.7614% + testFirstDerivative(movingRegressor, 12.04945082759771) // Datapoint: 28, Theoretical value: 15.7268191179949, Error: -23.5492% + testSecondDerivative(movingRegressor, -44.46299143724117) // Datapoint: 28, Theoretical value: -47.2370928078489, Error: -5.7614% movingRegressor.push(3.50798032628076, flywheelPosition(39)) // Datapoint 39 - testFirstDerivative(movingRegressor, 7.9550999676935525) // Datapoint: 29, Theoretical value: 12.4936663152318, Error: -36.3269% - testSecondDerivative(movingRegressor, -35.69289163563467) // Datapoint: 29, Theoretical value: -39.7484244987643, Error: -10.203% + testFirstDerivative(movingRegressor, 7.995979006137617) // Datapoint: 29, Theoretical value: 12.4936663152318, Error: -36.3269% + testSecondDerivative(movingRegressor, -35.67688197212385) // Datapoint: 29, Theoretical value: -39.7484244987643, Error: -10.203% movingRegressor.push(3.54829385426288, flywheelPosition(40)) // Datapoint 40 - testFirstDerivative(movingRegressor, 4.490581497457299) // Datapoint: 30, Theoretical value: 9.03268562508831, Error: -50.2852% - testSecondDerivative(movingRegressor, -26.59823204822437) // Datapoint: 30, Theoretical value: -31.164858820935, Error: -14.6531% + testFirstDerivative(movingRegressor, 4.545499777144109) // Datapoint: 30, Theoretical value: 9.03268562508831, Error: -50.2852% + testSecondDerivative(movingRegressor, -26.644166155256052) // Datapoint: 30, Theoretical value: -31.164858820935, Error: -14.6531% movingRegressor.push(3.58475763981283, flywheelPosition(41)) // Datapoint 41 - testFirstDerivative(movingRegressor, 3.815904626117252) // Datapoint: 31, Theoretical value: 5.18791555937216, Error: -26.4463% - testSecondDerivative(movingRegressor, -19.03067846863168) // Datapoint: 31, Theoretical value: -20.5611388761721, Error: -7.4435% + testFirstDerivative(movingRegressor, 3.8462783014954773) // Datapoint: 31, Theoretical value: 5.18791555937216, Error: -26.4463% + testSecondDerivative(movingRegressor, -19.207586578866348) // Datapoint: 31, Theoretical value: -20.5611388761721, Error: -7.4435% movingRegressor.push(3.61811148377765, flywheelPosition(42)) // Datapoint 42 - testFirstDerivative(movingRegressor, 3.1453638235989767) // Datapoint: 32, Theoretical value: 0 - testSecondDerivative(movingRegressor, 4.5598970291679296e-15) // Datapoint: 32, Theoretical value: 0 + testFirstDerivative(movingRegressor, 3.1383576841321967) // Datapoint: 32, Theoretical value: 0 + testSecondDerivative(movingRegressor, 4.409564582673597e-15) // Datapoint: 32, Theoretical value: 0 movingRegressor.push(3.64889518617698, flywheelPosition(43)) // Datapoint 43 - testFirstDerivative(movingRegressor, 3.8159046261167617) // Datapoint: 33, Theoretical value: 5.18791555937215, Error: -26.4463% - testSecondDerivative(movingRegressor, 19.03067846863373) // Datapoint: 33, Theoretical value: 20.5611388761721, Error: -7.4435% + testFirstDerivative(movingRegressor, 3.8462783014949977) // Datapoint: 33, Theoretical value: 5.18791555937215, Error: -26.4463% + testSecondDerivative(movingRegressor, 19.2075865788684) // Datapoint: 33, Theoretical value: 20.5611388761721, Error: -7.4435% movingRegressor.push(3.67751551598147, flywheelPosition(44)) // Datapoint 44 - testFirstDerivative(movingRegressor, 4.490581497456901) // Datapoint: 34, Theoretical value: 9.03268562508831, Error: -50.2852% - testSecondDerivative(movingRegressor, 26.598232048227107) // Datapoint: 34, Theoretical value: 31.164858820935, Error: -14.6531% + testFirstDerivative(movingRegressor, 4.545499777143718) // Datapoint: 34, Theoretical value: 9.03268562508831, Error: -50.2852% + testSecondDerivative(movingRegressor, 26.64416615525877) // Datapoint: 34, Theoretical value: 31.164858820935, Error: -14.6531% movingRegressor.push(3.7042871320382, flywheelPosition(45)) // Datapoint 45 - testFirstDerivative(movingRegressor, 7.955099967691808) // Datapoint: 35, Theoretical value: 12.4936663152318, Error: -36.3269% - testSecondDerivative(movingRegressor, 35.69289163564905) // Datapoint: 35, Theoretical value: 39.7484244987643, Error: -10.203% + testFirstDerivative(movingRegressor, 7.995979006135855) // Datapoint: 35, Theoretical value: 12.4936663152318, Error: -36.3269% + testSecondDerivative(movingRegressor, 35.67688197213815) // Datapoint: 35, Theoretical value: 39.7484244987643, Error: -10.203% movingRegressor.push(3.72945878658716, flywheelPosition(46)) // Datapoint 46 - testFirstDerivative(movingRegressor, 12.023279845401277) // Datapoint: 36, Theoretical value: 15.7268191179949, Error: -23.5492% - testSecondDerivative(movingRegressor, 44.51555975743898) // Datapoint: 36, Theoretical value: 47.2370928078489, Error: -5.7614% + testFirstDerivative(movingRegressor, 12.049450827592068) // Datapoint: 36, Theoretical value: 15.7268191179949, Error: -23.5492% + testSecondDerivative(movingRegressor, 44.46299143727433) // Datapoint: 36, Theoretical value: 47.2370928078489, Error: -5.7614% movingRegressor.push(3.75323076432218, flywheelPosition(47)) // Datapoint 47 - testFirstDerivative(movingRegressor, 16.540358644457484) // Datapoint: 37, Theoretical value: 18.8004784715502, Error: -12.0216% - testSecondDerivative(movingRegressor, 53.90265452726991) // Datapoint: 37, Theoretical value: 54.0044029484732, Error: -0.1884% + testFirstDerivative(movingRegressor, 16.55328654744136) // Datapoint: 37, Theoretical value: 18.8004784715502, Error: -12.0216% + testSecondDerivative(movingRegressor, 53.820834401312524) // Datapoint: 37, Theoretical value: 54.0044029484732, Error: -0.1884% movingRegressor.push(3.77576686986435, flywheelPosition(48)) // Datapoint 48 - testFirstDerivative(movingRegressor, 20.834932506136) // Datapoint: 38, Theoretical value: 21.7527364967177, Error: -4.2193% - testSecondDerivative(movingRegressor, 63.20422464021262) // Datapoint: 38, Theoretical value: 60.2473455054648, Error: 4.9079% + testFirstDerivative(movingRegressor, 20.837518238271798) // Datapoint: 38, Theoretical value: 21.7527364967177, Error: -4.2193% + testSecondDerivative(movingRegressor, 63.0985704889981) // Datapoint: 38, Theoretical value: 60.2473455054648, Error: 4.9079% movingRegressor.push(3.79720289770384, flywheelPosition(49)) // Datapoint 49 - testFirstDerivative(movingRegressor, 24.264439359918924) // Datapoint: 39, Theoretical value: 24.6077174058151, Error: -1.395% - testSecondDerivative(movingRegressor, 70.22915254420002) // Datapoint: 39, Theoretical value: 66.0854711273397, Error: 6.2702% + testFirstDerivative(movingRegressor, 24.260710718959217) // Datapoint: 39, Theoretical value: 24.6077174058151, Error: -1.395% + testSecondDerivative(movingRegressor, 70.11339732083867) // Datapoint: 39, Theoretical value: 66.0854711273397, Error: 6.2702% movingRegressor.push(3.81765276034255, flywheelPosition(50)) // Datapoint 50 - testFirstDerivative(movingRegressor, 27.359065006261318) // Datapoint: 40, Theoretical value: 27.3819824840534, Error: -0.0837% - testSecondDerivative(movingRegressor, 75.91047202678642) // Datapoint: 40, Theoretical value: 71.5980441226458, Error: 6.0231% + testFirstDerivative(movingRegressor, 27.35085176667826) // Datapoint: 40, Theoretical value: 27.3819824840534, Error: -0.0837% + testSecondDerivative(movingRegressor, 75.79177233154196) // Datapoint: 40, Theoretical value: 71.5980441226458, Error: 6.0231% movingRegressor.push(3.83721301460343, flywheelPosition(51)) // Datapoint 51 - testFirstDerivative(movingRegressor, 30.005326217703555) // Datapoint: 41, Theoretical value: 30.0875556300011, Error: -0.2733% - testSecondDerivative(movingRegressor, 78.81036386634156) // Datapoint: 41, Theoretical value: 76.8409405553369, Error: 2.563% + testFirstDerivative(movingRegressor, 30.000816353087885) // Datapoint: 41, Theoretical value: 30.0875556300011, Error: -0.2733% + testSecondDerivative(movingRegressor, 78.74803078955205) // Datapoint: 41, Theoretical value: 76.8409405553369, Error: 2.563% movingRegressor.push(3.85596626603776, flywheelPosition(52)) // Datapoint 52 - testFirstDerivative(movingRegressor, 32.556646337185555) // Datapoint: 42, Theoretical value: 32.7335342472893, Error: -0.5404% - testSecondDerivative(movingRegressor, 81.74563687146653) // Datapoint: 42, Theoretical value: 81.8553682135038, Error: -0.1341% + testFirstDerivative(movingRegressor, 32.5547042780135) // Datapoint: 42, Theoretical value: 32.7335342472893, Error: -0.5404% + testSecondDerivative(movingRegressor, 81.72110714427114) // Datapoint: 42, Theoretical value: 81.8553682135038, Error: -0.1341% movingRegressor.push(3.8739837710181, flywheelPosition(53)) // Datapoint 53 testFirstDerivative(movingRegressor, 35.03463828636535) // Datapoint: 43, Theoretical value: 35.3270234685551, Error: -0.8277% testSecondDerivative(movingRegressor, 84.72131405455926) // Datapoint: 43, Theoretical value: 86.6727901598316, Error: -2.2515% @@ -1013,68 +1025,68 @@ test('Test of correct algorithmic behaviourof FullTSQuadraticEstimator in moving testFirstDerivative(movingRegressor, 35.034638286358415) // Datapoint: 85, Theoretical value: 35.3270234685551, Error: -0.8277% testSecondDerivative(movingRegressor, -84.7213140545765) // Datapoint: 85, Theoretical value: -86.6727901598317, Error: -2.2515% movingRegressor.push(6.04282057476568, flywheelPosition(96)) // Datapoint 96 - testFirstDerivative(movingRegressor, 32.55664633717993) // Datapoint: 86, Theoretical value: 32.7335342472893, Error: -0.5404% - testSecondDerivative(movingRegressor, -81.74563687147501) // Datapoint: 86, Theoretical value: -81.8553682135038, Error: -0.1341% + testFirstDerivative(movingRegressor, 32.55470427800782) // Datapoint: 86, Theoretical value: 32.7335342472893, Error: -0.5404% + testSecondDerivative(movingRegressor, -81.72110714427964) // Datapoint: 86, Theoretical value: -81.8553682135038, Error: -0.1341% movingRegressor.push(7.05208676193867, flywheelPosition(97)) // Datapoint 97 - testFirstDerivative(movingRegressor, 30.005326217700258) // Datapoint: 87, Theoretical value: 30.0875556300011, Error: -0.2733% - testSecondDerivative(movingRegressor, -78.81036386632607) // Datapoint: 87, Theoretical value: -76.8409405553369, Error: 2.563% + testFirstDerivative(movingRegressor, 30.0008163530847) // Datapoint: 87, Theoretical value: 30.0875556300011, Error: -0.2733% + testSecondDerivative(movingRegressor, -78.74803078953659) // Datapoint: 87, Theoretical value: -76.8409405553369, Error: 2.563% movingRegressor.push(7.20216298372543, flywheelPosition(98)) // Datapoint 98 - testFirstDerivative(movingRegressor, 27.35906500626146) // Datapoint: 88, Theoretical value: 27.3819824840534, Error: -0.0837% - testSecondDerivative(movingRegressor, -75.91047202675729) // Datapoint: 88, Theoretical value: -71.5980441226457, Error: 6.0231% + testFirstDerivative(movingRegressor, 27.350851766678318) // Datapoint: 88, Theoretical value: 27.3819824840534, Error: -0.0837% + testSecondDerivative(movingRegressor, -75.7917723315129) // Datapoint: 88, Theoretical value: -71.5980441226457, Error: 6.0231% movingRegressor.push(7.30009469043486, flywheelPosition(99)) // Datapoint 99 - testFirstDerivative(movingRegressor, 24.264439359917958) // Datapoint: 89, Theoretical value: 24.6077174058151, Error: -1.395% - testSecondDerivative(movingRegressor, -70.22915254417521) // Datapoint: 89, Theoretical value: -66.0854711273398, Error: 6.2702% + testFirstDerivative(movingRegressor, 24.260710718958364) // Datapoint: 89, Theoretical value: 24.6077174058151, Error: -1.395% + testSecondDerivative(movingRegressor, -70.11339732081393) // Datapoint: 89, Theoretical value: -66.0854711273398, Error: 6.2702% movingRegressor.push(7.37455529281604, flywheelPosition(100)) // Datapoint 100 - testFirstDerivative(movingRegressor, 20.83493250613799) // Datapoint: 90, Theoretical value: 21.7527364967177, Error: -4.2193% - testSecondDerivative(movingRegressor, -63.20422464018079) // Datapoint: 90, Theoretical value: -60.2473455054648, Error: 4.9079% + testFirstDerivative(movingRegressor, 20.837518238273844) // Datapoint: 90, Theoretical value: 21.7527364967177, Error: -4.2193% + testSecondDerivative(movingRegressor, -63.09857048896637) // Datapoint: 90, Theoretical value: -60.2473455054648, Error: 4.9079% movingRegressor.push(7.43533506951812, flywheelPosition(101)) // Datapoint 101 - testFirstDerivative(movingRegressor, 16.540358644462685) // Datapoint: 91, Theoretical value: 18.8004784715502, Error: -12.0216% - testSecondDerivative(movingRegressor, -53.90265452723021) // Datapoint: 91, Theoretical value: -54.0044029484732, Error: -0.1884% + testFirstDerivative(movingRegressor, 16.55328654744659) // Datapoint: 91, Theoretical value: 18.8004784715502, Error: -12.0216% + testSecondDerivative(movingRegressor, -53.82083440127297) // Datapoint: 91, Theoretical value: -54.0044029484732, Error: -0.1884% movingRegressor.push(7.48704928321522, flywheelPosition(102)) // Datapoint 102 - testFirstDerivative(movingRegressor, 12.023279845405199) // Datapoint: 92, Theoretical value: 15.7268191179949, Error: -23.5492% - testSecondDerivative(movingRegressor, -44.51555975741568) // Datapoint: 92, Theoretical value: -47.2370928078489, Error: -5.7614% + testFirstDerivative(movingRegressor, 12.04945082759599) // Datapoint: 92, Theoretical value: 15.7268191179949, Error: -23.5492% + testSecondDerivative(movingRegressor, -44.46299143725113) // Datapoint: 92, Theoretical value: -47.2370928078489, Error: -5.7614% movingRegressor.push(7.53226852670045, flywheelPosition(103)) // Datapoint 103 - testFirstDerivative(movingRegressor, 7.955099967693059) // Datapoint: 93, Theoretical value: 12.4936663152318, Error: -36.3269% - testSecondDerivative(movingRegressor, -35.69289163563958) // Datapoint: 93, Theoretical value: -39.7484244987643, Error: -10.203% + testFirstDerivative(movingRegressor, 7.995979006137162) // Datapoint: 93, Theoretical value: 12.4936663152318, Error: -36.3269% + testSecondDerivative(movingRegressor, -35.676881972128726) // Datapoint: 93, Theoretical value: -39.7484244987643, Error: -10.203% movingRegressor.push(7.57258205468257, flywheelPosition(104)) // Datapoint 104 - testFirstDerivative(movingRegressor, 4.490581497457271) // Datapoint: 94, Theoretical value: 9.03268562508831, Error: -50.2852% - testSecondDerivative(movingRegressor, -26.598232048225814) // Datapoint: 94, Theoretical value: -31.164858820935, Error: -14.6531% + testFirstDerivative(movingRegressor, 4.5454997771441015) // Datapoint: 94, Theoretical value: 9.03268562508831, Error: -50.2852% + testSecondDerivative(movingRegressor, -26.64416615525746) // Datapoint: 94, Theoretical value: -31.164858820935, Error: -14.6531% movingRegressor.push(7.60904584023252, flywheelPosition(105)) // Datapoint 105 - testFirstDerivative(movingRegressor, 3.81590462611652) // Datapoint: 95, Theoretical value: 5.18791555937216, Error: -26.4463% - testSecondDerivative(movingRegressor, -19.03067846863412) // Datapoint: 95, Theoretical value: -20.5611388761721, Error: -7.4435% + testFirstDerivative(movingRegressor, 3.846278301494735) // Datapoint: 95, Theoretical value: 5.18791555937216, Error: -26.4463% + testSecondDerivative(movingRegressor, -19.20758657886873) // Datapoint: 95, Theoretical value: -20.5611388761721, Error: -7.4435% movingRegressor.push(7.64239968419734, flywheelPosition(106)) // Datapoint 106 - testFirstDerivative(movingRegressor, 3.145363823598987) // Datapoint: 96, Theoretical value: 0 - testSecondDerivative(movingRegressor, 1.3823022937382807e-14) // Datapoint: 96, Theoretical value: 0 + testFirstDerivative(movingRegressor, 3.1383576841322074) // Datapoint: 96, Theoretical value: 0 + testSecondDerivative(movingRegressor, 1.3705117896314261e-14) // Datapoint: 96, Theoretical value: 0 movingRegressor.push(7.67318338659667, flywheelPosition(107)) // Datapoint 107 - testFirstDerivative(movingRegressor, 3.8159046261164633) // Datapoint: 97, Theoretical value: 5.18791555937215, Error: -26.4463% - testSecondDerivative(movingRegressor, 19.030678468636108) // Datapoint: 97, Theoretical value: 20.5611388761721, Error: -7.4435% + testFirstDerivative(movingRegressor, 3.846278301494692) // Datapoint: 97, Theoretical value: 5.18791555937215, Error: -26.4463% + testSecondDerivative(movingRegressor, 19.20758657887071) // Datapoint: 97, Theoretical value: 20.5611388761721, Error: -7.4435% movingRegressor.push(7.70180371640116, flywheelPosition(108)) // Datapoint 108 - testFirstDerivative(movingRegressor, 4.490581497456958) // Datapoint: 98, Theoretical value: 9.03268562508831, Error: -50.2852% - testSecondDerivative(movingRegressor, 26.598232048228212) // Datapoint: 98, Theoretical value: 31.164858820935, Error: -14.6531% + testFirstDerivative(movingRegressor, 4.545499777143846) // Datapoint: 98, Theoretical value: 9.03268562508831, Error: -50.2852% + testSecondDerivative(movingRegressor, 26.644166155259857) // Datapoint: 98, Theoretical value: 31.164858820935, Error: -14.6531% movingRegressor.push(7.72857533245789, flywheelPosition(109)) // Datapoint 109 - testFirstDerivative(movingRegressor, 7.955099967689733) // Datapoint: 99, Theoretical value: 12.4936663152318, Error: -36.3269% - testSecondDerivative(movingRegressor, 35.692891635658704) // Datapoint: 99, Theoretical value: 39.7484244987643, Error: -10.203% + testFirstDerivative(movingRegressor, 7.995979006133808) // Datapoint: 99, Theoretical value: 12.4936663152318, Error: -36.3269% + testSecondDerivative(movingRegressor, 35.67688197214775) // Datapoint: 99, Theoretical value: 39.7484244987643, Error: -10.203% movingRegressor.push(7.75374698700685, flywheelPosition(110)) // Datapoint 110 - testFirstDerivative(movingRegressor, 12.023279845411594) // Datapoint: 100, Theoretical value: 15.7268191179949, Error: -23.5492% - testSecondDerivative(movingRegressor, 44.51555975741129) // Datapoint: 100, Theoretical value: 47.2370928078489, Error: -5.7614% + testFirstDerivative(movingRegressor, 12.049450827602357) // Datapoint: 100, Theoretical value: 15.7268191179949, Error: -23.5492% + testSecondDerivative(movingRegressor, 44.46299143724677) // Datapoint: 100, Theoretical value: 47.2370928078489, Error: -5.7614% movingRegressor.push(7.77751896474187, flywheelPosition(111)) // Datapoint 111 - testFirstDerivative(movingRegressor, 16.540358644468085) // Datapoint: 101, Theoretical value: 18.8004784715502, Error: -12.0216% - testSecondDerivative(movingRegressor, 53.9026545272291) // Datapoint: 101, Theoretical value: 54.0044029484732, Error: -0.1884% + testFirstDerivative(movingRegressor, 16.55328654745199) // Datapoint: 101, Theoretical value: 18.8004784715502, Error: -12.0216% + testSecondDerivative(movingRegressor, 53.82083440127187) // Datapoint: 101, Theoretical value: 54.0044029484732, Error: -0.1884% movingRegressor.push(7.80005507028404, flywheelPosition(112)) // Datapoint 112 - testFirstDerivative(movingRegressor, 20.834932506145492) // Datapoint: 102, Theoretical value: 21.7527364967177, Error: -4.2193% - testSecondDerivative(movingRegressor, 63.2042246401701) // Datapoint: 102, Theoretical value: 60.2473455054648, Error: 4.9079% + testFirstDerivative(movingRegressor, 20.837518238281234) // Datapoint: 102, Theoretical value: 21.7527364967177, Error: -4.2193% + testSecondDerivative(movingRegressor, 63.09857048895572) // Datapoint: 102, Theoretical value: 60.2473455054648, Error: 4.9079% movingRegressor.push(7.82149109812353, flywheelPosition(113)) // Datapoint 113 - testFirstDerivative(movingRegressor, 24.264439359926428) // Datapoint: 103, Theoretical value: 24.6077174058151, Error: -1.395% - testSecondDerivative(movingRegressor, 70.22915254415649) // Datapoint: 103, Theoretical value: 66.0854711273397, Error: 6.2702% + testFirstDerivative(movingRegressor, 24.260710718966607) // Datapoint: 103, Theoretical value: 24.6077174058151, Error: -1.395% + testSecondDerivative(movingRegressor, 70.11339732079524) // Datapoint: 103, Theoretical value: 66.0854711273397, Error: 6.2702% movingRegressor.push(7.84194096076223, flywheelPosition(114)) // Datapoint 114 - testFirstDerivative(movingRegressor, 27.359065006266974) // Datapoint: 104, Theoretical value: 27.3819824840534, Error: -0.0837% - testSecondDerivative(movingRegressor, 75.91047202674271) // Datapoint: 104, Theoretical value: 71.5980441226458, Error: 6.0231% + testFirstDerivative(movingRegressor, 27.350851766683718) // Datapoint: 104, Theoretical value: 27.3819824840534, Error: -0.0837% + testSecondDerivative(movingRegressor, 75.79177233149834) // Datapoint: 104, Theoretical value: 71.5980441226458, Error: 6.0231% movingRegressor.push(7.86150121502312, flywheelPosition(115)) // Datapoint 115 - testFirstDerivative(movingRegressor, 30.005326217706283) // Datapoint: 105, Theoretical value: 30.0875556300011, Error: -0.2733% - testSecondDerivative(movingRegressor, 78.81036386630774) // Datapoint: 105, Theoretical value: 76.8409405553369, Error: 2.563% + testFirstDerivative(movingRegressor, 30.000816353090613) // Datapoint: 105, Theoretical value: 30.0875556300011, Error: -0.2733% + testSecondDerivative(movingRegressor, 78.7480307895183) // Datapoint: 105, Theoretical value: 76.8409405553369, Error: 2.563% movingRegressor.push(7.88025446645745, flywheelPosition(116)) // Datapoint 116 - testFirstDerivative(movingRegressor, 32.5566463371847) // Datapoint: 106, Theoretical value: 32.7335342472893, Error: -0.5404% - testSecondDerivative(movingRegressor, 81.74563687144989) // Datapoint: 106, Theoretical value: 81.8553682135038, Error: -0.1341% + testFirstDerivative(movingRegressor, 32.55470427801254) // Datapoint: 106, Theoretical value: 32.7335342472893, Error: -0.5404% + testSecondDerivative(movingRegressor, 81.72110714425453) // Datapoint: 106, Theoretical value: 81.8553682135038, Error: -0.1341% movingRegressor.push(7.89827197143778, flywheelPosition(117)) // Datapoint 117 testFirstDerivative(movingRegressor, 35.034638286363815) // Datapoint: 107, Theoretical value: 35.3270234685551, Error: -0.8277% testSecondDerivative(movingRegressor, 84.72131405454387) // Datapoint: 107, Theoretical value: 86.6727901598316, Error: -2.2515% From 9e2d30f3604b85a119def98e24ed37cc45428767 Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Wed, 7 Jan 2026 17:16:04 +0100 Subject: [PATCH 111/118] Added stresstest --- app/engine/utils/Series.test.js | 73 +++++++++++++++++++++++++++++++-- 1 file changed, 69 insertions(+), 4 deletions(-) diff --git a/app/engine/utils/Series.test.js b/app/engine/utils/Series.test.js index 1d9962c3ec..94d63d45fb 100644 --- a/app/engine/utils/Series.test.js +++ b/app/engine/utils/Series.test.js @@ -1,15 +1,19 @@ 'use strict' -/* - Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor -*/ /** - * As this object is fundamental for most other utility objects, we must test its behaviour quite thoroughly + * @copyright [OpenRowingMonitor]{@link https://github.com/JaapvanEkris/openrowingmonitor} + * + * @file Tests of the Series object. As this object is fundamental for most other utility objects, we must test its behaviour quite thoroughly + * Please note: this file contains commented out stress tests of the length(), sum(), average() functions, to detect any issues with numerical stability + * As these tests tend to run in the dozens of minutes, we do not run them systematically, but they should be run when the series object is changed. */ import { test } from 'uvu' import * as assert from 'uvu/assert' import { createSeries } from './Series.js' +/** + * @description Test behaviour for no datapoints + */ test('Series behaviour with an empty series', () => { const dataSeries = createSeries(3) testLength(dataSeries, 0) @@ -26,6 +30,9 @@ test('Series behaviour with an empty series', () => { testMaximum(dataSeries, 0) }) +/** + * @description Test behaviour for a single datapoint + */ test('Series behaviour with a single pushed value. Series = [9]', () => { const dataSeries = createSeries(3) dataSeries.push(9) @@ -43,6 +50,9 @@ test('Series behaviour with a single pushed value. Series = [9]', () => { testMaximum(dataSeries, 9) }) +/** + * @description Test behaviour for two datapoints + */ test('Series behaviour with a second pushed value. Series = [9, 3]', () => { const dataSeries = createSeries(3) dataSeries.push(9) @@ -61,6 +71,9 @@ test('Series behaviour with a second pushed value. Series = [9, 3]', () => { testMaximum(dataSeries, 9) }) +/** + * @description Test behaviour for three datapoints + */ test('Series behaviour with a third pushed value. Series = [9, 3, 6]', () => { const dataSeries = createSeries(3) dataSeries.push(9) @@ -80,6 +93,9 @@ test('Series behaviour with a third pushed value. Series = [9, 3, 6]', () => { testMaximum(dataSeries, 9) }) +/** + * @description Test behaviour for four datapoints + */ test('Series behaviour with a fourth pushed value. Series = [3, 6, 12]', () => { const dataSeries = createSeries(3) dataSeries.push(9) @@ -100,6 +116,9 @@ test('Series behaviour with a fourth pushed value. Series = [3, 6, 12]', () => { testMaximum(dataSeries, 12) }) +/** + * @description Test behaviour for five datapoints + */ test('Series behaviour with a fifth pushed value. Series = [6, 12, -3]', () => { const dataSeries = createSeries(3) dataSeries.push(9) @@ -121,6 +140,9 @@ test('Series behaviour with a fifth pushed value. Series = [6, 12, -3]', () => { testMaximum(dataSeries, 12) }) +/** + * @description Test behaviour for recalculations of the min/max values + */ test('Series behaviour pushing out the min and max value and forcing a recalculate of min/max via the array.', () => { const dataSeries = createSeries(3) dataSeries.push(9) @@ -136,6 +158,9 @@ test('Series behaviour pushing out the min and max value and forcing a recalcula testMaximum(dataSeries, 6) }) +/** + * @description Test behaviour for recalculations of the min/max values + */ test('Series behaviour pushing out the min and max value, replacing them just in time.', () => { const dataSeries = createSeries(3) dataSeries.push(9) @@ -151,6 +176,9 @@ test('Series behaviour pushing out the min and max value, replacing them just in testMaximum(dataSeries, 12) }) +/** + * @description Test behaviour after a reset() + */ test('Series behaviour with a five pushed values followed by a reset, Series = []', () => { const dataSeries = createSeries(3) dataSeries.push(9) @@ -171,6 +199,43 @@ test('Series behaviour with a five pushed values followed by a reset, Series = [ testMedian(dataSeries, 0) }) +/* These stress tests test the reliability of the sum(), average() and length() function after a huge number of updates +// This specific test takes a long time (over 10 minutes), so only run them manually when changing the series module +// Javascript maximum array length is 4294967295, as heap memory is limited, we stay with 2^25 datapoints +test('Stress test of the series object, 33.554.432 (2^25) datapoints', () => { + const dataSeries = createSeries() + let j = 0 + let randomvalue + while (j < 16777216) { + randomvalue = Math.random() + dataSeries.push(randomvalue) + dataSeries.push(1 - randomvalue) + j++ + } + testLength(dataSeries, 33554432) + testSum(dataSeries, 16777216) + testAverage(dataSeries, 0.5) + testMedian(dataSeries, 0.5) +}) + +// Javascript maximum array length is 4294967295, as heap memory is limited, we stay with 2^25 datapoints +// This test takes several hours (!) due to the many large array shifts, so only run them manually when changing the series module +test('Stress test of the series object, 67.108.864 datapoints, with a maxLength of 33.554.432 (2^25)', () => { + const dataSeries = createSeries(33554432) + let j = 0 + let randomvalue + while (j < 33554432) { + randomvalue = Math.random() + dataSeries.push(randomvalue) + dataSeries.push(1 - randomvalue) + j++ + } + testLength(dataSeries, 33554432) + testSum(dataSeries, 16777216) + testAverage(dataSeries, 0.5) + testMedian(dataSeries, 0.5) +}) */ + function testLength (series, expectedValue) { assert.ok(series.length() === expectedValue, `Expected length should be ${expectedValue}, encountered ${series.length()}`) } From 0898bc00c62cf6974a7e9c8ed4b8beb2314a79dc Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Wed, 7 Jan 2026 18:51:18 +0100 Subject: [PATCH 112/118] Adaptation to new filter properties and weighted approach --- app/engine/Flywheel.test.js | 331 ++++++++++++++++++++---------------- 1 file changed, 188 insertions(+), 143 deletions(-) diff --git a/app/engine/Flywheel.test.js b/app/engine/Flywheel.test.js index c44c3ad1c8..d98494d350 100644 --- a/app/engine/Flywheel.test.js +++ b/app/engine/Flywheel.test.js @@ -1,9 +1,8 @@ 'use strict' /** - * Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor - */ -/** - * Tests of the Flywheel object + * @copyright [OpenRowingMonitor]{@link https://github.com/JaapvanEkris/openrowingmonitor} + * + * @file Tests of the Flywheel object */ import { test } from 'uvu' import * as assert from 'uvu/assert' @@ -25,7 +24,7 @@ const baseConfig = { // Based on Concept 2 settings, as this is the validation s maximumTimeBetweenImpulses: 0.020, flankLength: 12, systematicErrorAgressiveness: 0, - systematicErrorMaximumChange: 1, + systematicErrorNumberOfDatapoints: 1, minimumStrokeQuality: 0.36, minimumForceBeforeStroke: 10, minimumRecoverySlope: 0.00070, @@ -37,7 +36,9 @@ const baseConfig = { // Based on Concept 2 settings, as this is the validation s magicConstant: 2.8 } -// Test behaviour for no datapoints +/** + * @description Test behaviour for no datapoints + */ test('Correct Flywheel behaviour at initialisation', () => { const flywheel = createFlywheel(baseConfig) testDeltaTime(flywheel, 0) @@ -52,14 +53,20 @@ test('Correct Flywheel behaviour at initialisation', () => { testIsPowered(flywheel, false) }) -// Test behaviour for one datapoint +/** + * @todo Test behaviour for one datapoint + */ -// Test behaviour for perfect upgoing flank +/** + * @todo Test behaviour for perfect upgoing flank + */ -// Test behaviour for perfect downgoing flank +/** + * @todo Test behaviour for perfect downgoing flank + */ /** - * Test of the integration of the underlying FullTSQuadraticEstimator object + * @description Test of the integration of the underlying FullTSQuadraticEstimator object * This uses the same data as the function y = 2 x^2 + 4 * x */ test('Test of correct algorithmic integration of FullTSQuadraticEstimator and Flywheel object for quadratic function f(x) = 2 * x^2 + 4 * x', () => { @@ -75,7 +82,7 @@ test('Test of correct algorithmic integration of FullTSQuadraticEstimator and Fl maximumTimeBetweenImpulses: 1, flankLength: 12, systematicErrorAgressiveness: 0, - systematicErrorMaximumChange: 1, + systematicErrorNumberOfDatapoints: 1, minimumStrokeQuality: 0.36, minimumForceBeforeStroke: 0, minimumRecoverySlope: 0.00070, @@ -162,126 +169,126 @@ test('Test of correct algorithmic integration of FullTSQuadraticEstimator and Fl testDeltaTime(flywheel, 0) // Values from Datapoint 0 are now passsing through testSpinningTime(flywheel, 0) testAngularPosition(flywheel, 0) - testAngularVelocity(flywheel, 4.000000000000003) - testAngularAcceleration(flywheel, 3.999999999999983) + testAngularVelocity(flywheel, 4.000000000000004) + testAngularAcceleration(flywheel, 3.99999999999998) flywheel.pushValue(0.095324565640171) // Datapoint 13 testDeltaTime(flywheel, 0.234341433963188) // Values from Datapoint 1 are now passsing through testSpinningTime(flywheel, 0.234341433963188) testAngularPosition(flywheel, 1.0471975511965976) - testAngularVelocity(flywheel, 4.937365735852749) - testAngularAcceleration(flywheel, 3.999999999999982) + testAngularVelocity(flywheel, 4.937365735852752) + testAngularAcceleration(flywheel, 3.999999999999979) flywheel.pushValue(0.092177973027300) // Datapoint 14 testDeltaTime(flywheel, 0.196461680094298) // Values from Datapoint 2 are now passsing through testSpinningTime(flywheel, 0.430803114057486) testAngularPosition(flywheel, 2.0943951023931953) - testAngularVelocity(flywheel, 5.7232124562299385) - testAngularAcceleration(flywheel, 3.9999999999999822) + testAngularVelocity(flywheel, 5.723212456229939) + testAngularAcceleration(flywheel, 3.999999999999979) flywheel.pushValue(0.089323823233014) // Datapoint 15 testDeltaTime(flywheel, 0.172567188397595) // Values from Datapoint 3 are now passsing through testSpinningTime(flywheel, 0.6033703024550809) testAngularPosition(flywheel, 3.141592653589793) testAngularVelocity(flywheel, 6.413481209820315) - testAngularAcceleration(flywheel, 3.999999999999983) + testAngularAcceleration(flywheel, 3.9999999999999787) flywheel.pushValue(0.086719441920360) // Datapoint 16 testDeltaTime(flywheel, 0.155718979643243) // Values from Datapoint 4 are now passsing through testSpinningTime(flywheel, 0.7590892820983239) testAngularPosition(flywheel, 4.1887902047863905) testAngularVelocity(flywheel, 7.036357128393282) - testAngularAcceleration(flywheel, 3.999999999999982) + testAngularAcceleration(flywheel, 3.999999999999977) flywheel.pushValue(0.084330395149166) // Datapoint 17 testDeltaTime(flywheel, 0.143013206725950) // Values from Datapoint 5 are now passsing through testSpinningTime(flywheel, 0.9021024888242739) testAngularPosition(flywheel, 5.235987755982988) testAngularVelocity(flywheel, 7.608409955297075) - testAngularAcceleration(flywheel, 3.9999999999999805) + testAngularAcceleration(flywheel, 3.999999999999975) flywheel.pushValue(0.082128549835466) // Datapoint 18 testDeltaTime(flywheel, 0.132987841748253) // Values from Datapoint 6 are now passsing through testSpinningTime(flywheel, 1.035090330572527) testAngularPosition(flywheel, 6.283185307179586) testAngularVelocity(flywheel, 8.140361322290087) - testAngularAcceleration(flywheel, 3.9999999999999845) + testAngularAcceleration(flywheel, 3.9999999999999782) flywheel.pushValue(0.080090664596669) // Datapoint 19 testDeltaTime(flywheel, 0.124815090780014) // Values from Datapoint 7 are now passsing through testSpinningTime(flywheel, 1.159905421352541) testAngularPosition(flywheel, 7.330382858376184) - testAngularVelocity(flywheel, 8.639621685410141) - testAngularAcceleration(flywheel, 3.999999999999988) + testAngularVelocity(flywheel, 8.639621685410138) + testAngularAcceleration(flywheel, 3.99999999999998) flywheel.pushValue(0.078197347646078) // Datapoint 20 testDeltaTime(flywheel, 0.117986192571703) // Values from Datapoint 8 are now passsing through testSpinningTime(flywheel, 1.277891613924244) testAngularPosition(flywheel, 8.377580409572781) - testAngularVelocity(flywheel, 9.111566455696956) - testAngularAcceleration(flywheel, 3.99999999999999) + testAngularVelocity(flywheel, 9.111566455696952) + testAngularAcceleration(flywheel, 3.999999999999985) flywheel.pushValue(0.076432273828253) // Datapoint 21 testDeltaTime(flywheel, 0.112168841458569) // Values from Datapoint 9 are now passsing through testSpinningTime(flywheel, 1.390060455382813) testAngularPosition(flywheel, 9.42477796076938) - testAngularVelocity(flywheel, 9.560241821531234) - testAngularAcceleration(flywheel, 3.999999999999988) + testAngularVelocity(flywheel, 9.560241821531228) + testAngularAcceleration(flywheel, 3.9999999999999845) flywheel.pushValue(0.074781587915460) // Datapoint 22 testDeltaTime(flywheel, 0.107135523306685) // Values from Datapoint 10 are now passsing through testSpinningTime(flywheel, 1.4971959786894982) testAngularPosition(flywheel, 10.471975511965976) - testAngularVelocity(flywheel, 9.988783914757978) - testAngularAcceleration(flywheel, 3.9999999999999813) + testAngularVelocity(flywheel, 9.98878391475797) + testAngularAcceleration(flywheel, 3.99999999999998) flywheel.pushValue(0.073233443959153) // Datapoint 23 testDeltaTime(flywheel, 0.102724506937187) // Values from Datapoint 11 are now passsing through testSpinningTime(flywheel, 1.599920485626685) testAngularPosition(flywheel, 11.519173063162574) - testAngularVelocity(flywheel, 10.399681942506728) - testAngularAcceleration(flywheel, 3.9999999999999707) + testAngularVelocity(flywheel, 10.399681942506724) + testAngularAcceleration(flywheel, 3.999999999999972) flywheel.pushValue(0.071777645486524) // Datapoint 24 testDeltaTime(flywheel, 0.098817239158663) // Values from Datapoint 12 are now passsing through testSpinningTime(flywheel, 1.6987377247853481) testAngularPosition(flywheel, 12.566370614359172) - testAngularVelocity(flywheel, 10.79495089914138) - testAngularAcceleration(flywheel, 3.9999999999999565) + testAngularVelocity(flywheel, 10.794950899141375) + testAngularAcceleration(flywheel, 3.99999999999996) flywheel.pushValue(0.070405361445316) // Datapoint 25 testDeltaTime(flywheel, 0.095324565640171) // Values from Datapoint 13 are now passsing through testSpinningTime(flywheel, 1.794062290425519) testAngularPosition(flywheel, 13.613568165555769) - testAngularVelocity(flywheel, 11.176249161702064) - testAngularAcceleration(flywheel, 3.9999999999999423) + testAngularVelocity(flywheel, 11.17624916170206) + testAngularAcceleration(flywheel, 3.9999999999999463) flywheel.pushValue(0.069108899742145) // Datapoint 26 testDeltaTime(flywheel, 0.092177973027300) // Values from Datapoint 14 are now passsing through testSpinningTime(flywheel, 1.886240263452819) testAngularPosition(flywheel, 14.660765716752367) - testAngularVelocity(flywheel, 11.54496105381126) - testAngularAcceleration(flywheel, 3.999999999999928) + testAngularVelocity(flywheel, 11.544961053811264) + testAngularAcceleration(flywheel, 3.999999999999933) flywheel.pushValue(0.067881525062373) // Datapoint 27 testDeltaTime(flywheel, 0.089323823233014) // Values from Datapoint 15 are now passsing through testSpinningTime(flywheel, 1.975564086685833) testAngularPosition(flywheel, 15.707963267948964) testAngularVelocity(flywheel, 11.902256346743307) - testAngularAcceleration(flywheel, 3.999999999999916) + testAngularAcceleration(flywheel, 3.9999999999999245) flywheel.pushValue(0.066717311088441) // Datapoint 28 testDeltaTime(flywheel, 0.086719441920360) // Values from Datapoint 16 are now passsing through testSpinningTime(flywheel, 2.062283528606193) testAngularPosition(flywheel, 16.755160819145562) testAngularVelocity(flywheel, 12.249134114424734) - testAngularAcceleration(flywheel, 3.999999999999907) + testAngularAcceleration(flywheel, 3.9999999999999245) flywheel.pushValue(0.065611019694526) // Datapoint 29 testDeltaTime(flywheel, 0.084330395149166) // Values from Datapoint 17 are now passsing through testSpinningTime(flywheel, 2.1466139237553588) testAngularPosition(flywheel, 17.80235837034216) - testAngularVelocity(flywheel, 12.586455695021382) - testAngularAcceleration(flywheel, 3.999999999999908) + testAngularVelocity(flywheel, 12.586455695021384) + testAngularAcceleration(flywheel, 3.9999999999999396) flywheel.pushValue(0.064558001484125) // Datapoint 30 testDeltaTime(flywheel, 0.082128549835466) // Values from Datapoint 18 are now passsing through testSpinningTime(flywheel, 2.228742473590825) testAngularPosition(flywheel, 18.84955592153876) - testAngularVelocity(flywheel, 12.914969894363228) - testAngularAcceleration(flywheel, 3.9999999999999165) + testAngularVelocity(flywheel, 12.914969894363232) + testAngularAcceleration(flywheel, 3.9999999999999574) flywheel.pushValue(0.063554113352442) // Datapoint 31 testDeltaTime(flywheel, 0.080090664596669) // Values from Datapoint 19 are now passsing through testSpinningTime(flywheel, 2.308833138187494) testAngularPosition(flywheel, 19.896753472735355) - testAngularVelocity(flywheel, 13.235332552749883) - testAngularAcceleration(flywheel, 3.9999999999999303) + testAngularVelocity(flywheel, 13.235332552749886) + testAngularAcceleration(flywheel, 3.9999999999999867) }) /** - * Test of the integration of the underlying FullTSQuadraticEstimator object + * @description Test of the integration of the underlying FullTSQuadraticEstimator object * The data follows the function y = X^3 + 2 * x^2 + 4 * x * To test if multiple quadratic regressions can decently approximate a cubic function */ @@ -298,7 +305,7 @@ test('Test of correct algorithmic integration of FullTSQuadraticEstimator and Fl maximumTimeBetweenImpulses: 1, flankLength: 12, systematicErrorAgressiveness: 0, - systematicErrorMaximumChange: 1, + systematicErrorNumberOfDatapoints: 1, minimumStrokeQuality: 0.36, minimumForceBeforeStroke: 0, minimumRecoverySlope: 0.00070, @@ -385,126 +392,126 @@ test('Test of correct algorithmic integration of FullTSQuadraticEstimator and Fl testDeltaTime(flywheel, 0) // Values from Datapoint 0 are now passsing through testSpinningTime(flywheel, 0) testAngularPosition(flywheel, 0) - testAngularVelocity(flywheel, 3.161921856069136) // Theoretical value: 4 - testAngularAcceleration(flywheel, 7.251023549310242) // Theoretical value: 4 + testAngularVelocity(flywheel, 3.1619218560691382) // Theoretical value: 4 + testAngularAcceleration(flywheel, 7.251023549310239) // Theoretical value: 4 flywheel.pushValue(0.064914611722656) // Datapoint 13 testDeltaTime(flywheel, 0.231815755285445) // Values from Datapoint 1 are now passsing through testSpinningTime(flywheel, 0.231815755285445) testAngularPosition(flywheel, 1.0471975511965976) - testAngularVelocity(flywheel, 4.795017407170436) // Theoretical value: 5.088478654, error: -6,64% - testAngularAcceleration(flywheel, 7.324931550092111) // Theoretical value: 5.390894532, error: 38,46% + testAngularVelocity(flywheel, 4.7950174071704375) // Theoretical value: 5.088478654, error: -6,64% + testAngularAcceleration(flywheel, 7.324931550092107) // Theoretical value: 5.390894532, error: 38,46% flywheel.pushValue(0.061784830519864) // Datapoint 14 testDeltaTime(flywheel, 0.186170118209325) // Values from Datapoint 2 are now passsing through testSpinningTime(flywheel, 0.41798587349477) testAngularPosition(flywheel, 2.0943951023931953) testAngularVelocity(flywheel, 6.098616558470422) // Theoretical value: 6.196080065, error: -2,14% - testAngularAcceleration(flywheel, 7.656104494382673) // Theoretical value: 6.507915241, error: 18,21% + testAngularAcceleration(flywheel, 7.6561044943826655) // Theoretical value: 6.507915241, error: 18,21% flywheel.pushValue(0.058995265576639) // Datapoint 15 testDeltaTime(flywheel, 0.155673811324399) // Values from Datapoint 3 are now passsing through testSpinningTime(flywheel, 0.5736596848191691) testAngularPosition(flywheel, 3.141592653589793) - testAngularVelocity(flywheel, 7.26104514687623) // Theoretical value: 7.281895041, error: -0,79% - testAngularAcceleration(flywheel, 8.125127482273886) // Theoretical value: 7.441958109, error: 9,49% + testAngularVelocity(flywheel, 7.261045146876231) // Theoretical value: 7.281895041, error: -0,79% + testAngularAcceleration(flywheel, 8.125127482273879) // Theoretical value: 7.441958109, error: 9,49% flywheel.pushValue(0.056491331538715) // Datapoint 16 testDeltaTime(flywheel, 0.134264409859047) // Values from Datapoint 4 are now passsing through testSpinningTime(flywheel, 0.707924094678216) testAngularPosition(flywheel, 4.1887902047863905) - testAngularVelocity(flywheel, 8.335452316712823) // Theoretical value: 8.33516595, error: -0,42% - testAngularAcceleration(flywheel, 8.59108553240516) // Theoretical value: 8.247544568, error: 4,32% + testAngularVelocity(flywheel, 8.335452316712825) // Theoretical value: 8.33516595, error: -0,42% + testAngularAcceleration(flywheel, 8.591085532405152) // Theoretical value: 8.247544568, error: 4,32% flywheel.pushValue(0.054229670373632) // Datapoint 17 testDeltaTime(flywheel, 0.118490308292909) // Values from Datapoint 5 are now passsing through testSpinningTime(flywheel, 0.826414402971125) testAngularPosition(flywheel, 5.235987755982988) - testAngularVelocity(flywheel, 9.346198019520207) // Theoretical value: 9.354539908, error: -0,44% - testAngularAcceleration(flywheel, 9.058162877855928) // Theoretical value: 8.958486418, error: 1,06% + testAngularVelocity(flywheel, 9.346198019520214) // Theoretical value: 9.354539908, error: -0,44% + testAngularAcceleration(flywheel, 9.058162877855903) // Theoretical value: 8.958486418, error: 1,06% flywheel.pushValue(0.052175392433679) // Datapoint 18 testDeltaTime(flywheel, 0.106396192260267) // Values from Datapoint 6 are now passsing through testSpinningTime(flywheel, 0.932810595231392) testAngularPosition(flywheel, 6.283185307179586) - testAngularVelocity(flywheel, 10.314972131734729) // Theoretical value: 10.3416492, error: -0,56% - testAngularAcceleration(flywheel, 9.531782371110216) // Theoretical value: 9.596863571, error: -0,95% + testAngularVelocity(flywheel, 10.314972131734738) // Theoretical value: 10.3416492, error: -0,56% + testAngularAcceleration(flywheel, 9.531782371110172) // Theoretical value: 9.596863571, error: -0,95% flywheel.pushValue(0.05030009417797) // Datapoint 19 testDeltaTime(flywheel, 0.096822693623239) // Values from Datapoint 7 are now passsing through testSpinningTime(flywheel, 1.029633288854631) testAngularPosition(flywheel, 7.330382858376184) - testAngularVelocity(flywheel, 11.25302645243177) // Theoretical value: 11.29896728, error: -0,68% - testAngularAcceleration(flywheel, 10.006689891934789) // Theoretical value: 10.17779973, error: -2,15% + testAngularVelocity(flywheel, 11.253026452431792) // Theoretical value: 11.29896728, error: -0,68% + testAngularAcceleration(flywheel, 10.006689891934712) // Theoretical value: 10.17779973, error: -2,15% flywheel.pushValue(0.04858040892819) // Datapoint 20 testDeltaTime(flywheel, 0.08904704613513) // Values from Datapoint 8 are now passsing through testSpinningTime(flywheel, 1.118680334989761) testAngularPosition(flywheel, 8.377580409572781) - testAngularVelocity(flywheel, 12.167114512288885) // Theoretical value: 12.22905842, error: -0,76% - testAngularAcceleration(flywheel, 10.479926499860365) // Theoretical value: 10.71208201, error: -2,78% + testAngularVelocity(flywheel, 12.167114512288897) // Theoretical value: 12.22905842, error: -0,76% + testAngularAcceleration(flywheel, 10.479926499860289) // Theoretical value: 10.71208201, error: -2,78% flywheel.pushValue(0.046996930546829) // Datapoint 21 testDeltaTime(flywheel, 0.08259777558252) // Values from Datapoint 9 are now passsing through testSpinningTime(flywheel, 1.201278110572281) testAngularPosition(flywheel, 9.42477796076938) - testAngularVelocity(flywheel, 13.06228935387465) // Theoretical value: 13.13431974, error: -0,79% - testAngularAcceleration(flywheel, 10.945741904208703) // Theoretical value: 11.20766866, error: -3,03% + testAngularVelocity(flywheel, 13.062289353874645) // Theoretical value: 13.13431974, error: -0,79% + testAngularAcceleration(flywheel, 10.945741904208647) // Theoretical value: 11.20766866, error: -3,03% flywheel.pushValue(0.045533402601137) // Datapoint 22 testDeltaTime(flywheel, 0.077155055952201) // Values from Datapoint 10 are now passsing through testSpinningTime(flywheel, 1.278433166524482) testAngularPosition(flywheel, 10.471975511965976) - testAngularVelocity(flywheel, 13.940750925066359) // Theoretical value: 14.01690675, error: -0,78% - testAngularAcceleration(flywheel, 11.403650671998252) // Theoretical value: 11.670599, error: -2,98% + testAngularVelocity(flywheel, 13.94075092506632) // Theoretical value: 14.01690675, error: -0,78% + testAngularAcceleration(flywheel, 11.403650671998298) // Theoretical value: 11.670599, error: -2,98% flywheel.pushValue(0.044176099545603) // Datapoint 23 testDeltaTime(flywheel, 0.072494552013330) // Values from Datapoint 11 are now passsing through testSpinningTime(flywheel, 1.350927718537812) testAngularPosition(flywheel, 11.519173063162574) - testAngularVelocity(flywheel, 14.806694981766503) // Theoretical value: 14.87872798, error: -0,69% - testAngularAcceleration(flywheel, 11.85668968195581) // Theoretical value: 12.10556631, error: -2,69% + testAngularVelocity(flywheel, 14.80669498176648) // Theoretical value: 14.87872798, error: -0,69% + testAngularAcceleration(flywheel, 11.856689681955814) // Theoretical value: 12.10556631, error: -2,69% flywheel.pushValue(0.042913348809906) // Datapoint 24 testDeltaTime(flywheel, 0.068454336759262) // Values from Datapoint 12 are now passsing through testSpinningTime(flywheel, 1.419382055297074) testAngularPosition(flywheel, 12.566370614359172) - testAngularVelocity(flywheel, 15.65933144364903) // Theoretical value: 15.72146448, error: -0,57% - testAngularAcceleration(flywheel, 12.303309060001375) // Theoretical value: 12.51629233, error: -2,22% + testAngularVelocity(flywheel, 15.659331443649155) // Theoretical value: 15.72146448, error: -0,57% + testAngularAcceleration(flywheel, 12.303309060000915) // Theoretical value: 12.51629233, error: -2,22% flywheel.pushValue(0.041735157665124) // Datapoint 25 testDeltaTime(flywheel, 0.064914611722656) // Values from Datapoint 13 are now passsing through, so we cleared all startup noise testSpinningTime(flywheel, 1.484296667019730) testAngularPosition(flywheel, 13.613568165555769) - testAngularVelocity(flywheel, 16.49273676896862) // Theoretical value: 16.54659646, error: -0,47% - testAngularAcceleration(flywheel, 12.721354618621646) // Theoretical value: 12.90578, error: -1,86% + testAngularVelocity(flywheel, 16.492736768968747) // Theoretical value: 16.54659646, error: -0,47% + testAngularAcceleration(flywheel, 12.721354618621062) // Theoretical value: 12.90578, error: -1,86% flywheel.pushValue(0.040632918960300) // Datapoint 26 testDeltaTime(flywheel, 0.061784830519864) // Values from Datapoint 14 are now passsing through testSpinningTime(flywheel, 1.546081497539594) testAngularPosition(flywheel, 14.660765716752367) - testAngularVelocity(flywheel, 17.30769121071966) // Theoretical value: 17.35542998, error: -0,40% - testAngularAcceleration(flywheel, 13.113972550977003) // Theoretical value: 13.27648899, error: -1,59% + testAngularVelocity(flywheel, 17.307691210719753) // Theoretical value: 17.35542998, error: -0,40% + testAngularAcceleration(flywheel, 13.11397255097641) // Theoretical value: 13.27648899, error: -1,59% flywheel.pushValue(0.039599176898486) // Datapoint 27 testDeltaTime(flywheel, 0.058995265576639) // Values from Datapoint 15 are now passsing through testSpinningTime(flywheel, 1.605076763116233) testAngularPosition(flywheel, 15.707963267948964) - testAngularVelocity(flywheel, 18.106493986724598) // Theoretical value: 18.1491213, error: -0,34% - testAngularAcceleration(flywheel, 13.486098587072432) // Theoretical value: 13.63046058, error: -1,38% + testAngularVelocity(flywheel, 18.10649398672465) // Theoretical value: 18.1491213, error: -0,34% + testAngularAcceleration(flywheel, 13.486098587071863) // Theoretical value: 13.63046058, error: -1,38% flywheel.pushValue(0.038627438996519) // Datapoint 28 testDeltaTime(flywheel, 0.056491331538715) // Values from Datapoint 16 are now passsing through testSpinningTime(flywheel, 1.661568094654948) testAngularPosition(flywheel, 16.755160819145562) - testAngularVelocity(flywheel, 18.89042654239583) // Theoretical value: 18.92869798, error: -0,29% - testAngularAcceleration(flywheel, 13.840428977172227) // Theoretical value: 13.96940857, error: -1,20% + testAngularVelocity(flywheel, 18.890426542395847) // Theoretical value: 18.92869798, error: -0,29% + testAngularAcceleration(flywheel, 13.840428977171639) // Theoretical value: 13.96940857, error: -1,20% flywheel.pushValue(0.037712023914259) // Datapoint 29 testDeltaTime(flywheel, 0.054229670373632) // Values from Datapoint 17 are now passsing through testSpinningTime(flywheel, 1.715797765028580) testAngularPosition(flywheel, 17.80235837034216) - testAngularVelocity(flywheel, 19.660398675998643) // Theoretical value: 19.69507697, error: -0,26% - testAngularAcceleration(flywheel, 14.178743620220319) // Theoretical value: 14.29478659, error: -1,06% + testAngularVelocity(flywheel, 19.660398675998614) // Theoretical value: 19.69507697, error: -0,26% + testAngularAcceleration(flywheel, 14.178743620219855) // Theoretical value: 14.29478659, error: -1,06% flywheel.pushValue(0.036847937394809) // Datapoint 30 testDeltaTime(flywheel, 0.052175392433679) // Values from Datapoint 18 are now passsing through testSpinningTime(flywheel, 1.767973157462259) testAngularPosition(flywheel, 18.84955592153876) - testAngularVelocity(flywheel, 20.41744737019348) // Theoretical value: 20.44907989, error: -0,23% - testAngularAcceleration(flywheel, 14.502790132816703) // Theoretical value: 14.60783894, error: -0,94% + testAngularVelocity(flywheel, 20.41744737019342) // Theoretical value: 20.44907989, error: -0,23% + testAngularAcceleration(flywheel, 14.502790132816358) // Theoretical value: 14.60783894, error: -0,94% flywheel.pushValue(0.036030770419579) // Datapoint 31 testDeltaTime(flywheel, 0.05030009417797) // Values from Datapoint 19 are now passsing through testSpinningTime(flywheel, 1.8182732516402291) testAngularPosition(flywheel, 19.896753472735355) - testAngularVelocity(flywheel, 21.162376267362447) // Theoretical value: 21.19144586, error: -0,20% - testAngularAcceleration(flywheel, 14.813903373334773) // Theoretical value: 14.90963951, error: -0,83% + testAngularVelocity(flywheel, 21.162376267362376) // Theoretical value: 21.19144586, error: -0,20% + testAngularAcceleration(flywheel, 14.813903373334538) // Theoretical value: 14.90963951, error: -0,83% }) /** - * Test of the integration of the underlying FullTSQuadraticEstimator object + * @description Test of the integration of the underlying FullTSQuadraticEstimator object * The data follows the function y = X^3 + 2 * x^2 + 4 * x with a +/-0.0001 sec injected noise in currentDt * To test if multiple quadratic regressions can decently approximate a cubic function with noise * Please note: theoretical values are based on the perfect function (i.e. without noise) @@ -522,7 +529,7 @@ test('Test of correct algorithmic integration of FullTSQuadraticEstimator and Fl maximumTimeBetweenImpulses: 1, flankLength: 12, systematicErrorAgressiveness: 0, - systematicErrorMaximumChange: 1, + systematicErrorNumberOfDatapoints: 1, minimumStrokeQuality: 0.36, minimumForceBeforeStroke: 0, minimumRecoverySlope: 0.00070, @@ -609,125 +616,131 @@ test('Test of correct algorithmic integration of FullTSQuadraticEstimator and Fl testDeltaTime(flywheel, 0) // Values from Datapoint 0 are now passsing through testSpinningTime(flywheel, 0) testAngularPosition(flywheel, 0) - testAngularVelocity(flywheel, 3.1651252708296953) // Theoretical value: 4 + testAngularVelocity(flywheel, 3.1651252708296993) // Theoretical value: 4 testAngularAcceleration(flywheel, 7.2468812808500696) // Theoretical value: 4 flywheel.pushValue(0.065014611722656) // Datapoint 13 testDeltaTime(flywheel, 0.231915755285445) // Values from Datapoint 1 are now passsing through testSpinningTime(flywheel, 0.231915755285445) testAngularPosition(flywheel, 1.0471975511965976) - testAngularVelocity(flywheel, 4.798189682557517) // Theoretical value: 5.088478654, error: -6.58% - testAngularAcceleration(flywheel, 7.32078401292801) // Theoretical value: 5.390894532, error: 38.38% + testAngularVelocity(flywheel, 4.7981896825575205) // Theoretical value: 5.088478654, error: -6.58% + testAngularAcceleration(flywheel, 7.320784012928006) // Theoretical value: 5.390894532, error: 38.38% flywheel.pushValue(0.061684830519864) // Datapoint 14 testDeltaTime(flywheel, 0.186070118209325) // Values from Datapoint 2 are now passsing through testSpinningTime(flywheel, 0.41798587349477) testAngularPosition(flywheel, 2.0943951023931953) - testAngularVelocity(flywheel, 6.100352571838147) // Theoretical value: 6.196080065, error: -2.11% - testAngularAcceleration(flywheel, 7.650380140052499) // Theoretical value: 6.507915241, error: 18.14% + testAngularVelocity(flywheel, 6.100352571838149) // Theoretical value: 6.196080065, error: -2.11% + testAngularAcceleration(flywheel, 7.650380140052492) // Theoretical value: 6.507915241, error: 18.14% flywheel.pushValue(0.059095265576639) // Datapoint 15 testDeltaTime(flywheel, 0.155773811324398) // Values from Datapoint 3 are now passsing through testSpinningTime(flywheel, 0.573759684819168) testAngularPosition(flywheel, 3.141592653589793) - testAngularVelocity(flywheel, 7.262664999379815) // Theoretical value: 7.281895041, error: -0.77% - testAngularAcceleration(flywheel, 8.117964017032834) // Theoretical value: 7.441958109, error: 9.40% + testAngularVelocity(flywheel, 7.262664999379819) // Theoretical value: 7.281895041, error: -0.77% + testAngularAcceleration(flywheel, 8.117964017032822) // Theoretical value: 7.441958109, error: 9.40% flywheel.pushValue(0.056391331538715) // Datapoint 16 testDeltaTime(flywheel, 0.134164409859047) // Values from Datapoint 4 are now passsing through testSpinningTime(flywheel, 0.7079240946782149) testAngularPosition(flywheel, 4.1887902047863905) - testAngularVelocity(flywheel, 8.33567148791435) // Theoretical value: 8.33516595, error: -0.42% - testAngularAcceleration(flywheel, 8.584272213871065) // Theoretical value: 8.247544568, error: 4.24% + testAngularVelocity(flywheel, 8.335671487914347) // Theoretical value: 8.33516595, error: -0.42% + testAngularAcceleration(flywheel, 8.58427221387106) // Theoretical value: 8.247544568, error: 4.24% flywheel.pushValue(0.054329670373632) // Datapoint 17 testDeltaTime(flywheel, 0.118590308292909) // Values from Datapoint 5 are now passsing through testSpinningTime(flywheel, 0.8265144029711239) testAngularPosition(flywheel, 5.235987755982988) - testAngularVelocity(flywheel, 9.3471099262632) // Theoretical value: 9.354539908, error: -0.44% - testAngularAcceleration(flywheel, 9.052626876076237) // Theoretical value: 8.958486418, error: 1.00% + testAngularVelocity(flywheel, 9.347109926263196) // Theoretical value: 9.354539908, error: -0.44% + testAngularAcceleration(flywheel, 9.052626876076234) // Theoretical value: 8.958486418, error: 1.00% flywheel.pushValue(0.052075392433679) // Datapoint 18 testDeltaTime(flywheel, 0.106296192260267) // Values from Datapoint 6 are now passsing through testSpinningTime(flywheel, 0.9328105952313909) testAngularPosition(flywheel, 6.283185307179586) - testAngularVelocity(flywheel, 10.31470693514443) // Theoretical value: 10.3416492, error: -0.56% - testAngularAcceleration(flywheel, 9.526534690784072) // Theoretical value: 9.596863571, error: -1.00% + testAngularVelocity(flywheel, 10.314706935144432) // Theoretical value: 10.3416492, error: -0.56% + testAngularAcceleration(flywheel, 9.52653469078407) // Theoretical value: 9.596863571, error: -1.00% flywheel.pushValue(0.05040009417797) // Datapoint 19 testDeltaTime(flywheel, 0.096922693623239) // Values from Datapoint 7 are now passsing through testSpinningTime(flywheel, 1.0297332888546298) testAngularPosition(flywheel, 7.330382858376184) - testAngularVelocity(flywheel, 11.25365342146104) // Theoretical value: 11.29896728, error: -0.67% - testAngularAcceleration(flywheel, 10.001358612662708) // Theoretical value: 10.17779973, error: -2.21% + testAngularVelocity(flywheel, 11.253653421461035) // Theoretical value: 11.29896728, error: -0.67% + testAngularAcceleration(flywheel, 10.001358612662711) // Theoretical value: 10.17779973, error: -2.21% flywheel.pushValue(0.04848040892819) // Datapoint 20 testDeltaTime(flywheel, 0.08894704613513) // Values from Datapoint 8 are now passsing through testSpinningTime(flywheel, 1.1186803349897598) testAngularPosition(flywheel, 8.377580409572781) - testAngularVelocity(flywheel, 12.1667674474633) // Theoretical value: 12.22905842, error: -0.76% - testAngularAcceleration(flywheel, 10.47394441606816) // Theoretical value: 10.71208201, error: -2.84% + testAngularVelocity(flywheel, 12.166767447463288) // Theoretical value: 12.22905842, error: -0.76% + testAngularAcceleration(flywheel, 10.47394441606818) // Theoretical value: 10.71208201, error: -2.84% flywheel.pushValue(0.047096930546829) // Datapoint 21 testDeltaTime(flywheel, 0.08269777558252) // Values from Datapoint 9 are now passsing through testSpinningTime(flywheel, 1.2013781105722798) testAngularPosition(flywheel, 9.42477796076938) - testAngularVelocity(flywheel, 13.062997567333905) // Theoretical value: 13.13431974, error: -0.79% - testAngularAcceleration(flywheel, 10.940063240068048) // Theoretical value: 11.20766866, error: -3.08% + testAngularVelocity(flywheel, 13.062997567333893) // Theoretical value: 13.13431974, error: -0.79% + testAngularAcceleration(flywheel, 10.940063240068076) // Theoretical value: 11.20766866, error: -3.08% flywheel.pushValue(0.045433402601137) // Datapoint 22 testDeltaTime(flywheel, 0.077055055952201) // Values from Datapoint 10 are now passsing through testSpinningTime(flywheel, 1.2784331665244808) testAngularPosition(flywheel, 10.471975511965976) - testAngularVelocity(flywheel, 13.940480188006532) // Theoretical value: 14.01690675, error: -0.78% - testAngularAcceleration(flywheel, 11.397389413208403) // Theoretical value: 11.670599, error: -3.04% + testAngularVelocity(flywheel, 13.940480188006552) // Theoretical value: 14.01690675, error: -0.78% + testAngularAcceleration(flywheel, 11.397389413208364) // Theoretical value: 11.670599, error: -3.04% flywheel.pushValue(0.044276099545603) // Datapoint 23 testDeltaTime(flywheel, 0.07259455201333) // Values from Datapoint 11 are now passsing through testSpinningTime(flywheel, 1.3510277185378108) testAngularPosition(flywheel, 11.519173063162574) - testAngularVelocity(flywheel, 14.807840698982407) // Theoretical value: 14.87872798, error: -0.68% - testAngularAcceleration(flywheel, 11.848780564150392) // Theoretical value: 12.10556631, error: -2.76% + testAngularVelocity(flywheel, 14.807840698982423) // Theoretical value: 14.87872798, error: -0.68% + testAngularAcceleration(flywheel, 11.848780564150369) // Theoretical value: 12.10556631, error: -2.76% flywheel.pushValue(0.042813348809906) // Datapoint 24 testDeltaTime(flywheel, 0.068354336759262) // Values from Datapoint 12 are now passsing through testSpinningTime(flywheel, 1.4193820552970728) testAngularPosition(flywheel, 12.566370614359172) - testAngularVelocity(flywheel, 15.659177267217961) // Theoretical value: 15.72146448, error: -0.57% - testAngularAcceleration(flywheel, 12.293943915780241) // Theoretical value: 12.51629233, error: -2.30% + testAngularVelocity(flywheel, 15.65917726721796) // Theoretical value: 15.72146448, error: -0.57% + testAngularAcceleration(flywheel, 12.293943915780252) // Theoretical value: 12.51629233, error: -2.30% flywheel.pushValue(0.041835157665124) // Datapoint 25 testDeltaTime(flywheel, 0.065014611722656) // Values from Datapoint 13 are now passsing through, so we cleared all startup noise testSpinningTime(flywheel, 1.4843966670197288) testAngularPosition(flywheel, 13.613568165555769) - testAngularVelocity(flywheel, 16.494472505376102) // Theoretical value: 16.54659646, error: -0.46% - testAngularAcceleration(flywheel, 12.710407075508463) // Theoretical value: 12.90578, error: -1.95% + testAngularVelocity(flywheel, 16.49447250537608) // Theoretical value: 16.54659646, error: -0.46% + testAngularAcceleration(flywheel, 12.710407075508567) // Theoretical value: 12.90578, error: -1.95% flywheel.pushValue(0.040532918960300) // Datapoint 26 testDeltaTime(flywheel, 0.061684830519864) // Values from Datapoint 14 are now passsing through testSpinningTime(flywheel, 1.546081497539593) testAngularPosition(flywheel, 14.660765716752367) - testAngularVelocity(flywheel, 17.308891329044478) // Theoretical value: 17.35542998, error: -0.39% - testAngularAcceleration(flywheel, 13.100466914875762) // Theoretical value: 13.27648899, error: -1.70% + testAngularVelocity(flywheel, 17.308891329044464) // Theoretical value: 17.35542998, error: -0.39% + testAngularAcceleration(flywheel, 13.100466914875906) // Theoretical value: 13.27648899, error: -1.70% flywheel.pushValue(0.039699176898486) // Datapoint 27 testDeltaTime(flywheel, 0.059095265576639) // Values from Datapoint 15 are now passsing through testSpinningTime(flywheel, 1.605176763116232) testAngularPosition(flywheel, 15.707963267948964) - testAngularVelocity(flywheel, 18.10970282977479) // Theoretical value: 18.1491213, error: -0.32% - testAngularAcceleration(flywheel, 13.469377816872042) // Theoretical value: 13.63046058, error: -1.51% + testAngularVelocity(flywheel, 18.109702829774772) // Theoretical value: 18.1491213, error: -0.32% + testAngularAcceleration(flywheel, 13.469377816872242) // Theoretical value: 13.63046058, error: -1.51% flywheel.pushValue(0.038527438996519) // Datapoint 28 testDeltaTime(flywheel, 0.056391331538715) // Values from Datapoint 16 are now passsing through testSpinningTime(flywheel, 1.661568094654947) testAngularPosition(flywheel, 16.755160819145562) - testAngularVelocity(flywheel, 18.892749084779716) // Theoretical value: 18.92869798, error: -0.28% - testAngularAcceleration(flywheel, 13.819955339923922) // Theoretical value: 13.96940857, error: -1.35% + testAngularVelocity(flywheel, 18.892749084779705) // Theoretical value: 18.92869798, error: -0.28% + testAngularAcceleration(flywheel, 13.819955339924142) // Theoretical value: 13.96940857, error: -1.35% flywheel.pushValue(0.037812023914259) // Datapoint 29 testDeltaTime(flywheel, 0.054329670373632) // Values from Datapoint 17 are now passsing through testSpinningTime(flywheel, 1.715897765028579) testAngularPosition(flywheel, 17.80235837034216) - testAngularVelocity(flywheel, 19.664430174199467) // Theoretical value: 19.69507697, error: -0.24% - testAngularAcceleration(flywheel, 14.154531841302589) // Theoretical value: 14.29478659, error: -1.23% + testAngularVelocity(flywheel, 19.664430174199474) // Theoretical value: 19.69507697, error: -0.24% + testAngularAcceleration(flywheel, 14.154531841302834) // Theoretical value: 14.29478659, error: -1.23% flywheel.pushValue(0.036747937394809) // Datapoint 30 testDeltaTime(flywheel, 0.052075392433679) // Values from Datapoint 18 are now passsing through testSpinningTime(flywheel, 1.767973157462258) testAngularPosition(flywheel, 18.84955592153876) - testAngularVelocity(flywheel, 20.419916102229323) // Theoretical value: 20.44907989, error: -0.21% - testAngularAcceleration(flywheel, 14.474639639378722) // Theoretical value: 14.60783894, error: -1.13% + testAngularVelocity(flywheel, 20.419916102229333) // Theoretical value: 20.44907989, error: -0.21% + testAngularAcceleration(flywheel, 14.474639639378996) // Theoretical value: 14.60783894, error: -1.13% flywheel.pushValue(0.036130770419579) // Datapoint 31 testDeltaTime(flywheel, 0.05040009417797) // Values from Datapoint 19 are now passsing through testSpinningTime(flywheel, 1.818373251640228) testAngularPosition(flywheel, 19.896753472735355) - testAngularVelocity(flywheel, 21.166541683421826) // Theoretical value: 21.19144586, error: -0.18% - testAngularAcceleration(flywheel, 14.782028789603572) // Theoretical value: 14.90963951, error: -1.05% + testAngularVelocity(flywheel, 21.16654168342182) // Theoretical value: 21.19144586, error: -0.18% + testAngularAcceleration(flywheel, 14.782028789603949) // Theoretical value: 14.90963951, error: -1.05% }) -// Test behaviour for perfect stroke +/** + * @todo Test behaviour with noise CEC filter active + */ + +/** + * @description Test behaviour for perfect stroke + */ test('Correct Flywheel behaviour for a noisefree stroke', () => { const flywheel = createFlywheel(baseConfig) flywheel.maintainStateAndMetrics() @@ -764,8 +777,8 @@ test('Correct Flywheel behaviour for a noisefree stroke', () => { testSpinningTime(flywheel, 0.077918634) testAngularPosition(flywheel, 7.330382858376184) testAngularVelocity(flywheel, 94.88636656676766) - testAngularAcceleration(flywheel, 28.483961147947365) - testTorque(flywheel, 3.927072875980104) + testAngularAcceleration(flywheel, 28.483961147946758) + testTorque(flywheel, 3.9270728759800413) testDragFactor(flywheel, 0.00011) testIsDwelling(flywheel, false) testIsUnpowered(flywheel, false) @@ -788,9 +801,9 @@ test('Correct Flywheel behaviour for a noisefree stroke', () => { testDeltaTime(flywheel, 0.010722165) testSpinningTime(flywheel, 0.23894732900000007) testAngularPosition(flywheel, 23.03834612632515) - testAngularVelocity(flywheel, 97.06865123831885) - testAngularAcceleration(flywheel, -32.75873752642775) - testTorque(flywheel, -2.340970303119799) + testAngularVelocity(flywheel, 97.06865123831865) + testAngularAcceleration(flywheel, -32.75873752642214) + testTorque(flywheel, -2.340970303119225) testDragFactor(flywheel, 0.00011) testIsDwelling(flywheel, false) testIsUnpowered(flywheel, true) @@ -813,26 +826,38 @@ test('Correct Flywheel behaviour for a noisefree stroke', () => { testDeltaTime(flywheel, 0.020722165) testSpinningTime(flywheel, 0.43343548300000007) testAngularPosition(flywheel, 38.746309394274114) - testAngularVelocity(flywheel, 50.97532159524012) - testAngularAcceleration(flywheel, -157.76768934416657) - testTorque(flywheel, -15.980015596092377) + testAngularVelocity(flywheel, 50.975321595240146) + testAngularAcceleration(flywheel, -157.76768934416432) + testTorque(flywheel, -15.980015596092146) testDragFactor(flywheel, 0.00011) testIsDwelling(flywheel, true) testIsUnpowered(flywheel, true) testIsPowered(flywheel, false) }) -// Test behaviour for noisy upgoing flank +/** + * @todo Test behaviour for noisy upgoing flank + */ -// Test behaviour for noisy downgoing flank +/** + * @todo Test behaviour for noisy downgoing flank + */ -// Test behaviour for noisy stroke +/** + * @todo Test behaviour for noisy stroke + */ -// Test drag factor calculation +/** + * @todo Test drag factor calculation + */ -// Test Dynamic stroke detection +/** + * @todo Test Dynamic stroke detection + */ -// Test behaviour for not maintaining metrics +/** + * @description Test behaviour for not maintaining metrics + */ test('Correct Flywheel behaviour at maintainStateOnly', () => { const flywheel = createFlywheel(baseConfig) flywheel.maintainStateAndMetrics() @@ -903,6 +928,9 @@ test('Correct Flywheel behaviour at maintainStateOnly', () => { testIsPowered(flywheel, false) }) +/** + * @description Test behaviour for the WRX700 + */ test('Correct Flywheel behaviour with a SportsTech WRX700', async () => { const flywheel = createFlywheel(deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Sportstech_WRX700)) testSpinningTime(flywheel, 0) @@ -918,6 +946,9 @@ test('Correct Flywheel behaviour with a SportsTech WRX700', async () => { testDragFactor(flywheel, (rowerProfiles.Sportstech_WRX700.dragFactor / 1000000)) }) +/** + * @description Test behaviour for the DKN R-320 + */ test('Correct Flywheel behaviour with a DKN R-320', async () => { const flywheel = createFlywheel(deepMerge(rowerProfiles.DEFAULT, rowerProfiles.DKN_R320)) @@ -934,6 +965,9 @@ test('Correct Flywheel behaviour with a DKN R-320', async () => { testDragFactor(flywheel, (rowerProfiles.DKN_R320.dragFactor / 1000000)) }) +/** + * @description Test behaviour for the NordicTrack RX800 + */ test('Correct Flywheel behaviour with a NordicTrack RX800', async () => { const flywheel = createFlywheel(deepMerge(rowerProfiles.DEFAULT, rowerProfiles.NordicTrack_RX800)) testSpinningTime(flywheel, 0) @@ -950,6 +984,9 @@ test('Correct Flywheel behaviour with a NordicTrack RX800', async () => { testDragFactor(flywheel, (rowerProfiles.NordicTrack_RX800.dragFactor / 1000000)) }) +/** + * @description Test behaviour for the SportsTech WRX700 + */ test('Correct Flywheel behaviour with a full session on a SportsTech WRX700', async () => { const flywheel = createFlywheel(deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Sportstech_WRX700)) testSpinningTime(flywheel, 0) @@ -966,6 +1003,9 @@ test('Correct Flywheel behaviour with a full session on a SportsTech WRX700', as testDragFactor(flywheel, (rowerProfiles.Sportstech_WRX700.dragFactor / 1000000)) }) +/** + * @description Test behaviour for the C2 Model C + */ 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) @@ -981,6 +1021,9 @@ test('A full session for a Concept2 Model C should produce plausible results', a testDragFactor(flywheel, (rowerProfiles.Concept2_Model_C.dragFactor / 1000000)) }) +/** + * @description Test behaviour for the C2 RowErg + */ 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) @@ -996,7 +1039,9 @@ test('A full session for a Concept2 RowErg should produce plausible results', as testDragFactor(flywheel, (rowerProfiles.Concept2_RowErg.dragFactor / 1000000)) }) -// Test behaviour after reset +/** + * @todo Test behaviour after reset + */ function testDeltaTime (flywheel, expectedValue) { assert.ok(flywheel.deltaTime() === expectedValue, `deltaTime should be ${expectedValue} sec at ${flywheel.spinningTime()} sec, is ${flywheel.deltaTime()}`) From b47b88aa34f64f012bc93f01e058a393843f6f5e Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Thu, 8 Jan 2026 12:05:28 +0100 Subject: [PATCH 113/118] Updated to reflect algorithm changes --- app/engine/Rower.test.js | 101 ++++++++++++++++++++++----------------- 1 file changed, 58 insertions(+), 43 deletions(-) diff --git a/app/engine/Rower.test.js b/app/engine/Rower.test.js index 03018bb3d2..b1019ec77d 100644 --- a/app/engine/Rower.test.js +++ b/app/engine/Rower.test.js @@ -1,9 +1,8 @@ 'use strict' -/* - Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor -*/ /** - * This test is a test of the Rower object, that tests wether this object fills all fields correctly, given one validated rower, (the + * @copyright [OpenRowingMonitor]{@link https://github.com/JaapvanEkris/openrowingmonitor} + * + * @file 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 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. @@ -40,7 +39,9 @@ const baseConfig = { // Based on Concept 2 settings, as this is the validation s magicConstant: 2.8 } -// Test behaviour for no datapoints +/** + * @description Test behaviour for no datapoints + */ test('Correct rower behaviour at initialisation', () => { const rower = createRower(baseConfig) testStrokeState(rower, 'WaitingForDrive') @@ -61,9 +62,13 @@ test('Correct rower behaviour at initialisation', () => { testInstantHandlePower(rower, 0) }) -// Test behaviour for one datapoint +/** + * @todo Test behaviour for one datapoint + */ -// Test behaviour for three perfect identical strokes, including settingling behaviour of metrics +/** + * @description Test behaviour for three perfect identical strokes, including settingling behaviour of metrics + */ test('Test behaviour for three perfect identical strokes, including settingling behaviour of metrics', () => { const rower = createRower(baseConfig) testStrokeState(rower, 'WaitingForDrive') @@ -117,7 +122,7 @@ test('Test behaviour for three perfect identical strokes, including settingling testDrivePeakHandleForce(rower, undefined) testRecoveryDuration(rower, undefined) testRecoveryDragFactor(rower, undefined) - testInstantHandlePower(rower, 367.9769643691954) + testInstantHandlePower(rower, 367.97696436918955) // Recovery initial stroke starts here rower.handleRotationImpulse(0.010769) rower.handleRotationImpulse(0.010707554) @@ -145,8 +150,8 @@ test('Test behaviour for three perfect identical strokes, including settingling testDriveDuration(rower, 0.143485717) testDriveLinearDistance(rower, 0.4271903319416174) testDriveLength(rower, 0.1759291886010284) - testDriveAverageHandleForce(rower, 276.6342676838766) - testDrivePeakHandleForce(rower, 332.9918222212992) + testDriveAverageHandleForce(rower, 276.6342676838739) + testDrivePeakHandleForce(rower, 332.99182222129025) testRecoveryDuration(rower, undefined) testRecoveryDragFactor(rower, undefined) testInstantHandlePower(rower, 0) @@ -181,11 +186,11 @@ test('Test behaviour for three perfect identical strokes, including settingling testDriveDuration(rower, 0.143485717) testDriveLinearDistance(rower, 0.3895903211923076) testDriveLength(rower, 0.1759291886010284) - testDriveAverageHandleForce(rower, 236.9227932798214) - testDrivePeakHandleForce(rower, 378.6022382024728) + testDriveAverageHandleForce(rower, 236.92279327988305) + testDrivePeakHandleForce(rower, 378.60223820258005) testRecoveryDuration(rower, 0.21654112800000003) testRecoveryDragFactor(rower, 281.5961372923874) - testInstantHandlePower(rower, 502.73778232982295) + testInstantHandlePower(rower, 502.7377823299629) // Recovery second stroke starts here rower.handleRotationImpulse(0.010769) rower.handleRotationImpulse(0.010707554) @@ -213,8 +218,8 @@ test('Test behaviour for three perfect identical strokes, including settingling testDriveDuration(rower, 0.22872752900000004) testDriveLinearDistance(rower, 1.0226745931298076) testDriveLength(rower, 0.3078760800517996) - testDriveAverageHandleForce(rower, 288.45140756250663) - testDrivePeakHandleForce(rower, 447.10851434893794) + testDriveAverageHandleForce(rower, 288.45140756259053) + testDrivePeakHandleForce(rower, 447.108514349131) testRecoveryDuration(rower, 0.21654112800000003) testRecoveryDragFactor(rower, 281.5961372923874) testInstantHandlePower(rower, 0) @@ -249,11 +254,11 @@ test('Test behaviour for three perfect identical strokes, including settingling testDriveDuration(rower, 0.22872752900000004) testDriveLinearDistance(rower, 0.5843854817884615) testDriveLength(rower, 0.3078760800517996) - testDriveAverageHandleForce(rower, 192.2653879294713) - testDrivePeakHandleForce(rower, 378.6022382013243) + testDriveAverageHandleForce(rower, 192.2653879294337) + testDrivePeakHandleForce(rower, 378.6022382039591) testRecoveryDuration(rower, 0.09812447700000015) testRecoveryDragFactor(rower, 281.5961372923874) - testInstantHandlePower(rower, 502.7377823283394) + testInstantHandlePower(rower, 502.73778233173203) // Recovery third stroke starts here rower.handleRotationImpulse(0.010769) rower.handleRotationImpulse(0.010707554) @@ -281,8 +286,8 @@ test('Test behaviour for three perfect identical strokes, including settingling testDriveDuration(rower, 0.27311228700000023) testDriveLinearDistance(rower, 1.2174697537259611) testDriveLength(rower, 0.36651914291880905) - testDriveAverageHandleForce(rower, 256.5447026919334) - testDrivePeakHandleForce(rower, 447.1085143475723) + testDriveAverageHandleForce(rower, 256.5447026931294) + testDrivePeakHandleForce(rower, 447.1085143512751) testRecoveryDuration(rower, 0.09812447700000015) testRecoveryDragFactor(rower, 281.5961372923874) testInstantHandlePower(rower, 0) @@ -313,33 +318,28 @@ test('Test behaviour for three perfect identical strokes, including settingling testDriveDuration(rower, 0.27311228700000023) testDriveLinearDistance(rower, 1.2174697537259611) testDriveLength(rower, 0.36651914291880905) - testDriveAverageHandleForce(rower, 256.5447026919334) - testDrivePeakHandleForce(rower, 447.1085143475723) + testDriveAverageHandleForce(rower, 256.5447026931294) + testDrivePeakHandleForce(rower, 447.1085143512751) testRecoveryDuration(rower, 0.17448815399999995) testRecoveryDragFactor(rower, 281.5961372923874) testInstantHandlePower(rower, 0) }) -// Test behaviour for noisy upgoing flank - -// Test behaviour for noisy downgoing flank - -// Test behaviour for noisy stroke - -// Test behaviour after reset - -// Test behaviour for one datapoint - -// Test behaviour for noisy stroke - -// Test drag factor calculation - -// Test Dynamic stroke detection +/** + * @todo Test behaviour for noisy stroke + */ -// Test behaviour after reset +/** + * @todo Test behaviour after reset + */ -// Test behaviour with real-life data +/** + * @todo Test drag factor calculation + */ +/** + * @description Test behaviour for the Sportstech WRX700 + */ test('sample data for Sportstech WRX700 should produce plausible results', async () => { const rower = createRower(deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Sportstech_WRX700)) testTotalMovingTimeSinceStart(rower, 0) @@ -356,6 +356,9 @@ test('sample data for Sportstech WRX700 should produce plausible results', async testRecoveryDragFactor(rower, rowerProfiles.Sportstech_WRX700.dragFactor) }) +/** + * @description Test behaviour for the DKN R-320 + */ test('sample data for DKN R-320 should produce plausible results', async () => { const rower = createRower(deepMerge(rowerProfiles.DEFAULT, rowerProfiles.DKN_R320)) testTotalMovingTimeSinceStart(rower, 0) @@ -373,6 +376,9 @@ test('sample data for DKN R-320 should produce plausible results', async () => { testRecoveryDragFactor(rower, rowerProfiles.DKN_R320.dragFactor) }) +/** + * @description Test behaviour for the NordicTrack RX800 + */ test('sample data for NordicTrack RX800 should produce plausible results', async () => { const rower = createRower(deepMerge(rowerProfiles.DEFAULT, rowerProfiles.NordicTrack_RX800)) testTotalMovingTimeSinceStart(rower, 0) @@ -389,6 +395,9 @@ test('sample data for NordicTrack RX800 should produce plausible results', async testRecoveryDragFactor(rower, 493.8082148322739) }) +/** + * @description Test behaviour for the SportsTech WRX700 in a full session + */ test('A full session for SportsTech WRX700 should produce plausible results', async () => { const rower = createRower(deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Sportstech_WRX700)) testTotalMovingTimeSinceStart(rower, 0) @@ -405,6 +414,9 @@ test('A full session for SportsTech WRX700 should produce plausible results', as testRecoveryDragFactor(rower, rowerProfiles.Sportstech_WRX700.dragFactor) }) +/** + * @description Test behaviour for the C2 Model C + */ test('A full session for a Concept2 Model C should produce plausible results', async () => { const rower = createRower(deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Concept2_Model_C)) testTotalMovingTimeSinceStart(rower, 0) @@ -418,9 +430,12 @@ test('A full session for a Concept2 Model C should produce plausible results', a testTotalLinearDistanceSinceStart(rower, 552.2056895088467) testTotalNumberOfStrokes(rower, 83) // As dragFactor isn't static, it should have changed - testRecoveryDragFactor(rower, 123.64632740545652) + testRecoveryDragFactor(rower, 123.64632740545646) }) +/** + * @description Test behaviour for the C2 RowErg + */ test('A full session for a Concept2 RowErg should produce plausible results', async () => { const rower = createRower(deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Concept2_RowErg)) testTotalMovingTimeSinceStart(rower, 0) @@ -430,11 +445,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.0294331572366) - testTotalLinearDistanceSinceStart(rower, 2027.8951016561075) + testTotalMovingTimeSinceStart(rower, 590.0232672488145) + testTotalLinearDistanceSinceStart(rower, 2027.8404221844912) testTotalNumberOfStrokes(rower, 206) // As dragFactor isn't static, it should have changed - testRecoveryDragFactor(rower, 80.70650785533269) + testRecoveryDragFactor(rower, 80.70871681343775) }) function testStrokeState (rower, expectedValue) { From 09f9049b681cffeeec183d1a1bba032fa4d3647c Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Thu, 8 Jan 2026 12:08:52 +0100 Subject: [PATCH 114/118] Fixed Lint warnings --- app/engine/utils/TSQuadraticSeries.js | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/app/engine/utils/TSQuadraticSeries.js b/app/engine/utils/TSQuadraticSeries.js index 5b62416e1f..9344ccd609 100644 --- a/app/engine/utils/TSQuadraticSeries.js +++ b/app/engine/utils/TSQuadraticSeries.js @@ -67,7 +67,12 @@ export function createTSQuadraticSeries (maxSeriesLength = 0) { Y.push(y) weight.push(w) WY.push(w * y) - + _A = 0 + _B = 0 + _C = 0 + _sst = 0 + _goodnessOfFit = 0 + if (X.length() >= 3) { // There are now at least three datapoints in the X and Y arrays, so let's calculate the A portion belonging for the new datapoint via Quadratic Theil-Sen regression let i = 0 @@ -79,7 +84,7 @@ export function createTSQuadraticSeries (maxSeriesLength = 0) { while (i < X.length() - 2) { j = i + 1 while (j < X.length() - 1) { - combinedweight = weight.get(i) * weight.get(j) * w + combinedweight = weight.get(i) * weight.get(j) * w coeffA = calculateA(i, j, X.length() - 1) A.push(X.get(i), coeffA, combinedweight) j++ @@ -94,12 +99,6 @@ export function createTSQuadraticSeries (maxSeriesLength = 0) { _C = null _sst = null _goodnessOfFit = null - } else { - _A = 0 - _B = 0 - _C = 0 - _sst = 0 - _goodnessOfFit = 0 } } From 54780d1c499d69d79a6b1a6b02af1ce9c6133950 Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Thu, 8 Jan 2026 12:23:17 +0100 Subject: [PATCH 115/118] Fixed Lint warnings --- app/engine/utils/TSQuadraticSeries.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/engine/utils/TSQuadraticSeries.js b/app/engine/utils/TSQuadraticSeries.js index 9344ccd609..83d760c8bc 100644 --- a/app/engine/utils/TSQuadraticSeries.js +++ b/app/engine/utils/TSQuadraticSeries.js @@ -54,6 +54,7 @@ export function createTSQuadraticSeries (maxSeriesLength = 0) { * Invariant: BinrySearchTree A contains all calculated a's (as in the general formula y = a * x^2 + b * x + c), * where the a's are labeled in the BinarySearchTree with their Xi when they BEGIN in the point (Xi, Yi) */ + /* eslint-disable max-statements - A lot of variables have to be set */ function push (x, y, w = 1) { if (x === undefined || isNaN(x) || y === undefined || isNaN(y)) { return } @@ -72,7 +73,7 @@ export function createTSQuadraticSeries (maxSeriesLength = 0) { _C = 0 _sst = 0 _goodnessOfFit = 0 - + if (X.length() >= 3) { // There are now at least three datapoints in the X and Y arrays, so let's calculate the A portion belonging for the new datapoint via Quadratic Theil-Sen regression let i = 0 @@ -101,6 +102,7 @@ export function createTSQuadraticSeries (maxSeriesLength = 0) { _goodnessOfFit = null } } + /* eslint-enable max-statements */ /** * @param {integer} position - the position in the flank of the requested value (default = 0) From 7727f97b01a55917d390ee00c59ad8c8d6df02eb Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Thu, 8 Jan 2026 12:36:11 +0100 Subject: [PATCH 116/118] Fixes Lint error --- app/engine/utils/TSQuadraticSeries.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/engine/utils/TSQuadraticSeries.js b/app/engine/utils/TSQuadraticSeries.js index 83d760c8bc..c7d3ca07e0 100644 --- a/app/engine/utils/TSQuadraticSeries.js +++ b/app/engine/utils/TSQuadraticSeries.js @@ -54,7 +54,7 @@ export function createTSQuadraticSeries (maxSeriesLength = 0) { * Invariant: BinrySearchTree A contains all calculated a's (as in the general formula y = a * x^2 + b * x + c), * where the a's are labeled in the BinarySearchTree with their Xi when they BEGIN in the point (Xi, Yi) */ - /* eslint-disable max-statements - A lot of variables have to be set */ + /* eslint-disable max-statements -- A lot of variables have to be set */ function push (x, y, w = 1) { if (x === undefined || isNaN(x) || y === undefined || isNaN(y)) { return } From 3775ea6c31a523ba412e2fcfa22873c26c1b4e73 Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Thu, 8 Jan 2026 12:45:43 +0100 Subject: [PATCH 117/118] Adaptation to algorithm improvements --- app/engine/RowingStatistics.test.js | 101 ++++++++++++++++------------ 1 file changed, 57 insertions(+), 44 deletions(-) diff --git a/app/engine/RowingStatistics.test.js b/app/engine/RowingStatistics.test.js index 841c9c6f6a..18867954b7 100644 --- a/app/engine/RowingStatistics.test.js +++ b/app/engine/RowingStatistics.test.js @@ -1,14 +1,10 @@ 'use strict' -/* - Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor -*/ /** - * 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 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. -*/ -// @ToDo: test the effects of smoothing parameters + * @copyright [OpenRowingMonitor]{@link https://github.com/JaapvanEkris/openrowingmonitor} + * + * @file This test is a test of the Rowingstatistics object, that tests wether this object fills all fields correctly, given one validated rower, (the + * Concept2 RowErg) using a validated cycle of strokes. The combination with all possible known rowers is tested. + */ import { test } from 'uvu' import * as assert from 'uvu/assert' import rowerProfiles from '../../config/rowerProfiles.js' @@ -34,7 +30,8 @@ const baseConfig = { minimumTimeBetweenImpulses: 0.005, maximumTimeBetweenImpulses: 0.017, flankLength: 12, - smoothing: 1, + systematicErrorAgressiveness: 0, + systematicErrorMaximumChange: 1, minimumStrokeQuality: 0.36, minimumForceBeforeStroke: 20, // Modification to standard settings to shorten test cases minimumRecoverySlope: 0.00070, @@ -47,7 +44,9 @@ const baseConfig = { } } -// Test behaviour for no datapoints +/** + * @description Test behaviour for no datapoints + */ test('Correct rower behaviour at initialisation', () => { const rowingStatistics = createRowingStatistics(baseConfig) testStrokeState(rowingStatistics, 'WaitingForDrive') @@ -68,9 +67,13 @@ test('Correct rower behaviour at initialisation', () => { testInstantHandlePower(rowingStatistics, undefined) }) -// Test behaviour for one datapoint +/** + * @todo Test behaviour for one datapoint + */ -// Test behaviour for three perfect identical strokes, including settingling behaviour of metrics +/** + * @description Test behaviour for three perfect identical strokes, including settingling behaviour of metrics + */ test('Test behaviour for three perfect identical strokes, including settingling behaviour of metrics', () => { const rowingStatistics = createRowingStatistics(baseConfig) testStrokeState(rowingStatistics, 'WaitingForDrive') @@ -152,8 +155,8 @@ test('Test behaviour for three perfect identical strokes, including settingling testDriveDuration(rowingStatistics, undefined) testDriveDistance(rowingStatistics, 0.4271903319416174) testDriveLength(rowingStatistics, 0.1759291886010284) - testDriveAverageHandleForce(rowingStatistics, 276.6342676838766) - testDrivePeakHandleForce(rowingStatistics, 332.9918222212992) + testDriveAverageHandleForce(rowingStatistics, 276.6342676838739) + testDrivePeakHandleForce(rowingStatistics, 332.99182222129025) testRecoveryDuration(rowingStatistics, undefined) testDragFactor(rowingStatistics, undefined) testInstantHandlePower(rowingStatistics, undefined) @@ -188,8 +191,8 @@ test('Test behaviour for three perfect identical strokes, including settingling testDriveDuration(rowingStatistics, 0.143485717) testDriveDistance(rowingStatistics, 0.4271903319416174) testDriveLength(rowingStatistics, 0.1759291886010284) - testDriveAverageHandleForce(rowingStatistics, 276.6342676838766) - testDrivePeakHandleForce(rowingStatistics, 332.9918222212992) + testDriveAverageHandleForce(rowingStatistics, 276.6342676838739) + testDrivePeakHandleForce(rowingStatistics, 332.99182222129025) testRecoveryDuration(rowingStatistics, 0.21654112800000003) testDragFactor(rowingStatistics, 281.5961372923874) testInstantHandlePower(rowingStatistics, undefined) @@ -220,8 +223,8 @@ test('Test behaviour for three perfect identical strokes, including settingling testDriveDuration(rowingStatistics, 0.22872752900000004) testDriveDistance(rowingStatistics, 1.0226745931298076) testDriveLength(rowingStatistics, 0.3078760800517996) - testDriveAverageHandleForce(rowingStatistics, 288.45140756250663) - testDrivePeakHandleForce(rowingStatistics, 447.10851434893794) + testDriveAverageHandleForce(rowingStatistics, 288.45140756259053) + testDrivePeakHandleForce(rowingStatistics, 447.108514349131) testRecoveryDuration(rowingStatistics, 0.21654112800000003) testDragFactor(rowingStatistics, 281.5961372923874) testInstantHandlePower(rowingStatistics, undefined) @@ -256,8 +259,8 @@ test('Test behaviour for three perfect identical strokes, including settingling testDriveDuration(rowingStatistics, 0.22872752900000004) testDriveDistance(rowingStatistics, 1.0226745931298076) testDriveLength(rowingStatistics, 0.3078760800517996) - testDriveAverageHandleForce(rowingStatistics, 288.45140756250663) - testDrivePeakHandleForce(rowingStatistics, 447.10851434893794) + testDriveAverageHandleForce(rowingStatistics, 288.45140756259053) + testDrivePeakHandleForce(rowingStatistics, 447.108514349131) testRecoveryDuration(rowingStatistics, 0.09812447700000015) testDragFactor(rowingStatistics, 281.5961372923874) testInstantHandlePower(rowingStatistics, undefined) @@ -288,8 +291,8 @@ test('Test behaviour for three perfect identical strokes, including settingling testDriveDuration(rowingStatistics, 0.27311228700000023) testDriveDistance(rowingStatistics, 1.2174697537259611) testDriveLength(rowingStatistics, 0.36651914291880905) - testDriveAverageHandleForce(rowingStatistics, 256.5447026919334) - testDrivePeakHandleForce(rowingStatistics, 447.1085143475723) + testDriveAverageHandleForce(rowingStatistics, 256.5447026931294) + testDrivePeakHandleForce(rowingStatistics, 447.1085143512751) testRecoveryDuration(rowingStatistics, 0.09812447700000015) testDragFactor(rowingStatistics, 281.5961372923874) testInstantHandlePower(rowingStatistics, undefined) @@ -327,26 +330,21 @@ test('Test behaviour for three perfect identical strokes, including settingling testInstantHandlePower(rowingStatistics, undefined) }) -// Test behaviour for noisy upgoing flank - -// Test behaviour for noisy downgoing flank - -// Test behaviour for noisy stroke - -// Test behaviour after reset - -// Test behaviour for one datapoint - -// Test behaviour for noisy stroke - -// Test drag factor calculation - -// Test Dynamic stroke detection +/** + * @todo Test the effects of smoothing parameters + */ -// Test behaviour after reset +/** + * @todo Test force curve behaviour + */ -// Test behaviour with real-life data +/** + * @todo Test behaviour after reset + */ +/** + * @description Test behaviour for the Sportstech WRX700 + */ test('sample data for Sportstech WRX700 should produce plausible results', async () => { const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Sportstech_WRX700) const testConfig = { @@ -372,6 +370,9 @@ test('sample data for Sportstech WRX700 should produce plausible results', async testDragFactor(rowingStatistics, rowerProfiles.Sportstech_WRX700.dragFactor) }) +/** + * @description Test behaviour for the DKN R-320 + */ test('sample data for DKN R-320 should produce plausible results', async () => { const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.DKN_R320) const testConfig = { @@ -397,6 +398,9 @@ test('sample data for DKN R-320 should produce plausible results', async () => { testDragFactor(rowingStatistics, rowerProfiles.DKN_R320.dragFactor) }) +/** + * @description Test behaviour for the NordicTrack RX800 + */ test('sample data for NordicTrack RX800 should produce plausible results', async () => { const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.NordicTrack_RX800) const testConfig = { @@ -422,6 +426,9 @@ test('sample data for NordicTrack RX800 should produce plausible results', async testDragFactor(rowingStatistics, 493.8082148322739) }) +/** + * @description Test behaviour for the SportsTech WRX700 in a full session + */ test('A full session for SportsTech WRX700 should produce plausible results', async () => { const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Sportstech_WRX700) const testConfig = { @@ -447,6 +454,9 @@ test('A full session for SportsTech WRX700 should produce plausible results', as testDragFactor(rowingStatistics, rowerProfiles.Sportstech_WRX700.dragFactor) }) +/** + * @description Test behaviour for the C2 Model C + */ test('A full session for a Concept2 Model C should produce plausible results', async () => { const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Concept2_Model_C) const testConfig = { @@ -469,9 +479,12 @@ test('A full session for a Concept2 Model C should produce plausible results', a testTotalLinearDistance(rowingStatistics, 552.2056895088467) testTotalNumberOfStrokes(rowingStatistics, 82) // As dragFactor isn't static, it should have changed - testDragFactor(rowingStatistics, 123.64632740545652) + testDragFactor(rowingStatistics, 123.64632740545646) }) +/** + * @description Test behaviour for the C2 RowErg + */ test('A full session for a Concept2 RowErg should produce plausible results', async () => { const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Concept2_RowErg) const testConfig = { @@ -490,11 +503,11 @@ test('A full session for a Concept2 RowErg should produce plausible results', as await replayRowingSession(rowingStatistics.handleRotationImpulse, { filename: 'recordings/Concept2_RowErg_Session_2000meters.csv', realtime: false, loop: false }) - testTotalMovingTime(rowingStatistics, 590.0294331572366) - testTotalLinearDistance(rowingStatistics, 2027.8951016561075) + testTotalMovingTime(rowingStatistics, 590.0232672488145) + testTotalLinearDistance(rowingStatistics, 2027.8404221844912) testTotalNumberOfStrokes(rowingStatistics, 205) // As dragFactor isn't static, it should have changed - testDragFactor(rowingStatistics, 80.70650785533269) + testDragFactor(rowingStatistics, 80.70871681343775) }) function testStrokeState (rowingStatistics, expectedValue) { From 3cec60bb6bdd39e2a02b05aa067dd86e51b46d01 Mon Sep 17 00:00:00 2001 From: Jaap van Ekris <82339657+JaapvanEkris@users.noreply.github.com> Date: Thu, 8 Jan 2026 13:40:03 +0100 Subject: [PATCH 118/118] Adaptation to improved algorithms --- app/engine/SessionManager.test.js | 163 ++++++++++++++++++++++-------- 1 file changed, 122 insertions(+), 41 deletions(-) diff --git a/app/engine/SessionManager.test.js b/app/engine/SessionManager.test.js index 5b505f03bc..f84141e1cf 100644 --- a/app/engine/SessionManager.test.js +++ b/app/engine/SessionManager.test.js @@ -1,12 +1,10 @@ 'use strict' -/* - Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor -*/ /** - * This test is a test of the SessionManager, that tests wether this object fills all fields correctly, + * @copyright [OpenRowingMonitor]{@link https://github.com/JaapvanEkris/openrowingmonitor} + * + * @file This test is a test of the SessionManager, that tests wether this object fills all fields correctly, * and cuts off a session, interval and split decently */ -// @ToDo: test the effects of smoothing parameters import { test } from 'uvu' import * as assert from 'uvu/assert' import rowerProfiles from '../../config/rowerProfiles.js' @@ -15,6 +13,21 @@ import { deepMerge } from '../tools/Helper.js' import { createSessionManager } from './SessionManager.js' +/** + * @todo Add inspections to all tests to inspect whether the 'workout' object contains all correct values as well + */ + +/** + * @todo Add inspections to all tests to inspect whether the 'interval' object contains all correct values + */ + +/** + * @todo Add splits and tests to inspect whether the 'split' object contains all correct values as well + */ + +/** + * @description Test behaviour for the Sportstech WRX700 in a 'Just Row' session + */ test('sample data for Sportstech WRX700 should produce plausible results for an unlimited run', async () => { const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Sportstech_WRX700) const testConfig = { @@ -39,12 +52,15 @@ test('sample data for Sportstech WRX700 should produce plausible results for an testTotalMovingTime(sessionManager, 46.302522627) testTotalLinearDistance(sessionManager, 165.58832475070278) - testTotalCalories(sessionManager, 13.14287499723497) + testTotalCalories(sessionManager, 13.142874997261865) testTotalNumberOfStrokes(sessionManager, 15) // As dragFactor is static, it should remain in place testDragFactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor) }) +/** + * @description Test behaviour for the Sportstech WRX700 in a single interval session with a Distance target + */ test('sample data for Sportstech WRX700 should produce plausible results for a 150 meter session', async () => { const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Sportstech_WRX700) const testConfig = { @@ -78,12 +94,15 @@ test('sample data for Sportstech WRX700 should produce plausible results for a 1 testTotalMovingTime(sessionManager, 41.876875768000005) testTotalLinearDistance(sessionManager, 150.02019165448286) - testTotalCalories(sessionManager, 12.047320967434432) + testTotalCalories(sessionManager, 12.047320967455441) testTotalNumberOfStrokes(sessionManager, 14) // As dragFactor is static, it should remain in place testDragFactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor) }) +/** + * @description Test behaviour for the Sportstech WRX700 in a single interval session with a Time target + */ test('sample data for Sportstech WRX700 should produce plausible results for a 45 seconds session', async () => { const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Sportstech_WRX700) const testConfig = { @@ -117,12 +136,15 @@ test('sample data for Sportstech WRX700 should produce plausible results for a 4 testTotalMovingTime(sessionManager, 45.077573161000004) testTotalLinearDistance(sessionManager, 162.75775509684462) - testTotalCalories(sessionManager, 13.040795875068302) + testTotalCalories(sessionManager, 13.040795875095199) testTotalNumberOfStrokes(sessionManager, 15) // As dragFactor is static, it should remain in place testDragFactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor) }) +/** + * @description Test behaviour for the SportsTech WRX700 in a single interval session with a Calorie target + */ test('sample data for Sportstech WRX700 should produce plausible results for a 13 calories session', async () => { const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Sportstech_WRX700) const testConfig = { @@ -155,12 +177,15 @@ test('sample data for Sportstech WRX700 should produce plausible results for a 1 testTotalMovingTime(sessionManager, 44.674583250000005) testTotalLinearDistance(sessionManager, 161.3424702699155) - testTotalCalories(sessionManager, 13.00721338248497) + testTotalCalories(sessionManager, 13.007213382511864) testTotalNumberOfStrokes(sessionManager, 15) // As dragFactor is static, it should remain in place testDragFactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor) }) +/** + * @description Test behaviour for the DKN R-320 in a 'Just Row' session + */ test('sample data for DKN R-320 should produce plausible results', async () => { const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.DKN_R320) const testConfig = { @@ -185,12 +210,15 @@ test('sample data for DKN R-320 should produce plausible results', async () => { testTotalMovingTime(sessionManager, 21.701535821) testTotalLinearDistance(sessionManager, 69.20242183779045) - testTotalCalories(sessionManager, 6.761544006859074) + testTotalCalories(sessionManager, 6.7615440068583315) testTotalNumberOfStrokes(sessionManager, 9) // As dragFactor is static, it should remain in place testDragFactor(sessionManager, rowerProfiles.DKN_R320.dragFactor) }) +/** + * @description Test behaviour for the NordicTrack RX800 in a 'Just Row' session + */ test('sample data for NordicTrack RX800 should produce plausible results without intervalsettings', async () => { const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.NordicTrack_RX800) const testConfig = { @@ -215,12 +243,15 @@ test('sample data for NordicTrack RX800 should produce plausible results without testTotalMovingTime(sessionManager, 22.368358745999995) testTotalLinearDistance(sessionManager, 80.8365747440095) - testTotalCalories(sessionManager, 4.848781772500018) + testTotalCalories(sessionManager, 4.8487817727235765) testTotalNumberOfStrokes(sessionManager, 9) // As dragFactor is dynamic, it should have changed testDragFactor(sessionManager, 493.8082148322739) }) +/** + * @description Test behaviour for the NordicTrack RX800 in a single interval session with a Time target + */ test('sample data for NordicTrack RX800 should produce plausible results for a 20 seconds session', async () => { const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.NordicTrack_RX800) const testConfig = { @@ -254,12 +285,15 @@ test('sample data for NordicTrack RX800 should produce plausible results for a 2 testTotalMovingTime(sessionManager, 20.02496380499998) testTotalLinearDistance(sessionManager, 72.36563503912126) - testTotalCalories(sessionManager, 4.369289275331837) + testTotalCalories(sessionManager, 4.369289275497461) testTotalNumberOfStrokes(sessionManager, 8) // As dragFactor is dynamic, it should have changed testDragFactor(sessionManager, 489.6362497474688) }) +/** + * @description Test behaviour for the NordicTrack RX800 in a single interval session with a Calorie target + */ test('sample data for NordicTrack RX800 should produce plausible results for a 20 calories session', async () => { const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.NordicTrack_RX800) const testConfig = { @@ -292,12 +326,15 @@ test('sample data for NordicTrack RX800 should produce plausible results for a 2 testTotalMovingTime(sessionManager, 22.368358745999995) testTotalLinearDistance(sessionManager, 80.8365747440095) - testTotalCalories(sessionManager, 4.848781772500018) + testTotalCalories(sessionManager, 4.8487817727235765) testTotalNumberOfStrokes(sessionManager, 9) // As dragFactor is dynamic, it should have changed testDragFactor(sessionManager, 493.8082148322739) }) +/** + * @description Test behaviour for the NordicTrack RX800 in a single interval session with a Distance target + */ test('sample data for NordicTrack RX800 should produce plausible results for a 75 meter session', async () => { const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.NordicTrack_RX800) const testConfig = { @@ -331,12 +368,15 @@ test('sample data for NordicTrack RX800 should produce plausible results for a 7 testTotalMovingTime(sessionManager, 20.78640177499998) testTotalLinearDistance(sessionManager, 75.02272363260582) - testTotalCalories(sessionManager, 4.7014508748360155) + testTotalCalories(sessionManager, 4.701450875048449) testTotalNumberOfStrokes(sessionManager, 9) // As dragFactor is dynamic, it should have changed testDragFactor(sessionManager, 493.8082148322739) }) +/** + * @description Test behaviour for the SportsTech WRX700 in a 'Just Row' session + */ test('A full unlimited session for SportsTech WRX700 should produce plausible results', async () => { const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Sportstech_WRX700) const testConfig = { @@ -361,12 +401,15 @@ test('A full unlimited session for SportsTech WRX700 should produce plausible re testTotalMovingTime(sessionManager, 2340.0100514160117) testTotalLinearDistance(sessionManager, 8406.084229545408) - testTotalCalories(sessionManager, 659.4761650968578) + testTotalCalories(sessionManager, 659.4761649276804) testTotalNumberOfStrokes(sessionManager, 845) // As dragFactor is static, it should remain in place testDragFactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor) }) +/** + * @description Test behaviour for the SportsTech WRX700 in a single interval session with a Distance target + */ test('A 8000 meter session for SportsTech WRX700 should produce plausible results', async () => { const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Sportstech_WRX700) const testConfig = { @@ -400,12 +443,15 @@ test('A 8000 meter session for SportsTech WRX700 should produce plausible result testTotalMovingTime(sessionManager, 2236.631120457007) testTotalLinearDistance(sessionManager, 8000.605126630226) - testTotalCalories(sessionManager, 625.5636651284267) + testTotalCalories(sessionManager, 625.5636651176962) testTotalNumberOfStrokes(sessionManager, 804) // As dragFactor is static, it should remain in place testDragFactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor) }) +/** + * @description Test behaviour for the SportsTech WRX700 in a single interval session with a Time target + */ test('A 2300 sec session for SportsTech WRX700 should produce plausible results', async () => { const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Sportstech_WRX700) const testConfig = { @@ -439,13 +485,16 @@ test('A 2300 sec session for SportsTech WRX700 should produce plausible results' testTotalMovingTime(sessionManager, 2300.00695516701) testTotalLinearDistance(sessionManager, 8251.818183410143) - testTotalCalories(sessionManager, 646.8205259437337) + testTotalCalories(sessionManager, 646.8205257461132) testTotalNumberOfStrokes(sessionManager, 830) // As dragFactor is static, it should remain in place testDragFactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor) }) -test('A 2400 sec session for SportsTech WRX700 should produce plausible results', async () => { +/** + * @description Test behaviour for the SportsTech WRX700 in a single interval session with a Time target, which will not be reached (test of stopping behaviour) + */ +test('A 2400 sec session with premature stop for SportsTech WRX700 should produce plausible results', async () => { const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Sportstech_WRX700) const testConfig = { loglevel: { @@ -478,12 +527,15 @@ test('A 2400 sec session for SportsTech WRX700 should produce plausible results' testTotalMovingTime(sessionManager, 2340.0100514160117) testTotalLinearDistance(sessionManager, 8406.084229545408) - testTotalCalories(sessionManager, 659.4761650968578) + testTotalCalories(sessionManager, 659.4761649276804) testTotalNumberOfStrokes(sessionManager, 845) // As dragFactor is static, it should remain in place testDragFactor(sessionManager, rowerProfiles.Sportstech_WRX700.dragFactor) }) +/** + * @description Test behaviour for the C2 Model C in a 'Just Row' session + */ test('A full session for a Concept2 Model C should produce plausible results', async () => { const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Concept2_Model_C) const testConfig = { @@ -508,12 +560,15 @@ test('A full session for a Concept2 Model C should produce plausible results', a testTotalMovingTime(sessionManager, 181.47141999999985) testTotalLinearDistance(sessionManager, 552.2056895088467) - testTotalCalories(sessionManager, 33.961418860794744) + testTotalCalories(sessionManager, 33.96141888570208) testTotalNumberOfStrokes(sessionManager, 82) // As dragFactor isn't static, it should have changed - testDragFactor(sessionManager, 123.64632740545652) + testDragFactor(sessionManager, 123.64632740545646) }) +/** + * @description Test behaviour for the C2 Model C in a single interval session with a Distance target + */ test('A 500 meter session for a Concept2 Model C should produce plausible results', async () => { const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Concept2_Model_C) const testConfig = { @@ -547,12 +602,15 @@ test('A 500 meter session for a Concept2 Model C should produce plausible result testTotalMovingTime(sessionManager, 156.83075199999985) testTotalLinearDistance(sessionManager, 500.0178754492436) - testTotalCalories(sessionManager, 30.87012555729047) + testTotalCalories(sessionManager, 30.87012556034265) testTotalNumberOfStrokes(sessionManager, 73) // As dragFactor isn't static, it should have changed testDragFactor(sessionManager, 123.18123281481081) }) +/** + * @description Test behaviour for the C2 Model C in a single interval session with a Time target + */ test('A 3 minute session for a Concept2 Model C should produce plausible results', async () => { const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Concept2_Model_C) const testConfig = { @@ -586,12 +644,15 @@ test('A 3 minute session for a Concept2 Model C should produce plausible results testTotalMovingTime(sessionManager, 180.96533299999987) testTotalLinearDistance(sessionManager, 551.9836036368948) - testTotalCalories(sessionManager, 33.91002250954926) + testTotalCalories(sessionManager, 33.91002253445811) testTotalNumberOfStrokes(sessionManager, 82) // As dragFactor isn't static, it should have changed - testDragFactor(sessionManager, 123.64632740545652) + testDragFactor(sessionManager, 123.64632740545646) }) +/** + * @description Test behaviour for the C2 Model C in a single interval session with a Calorie target + */ test('A 30 calorie session for a Concept2 Model C should produce plausible results', async () => { const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Concept2_Model_C) const testConfig = { @@ -624,12 +685,15 @@ test('A 30 calorie session for a Concept2 Model C should produce plausible resul testTotalMovingTime(sessionManager, 153.93554999999992) testTotalLinearDistance(sessionManager, 490.5541073829962) - testTotalCalories(sessionManager, 30.018254906974597) + testTotalCalories(sessionManager, 30.018254924945477) testTotalNumberOfStrokes(sessionManager, 72) // As dragFactor isn't static, it should have changed testDragFactor(sessionManager, 123.18123281481081) }) +/** + * @description Test behaviour for the C2 RowErg in a 'Just Row' session + */ test('A full session for a Concept2 RowErg should produce plausible results', async () => { const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Concept2_RowErg) const testConfig = { @@ -652,14 +716,17 @@ test('A full session for a Concept2 RowErg should produce plausible results', as await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/Concept2_RowErg_Session_2000meters.csv', realtime: false, loop: false }) - testTotalMovingTime(sessionManager, 590.0294331572366) - testTotalLinearDistance(sessionManager, 2027.8951016561075) - testTotalCalories(sessionManager, 113.55660950119214) + testTotalMovingTime(sessionManager, 590.0232672488145) + testTotalLinearDistance(sessionManager, 2027.8404221844912) + testTotalCalories(sessionManager, 113.70888316891056) testTotalNumberOfStrokes(sessionManager, 205) // As dragFactor isn't static, it should have changed - testDragFactor(sessionManager, 80.70650785533269) + testDragFactor(sessionManager, 80.70871681343775) }) +/** + * @description Test behaviour for the C2 RowErg in a single interval session with a Distance target + */ test('A 2000 meter session for a Concept2 RowErg should produce plausible results', async () => { const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Concept2_RowErg) const testConfig = { @@ -691,14 +758,17 @@ test('A 2000 meter session for a Concept2 RowErg should produce plausible result await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/Concept2_RowErg_Session_2000meters.csv', realtime: false, loop: false }) - testTotalMovingTime(sessionManager, 582.0058299961318) - testTotalLinearDistance(sessionManager, 2000.0206027129661) - testTotalCalories(sessionManager, 112.16536746119625) + testTotalMovingTime(sessionManager, 582.0172075458094) + testTotalLinearDistance(sessionManager, 2000.0305986433395) + testTotalCalories(sessionManager, 112.3248747739861) testTotalNumberOfStrokes(sessionManager, 203) // As dragFactor isn't static, it should have changed - testDragFactor(sessionManager, 80.68314716929032) + testDragFactor(sessionManager, 80.67710663511464) }) +/** + * @description Test behaviour for the C2 RowErg in a single interval session with a Time target + */ test('A 580 seconds session for a Concept2 RowErg should produce plausible results', async () => { const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Concept2_RowErg) const testConfig = { @@ -730,14 +800,17 @@ test('A 580 seconds session for a Concept2 RowErg should produce plausible resul await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/Concept2_RowErg_Session_2000meters.csv', realtime: false, loop: false }) - testTotalMovingTime(sessionManager, 580.0016078988951) - testTotalLinearDistance(sessionManager, 1993.2788181883743) - testTotalCalories(sessionManager, 111.76461106588519) + testTotalMovingTime(sessionManager, 580.0044837517516) + testTotalLinearDistance(sessionManager, 1993.2568687660857) + testTotalCalories(sessionManager, 111.91391015510767) testTotalNumberOfStrokes(sessionManager, 202) // As dragFactor isn't static, it should have changed - testDragFactor(sessionManager, 80.70729014258711) + testDragFactor(sessionManager, 80.69990852674464) }) +/** + * @description Test behaviour for the C2 RowErg in a single interval session with a Calorie target + */ test('A 100 calories session for a Concept2 RowErg should produce plausible results', async () => { const rowerProfile = deepMerge(rowerProfiles.DEFAULT, rowerProfiles.Concept2_RowErg) const testConfig = { @@ -768,14 +841,22 @@ test('A 100 calories session for a Concept2 RowErg should produce plausible resu await replayRowingSession(sessionManager.handleRotationImpulse, { filename: 'recordings/Concept2_RowErg_Session_2000meters.csv', realtime: false, loop: false }) - testTotalMovingTime(sessionManager, 520.3824691827283) - testTotalLinearDistance(sessionManager, 1786.2212497568994) - testTotalCalories(sessionManager, 100.00025111255141) + testTotalMovingTime(sessionManager, 518.8113511778027) + testTotalLinearDistance(sessionManager, 1780.6467553027344) + testTotalCalories(sessionManager, 100.00018360476473) testTotalNumberOfStrokes(sessionManager, 181) // As dragFactor isn't static, it should have changed - testDragFactor(sessionManager, 80.69402503758549) + testDragFactor(sessionManager, 80.66540957116986) }) +/** + * @todo Add tests for multiple planned intervals of the same type + */ + +/** + * @todo Add tests for multiple planned intervals of a different type, including pauses + */ + function testTotalMovingTime (sessionManager, expectedValue) { assert.ok(sessionManager.getMetrics().totalMovingTime === expectedValue, `totalMovingTime should be ${expectedValue} sec at ${sessionManager.getMetrics().totalMovingTime} sec, is ${sessionManager.getMetrics().totalMovingTime}`) }