running-tools

A collection of tools for runners and their coaches
git clone https://git.ashermorgan.net/running-tools/
Log | Files | Refs | README

commit df2e196cbdbe1a47eadb626b89c391ded5e75642
parent 21966fd2e29e494d9ec2eb8074e441faf5c88168
Author: Asher Morgan <59518073+ashermorgan@users.noreply.github.com>
Date:   Sat,  1 Jun 2024 10:44:32 -0700

Extract calculator logic to utils module

Diffstat:
Asrc/utils/calculators.js | 156+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/views/PaceCalculator.vue | 57+++------------------------------------------------------
Msrc/views/RaceCalculator.vue | 138+++++++------------------------------------------------------------------------
Atests/unit/utils/calculators.spec.js | 135+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 306 insertions(+), 180 deletions(-)

diff --git a/src/utils/calculators.js b/src/utils/calculators.js @@ -0,0 +1,156 @@ +import paceUtils from '@/utils/paces'; +import raceUtils from '@/utils/races'; +import unitUtils from '@/utils/units'; + +/** + * Calculate paces from a target + * @param {Object} input The input pace + * @param {Object} target The pace target + * @param {String} defaultUnitSystem The default unit system (imperial or metric) + * @returns {Object} The result + */ +function calculatePaceResults(input, target, defaultUnitSystem) { + const result = { + distanceValue: target.distanceValue, + distanceUnit: target.distanceUnit, + time: target.time, + result: target.result, + }; + + const pace = paceUtils.getPace(unitUtils.convertDistance(input.distanceValue, input.distanceUnit, + 'meters'), input.time); + + // Add missing value to result + if (target.result === 'time') { + // Convert target distance into meters + const d2 = unitUtils.convertDistance(target.distanceValue, target.distanceUnit, 'meters'); + + // Calculate time to travel distance at input pace + const time = paceUtils.getTime(pace, d2); + + // Update result + result.time = time; + } else { + // Calculate distance traveled in time at input pace + let distance = paceUtils.getDistance(pace, target.time); + + // Convert output distance into default distance unit + distance = unitUtils.convertDistance(distance, 'meters', + unitUtils.getDefaultDistanceUnit(defaultUnitSystem)); + + // Update result + result.distanceValue = distance; + result.distanceUnit = unitUtils.getDefaultDistanceUnit(defaultUnitSystem); + } + + // Return result + return result; +} + +/** + * Predict race results from a target + * @param {Object} input The input race + * @param {Object} target The race target + * @param {Object} options The race prediction options + * @param {String} defaultUnitSystem The default unit system (imperial or metric) + * @returns {Object} The result + */ +function calculateRaceResults(input, target, options, defaultUnitSystem) { + const result = { + distanceValue: target.distanceValue, + distanceUnit: target.distanceUnit, + time: target.time, + result: target.result, + }; + + const d1 = unitUtils.convertDistance(input.distanceValue, input.distanceUnit, 'meters'); + + // Add missing value to result + if (target.result === 'time') { + // Convert target distance into meters + const d2 = unitUtils.convertDistance(target.distanceValue, target.distanceUnit, 'meters'); + + // Get prediction + let time; + switch (options.model) { + default: + case 'AverageModel': + time = raceUtils.AverageModel.predictTime(d1, input.time, d2, + options.riegelExponent); + break; + case 'PurdyPointsModel': + time = raceUtils.PurdyPointsModel.predictTime(d1, input.time, d2); + break; + case 'VO2MaxModel': + time = raceUtils.VO2MaxModel.predictTime(d1, input.time, d2); + break; + case 'RiegelModel': + time = raceUtils.RiegelModel.predictTime(d1, input.time, d2, + options.riegelExponent); + break; + case 'CameronModel': + time = raceUtils.CameronModel.predictTime(d1, input.time, d2); + break; + } + + // Update result + result.time = time; + } else { + // Get prediction + let distance; + switch (options.model) { + default: + case 'AverageModel': + distance = raceUtils.AverageModel.predictDistance(input.time, d1, target.time, + options.riegelExponent); + break; + case 'PurdyPointsModel': + distance = raceUtils.PurdyPointsModel.predictDistance(input.time, d1, + target.time); + break; + case 'VO2MaxModel': + distance = raceUtils.VO2MaxModel.predictDistance(input.time, d1, target.time); + break; + case 'RiegelModel': + distance = raceUtils.RiegelModel.predictDistance(input.time, d1, target.time, + options.riegelExponent); + break; + case 'CameronModel': + distance = raceUtils.CameronModel.predictDistance(input.time, d1, target.time); + break; + } + + // Convert output distance into default distance unit + distance = unitUtils.convertDistance(distance, 'meters', + unitUtils.getDefaultDistanceUnit(defaultUnitSystem)); + + // Update result + result.distanceValue = distance; + result.distanceUnit = unitUtils.getDefaultDistanceUnit(defaultUnitSystem); + } + + // Return result + return result; +} + +/** + * Calculate race statistics from an input race + * @param {Object} input The input race + * @returns {Object} The race statistics + */ +function calculateRaceStats(input) { + const d1 = unitUtils.convertDistance(input.distanceValue, input.distanceUnit, 'meters'); + + return { + purdyPoints: raceUtils.PurdyPointsModel.getPurdyPoints(d1, input.time), + vo2Max: raceUtils.VO2MaxModel.getVO2Max(d1, input.time), + vo2: raceUtils.VO2MaxModel.getVO2(d1, input.time), + vo2MaxPercentage: raceUtils.VO2MaxModel.getVO2Percentage(input.time) * 100, + } +} + +export default { + calculatePaceResults, + calculateRaceResults, + calculateRaceStats, +}; diff --git a/src/views/PaceCalculator.vue b/src/views/PaceCalculator.vue @@ -24,15 +24,14 @@ </details> <h2>Equivalent Paces</h2> - <simple-target-table class="output" :calculate-result="calculatePace" + <simple-target-table class="output" :calculate-result="x => + calcUtils.calculatePaceResults(input, x, defaultUnitSystem)" :targets="targetSets[selectedTargetSet] ? targetSets[selectedTargetSet].targets : []"/> </div> </template> <script setup> -import { computed } from 'vue'; - -import paceUtils from '@/utils/paces'; +import calcUtils from '@/utils/calculators'; import targetUtils from '@/utils/targets'; import unitUtils from '@/utils/units'; @@ -65,56 +64,6 @@ const selectedTargetSet = useStorage('pace-calculator-target-set', '_pace_target * The target sets */ const targetSets = useStorage('target-sets', targetUtils.defaultTargetSets); - -/** - * The input pace (in seconds per meter) - */ -const pace = computed(() => { - const distance = unitUtils.convertDistance(input.value.distanceValue, input.value.distanceUnit, - 'meters'); - return paceUtils.getPace(distance, input.value.time); -}); - -/** - * Calculate paces from a target - * @param {Object} target The target - * @returns {Object} The result - */ -function calculatePace(target) { - // Initialize result - const result = { - distanceValue: target.distanceValue, - distanceUnit: target.distanceUnit, - time: target.time, - result: target.result, - }; - - // Add missing value to result - if (target.result === 'time') { - // Convert target distance into meters - const d2 = unitUtils.convertDistance(target.distanceValue, target.distanceUnit, 'meters'); - - // Calculate time to travel distance at input pace - const time = paceUtils.getTime(pace.value, d2); - - // Update result - result.time = time; - } else { - // Calculate distance traveled in time at input pace - let distance = paceUtils.getDistance(pace.value, target.time); - - // Convert output distance into default distance unit - distance = unitUtils.convertDistance(distance, 'meters', - unitUtils.getDefaultDistanceUnit(defaultUnitSystem.value)); - - // Update result - result.distanceValue = distance; - result.distanceUnit = unitUtils.getDefaultDistanceUnit(defaultUnitSystem.value); - } - - // Return result - return result; -} </script> <style scoped> diff --git a/src/views/RaceCalculator.vue b/src/views/RaceCalculator.vue @@ -10,14 +10,15 @@ <h2>Race Statistics</h2> </summary> <div> - Purdy Points: <b>{{ formatUtils.formatNumber(purdyPoints, 0, 1, true) }}</b> + Purdy Points: <b>{{ formatUtils.formatNumber(raceStats.purdyPoints, 0, 1, true) }}</b> </div> <div> - V&#775;O&#8322;: <b>{{ formatUtils.formatNumber(vo2, 0, 1, true) }}</b> ml/kg/min - (<b>{{ formatUtils.formatNumber(vo2Percentage, 0, 1, true) }}%</b> of max) + V&#775;O&#8322;: <b>{{ formatUtils.formatNumber(raceStats.vo2, 0, 1, true) }}</b> ml/kg/min + (<b>{{ formatUtils.formatNumber(raceStats.vo2MaxPercentage, 0, 1, true) }}%</b> of max) </div> <div> - V&#775;O&#8322; Max: <b>{{ formatUtils.formatNumber(vo2Max, 0, 1, true) }}</b> ml/kg/min + V&#775;O&#8322; Max: <b>{{ formatUtils.formatNumber(raceStats.vo2Max, 0, 1, true) }}</b> + ml/kg/min </div> </details> @@ -41,16 +42,17 @@ </details> <h2>Equivalent Race Results</h2> - <simple-target-table class="output" :calculate-result="predictResult" :default-unit-system="defaultUnitSystem" - :targets="targetSets[selectedTargetSet] ? targetSets[selectedTargetSet].targets : []" show-pace/> + <simple-target-table class="output" :default-unit-system="defaultUnitSystem" show-pace + :calculate-result="x => calcUtils.calculateRaceResults(input, x, options, defaultUnitSystem)" + :targets="targetSets[selectedTargetSet] ? targetSets[selectedTargetSet].targets : []"/> </div> </template> <script setup> import { computed } from 'vue'; +import calcUtils from '@/utils/calculators'; import formatUtils from '@/utils/format'; -import raceUtils from '@/utils/races'; import targetUtils from '@/utils/targets'; import unitUtils from '@/utils/units'; @@ -76,7 +78,7 @@ const input = useStorage('race-calculator-input', { const defaultUnitSystem = useStorage('default-unit-system', unitUtils.detectDefaultUnitSystem()); /** -* The race prediction model +* The race prediction options */ const options = useStorage('race-calculator-options', { model: 'AverageModel', @@ -94,125 +96,9 @@ const selectedTargetSet = useStorage('race-calculator-target-set', '_race_target let targetSets = useStorage('target-sets', targetUtils.defaultTargetSets); /** - * Predict race results from a target - * @param {Object} target The target - * @returns {Object} The result + * The statistics for the current input race */ -function predictResult(target) { - // Initialize result - const result = { - distanceValue: target.distanceValue, - distanceUnit: target.distanceUnit, - time: target.time, - result: target.result, - }; - - // Add missing value to result - if (target.result === 'time') { - // Convert target distance into meters - const d2 = unitUtils.convertDistance(target.distanceValue, target.distanceUnit, 'meters'); - - // Get prediction - let time; - switch (options.value.model) { - default: - case 'AverageModel': - time = raceUtils.AverageModel.predictTime(d1.value, input.value.time, d2, - options.value.riegelExponent); - break; - case 'PurdyPointsModel': - time = raceUtils.PurdyPointsModel.predictTime(d1.value, input.value.time, d2); - break; - case 'VO2MaxModel': - time = raceUtils.VO2MaxModel.predictTime(d1.value, input.value.time, d2); - break; - case 'RiegelModel': - time = raceUtils.RiegelModel.predictTime(d1.value, input.value.time, d2, - options.value.riegelExponent); - break; - case 'CameronModel': - time = raceUtils.CameronModel.predictTime(d1.value, input.value.time, d2); - break; - } - - // Update result - result.time = time; - } else { - // Get prediction - let distance; - switch (options.value.model) { - default: - case 'AverageModel': - distance = raceUtils.AverageModel.predictDistance(input.value.time, d1.value, target.time, - options.value.riegelExponent); - break; - case 'PurdyPointsModel': - distance = raceUtils.PurdyPointsModel.predictDistance(input.value.time, d1.value, - target.time); - break; - case 'VO2MaxModel': - distance = raceUtils.VO2MaxModel.predictDistance(input.value.time, d1.value, target.time); - break; - case 'RiegelModel': - distance = raceUtils.RiegelModel.predictDistance(input.value.time, d1.value, target.time, - options.value.riegelExponent); - break; - case 'CameronModel': - distance = raceUtils.CameronModel.predictDistance(input.value.time, d1.value, target.time); - break; - } - - // Convert output distance into default distance unit - distance = unitUtils.convertDistance(distance, 'meters', - unitUtils.getDefaultDistanceUnit(defaultUnitSystem.value)); - - // Update result - result.distanceValue = distance; - result.distanceUnit = unitUtils.getDefaultDistanceUnit(defaultUnitSystem.value); - } - - // Return result - return result; -} - -/** - * The input distance in meters - */ -const d1 = computed(() => { - return unitUtils.convertDistance(input.value.distanceValue, input.value.distanceUnit, 'meters'); -}); - -/** - * The Purdy Points for the input race - */ -const purdyPoints = computed(() => { - const result = raceUtils.PurdyPointsModel.getPurdyPoints(d1.value, input.value.time); - return result; -}); - -/** - * The VO2 Max calculated from the input race - */ -const vo2Max = computed(() => { - const result = raceUtils.VO2MaxModel.getVO2Max(d1.value, input.value.time); - return result; -}); - -/** - * The VO2 calculated from the input race - */ -const vo2 = computed(() => { - const result = raceUtils.VO2MaxModel.getVO2(d1.value, input.value.time); - return result; -}); - -/** - * The percentage of VO2 Max calculated from the input race - */ -const vo2Percentage = computed(() => { - const result = raceUtils.VO2MaxModel.getVO2Percentage(input.value.time) * 100; - return result; -}); +const raceStats = computed(() => calcUtils.calculateRaceStats(input.value)); </script> <style scoped> diff --git a/tests/unit/utils/calculators.spec.js b/tests/unit/utils/calculators.spec.js @@ -0,0 +1,135 @@ +import { test, expect } from 'vitest'; +import calculatorUtils from '@/utils/calculators'; + +test('should correctly calculate pace times', () => { + const input = { + distanceValue: 1, + distanceUnit: 'kilometers', + time: 100, + }; + const target = { + distanceValue: 20, + distanceUnit: 'meters', + result: 'time', + }; + + const result = calculatorUtils.calculatePaceResults(input, target, {}); + + expect(result).to.deep.equal({ + distanceValue: 20, + distanceUnit: 'meters', + time: 2, + result: 'time', + }); +}); + +test('should correctly calculate pace distances according to default units setting', () => { + const input = { + distanceValue: 2, + distanceUnit: 'miles', + time: 1200, + }; + const target = { + time: 600, + result: 'distance', + }; + + const result1 = calculatorUtils.calculatePaceResults(input, target, 'metric'); + const result2 = calculatorUtils.calculatePaceResults(input, target, 'imperial'); + + expect(result1.distanceValue).to.be.closeTo(1.609, 0.001); + expect(result1.distanceUnit).to.equal('kilometers'); + expect(result2.distanceValue).to.be.closeTo(1.000, 0.001); + expect(result2.distanceUnit).to.equal('miles'); +}); + +test('should correctly predict race times', () => { + const input = { + distanceValue: 5, + distanceUnit: 'kilometers', + time: 1200, + }; + const target = { + distanceValue: 10, + distanceUnit: 'kilometers', + result: 'time', + }; + const options = { + model: 'average', + riegelExponent: 1.06, + } + + const result = calculatorUtils.calculateRaceResults(input, target, options, 'imperial'); + + expect(result.time).to.be.closeTo(2495, 1); + expect(result.distanceValue).to.equal(10); + expect(result.distanceUnit).to.equal('kilometers'); + expect(result.result).to.equal('time'); +}); + +test('should correctly calculate race distances according to default units setting', () => { + const input = { + distanceValue: 5, + distanceUnit: 'kilometers', + time: 1200, + }; + const target = { + time: 2495, + result: 'distance', + }; + const options = { + model: 'average', + riegelExponent: 1.06, + } + + const result1 = calculatorUtils.calculateRaceResults(input, target, options, 'metric'); + const result2 = calculatorUtils.calculateRaceResults(input, target, options, 'imperial'); + + expect(result1.distanceValue).to.be.closeTo(10, 0.01); + expect(result1.distanceUnit).to.equal('kilometers'); + expect(result1.time).to.equal(2495); + expect(result1.result).to.equal('distance'); + expect(result2.distanceValue).to.be.closeTo(6.214, 0.01); + expect(result2.distanceUnit).to.equal('miles'); + expect(result2.time).to.equal(2495); + expect(result2.result).to.equal('distance'); +}); + +test('should correctly predict race times according to race options', () => { + const input = { + distanceValue: 2, + distanceUnit: 'miles', + time: 630, + }; + const target = { + distanceValue: 5, + distanceUnit: 'kilometers', + result: 'time', + }; + const options = { + model: 'RiegelModel', + riegelExponent: 1.12, + } + + const result = calculatorUtils.calculateRaceResults(input, target, options, 'imperial'); + + expect(result.time).to.be.closeTo(1031, 1); + expect(result.distanceValue).to.equal(5); + expect(result.distanceUnit).to.equal('kilometers'); + expect(result.result).to.equal('time'); +}); + +test('should correctly calculate race statistics', () => { + const input = { + distanceValue: 5, + distanceUnit: 'kilometers', + time: 1200, + }; + + const results = calculatorUtils.calculateRaceStats(input); + + expect(results.purdyPoints).to.be.closeTo(454.5, 0.1); + expect(results.vo2).to.be.closeTo(47.4, 0.1); + expect(results.vo2MaxPercentage).to.be.closeTo(95.3, 0.1); + expect(results.vo2Max).to.be.closeTo(49.8, 0.1); +});