running-tools

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

commit 7edfe58ae307c4120d55f40b2105d319e12e42ee
parent 827f219882e570c534cfde9028cab19c2b8e7e7f
Author: ashermorgan <59518073+ashermorgan@users.noreply.github.com>
Date:   Sun,  5 Sep 2021 08:30:44 -0700

Merge pull request #2 from ashermorgan/feature/time-based-targets

Add time based targets
Diffstat:
Asrc/components/TargetTable.vue | 312+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dsrc/components/TimeTable.vue | 292-------------------------------------------------------------------------------
Msrc/utils/races.js | 469++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
Msrc/views/PaceCalculator.vue | 115++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
Msrc/views/RaceCalculator.vue | 94++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
Atests/unit/components/TargetTable.spec.js | 34++++++++++++++++++++++++++++++++++
Dtests/unit/components/TimeTable.spec.js | 34----------------------------------
Mtests/unit/utils/races.spec.js | 156+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Mtests/unit/views/PaceCalculator.spec.js | 30+++++++++++++++++++++++++++++-
Mtests/unit/views/RaceCalculator.spec.js | 34++++++++++++++++++++++++++++++++--
10 files changed, 990 insertions(+), 580 deletions(-)

diff --git a/src/components/TargetTable.vue b/src/components/TargetTable.vue @@ -0,0 +1,312 @@ +<template> + <div class="time-table"> + <table class="results" v-show="!inEditMode"> + <thead> + <tr> + <th colspan="2">Distance</th> + + <th>Time</th> + + <th> + <button class="icon" title="Edit Targets" @click="inEditMode=true" v-blur> + <img alt="" src="@/assets/edit.svg"> + </button> + </th> + </tr> + </thead> + + <tbody> + <tr v-for="(item, index) in results" :key="index"> + <td :class="item.result === 'distance' ? 'result' : ''"> + {{ item.distanceValue.toFixed(2) }} + {{ distanceSymbols[item.distanceUnit] }} + </td> + + <td>in</td> + + <td colspan="2" :class="item.result === 'time' ? 'result' : ''"> + {{ formatDuration(item.time, 0, 2) }} + </td> + </tr> + + <tr v-if="results.length === 0" class="empty-message"> + <td colspan="4"> + There aren't any targets yet,<br> + click + <img alt="Edit Targets" src="@/assets/edit.svg"> + to edit the list of targets + </td> + </tr> + </tbody> + </table> + + <table class="targets" v-show="inEditMode"> + <thead> + <tr> + <th>Edit Targets</th> + + <th> + <button class="icon" title="Reset Targets" @click="resetTargets" v-blur> + <img alt="" src="@/assets/rotate-ccw.svg"> + </button> + <button class="icon" title="Close" @click="inEditMode=false" v-blur> + <img alt="" src="@/assets/x.svg"> + </button> + </th> + </tr> + </thead> + + <tbody> + <tr v-for="(item, index) in targets" :key="index"> + <td v-if="item.result === 'time'"> + <decimal-input v-model="item.distanceValue" aria-label="Distance Value" + :min="0" :digits="2"/> + <select v-model="item.distanceUnit" aria-label="Distance Unit"> + <option v-for="(value, key) in distanceUnits" :key="key" :value="key"> + {{ value }} + </option> + </select> + </td> + + <td v-else> + <time-input v-model="item.time" aria-label="Time"/> + </td> + + <td> + <button class="icon" title="Remove Target" @click="targets.splice(index, 1)" v-blur> + <img alt="" src="@/assets/trash-2.svg"> + </button> + </td> + </tr> + + <tr v-if="targets.length === 0" class="empty-message"> + <td colspan="2"> + There aren't any targets yet + </td> + </tr> + </tbody> + + <tfoot> + <tr> + <td colspan="2"> + <button title="Add Distance Target" @click="targets.push({ result: 'time', + distanceValue: 1, distanceUnit: 'miles' })" v-blur> + Add distance target + </button> + <button title="Add Time Target" @click="targets.push({ result: 'distance', + time: 600 })" v-blur> + Add time target + </button> + </td> + </tr> + </tfoot> + </table> + </div> +</template> + +<script> +import unitUtils from '@/utils/units'; +import storage from '@/utils/localStorage'; + +import DecimalInput from '@/components/DecimalInput.vue'; +import TimeInput from '@/components/TimeInput.vue'; + +import blur from '@/directives/blur'; + +export default { + name: 'TimeTable', + + components: { + DecimalInput, + TimeInput, + }, + + directives: { + blur, + }, + + props: { + /** + * The method that generates the time table rows + */ + calculateResult: { + type: Function, + required: true, + }, + + /** + * The default time table targets + */ + defaultTargets: { + type: Array, + default: () => [], + }, + + /** + * The localStorage key for the list of targets + */ + storageKey: { + type: String, + default: null, + }, + }, + + data() { + return { + /** + * The names of the distance units + */ + distanceUnits: unitUtils.DISTANCE_UNIT_NAMES, + + /** + * The symbols of the distance units + */ + distanceSymbols: unitUtils.DISTANCE_UNIT_SYMBOLS, + + /** + * The formatDuration method + */ + formatDuration: unitUtils.formatDuration, + + /** + * Whether the table is in edit mode + */ + inEditMode: false, + + /** + * The time table targets + */ + targets: storage.get(this.storageKey, this.defaultTargets), + }; + }, + + computed: { + /** + * The time table results + */ + results() { + // Calculate results + const result = []; + this.targets.forEach((row) => { + // Add result + result.push(this.calculateResult(row)); + }); + + // Sort results by time + result.sort((a, b) => a.time - b.time); + + // Return results + return result; + }, + }, + + watch: { + /** + * Sort targets + */ + inEditMode() { + this.sortTargets(); + }, + + /** + * Save targets + */ + targets: { + handler(newValue) { + if (this.storageKey !== null) { + storage.set(this.storageKey, newValue); + } + }, + deep: true, + }, + }, + + methods: { + /** + * Restore the default targets + */ + resetTargets() { + // Clone default targets array + this.targets = JSON.parse(JSON.stringify(this.defaultTargets)); + + // Sort targets + this.sortTargets(); + }, + + /** + * Sort the targets by distance + */ + sortTargets() { + this.targets = [ + ...this.targets.filter((item) => item.result === 'time') + .sort((a, b) => unitUtils.convertDistance(a.distanceValue, a.distanceUnit, 'meters') + - unitUtils.convertDistance(b.distanceValue, b.distanceUnit, 'meters')), + + ...this.targets.filter((item) => item.result === 'distance') + .sort((a, b) => a.time - b.time), + ]; + }, + }, + + /** + * Close edit targets table + */ + deactivated() { + this.inEditMode = false; + }, +}; +</script> + +<style scoped> +/* time table */ +.results th:last-child { + text-align: right; +} +.results .result { + font-weight: bold; +} + +/* edit targets table */ +.targets th:last-child, .targets td:last-child { + text-align: right; +} +.targets td select { + margin-left: 0.2em; + width: 8em; +} +.targets tfoot td { + text-align: center !important; + padding: 0.5em 0.2em; +} +.targets tfoot button { + margin: 0.5em; +} + +/* general table styles */ +table { + border-collapse: collapse; + width: 100%; + text-align: left; +} +table th, table td { + padding: 0.2em; +} +table button.icon { + height: 2em; + width: 2em; +} + +/* empty table message */ +.empty-message td { + text-align: center !important; +} +.empty-message img { + height: 1em; + width: 1em; +} +@media (prefers-color-scheme: dark) { + .empty-message img { + filter: invert(90%); + } +} +</style> diff --git a/src/components/TimeTable.vue b/src/components/TimeTable.vue @@ -1,292 +0,0 @@ -<template> - <div class="time-table"> - <table class="results" v-show="!inEditMode"> - <thead> - <tr> - <th colspan="2">Distance</th> - - <th>Time</th> - - <th> - <button class="icon" title="Edit Targets" @click="inEditMode=true" v-blur> - <img alt="" src="@/assets/edit.svg"> - </button> - </th> - </tr> - </thead> - - <tbody> - <tr v-for="(item, index) in results" :key="index"> - <td> - {{ item.distanceValue.toFixed(2) }} - {{ distanceSymbols[item.distanceUnit] }} - </td> - - <td>in</td> - - <td colspan="2"> - {{ formatDuration(item.time, 0, 2) }} - </td> - </tr> - - <tr v-if="results.length === 0" class="empty-message"> - <td colspan="4"> - There aren't any targets,<br> - click - <img alt="Edit Targets" src="@/assets/edit.svg"> - to add one - </td> - </tr> - </tbody> - </table> - - <table class="targets" v-show="inEditMode"> - <thead> - <tr> - <th>Edit Targets</th> - - <th> - <button class="icon" title="Reset Targets" @click="resetTargets" v-blur> - <img alt="" src="@/assets/rotate-ccw.svg"> - </button> - <button class="icon" title="Close" @click="inEditMode=false" v-blur> - <img alt="" src="@/assets/x.svg"> - </button> - </th> - </tr> - </thead> - - <tbody> - <tr v-for="(item, index) in targets" :key="index"> - <td> - <decimal-input v-model="item.distanceValue" aria-label="Distance Value" - :min="0" :digits="2"/> - <select v-model="item.distanceUnit" aria-label="Distance Unit"> - <option v-for="(value, key) in distanceUnits" :key="key" :value="key"> - {{ value }} - </option> - </select> - </td> - - <td> - <button class="icon" title="Remove Target" @click="targets.splice(index, 1)" v-blur> - <img alt="" src="@/assets/trash-2.svg"> - </button> - </td> - </tr> - - <tr v-if="targets.length === 0" class="empty-message"> - <td colspan="2"> - There aren't any targets,<br> - click - <img alt="Add Target" src="@/assets/plus-circle.svg"> - to add one - </td> - </tr> - </tbody> - - <tfoot> - <tr> - <td colspan="2"> - <button class="icon" title="Add Target" @click="targets.push({distanceValue: 1, - distanceUnit: 'miles'})" v-blur> - <img alt="" src="@/assets/plus-circle.svg"> - </button> - </td> - </tr> - </tfoot> - </table> - </div> -</template> - -<script> -import unitUtils from '@/utils/units'; -import storage from '@/utils/localStorage'; - -import DecimalInput from '@/components/DecimalInput.vue'; - -import blur from '@/directives/blur'; - -export default { - name: 'TimeTable', - - components: { - DecimalInput, - }, - - directives: { - blur, - }, - - props: { - /** - * The method that generates the time table rows - */ - calculateResult: { - type: Function, - required: true, - }, - - /** - * The default time table targets - */ - defaultTargets: { - type: Array, - default: () => [], - }, - - /** - * The localStorage key for the list of targets - */ - storageKey: { - type: String, - default: null, - }, - }, - - data() { - return { - /** - * The names of the distance units - */ - distanceUnits: unitUtils.DISTANCE_UNIT_NAMES, - - /** - * The symbols of the distance units - */ - distanceSymbols: unitUtils.DISTANCE_UNIT_SYMBOLS, - - /** - * The formatDuration method - */ - formatDuration: unitUtils.formatDuration, - - /** - * Whether the table is in edit mode - */ - inEditMode: false, - - /** - * The time table targets - */ - targets: storage.get(this.storageKey, this.defaultTargets), - }; - }, - - computed: { - /** - * The time table results - */ - results() { - // Calculate results - const result = []; - this.targets.forEach((row) => { - // Add result - result.push(this.calculateResult(row)); - }); - - // Sort results by time - result.sort((a, b) => a.time - b.time); - - // Return results - return result; - }, - }, - - watch: { - /** - * Sort targets - */ - inEditMode() { - this.sortTargets(); - }, - - /** - * Save targets - */ - targets: { - handler(newValue) { - if (this.storageKey !== null) { - storage.set(this.storageKey, newValue); - } - }, - deep: true, - }, - }, - - methods: { - /** - * Restore the default targets - */ - resetTargets() { - // Clone default targets array - this.targets = JSON.parse(JSON.stringify(this.defaultTargets)); - - // Sort targets - this.sortTargets(); - }, - - /** - * Sort the targets by distance - */ - sortTargets() { - this.targets.sort((a, b) => unitUtils.convertDistance(a.distanceValue, a.distanceUnit, - 'meters') - unitUtils.convertDistance(b.distanceValue, b.distanceUnit, 'meters')); - }, - }, - - /** - * Close edit targets table - */ - deactivated() { - this.inEditMode = false; - }, -}; -</script> - -<style scoped> -/* time table */ -.results th:last-child { - text-align: right; -} - -/* edit targets table */ -.targets th:last-child, .targets td:last-child { - text-align: right; -} -.targets td select { - margin-left: 0.2em; -} -.targets tfoot td { - text-align: center !important; - padding: 0.5em 0.2em; -} - -/* general table styles */ -table { - border-collapse: collapse; - width: 100%; - text-align: left; -} -table th, table td { - padding: 0.2em; -} -table button.icon { - height: 2em; - width: 2em; -} - -/* empty table message */ -.empty-message td { - text-align: center !important; -} -.empty-message img { - height: 1em; - width: 1em; -} -@media (prefers-color-scheme: dark) { - .empty-message img { - filter: invert(90%); - } -} -</style> diff --git a/src/utils/races.js b/src/utils/races.js @@ -1,159 +1,362 @@ /** - * Predict a race time using the Purdy Points Model - * https://www.cs.uml.edu/~phoffman/xcinfo3.html - * @param {Number} d1 The distance of the input race in meters - * @param {Number} t1 The finish time of the input race in seconds - * @param {Number} d2 The distance of the output race in meters - * @returns {Number} The predicted time for the output race in seconds + * Estimate the point at which a function returns a target value using Newton's Method + * @param {Number} initialEstimate The initial estimate + * @param {Number} target The target function output + * @param {Function} method The function + * @param {Function} derivative The function derivative + * @param {Number} precision The acceptable precision + * @param {Number} iterations The maximum number of iterations + * @returns {Number} The refined estimate */ -function PurdyPointsModel(d1, t1, d2) { - // Declare constants - const c1 = 11.15895; - const c2 = 4.304605; - const c3 = 0.5234627; - const c4 = 4.031560; - const c5 = 2.316157; - const r1 = 3.796158e-2; - const r2 = 1.646772e-3; - const r3 = 4.107670e-4; - const r4 = 7.068099e-6; - const r5 = 5.220990e-9; - - // Calculate world record velocity from running curve - const v1 = (-c1 * Math.exp(-r1 * d1)) - + (c2 * Math.exp(-r2 * d1)) - + (c3 * Math.exp(-r3 * d1)) - + (c4 * Math.exp(-r4 * d1)) - + (c5 * Math.exp(-r5 * d1)); - const v2 = (-c1 * Math.exp(-r1 * d2)) - + (c2 * Math.exp(-r2 * d2)) - + (c3 * Math.exp(-r3 * d2)) - + (c4 * Math.exp(-r4 * d2)) - + (c5 * Math.exp(-r5 * d2)); - - // Calculate world record time - const twsec1 = d1 / v1; - const twsec2 = d2 / v2; - - // Calculate constants - const k1 = 0.0654 - (0.00258 * v1); - const k2 = 0.0654 - (0.00258 * v2); - const a1 = 85 / k1; - const a2 = 85 / k2; - const b1 = 1 - (1035 / a1); - const b2 = 1 - (1035 / a2); - - // Calculate Purdy Points for distance 1 - const points = a1 * ((twsec1 / t1) - b1); - - // Calculate time for distance 2 - const seconds = (a2 * twsec2) / (points + (a2 * b2)); - - // Return predicted time - return seconds; -} +function NewtonsMethod(initialEstimate, target, method, derivative, precision, iterations = 500) { + // Initialize estimate + let estimate = initialEstimate; + let estimateValue; -/** - * Calculate a runner's VO2 max from their performance in a race - * @param {Number} d The race distance in meters - * @param {Number} t The finish time in minutes - * @returns {Number} The runner's VO2 max - */ -function VO2Max(d, t) { - const v = d / t; - const result = (-4.6 + (0.182 * v) + (0.000104 * (v ** 2))) - / (0.8 + (0.189 * Math.exp(-0.0128 * t)) + (0.299 * Math.exp(-0.193 * t))); - return result; + for (let i = 0; i < iterations; i += 1) { + // Evaluate function at estimate + estimateValue = method(estimate); + + // Check if estimate is close enough + if (Math.abs(target - estimateValue) < precision) { + break; + } + + // Refine estimate + estimate -= (estimateValue - target) / derivative(estimate); + } + + // Return refined estimate + return estimate; } -/** - * Calculate the derivative with respect to time of the VO2 max curve at a specific point - * @param {Number} d The race distance in meters - * @param {Number} t The finish time in minutes - * @return {Number} The derivative +/* + * Methods that implement the Purdy Points race prediction model + * https://www.cs.uml.edu/~phoffman/xcinfo3.html */ -function VO2MaxDerivative(d, t) { - const result = ((-575000 * (t ** 2)) + (22750 * d * t) + (13 * (d ** 2))) / (125 - * (t ** 2) * (189 * Math.exp((-8 * t) / 625) + (299 * Math.exp((-193 * t) / 1000) + 800))); - return result; -} +const PurdyPointsModel = { + /** + * Predict a race time using the Purdy Points Model + * @param {Number} d1 The distance of the input race in meters + * @param {Number} t1 The finish time of the input race in seconds + * @param {Number} d2 The distance of the output race in meters + * @returns {Number} The predicted time for the output race in seconds + */ + predictTime(d1, t1, d2) { + // Declare constants + const c1 = 11.15895; + const c2 = 4.304605; + const c3 = 0.5234627; + const c4 = 4.031560; + const c5 = 2.316157; + const r1 = 3.796158e-2; + const r2 = 1.646772e-3; + const r3 = 4.107670e-4; + const r4 = 7.068099e-6; + const r5 = 5.220990e-9; -/** - * Predict a race time using the VO2 Max Model + // Calculate world record velocity from running curve + const v1 = (-c1 * Math.exp(-r1 * d1)) + + (c2 * Math.exp(-r2 * d1)) + + (c3 * Math.exp(-r3 * d1)) + + (c4 * Math.exp(-r4 * d1)) + + (c5 * Math.exp(-r5 * d1)); + const v2 = (-c1 * Math.exp(-r1 * d2)) + + (c2 * Math.exp(-r2 * d2)) + + (c3 * Math.exp(-r3 * d2)) + + (c4 * Math.exp(-r4 * d2)) + + (c5 * Math.exp(-r5 * d2)); + + // Calculate world record time + const twsec1 = d1 / v1; + const twsec2 = d2 / v2; + + // Calculate constants + const k1 = 0.0654 - (0.00258 * v1); + const k2 = 0.0654 - (0.00258 * v2); + const a1 = 85 / k1; + const a2 = 85 / k2; + const b1 = 1 - (1035 / a1); + const b2 = 1 - (1035 / a2); + + // Calculate Purdy Points for distance 1 + const points = a1 * ((twsec1 / t1) - b1); + + // Calculate time for distance 2 + const seconds = (a2 * twsec2) / (points + (a2 * b2)); + + // Return predicted time + return seconds; + }, + + /** + * Calculate the derivative with respect to distance of the Purdy Points curve at a specific point + * @param {Number} d1 The distance of the input race in meters + * @param {Number} t1 The finish time of the input race in seconds + * @param {Number} d2 The distance of the output race in meters + * @return {Number} The derivative with respect to distance + */ + derivative(d1, t1, d2) { + const result = (85 * d2) / (((2316157 * Math.exp(-(522099 * d2) / 100000000000000)) / 1000000 + + (100789 * Math.exp(-(7068099 * d2) / 1000000000000)) / 25000 + (5234627 * Math.exp(-(410767 + * d2) / 1000000000)) / 10000000 + (860921 * Math.exp(-(411693 * d2) / 250000000)) / 200000 + - (223179 * Math.exp(-(1898079 * d2) / 50000000)) / 20000) * (327 / 5000 - (129 * ((2316157 + * Math.exp(-(522099 * d2) / 100000000000000)) / 1000000 + (100789 * Math.exp(-(7068099 * d2) + / 1000000000000)) / 25000 + (5234627 * Math.exp(-(410767 * d2) / 1000000000)) / 10000000 + + (860921 * Math.exp(-(411693 * d2) / 250000000)) / 200000 - (223179 * Math.exp(-(1898079 + * d2) / 50000000)) / 20000)) / 50000) * ((85 * (1 - (207 * (327 / 5000 - (129 * ((2316157 + * Math.exp(-(522099 * d2) / 100000000000000)) / 1000000 + (100789 * Math.exp(-(7068099 * d2) + / 1000000000000)) / 25000 + (5234627 * Math.exp(-(410767 * d2) / 1000000000)) / 10000000 + + (860921 * Math.exp(-(411693 * d2) / 250000000)) / 200000 - (223179 * Math.exp(-(1898079 + * d2) / 50000000)) / 20000)) / 50000)) / 17)) / (327 / 5000 - (129 * ((2316157 + * Math.exp(-(522099 * d2) / 100000000000000)) / 1000000 + (100789 * Math.exp(-(7068099 * d2) + / 1000000000000)) / 25000 + (5234627 * Math.exp(-(410767 * d2) / 1000000000)) / 10000000 + + (860921 * Math.exp(-(411693 * d2) / 250000000)) / 200000 - (223179 * Math.exp(-(1898079 + * d2) / 50000000)) / 20000)) / 50000) + (85 * (d1 / (((2316157 * Math.exp(-(522099 * d1) + / 100000000000000)) / 1000000 + (100789 * Math.exp(-(7068099 * d1) / 1000000000000)) / 25000 + + (5234627 * Math.exp(-(410767 * d1) / 1000000000)) / 10000000 + (860921 * Math.exp(-(411693 + * d1) / 250000000)) / 200000 - (223179 * Math.exp(-(1898079 * d1) / 50000000)) / 20000) * t1) + + (207 * (327 / 5000 - (129 * ((2316157 * Math.exp(-(522099 * d1) / 100000000000000)) + / 1000000 + (100789 * Math.exp(-(7068099 * d1) / 1000000000000)) / 25000 + (5234627 + * Math.exp(-(410767 * d1) / 1000000000)) / 10000000 + (860921 * Math.exp(-(411693 * d1) + / 250000000)) / 200000 - (223179 * Math.exp(-(1898079 * d1) / 50000000)) / 20000)) / 50000)) + / 17 - 1)) / (327 / 5000 - (129 * ((2316157 * Math.exp(-(522099 * d1) / 100000000000000)) + / 1000000 + (100789 * Math.exp(-(7068099 * d1) / 1000000000000)) / 25000 + (5234627 + * Math.exp(-(410767 * d1) / 1000000000)) / 10000000 + (860921 * Math.exp(-(411693 * d1) + / 250000000)) / 200000 - (223179 * Math.exp(-(1898079 * d1) / 50000000)) / 20000)) / 50000))); + return result; + }, + + /** + * Predict a race distance using the Purdy Points Model + * @param {Number} t1 The finish time of the input race in seconds + * @param {Number} d1 The distance of the input race in meters + * @param {Number} t2 The finish time of the output race in seconds + * @returns {Number} The predicted distance for the output race in meters + */ + predictDistance(t1, d1, t2) { + // Initialize estimate + let estimate = (d1 * t2) / t1; + + // Refine estimate + const method = (x) => this.predictTime(d1, t1, x); + const derivative = (x) => this.derivative(d1, t1, x) / 100; // Derivative on its own is too slow + estimate = NewtonsMethod(estimate, t2, method, derivative, 0.01); + + // Return estimate + return estimate; + }, +}; + +/* + * Methods that implement the VO2 Max race prediction model * http://run-down.com/statistics/calcs_explained.php - * @param {Number} d1 The distance of the input race in meters - * @param {Number} t1 The finish time of the input race in seconds - * @param {Number} d2 The distance of the output race in meters - * @param {Number} iterations The maximum number of times to refine the prediction - * @returns {Number} The predicted time for the output race in seconds */ -function VO2MaxModel(d1, t1, d2, iterations = 500) { - // Calculate input VO2 max - const inputVO2 = VO2Max(d1, t1 / 60); +const VO2MaxModel = { + /** + * Calculate a runner's VO2 max from their performance in a race + * @param {Number} d The race distance in meters + * @param {Number} t The finish time in minutes + * @returns {Number} The runner's VO2 max + */ + VO2Max(d, t) { + const v = d / t; + const result = (-4.6 + (0.182 * v) + (0.000104 * (v ** 2))) + / (0.8 + (0.189 * Math.exp(-0.0128 * t)) + (0.299 * Math.exp(-0.193 * t))); + return result; + }, - // Initialize estimate - let estimate = (t1 * d2) / (d1 * 60); - let estimateVO2; + /** + * Calculate the derivative with respect to time of the VO2 max curve at a specific point + * @param {Number} d The race distance in meters + * @param {Number} t The finish time in minutes + * @return {Number} The derivative with respect to time + */ + VO2MaxTimeDerivative(d, t) { + const result = -((-575000 * (t ** 2)) + (22750 * d * t) + (13 * (d ** 2))) / (125 + * (t ** 2) * (189 * Math.exp((-8 * t) / 625) + (299 * Math.exp((-193 * t) / 1000) + 800))); + return result; + }, - for (let i = 0; i < iterations; i += 1) { - // Get estimate's VO2 max - estimateVO2 = VO2Max(d2, estimate); + /** + * Predict a race time using the VO2 Max Model + * @param {Number} d1 The distance of the input race in meters + * @param {Number} t1 The finish time of the input race in seconds + * @param {Number} d2 The distance of the output race in meters + * @returns {Number} The predicted time for the output race in seconds + */ + predictTime(d1, t1, d2) { + // Calculate input VO2 max + const inputVO2 = this.VO2Max(d1, t1 / 60); - // Check if estimate is close enough - if (Math.abs(inputVO2 - estimateVO2) < 0.0001) { - break; - } + // Initialize estimate + let estimate = (t1 * d2) / (d1 * 60); // Refine estimate - estimate += (estimateVO2 - inputVO2) / VO2MaxDerivative(d2, estimate); - } + const method = (x) => this.VO2Max(d2, x); + const derivative = (x) => this.VO2MaxTimeDerivative(d2, x); + estimate = NewtonsMethod(estimate, inputVO2, method, derivative, 0.0001); - // Return estimate - return estimate * 60; -} + // Return estimate + return estimate * 60; + }, -/** - * Predict a race time using Dave Cameron's Model + /** + * Calculate the derivative with respect to distance of the VO2 max curve at a specific point + * @param {Number} d The race distance in meters + * @param {Number} t The finish time in minutes + * @return {Number} The derivative with respect to distance + */ + VO2MaxDistanceDerivative(d, t) { + const result = ((26 * d) + (22750 * t)) / (125 * (t ** 2) * ((189 * Math.exp(-(8 * t) / 625)) + + (299 * Math.exp(-(193 * t) / 1000)) + 800)); + return result; + }, + + /** + * Predict a race distance using the VO2 Max Model + * @param {Number} t1 The finish time of the input race in seconds + * @param {Number} d1 The distance of the input race in meters + * @param {Number} t2 The finish time of the output race in seconds + * @returns {Number} The predicted distance for the output race in meters + */ + predictDistance(t1, d1, t2) { + // Calculate input VO2 max + const inputVO2 = this.VO2Max(d1, t1 / 60); + + // Initialize estimate + let estimate = (d1 * t2) / t1; + + // Refine estimate + const method = (x) => this.VO2Max(x, t2 / 60); + const derivative = (x) => this.VO2MaxDistanceDerivative(x, t2 / 60); + estimate = NewtonsMethod(estimate, inputVO2, method, derivative, 0.0001); + + // Return estimate + return estimate; + }, +}; + +/* + * Methods that implement Dave Cameron's race prediction model * https://www.cs.uml.edu/~phoffman/cammod.html - * @param {Number} d1 The distance of the input race in meters - * @param {Number} t1 The finish time of the input race in seconds - * @param {Number} d2 The distance of the output race in meters - * @returns {Number} The predicted time for the output race in seconds */ -function CameronModel(d1, t1, d2) { - const a = 13.49681 - (0.000030363 * d1) + (835.7114 / (d1 ** 0.7905)); - const b = 13.49681 - (0.000030363 * d2) + (835.7114 / (d2 ** 0.7905)); - return (t1 / d1) * (a / b) * d2; -} +const CameronModel = { + /** + * Predict a race time using Dave Cameron's Model + * @param {Number} d1 The distance of the input race in meters + * @param {Number} t1 The finish time of the input race in seconds + * @param {Number} d2 The distance of the output race in meters + * @returns {Number} The predicted time for the output race in seconds + */ + predictTime(d1, t1, d2) { + const a = 13.49681 - (0.000030363 * d1) + (835.7114 / (d1 ** 0.7905)); + const b = 13.49681 - (0.000030363 * d2) + (835.7114 / (d2 ** 0.7905)); + return (t1 / d1) * (a / b) * d2; + }, -/** - * Predict a race time using Pete Riegel's Model + /** + * Calculate the derivative with respect to distance of the Cameron curve at a specific point + * @param {Number} d1 The distance of the input race in meters + * @param {Number} t1 The finish time of the input race in seconds + * @param {Number} d2 The distance of the output race in meters + * @return {Number} The derivative with respect to distance + */ + derivative(d1, t1, d2) { + const result = -(100 * (30363 * (d1 ** (3581 / 2000)) - 13496810000 * (d1 ** (1581 / 2000)) + - 835711400000) * t1 * (134968100 * (d2 ** (3581 / 2000)) + 14963412617 * d2)) / ((d1 ** (3581 + / 2000)) * (d2 ** (419 / 2000)) * ((30363 * (d2 ** (3581 / 2000)) - 13496810000 * (d2 ** (1581 + / 2000)) - 835711400000) ** 2)); + return result; + }, + + /** + * Predict a race distance using Dave Cameron's Model + * @param {Number} t1 The finish time of the input race in seconds + * @param {Number} d1 The distance of the input race in meters + * @param {Number} t2 The finish time of the output race in seconds + * @returns {Number} The predicted distance for the output race in meters + */ + predictDistance(t1, d1, t2) { + // Initialize estimate + let estimate = (d1 * t2) / t1; + + // Refine estimate + const method = (x) => this.predictTime(d1, t1, x); + const derivative = (x) => this.derivative(d1, t1, x); + estimate = NewtonsMethod(estimate, t2, method, derivative, 0.01); + + // Return estimate + return estimate; + }, +}; + +/* + * Methods that implement Pete Riegel's race prediction model * https://en.wikipedia.org/wiki/Peter_Riegel - * @param {Number} d1 The distance of the input race in meters - * @param {Number} t1 The finish time of the input race in seconds - * @param {Number} d2 The distance of the output race in meters - * @param {Number} c The value of the exponent in the equation - * @returns {Number} The predicted time for the output race in seconds */ -function RiegelModel(d1, t1, d2, c = 1.06) { - return t1 * ((d2 / d1) ** c); -} +const RiegelModel = { + /** + * Predict a race time using Pete Riegel's Model + * @param {Number} d1 The distance of the input race in meters + * @param {Number} t1 The finish time of the input race in seconds + * @param {Number} d2 The distance of the output race in meters + * @param {Number} c The value of the exponent in the equation + * @returns {Number} The predicted time for the output race in seconds + */ + predictTime(d1, t1, d2, c = 1.06) { + return t1 * ((d2 / d1) ** c); + }, -/** - * Predict a race time by averaging the results of different models - * @param {Number} d1 The distance of the input race in meters - * @param {Number} t1 The finish time of the input race in seconds - * @param {Number} d2 The distance of the output race in meters - * @param {Number} c The value of the exponent in Pete Riegel's Model - * @returns {Number} The predicted time for the output race in seconds + /** + * Predict a race distance using Pete Riegel's Model + * @param {Number} t1 The finish time of the input race in seconds + * @param {Number} d1 The distance of the input race in meters + * @param {Number} t2 The finish time of the output race in seconds + * @param {Number} c The value of the exponent in the equation + * @returns {Number} The predicted distance for the output race in meters + */ + predictDistance(t1, d1, t2, c = 1.06) { + return d1 * ((t2 / t1) ** (1 / c)); + }, +}; + +/* + * Methods that average the results of different race prediction models */ -function AverageModel(d1, t1, d2, c = 1.06) { - const purdy = PurdyPointsModel(d1, t1, d2); - const vo2max = VO2MaxModel(d1, t1, d2); - const cameron = CameronModel(d1, t1, d2); - const riegel = RiegelModel(d1, t1, d2, c); - return (purdy + vo2max + cameron + riegel) / 4; -} +const AverageModel = { + /** + * Predict a race time by averaging the results of different models + * @param {Number} d1 The distance of the input race in meters + * @param {Number} t1 The finish time of the input race in seconds + * @param {Number} d2 The distance of the output race in meters + * @param {Number} c The value of the exponent in Pete Riegel's Model + * @returns {Number} The predicted time for the output race in seconds + */ + predictTime(d1, t1, d2, c = 1.06) { + const purdy = PurdyPointsModel.predictTime(d1, t1, d2); + const vo2max = VO2MaxModel.predictTime(d1, t1, d2); + const cameron = CameronModel.predictTime(d1, t1, d2); + const riegel = RiegelModel.predictTime(d1, t1, d2, c); + return (purdy + vo2max + cameron + riegel) / 4; + }, + + /** + * Predict a race distance by averaging the results of different models + * @param {Number} t1 The finish time of the input race in seconds + * @param {Number} d1 The distance of the input race in meters + * @param {Number} t2 The finish time of the output race in seconds + * @param {Number} c The value of the exponent in Pete Riegel's Model + * @returns {Number} The predicted distance for the output race in meters + */ + predictDistance(t1, d1, t2, c = 1.06) { + const purdy = PurdyPointsModel.predictDistance(t1, d1, t2); + const vo2max = VO2MaxModel.predictDistance(t1, d1, t2); + const cameron = CameronModel.predictDistance(t1, d1, t2); + const riegel = RiegelModel.predictDistance(t1, d1, t2, c); + return (purdy + vo2max + cameron + riegel) / 4; + }, +}; export default { PurdyPointsModel, diff --git a/src/views/PaceCalculator.vue b/src/views/PaceCalculator.vue @@ -15,7 +15,7 @@ <p>is the same pace as running</p> - <time-table class="output" :calculate-result="calculatePace" :default-targets="defaultTargets" + <target-table class="output" :calculate-result="calculatePace" :default-targets="defaultTargets" storage-key="pace-calculator-targets"/> </div> </template> @@ -26,7 +26,7 @@ import unitUtils from '@/utils/units'; import DecimalInput from '@/components/DecimalInput.vue'; import TimeInput from '@/components/TimeInput.vue'; -import TimeTable from '@/components/TimeTable.vue'; +import TargetTable from '@/components/TargetTable.vue'; export default { name: 'PaceCalculator', @@ -34,7 +34,7 @@ export default { components: { DecimalInput, TimeInput, - TimeTable, + TargetTable, }, data() { @@ -52,7 +52,7 @@ export default { /** * The input time value */ - inputTime: 10 * 60, + inputTime: 8 * 60, /** * The names of the distance units @@ -63,37 +63,41 @@ export default { * The default output targets */ defaultTargets: [ - { distanceValue: 100, distanceUnit: 'meters' }, - { distanceValue: 200, distanceUnit: 'meters' }, - { distanceValue: 300, distanceUnit: 'meters' }, - { distanceValue: 400, distanceUnit: 'meters' }, - { distanceValue: 600, distanceUnit: 'meters' }, - { distanceValue: 800, distanceUnit: 'meters' }, - { distanceValue: 1000, distanceUnit: 'meters' }, - { distanceValue: 1200, distanceUnit: 'meters' }, - { distanceValue: 1500, distanceUnit: 'meters' }, - { distanceValue: 1600, distanceUnit: 'meters' }, - { distanceValue: 3200, distanceUnit: 'meters' }, - - { distanceValue: 2, distanceUnit: 'kilometers' }, - { distanceValue: 3, distanceUnit: 'kilometers' }, - { distanceValue: 4, distanceUnit: 'kilometers' }, - { distanceValue: 5, distanceUnit: 'kilometers' }, - { distanceValue: 6, distanceUnit: 'kilometers' }, - { distanceValue: 8, distanceUnit: 'kilometers' }, - { distanceValue: 10, distanceUnit: 'kilometers' }, - { distanceValue: 15, distanceUnit: 'kilometers' }, - - { distanceValue: 1, distanceUnit: 'miles' }, - { distanceValue: 2, distanceUnit: 'miles' }, - { distanceValue: 3, distanceUnit: 'miles' }, - { distanceValue: 5, distanceUnit: 'miles' }, - { distanceValue: 6, distanceUnit: 'miles' }, - { distanceValue: 8, distanceUnit: 'miles' }, - { distanceValue: 10, distanceUnit: 'miles' }, - - { distanceValue: 0.5, distanceUnit: 'marathons' }, - { distanceValue: 1, distanceUnit: 'marathons' }, + { result: 'time', distanceValue: 100, distanceUnit: 'meters' }, + { result: 'time', distanceValue: 200, distanceUnit: 'meters' }, + { result: 'time', distanceValue: 300, distanceUnit: 'meters' }, + { result: 'time', distanceValue: 400, distanceUnit: 'meters' }, + { result: 'time', distanceValue: 600, distanceUnit: 'meters' }, + { result: 'time', distanceValue: 800, distanceUnit: 'meters' }, + { result: 'time', distanceValue: 1000, distanceUnit: 'meters' }, + { result: 'time', distanceValue: 1200, distanceUnit: 'meters' }, + { result: 'time', distanceValue: 1500, distanceUnit: 'meters' }, + { result: 'time', distanceValue: 1600, distanceUnit: 'meters' }, + { result: 'time', distanceValue: 3200, distanceUnit: 'meters' }, + + { result: 'time', distanceValue: 2, distanceUnit: 'kilometers' }, + { result: 'time', distanceValue: 3, distanceUnit: 'kilometers' }, + { result: 'time', distanceValue: 4, distanceUnit: 'kilometers' }, + { result: 'time', distanceValue: 5, distanceUnit: 'kilometers' }, + { result: 'time', distanceValue: 6, distanceUnit: 'kilometers' }, + { result: 'time', distanceValue: 8, distanceUnit: 'kilometers' }, + { result: 'time', distanceValue: 10, distanceUnit: 'kilometers' }, + { result: 'time', distanceValue: 15, distanceUnit: 'kilometers' }, + + { result: 'time', distanceValue: 1, distanceUnit: 'miles' }, + { result: 'time', distanceValue: 2, distanceUnit: 'miles' }, + { result: 'time', distanceValue: 3, distanceUnit: 'miles' }, + { result: 'time', distanceValue: 5, distanceUnit: 'miles' }, + { result: 'time', distanceValue: 6, distanceUnit: 'miles' }, + { result: 'time', distanceValue: 8, distanceUnit: 'miles' }, + { result: 'time', distanceValue: 10, distanceUnit: 'miles' }, + + { result: 'time', distanceValue: 0.5, distanceUnit: 'marathons' }, + { result: 'time', distanceValue: 1, distanceUnit: 'marathons' }, + + { result: 'distance', distanceUnit: 'miles', time: 600 }, + { result: 'distance', distanceUnit: 'miles', time: 1800 }, + { result: 'distance', distanceUnit: 'miles', time: 3600 }, ], }; }, @@ -116,19 +120,40 @@ export default { * @returns {Object} The result */ calculatePace(target) { - // Convert distance into meters - const distance = unitUtils.convertDistance(target.distanceValue, target.distanceUnit, - unitUtils.DISTANCE_UNITS.meters); - - // Calculate time to travel distance at input pace - const time = paceUtils.getTime(this.pace, distance); - - // Return result - return { + // Initialize result + const result = { distanceValue: target.distanceValue, distanceUnit: target.distanceUnit, - time, + 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, + unitUtils.DISTANCE_UNITS.meters); + + // Calculate time to travel distance at input pace + const time = paceUtils.getTime(this.pace, d2); + + // Update result + result.time = time; + } else { + // Calculate distance traveled in time at input pace + let distance = paceUtils.getDistance(this.pace, target.time); + + // Convert output distance into miles + distance = unitUtils.convertDistance(distance, unitUtils.DISTANCE_UNITS.meters, + unitUtils.DISTANCE_UNITS.miles); + + // Update result + result.distanceValue = distance; + result.distanceUnit = 'miles'; + } + + // Return result + return result; }, }, }; diff --git a/src/views/RaceCalculator.vue b/src/views/RaceCalculator.vue @@ -14,7 +14,7 @@ <p>is approximately equivalent to running</p> - <time-table class="output" :calculate-result="predictTime" :default-targets="defaultTargets" + <target-table class="output" :calculate-result="predictTime" :default-targets="defaultTargets" storage-key="race-calculator-targets"/> </div> </template> @@ -25,7 +25,7 @@ import unitUtils from '@/utils/units'; import DecimalInput from '@/components/DecimalInput.vue'; import TimeInput from '@/components/TimeInput.vue'; -import TimeTable from '@/components/TimeTable.vue'; +import TargetTable from '@/components/TargetTable.vue'; export default { name: 'RaceCalculator', @@ -33,7 +33,7 @@ export default { components: { DecimalInput, TimeInput, - TimeTable, + TargetTable, }, data() { @@ -62,28 +62,31 @@ export default { * The default output targets */ defaultTargets: [ - { distanceValue: 400, distanceUnit: 'meters' }, - { distanceValue: 800, distanceUnit: 'meters' }, - { distanceValue: 1000, distanceUnit: 'meters' }, - { distanceValue: 1200, distanceUnit: 'meters' }, - { distanceValue: 1500, distanceUnit: 'meters' }, - { distanceValue: 1600, distanceUnit: 'meters' }, - { distanceValue: 3200, distanceUnit: 'meters' }, - - { distanceValue: 3, distanceUnit: 'kilometers' }, - { distanceValue: 5, distanceUnit: 'kilometers' }, - { distanceValue: 8, distanceUnit: 'kilometers' }, - { distanceValue: 10, distanceUnit: 'kilometers' }, - { distanceValue: 15, distanceUnit: 'kilometers' }, - - { distanceValue: 1, distanceUnit: 'miles' }, - { distanceValue: 2, distanceUnit: 'miles' }, - { distanceValue: 3, distanceUnit: 'miles' }, - { distanceValue: 5, distanceUnit: 'miles' }, - { distanceValue: 10, distanceUnit: 'miles' }, - - { distanceValue: 0.5, distanceUnit: 'marathons' }, - { distanceValue: 1, distanceUnit: 'marathons' }, + { result: 'time', distanceValue: 400, distanceUnit: 'meters' }, + { result: 'time', distanceValue: 800, distanceUnit: 'meters' }, + { result: 'time', distanceValue: 1000, distanceUnit: 'meters' }, + { result: 'time', distanceValue: 1200, distanceUnit: 'meters' }, + { result: 'time', distanceValue: 1500, distanceUnit: 'meters' }, + { result: 'time', distanceValue: 1600, distanceUnit: 'meters' }, + { result: 'time', distanceValue: 3200, distanceUnit: 'meters' }, + + { result: 'time', distanceValue: 3, distanceUnit: 'kilometers' }, + { result: 'time', distanceValue: 5, distanceUnit: 'kilometers' }, + { result: 'time', distanceValue: 8, distanceUnit: 'kilometers' }, + { result: 'time', distanceValue: 10, distanceUnit: 'kilometers' }, + { result: 'time', distanceValue: 15, distanceUnit: 'kilometers' }, + + { result: 'time', distanceValue: 1, distanceUnit: 'miles' }, + { result: 'time', distanceValue: 2, distanceUnit: 'miles' }, + { result: 'time', distanceValue: 3, distanceUnit: 'miles' }, + { result: 'time', distanceValue: 5, distanceUnit: 'miles' }, + { result: 'time', distanceValue: 10, distanceUnit: 'miles' }, + + { result: 'time', distanceValue: 0.5, distanceUnit: 'marathons' }, + { result: 'time', distanceValue: 1, distanceUnit: 'marathons' }, + + { result: 'distance', distanceUnit: 'miles', time: 600 }, + { result: 'distance', distanceUnit: 'miles', time: 3600 }, ], }; }, @@ -95,21 +98,44 @@ export default { * @returns {Object} The result */ predictTime(target) { - // Convert distances into meters + // Convert input race distance into meters const d1 = unitUtils.convertDistance(this.inputDistance, this.inputUnit, unitUtils.DISTANCE_UNITS.meters); - const d2 = unitUtils.convertDistance(target.distanceValue, target.distanceUnit, - unitUtils.DISTANCE_UNITS.meters); - - // Get prediction - const time = raceUtils.AverageModel(d1, this.inputTime, d2); - // Return result - return { + // Initialize result + const result = { distanceValue: target.distanceValue, distanceUnit: target.distanceUnit, - time, + 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, + unitUtils.DISTANCE_UNITS.meters); + + // Get prediction + const time = raceUtils.AverageModel.predictTime(d1, this.inputTime, d2); + + // Update result + result.time = time; + } else { + // Get prediction + let distance = raceUtils.AverageModel.predictDistance(this.inputTime, d1, target.time); + + // Convert output distance into miles + distance = unitUtils.convertDistance(distance, unitUtils.DISTANCE_UNITS.meters, + unitUtils.DISTANCE_UNITS.miles); + + // Update result + result.distanceValue = distance; + result.distanceUnit = 'miles'; + } + + // Return result + return result; }, }, }; diff --git a/tests/unit/components/TargetTable.spec.js b/tests/unit/components/TargetTable.spec.js @@ -0,0 +1,34 @@ +/* eslint-disable no-underscore-dangle */ + +import { expect } from 'chai'; +import { shallowMount } from '@vue/test-utils'; +import TargetTable from '@/components/TargetTable.vue'; + +describe('components/TargetTable.vue', () => { + it('results should be correct and sorted by time', () => { + // Initialize component + const wrapper = shallowMount(TargetTable, { + propsData: { + calculateResult: (row) => ({ + distanceValue: row.distanceValue, + distanceUnit: row.distanceUnit, + time: row.distanceValue + 1, + }), + defaultTargets: [ + { distanceValue: 20, distanceUnit: 'meters' }, + { distanceValue: 100, distanceUnit: 'meters' }, + { distanceValue: 1, distanceUnit: 'kilometers' }, + { distanceValue: 10, distanceUnit: 'meters' }, + ], + }, + }); + + // Assert results are correct + expect(wrapper.vm._computedWatchers.results.value).to.deep.equal([ + { distanceValue: 1, distanceUnit: 'kilometers', time: 2 }, + { distanceValue: 10, distanceUnit: 'meters', time: 11 }, + { distanceValue: 20, distanceUnit: 'meters', time: 21 }, + { distanceValue: 100, distanceUnit: 'meters', time: 101 }, + ]); + }); +}); diff --git a/tests/unit/components/TimeTable.spec.js b/tests/unit/components/TimeTable.spec.js @@ -1,34 +0,0 @@ -/* eslint-disable no-underscore-dangle */ - -import { expect } from 'chai'; -import { shallowMount } from '@vue/test-utils'; -import TimeTable from '@/components/TimeTable.vue'; - -describe('components/TimeTable.vue', () => { - it('results should be correct and sorted by time', () => { - // Initialize component - const wrapper = shallowMount(TimeTable, { - propsData: { - calculateResult: (row) => ({ - distanceValue: row.distanceValue, - distanceUnit: row.distanceUnit, - time: row.distanceValue + 1, - }), - defaultTargets: [ - { distanceValue: 20, distanceUnit: 'meters' }, - { distanceValue: 100, distanceUnit: 'meters' }, - { distanceValue: 1, distanceUnit: 'kilometers' }, - { distanceValue: 10, distanceUnit: 'meters' }, - ], - }, - }); - - // Assert results are correct - expect(wrapper.vm._computedWatchers.results.value).to.deep.equal([ - { distanceValue: 1, distanceUnit: 'kilometers', time: 2 }, - { distanceValue: 10, distanceUnit: 'meters', time: 11 }, - { distanceValue: 20, distanceUnit: 'meters', time: 21 }, - { distanceValue: 100, distanceUnit: 'meters', time: 101 }, - ]); - }); -}); diff --git a/tests/unit/utils/races.spec.js b/tests/unit/utils/races.spec.js @@ -2,67 +2,145 @@ import { expect } from 'chai'; import raceUtils from '@/utils/races'; describe('utils/races.js', () => { - describe('PurdyPointsModel method', () => { - it('Predictions should be approximately correct', () => { - const result = raceUtils.PurdyPointsModel(5000, 1200, 10000); - expect(result).to.be.closeTo(2490, 1); + describe('PurdyPointsModel', () => { + describe('PredictTime method', () => { + it('Predictions should be approximately correct', () => { + const result = raceUtils.PurdyPointsModel.predictTime(5000, 1200, 10000); + expect(result).to.be.closeTo(2490, 1); + }); + + it('Should predict identical times for itentical distances', () => { + const result = raceUtils.PurdyPointsModel.predictTime(5000, 1200, 5000); + expect(result).to.be.closeTo(1200, 0.001); + }); }); - it('Should predict identical times for itentical distances', () => { - const result = raceUtils.PurdyPointsModel(5000, 1200, 5000); - expect(result).to.be.closeTo(1200, 0.001); + describe('PredictDistance method', () => { + it('Predictions should be approximately correct', () => { + const result = raceUtils.PurdyPointsModel.predictDistance(1200, 5000, 2490); + expect(result).to.be.closeTo(10000, 10); + }); + + it('Should predict identical times for itentical distances', () => { + const result = raceUtils.PurdyPointsModel.predictDistance(1200, 5000, 1200); + expect(result).to.be.closeTo(5000, 0.001); + }); }); }); - describe('VO2MaxModel method', () => { - it('Predictions should be approximately correct', () => { - const result = raceUtils.VO2MaxModel(5000, 1200, 10000); - expect(result).to.be.closeTo(2488, 1); + describe('VO2MaxModel', () => { + describe('PredictTime method', () => { + it('Predictions should be approximately correct', () => { + const result = raceUtils.VO2MaxModel.predictTime(5000, 1200, 10000); + expect(result).to.be.closeTo(2488, 1); + }); + + it('Should predict identical times for itentical distances', () => { + const result = raceUtils.VO2MaxModel.predictTime(5000, 1200, 5000); + expect(result).to.be.closeTo(1200, 0.001); + }); }); - it('Should predict identical times for itentical distances', () => { - const result = raceUtils.VO2MaxModel(5000, 1200, 5000); - expect(result).to.be.closeTo(1200, 0.001); + describe('PredictDistance method', () => { + it('Predictions should be approximately correct', () => { + const result = raceUtils.VO2MaxModel.predictDistance(1200, 5000, 2488); + expect(result).to.be.closeTo(10000, 10); + }); + + it('Should predict identical times for itentical distances', () => { + const result = raceUtils.VO2MaxModel.predictDistance(1200, 5000, 1200); + expect(result).to.be.closeTo(5000, 0.001); + }); }); }); - describe('CameronModel method', () => { - it('Predictions should be approximately correct', () => { - const result = raceUtils.CameronModel(5000, 1200, 10000); - expect(result).to.be.closeTo(2500, 1); + describe('CameronModel', () => { + describe('PredictTime method', () => { + it('Predictions should be approximately correct', () => { + const result = raceUtils.CameronModel.predictTime(5000, 1200, 10000); + expect(result).to.be.closeTo(2500, 1); + }); + + it('Should predict identical times for itentical distances', () => { + const result = raceUtils.CameronModel.predictTime(5000, 1200, 5000); + expect(result).to.be.closeTo(1200, 0.001); + }); }); - it('Should predict identical times for itentical distances', () => { - const result = raceUtils.CameronModel(5000, 1200, 5000); - expect(result).to.be.closeTo(1200, 0.001); + describe('PredictDistance method', () => { + it('Predictions should be approximately correct', () => { + const result = raceUtils.CameronModel.predictDistance(1200, 5000, 2500); + expect(result).to.be.closeTo(10000, 10); + }); + + it('Should predict identical times for itentical distances', () => { + const result = raceUtils.CameronModel.predictDistance(1200, 5000, 1200); + expect(result).to.be.closeTo(5000, 0.001); + }); }); }); - describe('RiegelModel method', () => { - it('Predictions should be approximately correct', () => { - const result = raceUtils.RiegelModel(5000, 1200, 10000); - expect(result).to.be.closeTo(2502, 1); + describe('RiegelModel', () => { + describe('PredictTime method', () => { + it('Predictions should be approximately correct', () => { + const result = raceUtils.RiegelModel.predictTime(5000, 1200, 10000); + expect(result).to.be.closeTo(2502, 1); + }); + + it('Should predict identical times for itentical distances', () => { + const result = raceUtils.RiegelModel.predictTime(5000, 1200, 5000); + expect(result).to.be.closeTo(1200, 0.001); + }); }); - it('Should predict identical times for itentical distances', () => { - const result = raceUtils.RiegelModel(5000, 1200, 5000); - expect(result).to.be.closeTo(1200, 0.001); + describe('PredictDistance method', () => { + it('Predictions should be approximately correct', () => { + const result = raceUtils.RiegelModel.predictDistance(1200, 5000, 2502); + expect(result).to.be.closeTo(10000, 10); + }); + + it('Should predict identical times for itentical distances', () => { + const result = raceUtils.RiegelModel.predictDistance(1200, 5000, 1200); + expect(result).to.be.closeTo(5000, 0.001); + }); }); }); - describe('AverageModel method', () => { - it('Predictions should be correct', () => { - const result = raceUtils.AverageModel(5000, 1200, 10000); - const riegel = raceUtils.RiegelModel(5000, 1200, 10000); - const cameron = raceUtils.CameronModel(5000, 1200, 10000); - const purdyPoints = raceUtils.PurdyPointsModel(5000, 1200, 10000); - const vo2Max = raceUtils.VO2MaxModel(5000, 1200, 10000); - expect(result).to.equal((riegel + cameron + purdyPoints + vo2Max) / 4); + describe('AverageModel', () => { + describe('PredictTime method', () => { + it('Predictions should be correct', () => { + const riegel = raceUtils.RiegelModel.predictTime(5000, 1200, 10000); + const cameron = raceUtils.CameronModel.predictTime(5000, 1200, 10000); + const purdyPoints = raceUtils.PurdyPointsModel.predictTime(5000, 1200, 10000); + const vo2Max = raceUtils.VO2MaxModel.predictTime(5000, 1200, 10000); + const expected = (riegel + cameron + purdyPoints + vo2Max) / 4; + + const result = raceUtils.AverageModel.predictTime(5000, 1200, 10000); + expect(result).to.equal(expected); + }); + + it('Should predict identical times for itentical distances', () => { + const result = raceUtils.AverageModel.predictTime(5000, 1200, 5000); + expect(result).to.be.closeTo(1200, 0.001); + }); }); - it('Should predict identical times for itentical distances', () => { - const result = raceUtils.AverageModel(5000, 1200, 5000); - expect(result).to.be.closeTo(1200, 0.001); + describe('PredictDistance method', () => { + it('Predictions should be correct', () => { + const riegel = raceUtils.RiegelModel.predictTime(5000, 1200, 10000); + const cameron = raceUtils.CameronModel.predictTime(5000, 1200, 10000); + const purdyPoints = raceUtils.PurdyPointsModel.predictTime(5000, 1200, 10000); + const vo2Max = raceUtils.VO2MaxModel.predictTime(5000, 1200, 10000); + const expected = (riegel + cameron + purdyPoints + vo2Max) / 4; + + const result = raceUtils.AverageModel.predictDistance(1200, 5000, expected); + expect(result).to.be.closeTo(10000, 10); + }); + + it('Should predict identical times for itentical distances', () => { + const result = raceUtils.AverageModel.predictDistance(1200, 5000, 1200); + expect(result).to.be.closeTo(5000, 0.001); + }); }); }); }); diff --git a/tests/unit/views/PaceCalculator.spec.js b/tests/unit/views/PaceCalculator.spec.js @@ -5,7 +5,7 @@ import { shallowMount } from '@vue/test-utils'; import PaceCalculator from '@/views/PaceCalculator.vue'; describe('views/PaceCalculator.vue', () => { - it('should correctly calculate paces', async () => { + it('should correctly calculate times', async () => { // Initialize component const wrapper = shallowMount(PaceCalculator); @@ -20,6 +20,7 @@ describe('views/PaceCalculator.vue', () => { const result = wrapper.vm.calculatePace({ distanceValue: 20, distanceUnit: 'meters', + result: 'time', }); // Assert result is correct @@ -27,6 +28,33 @@ describe('views/PaceCalculator.vue', () => { distanceValue: 20, distanceUnit: 'meters', time: 2, + result: 'time', + }); + }); + + it('should correctly calculate distances', async () => { + // Initialize component + const wrapper = shallowMount(PaceCalculator); + + // Override input values + await wrapper.setData({ + inputDistance: 1, + inputUnit: 'miles', + inputTime: 100, + }); + + // Calculate paces + const result = wrapper.vm.calculatePace({ + time: 200, + result: 'distance', + }); + + // Assert result is correct + expect(result).to.deep.equal({ + distanceValue: 2, + distanceUnit: 'miles', + time: 200, + result: 'distance', }); }); }); diff --git a/tests/unit/views/RaceCalculator.spec.js b/tests/unit/views/RaceCalculator.spec.js @@ -3,6 +3,7 @@ import { expect } from 'chai'; import { shallowMount } from '@vue/test-utils'; import raceUtils from '@/utils/races'; +import unitUtils from '@/utils/units'; import RaceCalculator from '@/views/RaceCalculator.vue'; describe('views/RaceCalculator.vue', () => { @@ -14,21 +15,50 @@ describe('views/RaceCalculator.vue', () => { await wrapper.setData({ inputDistance: 5, inputUnit: 'kilometers', - inputTime: 20 * 60, + inputTime: 1200, }); // Predict race times const result = wrapper.vm.predictTime({ distanceValue: 10, distanceUnit: 'kilometers', + result: 'time', }); // Assert result is correct - const prediction = raceUtils.AverageModel(5000, 1200, 10000); + const prediction = raceUtils.AverageModel.predictTime(5000, 1200, 10000); expect(result).to.deep.equal({ distanceValue: 10, distanceUnit: 'kilometers', time: prediction, + result: 'time', + }); + }); + + it('should correctly predict race distances', async () => { + // Initialize component + const wrapper = shallowMount(RaceCalculator); + + // Override input values + await wrapper.setData({ + inputDistance: 5, + inputUnit: 'kilometers', + inputTime: 1200, + }); + + // Predict race distances + const result = wrapper.vm.predictTime({ + time: 2460, + result: 'distance', + }); + + // Assert result is correct + const prediction = raceUtils.AverageModel.predictDistance(1200, 5000, 2460); + expect(result).to.deep.equal({ + distanceValue: unitUtils.convertDistance(prediction, 'meters', 'miles'), + distanceUnit: 'miles', + time: 2460, + result: 'distance', }); }); });