'use strict' /* Open Rowing Monitor, https://github.com/laberning/openrowingmonitor 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 drive or recovery phase). Please note: The array contains a buffer of flankLenght measured currentDt's, BEFORE they are actually processed Please note2: This implements Linear regression to obtain the drag factor. We deliberatly DO NOT include the flank data as we don't know wether they will belong to a Drive or Recovery phase. So we include things which we know for certain that are part of a specific phase, i.e. dirtyDataPoints[flankLength], which will be eliminated from the flank The calculation of angular velocity and acceleration is based on Quadratic Regression, as the second derivative tends to be quite fragile when small errors are thrown in the mix. The math behind this approach can be found in https://physics.info/motion-equations/ which is intended for simple linear motion, but the formula are identical when applied to angular distances, velocities and accelerations. */ import loglevel from 'loglevel' import { createStreamFilter } from './utils/StreamFilter.js' import { createOLSLinearSeries } from './utils/OLSLinearSeries.js' import { createTSLinearSeries } from './utils/FullTSLinearSeries.js' import { createTSQuadraticSeries } from './utils/FullTSQuadraticSeries.js' import { createWeighedSeries } from './utils/WeighedSeries.js' const log = loglevel.getLogger('RowingEngine') function createFlywheel (rowerSettings) { const angularDisplacementPerImpulse = (2.0 * Math.PI) / rowerSettings.numOfImpulsesPerRevolution const flankLength = rowerSettings.flankLength const minimumDragFactorSamples = Math.floor(rowerSettings.minimumRecoveryTime / rowerSettings.maximumTimeBetweenImpulses) const minumumTorqueBeforeStroke = rowerSettings.minumumForceBeforeStroke * (rowerSettings.sprocketRadius / 100) const currentDt = createStreamFilter(rowerSettings.smoothing, rowerSettings.maximumTimeBetweenImpulses) const _deltaTime = createOLSLinearSeries(flankLength) const _angularDistance = createTSQuadraticSeries(flankLength) const _angularVelocityMatrix = [] const _angularAccelerationMatrix = [] const drag = createWeighedSeries(rowerSettings.dragFactorSmoothing, (rowerSettings.dragFactor / 1000000)) const recoveryDeltaTime = createTSLinearSeries() const strokedetectionMinimalGoodnessOfFit = rowerSettings.minimumStrokeQuality const minumumRecoverySlope = createWeighedSeries(rowerSettings.dragFactorSmoothing, rowerSettings.minumumRecoverySlope) let _deltaTimeBeforeFlank let _angularVelocityAtBeginFlank let _angularVelocityBeforeFlank let _angularAccelerationAtBeginFlank let _angularAccelerationBeforeFlank let _torqueAtBeginFlank let _torqueBeforeFlank let inRecoveryPhase let maintainMetrics let totalNumberOfImpulses let totalTimeSpinning let currentCleanTime let currentRawTime let currentAngularDistance reset() function pushValue (dataPoint) { if (isNaN(dataPoint) || dataPoint < 0 || dataPoint > rowerSettings.maximumStrokeTimeBeforePause) { // This typicaly happends after a pause, we need to fix this as it throws off all time calculations log.debug(`*** WARNING: currentDt of ${dataPoint} sec isn't between 0 and maximumStrokeTimeBeforePause (${rowerSettings.maximumStrokeTimeBeforePause} sec)`) dataPoint = currentDt.clean() } if (dataPoint > rowerSettings.maximumTimeBetweenImpulses && maintainMetrics) { // This shouldn't happen, but let's log it to clarify there is some issue going on here log.debug(`*** WARNING: currentDt of ${dataPoint} sec is above maximumTimeBetweenImpulses (${rowerSettings.maximumTimeBetweenImpulses} sec)`) } if (dataPoint < rowerSettings.minimumTimeBetweenImpulses && maintainMetrics) { // This shouldn't happen, but let's log it to clarify there is some issue going on here log.debug(`*** WARNING: currentDt of ${dataPoint} sec is below minimumTimeBetweenImpulses (${rowerSettings.minimumTimeBetweenImpulses} sec)`) } currentDt.push(dataPoint) if (maintainMetrics && (_deltaTime.length() >= flankLength)) { // If we maintain metrics, update the angular position, spinning time of the flywheel and the associated metrics, // Also we nend feed the Drag calculation. We need to do this, BEFORE the array shifts, as the valueAtSeriesBeginvalue // value before the shift is certain to be part of a specific rowing phase (i.e. Drive or Recovery), once the buffer is filled completely totalNumberOfImpulses += 1 _deltaTimeBeforeFlank = _deltaTime.yAtSeriesBegin() totalTimeSpinning += _deltaTimeBeforeFlank _angularVelocityBeforeFlank = _angularVelocityAtBeginFlank _angularAccelerationBeforeFlank = _angularAccelerationAtBeginFlank _torqueBeforeFlank = _torqueAtBeginFlank // Feed the drag calculation, as we didn't reset the Semaphore in the previous cycle based on the current flank if (inRecoveryPhase) { recoveryDeltaTime.push(totalTimeSpinning, _deltaTimeBeforeFlank) } } else { _deltaTimeBeforeFlank = 0 _angularVelocityBeforeFlank = 0 _angularAccelerationBeforeFlank = 0 _torqueBeforeFlank = 0 } // Let's feed the stroke detection algorithm // Please note that deltaTime MUST use dirty data to be ale to use the OLS algorithms effictively (Otherwise the Goodness of Fit can't be used as a filter!) currentRawTime += currentDt.raw() currentAngularDistance += angularDisplacementPerImpulse _deltaTime.push(currentRawTime, currentDt.raw()) // Next are the metrics that are needed for more advanced metrics, like the foce curve currentCleanTime += currentDt.clean() _angularDistance.push(currentCleanTime, currentAngularDistance) // Let's update the matrix and calculate the angular velocity and acceleration if (_angularVelocityMatrix.length >= flankLength) { // The angularVelocityMatrix has reached its maximum length _angularVelocityMatrix.shift() _angularAccelerationMatrix.shift() } // Let's make room for a new set of values for angular velocity and acceleration _angularVelocityMatrix[_angularVelocityMatrix.length] = createWeighedSeries(flankLength, 0) _angularAccelerationMatrix[_angularAccelerationMatrix.length] = createWeighedSeries(flankLength, 0) let i = 0 const goodnessOfFit = _angularDistance.goodnessOfFit() while (i < _angularVelocityMatrix.length) { _angularVelocityMatrix[i].push(_angularDistance.firstDerivativeAtPosition(i), goodnessOfFit) _angularAccelerationMatrix[i].push(_angularDistance.secondDerivativeAtPosition(i), goodnessOfFit) i++ } _angularVelocityAtBeginFlank = _angularVelocityMatrix[0].weighedAverage() _angularAccelerationAtBeginFlank = _angularAccelerationMatrix[0].weighedAverage() // And finally calculate the torque _torqueAtBeginFlank = (rowerSettings.flywheelInertia * _angularAccelerationAtBeginFlank + drag.weighedAverage() * Math.pow(_angularVelocityAtBeginFlank, 2)) } function maintainStateOnly () { maintainMetrics = false } function maintainStateAndMetrics () { maintainMetrics = true } function markRecoveryPhaseStart () { inRecoveryPhase = true recoveryDeltaTime.reset() } function markRecoveryPhaseCompleted () { // Completion of the recovery phase inRecoveryPhase = false // As goodnessOfFit is calculated in-situ (for 220 datapoints on a C2) and is CPU intensive, we only calculate it only once and reuse the cached value const goodnessOfFit = recoveryDeltaTime.goodnessOfFit() // Calculation of the drag-factor if (rowerSettings.autoAdjustDragFactor && recoveryDeltaTime.length() > minimumDragFactorSamples && recoveryDeltaTime.slope() > 0 && (!drag.reliable() || goodnessOfFit >= rowerSettings.minimumDragQuality)) { drag.push(slopeToDrag(recoveryDeltaTime.slope()), goodnessOfFit) log.debug(`*** Calculated drag factor: ${(slopeToDrag(recoveryDeltaTime.slope()) * 1000000).toFixed(4)}, no. samples: ${recoveryDeltaTime.length()}, Goodness of Fit: ${goodnessOfFit.toFixed(4)}`) if (rowerSettings.autoAdjustRecoverySlope) { // We are allowed to autoadjust stroke detection slope as well, so let's do that minumumRecoverySlope.push((1 - rowerSettings.autoAdjustRecoverySlopeMargin) * recoveryDeltaTime.slope(), goodnessOfFit) log.debug(`*** Calculated recovery slope: ${recoveryDeltaTime.slope().toFixed(6)}, Goodness of Fit: ${goodnessOfFit.toFixed(4)}`) } else { // We aren't allowed to adjust the slope, let's report the slope to help help the user configure it log.debug(`*** Calculated recovery slope: ${recoveryDeltaTime.slope().toFixed(6)}, Goodness of Fit: ${goodnessOfFit.toFixed(4)}, not used as autoAdjustRecoverySlope isn't set to true`) } } else { if (!rowerSettings.autoAdjustDragFactor) { // autoAdjustDampingConstant = false, thus the update is skipped, but let's log the dragfactor anyway log.debug(`*** Calculated drag factor: ${(slopeToDrag(recoveryDeltaTime.slope()) * 1000000).toFixed(4)}, slope: ${recoveryDeltaTime.slope().toFixed(8)}, not used because autoAdjustDragFactor is not true`) } else { log.debug(`*** Calculated drag factor: ${(slopeToDrag(recoveryDeltaTime.slope()) * 1000000).toFixed(4)}, not used because reliability was too low. no. samples: ${recoveryDeltaTime.length()}, fit: ${goodnessOfFit.toFixed(4)}`) } } } function spinningTime () { // This function returns the time the flywheel is spinning in seconds BEFORE the beginning of the flank return totalTimeSpinning } function deltaTime () { return _deltaTimeBeforeFlank } function angularPosition () { // This function returns the absolute angular position of the flywheel in Radians BEFORE the beginning of the flank return totalNumberOfImpulses * angularDisplacementPerImpulse } function angularVelocity () { // This function returns the angular velocity of the flywheel in Radians/sec BEFORE the flank if (maintainMetrics && (_deltaTime.length() >= flankLength)) { return Math.max(0, _angularVelocityBeforeFlank) } else { return 0 } } function angularAcceleration () { // This function returns the angular acceleration of the flywheel in Radians/sec^2 BEFORE the flanl if (maintainMetrics && (_deltaTime.length() >= flankLength)) { return _angularAccelerationBeforeFlank } else { return 0 } } function torque () { if (maintainMetrics && (_deltaTime.length() >= flankLength)) { return _torqueBeforeFlank } else { return 0 } } function dragFactor () { // Ths function returns the current dragfactor of the flywheel return drag.weighedAverage() } function isDwelling () { // Check if the flywheel is spinning down beyond a recovery phase indicating that the rower has stopped rowing // We conclude this based on // * A decelerating flywheel as the slope of the CurrentDt's goes up // * All CurrentDt's in the flank are above the maximum if (_deltaTime.slope() > 0 && deltaTimesAbove(rowerSettings.maximumTimeBetweenImpulses)) { return true } else { return false } } function isAboveMinimumSpeed () { // Check if the flywheel has reached its minimum speed. We conclude this based on all CurrentDt's in the flank are below // the maximum, indicating a sufficiently fast flywheel if (deltaTimesEqualorBelow(rowerSettings.maximumTimeBetweenImpulses)) { return true } else { return false } } function isUnpowered () { if (deltaTimeSlopeAbove(minumumRecoverySlope.weighedAverage()) && torqueAbsent() && _deltaTime.length() >= flankLength) { // We reached the minimum number of increasing currentDt values return true } else { return false } } function isPowered () { if ((deltaTimeSlopeBelow(minumumRecoverySlope.weighedAverage()) && torquePresent()) || _deltaTime.length() < flankLength) { return true } else { return false } } function deltaTimesAbove (threshold) { if (_deltaTime.minimumY() >= threshold && _deltaTime.length() >= flankLength) { return true } else { return false } } function deltaTimesEqualorBelow (threshold) { if (_deltaTime.maximumY() <= threshold && _deltaTime.length() >= flankLength) { return true } else { return false } } function deltaTimeSlopeBelow (threshold) { // 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 if (_deltaTime.slope() < threshold && _deltaTime.length() >= flankLength) { return true } else { return false } } function deltaTimeSlopeAbove (threshold) { // 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 // Typically, a percentage of the current slope (i.e. dragfactor) is use if (_deltaTime.slope() >= threshold && _deltaTime.goodnessOfFit() >= strokedetectionMinimalGoodnessOfFit && _deltaTime.length() >= flankLength) { return true } else { return false } } function torquePresent () { // This is a typical indication that the flywheel is decelerating which might work on some machines: successive currentDt's are increasing if (_torqueAtBeginFlank > minumumTorqueBeforeStroke) { return true } else { return false } } function torqueAbsent () { // This is a typical indication that the flywheel is Accelerating which might work on some machines: successive currentDt's are decreasing if (_torqueAtBeginFlank < minumumTorqueBeforeStroke) { return true } else { return false } } function slopeToDrag (slope) { return ((slope * rowerSettings.flywheelInertia) / angularDisplacementPerImpulse) } function reset () { maintainMetrics = false inRecoveryPhase = false drag.reset() recoveryDeltaTime.reset() _deltaTime.reset() _angularDistance.reset() totalNumberOfImpulses = 0 totalTimeSpinning = 0 currentCleanTime = 0 currentRawTime = 0 currentAngularDistance = 0 _deltaTime.push(0, 0) _angularDistance.push(0, 0) _deltaTimeBeforeFlank = 0 _angularVelocityBeforeFlank = 0 _angularAccelerationBeforeFlank = 0 _torqueAtBeginFlank = 0 _torqueBeforeFlank = 0 } return { pushValue, maintainStateOnly, maintainStateAndMetrics, markRecoveryPhaseStart, markRecoveryPhaseCompleted, spinningTime, deltaTime, angularPosition, angularVelocity, angularAcceleration, torque, dragFactor, isDwelling, isAboveMinimumSpeed, isUnpowered, isPowered, reset } } export { createFlywheel }