calcpace 1.18.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +395 -11
- data/README.md +330 -46
- data/calcpace.gemspec +9 -5
- data/lib/calcpace/age_grading.rb +29 -24
- data/lib/calcpace/cameron_predictor.rb +73 -27
- data/lib/calcpace/checker.rb +101 -21
- data/lib/calcpace/converter.rb +19 -14
- data/lib/calcpace/data/environmental_factors.yml +88 -8
- data/lib/calcpace/data/friend_2015_vo2max_percentiles.yml +49 -0
- data/lib/calcpace/data/mldr_2025_road.yml +21 -0
- data/lib/calcpace/data/mldr_2025_road_open_standards.yml +46 -0
- data/lib/calcpace/environmental_adjuster.rb +65 -31
- data/lib/calcpace/errors.rb +3 -0
- data/lib/calcpace/grade_adjusted_pace.rb +114 -0
- data/lib/calcpace/humidity.rb +137 -0
- data/lib/calcpace/personalized_predictor.rb +220 -0
- data/lib/calcpace/race_predictor.rb +7 -2
- data/lib/calcpace/race_splits.rb +7 -5
- data/lib/calcpace/track_calculator.rb +183 -10
- data/lib/calcpace/training_zones.rb +42 -11
- data/lib/calcpace/version.rb +1 -1
- data/lib/calcpace/vo2max_estimator.rb +29 -4
- data/lib/calcpace/vo2max_norms.rb +121 -0
- data/lib/calcpace.rb +8 -1
- metadata +16 -9
- data/lib/calcpace/data/wma_2023_open_standards.yml +0 -38
- data/lib/calcpace/data/wma_2023_road.yml +0 -16
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Road-running age factors, one per single year of age, from Alan Jones'
|
|
2
|
+
# 2025 road age-grading tables (approved 2025-01-10 by the USATF Masters Long
|
|
3
|
+
# Distance Running Council). Source, sheet "Age Factors" of:
|
|
4
|
+
# https://github.com/AlanLyttonJones/Age-Grade-Tables/blob/4aac6737cb9f216c90a0a610355667cd3d921c61/2025%20Files/MaleRoadStd2025.xlsx
|
|
5
|
+
# https://github.com/AlanLyttonJones/Age-Grade-Tables/blob/4aac6737cb9f216c90a0a610355667cd3d921c61/2025%20Files/FemaleRoadStd2025.xlsx
|
|
6
|
+
# Columns used: "5 km" -> 5000, "10 km" -> 10000, "H. Mar" (21.0975 km) -> 21097,
|
|
7
|
+
# "Marathon" (42.195 km) -> 42195. The tables start at age 5; ages under 18 are
|
|
8
|
+
# left out because the gem refuses them. Factor = open standard / age standard,
|
|
9
|
+
# so age-graded time = time * factor. See the open standards file for the meta.
|
|
10
|
+
|
|
11
|
+
F:
|
|
12
|
+
"5000": { 18: 0.9953, 19: 1.0000, 20: 1.0000, 21: 1.0000, 22: 1.0000, 23: 1.0000, 24: 1.0000, 25: 1.0000, 26: 1.0000, 27: 0.9997, 28: 0.9990, 29: 0.9977, 30: 0.9959, 31: 0.9936, 32: 0.9908, 33: 0.9875, 34: 0.9836, 35: 0.9793, 36: 0.9744, 37: 0.9691, 38: 0.9632, 39: 0.9568, 40: 0.9499, 41: 0.9425, 42: 0.9346, 43: 0.9262, 44: 0.9172, 45: 0.9078, 46: 0.8980, 47: 0.8883, 48: 0.8786, 49: 0.8689, 50: 0.8592, 51: 0.8495, 52: 0.8398, 53: 0.8301, 54: 0.8204, 55: 0.8107, 56: 0.8009, 57: 0.7912, 58: 0.7815, 59: 0.7718, 60: 0.7621, 61: 0.7524, 62: 0.7427, 63: 0.7330, 64: 0.7233, 65: 0.7136, 66: 0.7038, 67: 0.6941, 68: 0.6844, 69: 0.6747, 70: 0.6650, 71: 0.6553, 72: 0.6456, 73: 0.6359, 74: 0.6262, 75: 0.6165, 76: 0.6067, 77: 0.5970, 78: 0.5868, 79: 0.5758, 80: 0.5640, 81: 0.5515, 82: 0.5382, 83: 0.5242, 84: 0.5094, 85: 0.4938, 86: 0.4775, 87: 0.4604, 88: 0.4426, 89: 0.4240, 90: 0.4046, 91: 0.3845, 92: 0.3636, 93: 0.3419, 94: 0.3195, 95: 0.2964, 96: 0.2725, 97: 0.2478, 98: 0.2223, 99: 0.1961, 100: 0.1692 }
|
|
13
|
+
"10000": { 18: 0.9820, 19: 0.9920, 20: 0.9980, 21: 1.0000, 22: 1.0000, 23: 1.0000, 24: 1.0000, 25: 1.0000, 26: 1.0000, 27: 1.0000, 28: 0.9998, 29: 0.9991, 30: 0.9980, 31: 0.9964, 32: 0.9944, 33: 0.9920, 34: 0.9891, 35: 0.9857, 36: 0.9819, 37: 0.9777, 38: 0.9730, 39: 0.9679, 40: 0.9623, 41: 0.9563, 42: 0.9499, 43: 0.9429, 44: 0.9356, 45: 0.9278, 46: 0.9195, 47: 0.9109, 48: 0.9017, 49: 0.8921, 50: 0.8822, 51: 0.8723, 52: 0.8623, 53: 0.8524, 54: 0.8425, 55: 0.8325, 56: 0.8226, 57: 0.8126, 58: 0.8027, 59: 0.7928, 60: 0.7828, 61: 0.7729, 62: 0.7629, 63: 0.7530, 64: 0.7431, 65: 0.7331, 66: 0.7232, 67: 0.7132, 68: 0.7033, 69: 0.6934, 70: 0.6834, 71: 0.6735, 72: 0.6635, 73: 0.6536, 74: 0.6437, 75: 0.6337, 76: 0.6234, 77: 0.6123, 78: 0.6005, 79: 0.5879, 80: 0.5745, 81: 0.5604, 82: 0.5455, 83: 0.5299, 84: 0.5135, 85: 0.4963, 86: 0.4784, 87: 0.4597, 88: 0.4403, 89: 0.4201, 90: 0.3991, 91: 0.3774, 92: 0.3549, 93: 0.3317, 94: 0.3077, 95: 0.2829, 96: 0.2574, 97: 0.2311, 98: 0.2041, 99: 0.1763, 100: 0.1477 }
|
|
14
|
+
"21097": { 18: 0.9680, 19: 0.9820, 20: 0.9920, 21: 0.9980, 22: 1.0000, 23: 1.0000, 24: 1.0000, 25: 1.0000, 26: 1.0000, 27: 1.0000, 28: 0.9998, 29: 0.9991, 30: 0.9979, 31: 0.9962, 32: 0.9941, 33: 0.9915, 34: 0.9884, 35: 0.9849, 36: 0.9809, 37: 0.9764, 38: 0.9714, 39: 0.9660, 40: 0.9601, 41: 0.9537, 42: 0.9468, 43: 0.9395, 44: 0.9317, 45: 0.9234, 46: 0.9147, 47: 0.9055, 48: 0.8958, 49: 0.8856, 50: 0.8753, 51: 0.8649, 52: 0.8546, 53: 0.8442, 54: 0.8339, 55: 0.8235, 56: 0.8132, 57: 0.8028, 58: 0.7925, 59: 0.7821, 60: 0.7718, 61: 0.7614, 62: 0.7511, 63: 0.7407, 64: 0.7304, 65: 0.7200, 66: 0.7097, 67: 0.6993, 68: 0.6890, 69: 0.6786, 70: 0.6683, 71: 0.6579, 72: 0.6476, 73: 0.6372, 74: 0.6269, 75: 0.6165, 76: 0.6059, 77: 0.5945, 78: 0.5822, 79: 0.5692, 80: 0.5554, 81: 0.5407, 82: 0.5253, 83: 0.5091, 84: 0.4921, 85: 0.4742, 86: 0.4556, 87: 0.4362, 88: 0.4159, 89: 0.3949, 90: 0.3731, 91: 0.3504, 92: 0.3270, 93: 0.3028, 94: 0.2778, 95: 0.2519, 96: 0.2253, 97: 0.1979, 98: 0.1696, 99: 0.1406, 100: 0.1108 }
|
|
15
|
+
"42195": { 18: 0.9217, 19: 0.9391, 20: 0.9553, 21: 0.9689, 22: 0.9801, 23: 0.9888, 24: 0.9950, 25: 0.9988, 26: 1.0000, 27: 1.0000, 28: 0.9998, 29: 0.9992, 30: 0.9983, 31: 0.9970, 32: 0.9953, 33: 0.9932, 34: 0.9907, 35: 0.9879, 36: 0.9847, 37: 0.9811, 38: 0.9771, 39: 0.9727, 40: 0.9680, 41: 0.9629, 42: 0.9574, 43: 0.9515, 44: 0.9453, 45: 0.9386, 46: 0.9316, 47: 0.9242, 48: 0.9165, 49: 0.9083, 50: 0.8998, 51: 0.8909, 52: 0.8816, 53: 0.8720, 54: 0.8619, 55: 0.8515, 56: 0.8407, 57: 0.8297, 58: 0.8186, 59: 0.8076, 60: 0.7965, 61: 0.7854, 62: 0.7744, 63: 0.7633, 64: 0.7523, 65: 0.7412, 66: 0.7301, 67: 0.7191, 68: 0.7080, 69: 0.6970, 70: 0.6859, 71: 0.6748, 72: 0.6638, 73: 0.6527, 74: 0.6413, 75: 0.6290, 76: 0.6159, 77: 0.6021, 78: 0.5874, 79: 0.5720, 80: 0.5557, 81: 0.5386, 82: 0.5208, 83: 0.5021, 84: 0.4827, 85: 0.4624, 86: 0.4413, 87: 0.4195, 88: 0.3968, 89: 0.3734, 90: 0.3491, 91: 0.3240, 92: 0.2982, 93: 0.2715, 94: 0.2441, 95: 0.2158, 96: 0.1867, 97: 0.1569, 98: 0.1262, 99: 0.0948, 100: 0.0625 }
|
|
16
|
+
|
|
17
|
+
M:
|
|
18
|
+
"5000": { 18: 0.9995, 19: 1.0000, 20: 1.0000, 21: 1.0000, 22: 1.0000, 23: 1.0000, 24: 1.0000, 25: 1.0000, 26: 1.0000, 27: 1.0000, 28: 1.0000, 29: 1.0000, 30: 0.9999, 31: 0.9988, 32: 0.9965, 33: 0.9930, 34: 0.9883, 35: 0.9824, 36: 0.9755, 37: 0.9685, 38: 0.9615, 39: 0.9545, 40: 0.9475, 41: 0.9405, 42: 0.9335, 43: 0.9265, 44: 0.9195, 45: 0.9125, 46: 0.9055, 47: 0.8985, 48: 0.8915, 49: 0.8845, 50: 0.8775, 51: 0.8705, 52: 0.8635, 53: 0.8565, 54: 0.8495, 55: 0.8425, 56: 0.8355, 57: 0.8285, 58: 0.8215, 59: 0.8145, 60: 0.8075, 61: 0.8005, 62: 0.7935, 63: 0.7865, 64: 0.7795, 65: 0.7725, 66: 0.7655, 67: 0.7585, 68: 0.7514, 69: 0.7436, 70: 0.7353, 71: 0.7264, 72: 0.7169, 73: 0.7068, 74: 0.6960, 75: 0.6847, 76: 0.6728, 77: 0.6603, 78: 0.6472, 79: 0.6334, 80: 0.6191, 81: 0.6042, 82: 0.5887, 83: 0.5726, 84: 0.5558, 85: 0.5385, 86: 0.5206, 87: 0.5021, 88: 0.4830, 89: 0.4632, 90: 0.4429, 91: 0.4220, 92: 0.4005, 93: 0.3784, 94: 0.3556, 95: 0.3323, 96: 0.3084, 97: 0.2839, 98: 0.2588, 99: 0.2330, 100: 0.2067 }
|
|
19
|
+
"10000": { 18: 0.9818, 19: 0.9903, 20: 0.9968, 21: 1.0000, 22: 1.0000, 23: 1.0000, 24: 1.0000, 25: 1.0000, 26: 1.0000, 27: 1.0000, 28: 1.0000, 29: 1.0000, 30: 1.0000, 31: 0.9996, 32: 0.9985, 33: 0.9967, 34: 0.9942, 35: 0.9909, 36: 0.9869, 37: 0.9822, 38: 0.9767, 39: 0.9705, 40: 0.9636, 41: 0.9561, 42: 0.9486, 43: 0.9411, 44: 0.9336, 45: 0.9261, 46: 0.9186, 47: 0.9111, 48: 0.9036, 49: 0.8961, 50: 0.8886, 51: 0.8811, 52: 0.8736, 53: 0.8661, 54: 0.8586, 55: 0.8511, 56: 0.8436, 57: 0.8361, 58: 0.8286, 59: 0.8211, 60: 0.8136, 61: 0.8061, 62: 0.7986, 63: 0.7911, 64: 0.7836, 65: 0.7761, 66: 0.7686, 67: 0.7611, 68: 0.7536, 69: 0.7461, 70: 0.7386, 71: 0.7308, 72: 0.7223, 73: 0.7131, 74: 0.7033, 75: 0.6928, 76: 0.6816, 77: 0.6697, 78: 0.6572, 79: 0.6440, 80: 0.6301, 81: 0.6156, 82: 0.6004, 83: 0.5845, 84: 0.5680, 85: 0.5508, 86: 0.5329, 87: 0.5143, 88: 0.4951, 89: 0.4752, 90: 0.4546, 91: 0.4334, 92: 0.4115, 93: 0.3889, 94: 0.3657, 95: 0.3418, 96: 0.3172, 97: 0.2919, 98: 0.2660, 99: 0.2394, 100: 0.2121 }
|
|
20
|
+
"21097": { 18: 0.9850, 19: 0.9950, 20: 1.0000, 21: 1.0000, 22: 1.0000, 23: 1.0000, 24: 1.0000, 25: 1.0000, 26: 1.0000, 27: 1.0000, 28: 1.0000, 29: 1.0000, 30: 1.0000, 31: 1.0000, 32: 0.9996, 33: 0.9982, 34: 0.9960, 35: 0.9928, 36: 0.9888, 37: 0.9839, 38: 0.9781, 39: 0.9714, 40: 0.9638, 41: 0.9560, 42: 0.9483, 43: 0.9405, 44: 0.9327, 45: 0.9249, 46: 0.9171, 47: 0.9094, 48: 0.9016, 49: 0.8938, 50: 0.8860, 51: 0.8782, 52: 0.8705, 53: 0.8627, 54: 0.8549, 55: 0.8471, 56: 0.8393, 57: 0.8316, 58: 0.8238, 59: 0.8160, 60: 0.8082, 61: 0.8004, 62: 0.7927, 63: 0.7849, 64: 0.7771, 65: 0.7693, 66: 0.7615, 67: 0.7538, 68: 0.7460, 69: 0.7382, 70: 0.7304, 71: 0.7223, 72: 0.7135, 73: 0.7040, 74: 0.6938, 75: 0.6830, 76: 0.6715, 77: 0.6593, 78: 0.6464, 79: 0.6328, 80: 0.6185, 81: 0.6036, 82: 0.5880, 83: 0.5717, 84: 0.5547, 85: 0.5370, 86: 0.5186, 87: 0.4996, 88: 0.4799, 89: 0.4595, 90: 0.4384, 91: 0.4167, 92: 0.3942, 93: 0.3711, 94: 0.3473, 95: 0.3228, 96: 0.2976, 97: 0.2718, 98: 0.2452, 99: 0.2180, 100: 0.1901 }
|
|
21
|
+
"42195": { 18: 0.9680, 19: 0.9820, 20: 0.9920, 21: 0.9980, 22: 1.0000, 23: 1.0000, 24: 1.0000, 25: 1.0000, 26: 1.0000, 27: 1.0000, 28: 1.0000, 29: 1.0000, 30: 1.0000, 31: 1.0000, 32: 1.0000, 33: 1.0000, 34: 1.0000, 35: 1.0000, 36: 0.9999, 37: 0.9979, 38: 0.9934, 39: 0.9865, 40: 0.9783, 41: 0.9701, 42: 0.9619, 43: 0.9537, 44: 0.9455, 45: 0.9373, 46: 0.9291, 47: 0.9209, 48: 0.9127, 49: 0.9045, 50: 0.8963, 51: 0.8881, 52: 0.8799, 53: 0.8717, 54: 0.8635, 55: 0.8553, 56: 0.8471, 57: 0.8389, 58: 0.8307, 59: 0.8225, 60: 0.8143, 61: 0.8061, 62: 0.7979, 63: 0.7897, 64: 0.7815, 65: 0.7733, 66: 0.7651, 67: 0.7569, 68: 0.7487, 69: 0.7405, 70: 0.7323, 71: 0.7241, 72: 0.7155, 73: 0.7063, 74: 0.6963, 75: 0.6857, 76: 0.6743, 77: 0.6623, 78: 0.6495, 79: 0.6361, 80: 0.6219, 81: 0.6071, 82: 0.5915, 83: 0.5753, 84: 0.5583, 85: 0.5407, 86: 0.5223, 87: 0.5033, 88: 0.4835, 89: 0.4631, 90: 0.4419, 91: 0.4201, 92: 0.3975, 93: 0.3743, 94: 0.3503, 95: 0.3257, 96: 0.3003, 97: 0.2743, 98: 0.2475, 99: 0.2201, 100: 0.1919 }
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
meta:
|
|
2
|
+
source: "Alan Jones, 2025 road age-grading tables (single-age bests by Tom Bernhard), approved 2025-01-10 by the USATF Masters Long Distance Running Council"
|
|
3
|
+
url: "https://github.com/AlanLyttonJones/Age-Grade-Tables/tree/4aac6737cb9f216c90a0a610355667cd3d921c61/2025%20Files"
|
|
4
|
+
files:
|
|
5
|
+
- "2025 Files/MaleRoadStd2025.xlsx (sheet \"Age Factors\", row \"OC sec\")"
|
|
6
|
+
- "2025 Files/FemaleRoadStd2025.xlsx (sheet \"Age Facctors\", row \"OC sec\")"
|
|
7
|
+
table_version: "MLDR_2025_ROAD_ONE_YEAR_FACTORS_V1"
|
|
8
|
+
|
|
9
|
+
# The Alan Jones road tables (also served by Howard Grubb's calculators) are
|
|
10
|
+
# numeric age factors and open standards only — they define no categories at
|
|
11
|
+
# all. The bands from Local Class (60%) upward follow the USATF Masters /
|
|
12
|
+
# National Masters News convention; the three bands under 60% are Calcpace's
|
|
13
|
+
# own extension, added to give recreational runners a meaningful label instead
|
|
14
|
+
# of a single catch-all.
|
|
15
|
+
age_grade_classifications:
|
|
16
|
+
- min: 100.0
|
|
17
|
+
label: "Approximate World Record Level"
|
|
18
|
+
- min: 90.0
|
|
19
|
+
label: "World Class"
|
|
20
|
+
- min: 80.0
|
|
21
|
+
label: "National Class"
|
|
22
|
+
- min: 70.0
|
|
23
|
+
label: "Regional Class"
|
|
24
|
+
- min: 60.0
|
|
25
|
+
label: "Local Class"
|
|
26
|
+
- min: 50.0
|
|
27
|
+
label: "Intermediate"
|
|
28
|
+
- min: 40.0
|
|
29
|
+
label: "Recreational"
|
|
30
|
+
- min: 0.0
|
|
31
|
+
label: "Active Beginner"
|
|
32
|
+
|
|
33
|
+
# Open-class (OC) road standards, in seconds: the best road time for the
|
|
34
|
+
# distance at any age. 21097 is the half marathon (21.0975 km), 42195 the
|
|
35
|
+
# marathon (42.195 km).
|
|
36
|
+
open_standards_seconds:
|
|
37
|
+
M:
|
|
38
|
+
"5000": 769.0 # 12:49
|
|
39
|
+
"10000": 1584.0 # 26:24
|
|
40
|
+
"21097": 3451.0 # 57:31
|
|
41
|
+
"42195": 7235.0 # 2:00:35
|
|
42
|
+
F:
|
|
43
|
+
"5000": 834.0 # 13:54
|
|
44
|
+
"10000": 1726.0 # 28:46
|
|
45
|
+
"21097": 3772.0 # 1:02:52
|
|
46
|
+
"42195": 7796.0 # 2:09:56
|
|
@@ -1,34 +1,71 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'yaml'
|
|
4
|
+
require_relative 'humidity'
|
|
4
5
|
|
|
5
6
|
# Module for adjusting race performance based on environmental conditions
|
|
6
7
|
#
|
|
7
8
|
# Scientific basis:
|
|
8
|
-
# - Heat:
|
|
9
|
+
# - Heat: Ely et al. (2007) "Impact of Weather on Marathon-Running Performance"
|
|
10
|
+
# (qualitative: slowing grows with WBGT, more for slower runners) and
|
|
11
|
+
# El Helou et al. (2012) "Impact of Environmental Parameters on Marathon
|
|
12
|
+
# Running Performance" (duration scaling, see HEAT_DURATION_FACTORS)
|
|
9
13
|
# - Altitude: NCAA Altitude Adjustment Factors (TFRRS)
|
|
14
|
+
# - Humidity: Australian Bureau of Meteorology simplified WBGT
|
|
15
|
+
# (WBGT = 0.567·Ta + 0.393·e + 3.94, e = vapour pressure in hPa)
|
|
10
16
|
module EnvironmentalAdjuster
|
|
11
17
|
DATA_PATH = File.expand_path('data/environmental_factors.yml', __dir__).freeze
|
|
12
18
|
FACTORS = YAML.safe_load_file(DATA_PATH, permitted_classes: [], aliases: false).freeze
|
|
13
19
|
|
|
20
|
+
# Heat duration scaling: [minutes, factor] points, joined by straight lines
|
|
21
|
+
# and flat outside the first and last point. The base heat penalty in
|
|
22
|
+
# environmental_factors.yml is for a 60-minute effort (factor 1.0).
|
|
23
|
+
# - 30 min (0.5x) and 60 min (1.0x): kept from the original model; no
|
|
24
|
+
# marathon dataset covers efforts this short.
|
|
25
|
+
# - 3 h (1.76x) and 4 h (2.81x, flat after): weighted least-squares fit to
|
|
26
|
+
# El Helou et al. (2012) Table S3 — the time penalty against 15 °C at
|
|
27
|
+
# 20 °C and 25 °C for eight finisher groups (2:41–4:54) divided by the
|
|
28
|
+
# 60-minute base (itself fitted to the same table). The 2 h value (1.38x)
|
|
29
|
+
# is the straight line 60 → 180 min: no group finishes between 1 h and
|
|
30
|
+
# 2:41. Derivation table in environmental_factors.yml.
|
|
31
|
+
HEAT_DURATION_FACTORS = [[30.0, 0.5], [60.0, 1.0], [180.0, 1.76], [240.0, 2.81]].freeze
|
|
32
|
+
|
|
33
|
+
# Relative humidity (%) the temperature-only heat curve stands for
|
|
34
|
+
# (see EnvironmentalAdjuster::Humidity)
|
|
35
|
+
REFERENCE_HUMIDITY = Humidity::REFERENCE_HUMIDITY
|
|
36
|
+
|
|
14
37
|
# Calculates the performance penalty percentage for given environmental conditions
|
|
15
38
|
#
|
|
16
39
|
# @param temperature [Numeric, nil] ambient temperature
|
|
17
40
|
# @param temperature_unit [Symbol, String] :c (Celsius) or :f (Fahrenheit)
|
|
18
41
|
# @param altitude [Numeric, nil] altitude in meters
|
|
19
42
|
# @param time_seconds [Numeric, nil] duration of the effort in seconds
|
|
20
|
-
# @
|
|
21
|
-
|
|
22
|
-
|
|
43
|
+
# @param humidity [Numeric, nil] relative humidity in % (0–100). Optional;
|
|
44
|
+
# without it (and without dew_point) the heat curve assumes
|
|
45
|
+
# REFERENCE_HUMIDITY. With it, the temperature is replaced by the effective
|
|
46
|
+
# temperature that has the same simplified WBGT at REFERENCE_HUMIDITY.
|
|
47
|
+
# @param dew_point [Numeric, nil] dew point, in temperature_unit. Alternative
|
|
48
|
+
# to humidity (pass one or the other), must not exceed the temperature
|
|
49
|
+
# @return [Hash] hash with :total_penalty_percent and breakdown in :factors;
|
|
50
|
+
# when humidity or dew_point is given, :factors also carries
|
|
51
|
+
# :effective_temperature_celsius
|
|
52
|
+
# @raise [ArgumentError] if humidity is outside 0–100, dew_point is above the
|
|
53
|
+
# temperature, both are given, or either is given without a temperature
|
|
54
|
+
#
|
|
55
|
+
# @example
|
|
56
|
+
# calc.calculate_penalty(temperature: 30, humidity: 90)[:total_penalty_percent] #=> 12.16
|
|
57
|
+
# calc.calculate_penalty(temperature: 30, humidity: 90)[:factors][:effective_temperature_celsius] #=> 35.94
|
|
58
|
+
# calc.calculate_penalty(temperature: 86, dew_point: 77, temperature_unit: :f)[:total_penalty_percent] #=> 11.07
|
|
59
|
+
def calculate_penalty(temperature: nil, temperature_unit: :c, altitude: nil, time_seconds: nil,
|
|
60
|
+
humidity: nil, dew_point: nil)
|
|
61
|
+
effective = effective_temperature(temperature, temperature_unit, humidity, dew_point)
|
|
62
|
+
heat_penalty = calculate_heat_penalty(effective, time_seconds)
|
|
23
63
|
altitude_penalty = calculate_altitude_penalty(altitude)
|
|
24
64
|
|
|
25
|
-
{
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
altitude: altitude_penalty
|
|
30
|
-
}
|
|
31
|
-
}
|
|
65
|
+
factors = { heat: heat_penalty, altitude: altitude_penalty }
|
|
66
|
+
factors[:effective_temperature_celsius] = effective.round(2) unless humidity.nil? && dew_point.nil?
|
|
67
|
+
|
|
68
|
+
{ total_penalty_percent: (heat_penalty + altitude_penalty).round(2), factors: factors }
|
|
32
69
|
end
|
|
33
70
|
|
|
34
71
|
# Adjusts a given time based on environmental conditions
|
|
@@ -71,10 +108,19 @@ module EnvironmentalAdjuster
|
|
|
71
108
|
|
|
72
109
|
private
|
|
73
110
|
|
|
74
|
-
|
|
75
|
-
|
|
111
|
+
# Air temperature in °C, moved to the temperature that has the same
|
|
112
|
+
# simplified WBGT at REFERENCE_HUMIDITY when humidity or dew point is known
|
|
113
|
+
def effective_temperature(temp, unit, humidity, dew_point)
|
|
114
|
+
Humidity.check_inputs!(temp, humidity, dew_point)
|
|
115
|
+
temp_c = temp && normalize_temperature(temp, unit)
|
|
116
|
+
return temp_c if temp_c.nil? || (humidity.nil? && dew_point.nil?)
|
|
117
|
+
|
|
118
|
+
Humidity.effective_temperature(temp_c, humidity: humidity,
|
|
119
|
+
dew_point_c: dew_point && normalize_temperature(dew_point, unit))
|
|
120
|
+
end
|
|
76
121
|
|
|
77
|
-
|
|
122
|
+
def calculate_heat_penalty(temp_c, time_seconds)
|
|
123
|
+
return 0.0 if temp_c.nil?
|
|
78
124
|
|
|
79
125
|
data = FACTORS.fetch('heat')
|
|
80
126
|
ideal_min, ideal_max = data.fetch('ideal_range_celsius')
|
|
@@ -89,23 +135,11 @@ module EnvironmentalAdjuster
|
|
|
89
135
|
def duration_factor(time_seconds)
|
|
90
136
|
return 1.0 if time_seconds.nil?
|
|
91
137
|
|
|
92
|
-
minutes = time_seconds / 60.0
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
0.5
|
|
98
|
-
elsif minutes <= 60
|
|
99
|
-
# Scale from 0.5x (30m) up to 1.0x (60m)
|
|
100
|
-
0.5 + (((minutes - 30.0) / 30.0) * 0.5)
|
|
101
|
-
elsif minutes <= 180
|
|
102
|
-
# Scale from 1.0x (60m) up to 3.0x (180m / 3h)
|
|
103
|
-
1.0 + (((minutes - 60.0) / 120.0) * 2.0)
|
|
104
|
-
else
|
|
105
|
-
# Scale from 3.0x (3h) up to 4.5x (4h)
|
|
106
|
-
capped_minutes = [minutes, 240.0].min
|
|
107
|
-
3.0 + (((capped_minutes - 180.0) / 60.0) * 1.5)
|
|
108
|
-
end
|
|
138
|
+
minutes = (time_seconds / 60.0).clamp(HEAT_DURATION_FACTORS.first.first, HEAT_DURATION_FACTORS.last.first)
|
|
139
|
+
(from_minutes, from_factor), (to_minutes, to_factor) =
|
|
140
|
+
HEAT_DURATION_FACTORS.each_cons(2).find { |_, (upper, _)| minutes <= upper }
|
|
141
|
+
|
|
142
|
+
from_factor + (((minutes - from_minutes) / (to_minutes - from_minutes)) * (to_factor - from_factor))
|
|
109
143
|
end
|
|
110
144
|
|
|
111
145
|
def normalize_temperature(temp, unit)
|
data/lib/calcpace/errors.rb
CHANGED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Module for grade-adjusted pace (GAP): the flat-ground pace that costs the
|
|
4
|
+
# same energy as a pace run on a slope
|
|
5
|
+
#
|
|
6
|
+
# Uses the energy cost of running on gradients measured by Minetti et al.
|
|
7
|
+
# (2002) on ten runners on a treadmill inclined from −45% to +45%:
|
|
8
|
+
#
|
|
9
|
+
# Cr(i) = 155.4·i⁵ − 30.4·i⁴ − 43.3·i³ + 46.3·i² + 19.5·i + 3.6 (R² = 0.999)
|
|
10
|
+
#
|
|
11
|
+
# where Cr is the metabolic cost in J·kg⁻¹·m⁻¹ and i the gradient as a
|
|
12
|
+
# fraction (rise over horizontal run, 0.05 = 5%). The adjustment factor is
|
|
13
|
+
# Cr(i) / Cr(0): running a metre of 10% climb costs about 1.66 flat metres,
|
|
14
|
+
# a metre of 10% descent about 0.60. Cost is cheapest near −20% and rises
|
|
15
|
+
# again on steeper descents, where braking takes over.
|
|
16
|
+
#
|
|
17
|
+
# The flat-equivalent pace is pace / factor: the speed that, on level ground,
|
|
18
|
+
# spends energy at the same rate. Minetti found the cost per metre independent
|
|
19
|
+
# of speed, so the factor does not depend on how fast the runner is going.
|
|
20
|
+
#
|
|
21
|
+
# Limits worth knowing:
|
|
22
|
+
# - The polynomial is a fit to treadmill measurements between −0.45 and +0.45;
|
|
23
|
+
# grades outside that range are clamped to it rather than extrapolated.
|
|
24
|
+
# - It is a metabolic model. It does not account for the muscular cost of long
|
|
25
|
+
# descents, technical terrain or the fact that a runner rarely holds the
|
|
26
|
+
# metabolic equivalent on a steep climb — field GAP models fitted to heart
|
|
27
|
+
# rate (Strava's, for instance) are gentler on climbs.
|
|
28
|
+
# - Its constant term (3.6) is the fit's value on the flat; Minetti measured
|
|
29
|
+
# 3.40 ± 0.24 J·kg⁻¹·m⁻¹ there. The factor divides by Cr(0) so it is exactly
|
|
30
|
+
# 1.0 on the flat.
|
|
31
|
+
#
|
|
32
|
+
# Reference: Minetti, A. E., Moia, C., Roi, G. S., Susta, D., & Ferretti, G.
|
|
33
|
+
# (2002). Energy cost of walking and running at extreme uphill and downhill
|
|
34
|
+
# slopes. Journal of Applied Physiology, 93(3), 1039–1046.
|
|
35
|
+
# https://doi.org/10.1152/japplphysiol.01177.2001
|
|
36
|
+
module GradeAdjustedPace
|
|
37
|
+
# Gradient range of Minetti et al.'s measurements, as fractions
|
|
38
|
+
MINETTI_GRADE_RANGE = (-0.45..0.45)
|
|
39
|
+
|
|
40
|
+
# Polynomial coefficients for Cr(i), highest power first (J·kg⁻¹·m⁻¹)
|
|
41
|
+
MINETTI_RUNNING_COEFFICIENTS = [155.4, -30.4, -43.3, 46.3, 19.5, 3.6].freeze
|
|
42
|
+
|
|
43
|
+
# Returns how many flat metres one metre at a given gradient is worth
|
|
44
|
+
#
|
|
45
|
+
# @param grade [Numeric] gradient as a fraction (0.05 = 5% uphill, −0.05 = 5%
|
|
46
|
+
# downhill); clamped to −0.45..0.45, the range Minetti et al. measured
|
|
47
|
+
# @return [Float] Cr(grade) / Cr(0) — 1.0 on the flat, above 1 uphill,
|
|
48
|
+
# below 1 on moderate descents
|
|
49
|
+
# @raise [ArgumentError] if grade is not a finite number
|
|
50
|
+
#
|
|
51
|
+
# @example
|
|
52
|
+
# calc.grade_adjustment_factor(0) #=> 1.0
|
|
53
|
+
# calc.grade_adjustment_factor(0.1) #=> 1.6578372222222222
|
|
54
|
+
# calc.grade_adjustment_factor(-0.1) #=> 0.5976961111111111
|
|
55
|
+
def grade_adjustment_factor(grade)
|
|
56
|
+
check_grade(grade)
|
|
57
|
+
|
|
58
|
+
minetti_running_cost(grade.to_f.clamp(MINETTI_GRADE_RANGE)) / minetti_running_cost(0.0)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Converts a pace run on a slope into the flat pace of equal effort
|
|
62
|
+
#
|
|
63
|
+
# @param pace [Numeric, String] pace in seconds per unit or time string (MM:SS or HH:MM:SS)
|
|
64
|
+
# @param grade [Numeric] gradient as a fraction (see #grade_adjustment_factor)
|
|
65
|
+
# @param unit [Symbol, String] unit the pace is expressed in — :km (default) or
|
|
66
|
+
# :mi. The factor is per distance, so the result comes back in the same unit
|
|
67
|
+
# and the unit only has to be a supported one
|
|
68
|
+
# @return [Float] flat-equivalent pace in seconds per unit
|
|
69
|
+
# @raise [ArgumentError] if grade is not a finite number
|
|
70
|
+
# @raise [Calcpace::NonPositiveInputError] if pace is not positive
|
|
71
|
+
# @raise [Calcpace::InvalidTimeFormatError] if a string pace is not a valid clock
|
|
72
|
+
# @raise [Calcpace::UnsupportedUnitError] if unit is not :km or :mi
|
|
73
|
+
#
|
|
74
|
+
# @example
|
|
75
|
+
# calc.grade_adjusted_pace(360, 0.1) #=> 217.1503903847952
|
|
76
|
+
# calc.grade_adjusted_pace('05:00', -0.05) #=> 393.31023895122013
|
|
77
|
+
# calc.grade_adjusted_pace(480, 0.05, unit: :mi) #=> 368.82127811700303
|
|
78
|
+
def grade_adjusted_pace(pace, grade, unit: :km)
|
|
79
|
+
pace_unit_meters(unit)
|
|
80
|
+
factor = grade_adjustment_factor(grade)
|
|
81
|
+
pace_seconds = pace_seconds_from(pace)
|
|
82
|
+
check_positive(pace_seconds, 'Pace')
|
|
83
|
+
|
|
84
|
+
pace_seconds.to_f / factor
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Grade-adjusted pace as a clock string
|
|
88
|
+
#
|
|
89
|
+
# @param pace [Numeric, String] pace in seconds per unit or time string
|
|
90
|
+
# @param grade [Numeric] gradient as a fraction
|
|
91
|
+
# @param unit [Symbol, String] unit the pace is expressed in — :km (default) or :mi
|
|
92
|
+
# @param compact [Boolean] when true, return the compact display format
|
|
93
|
+
# @return [String] flat-equivalent pace in HH:MM:SS format, or 'M:SS' / 'H:MM:SS'
|
|
94
|
+
# with compact: true
|
|
95
|
+
#
|
|
96
|
+
# @example
|
|
97
|
+
# calc.grade_adjusted_pace_clock('06:00', 0.1) #=> '00:03:37'
|
|
98
|
+
# calc.grade_adjusted_pace_clock('06:00', 0.1, compact: true) #=> '3:37'
|
|
99
|
+
def grade_adjusted_pace_clock(pace, grade, unit: :km, compact: false)
|
|
100
|
+
convert_to_clocktime(grade_adjusted_pace(pace, grade, unit: unit), compact: compact)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def check_grade(grade)
|
|
106
|
+
return if grade.is_a?(Numeric) && grade.to_f.finite?
|
|
107
|
+
|
|
108
|
+
raise ArgumentError, "Grade must be a finite number (a fraction: 0.05 = 5%), got #{grade.inspect}"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def minetti_running_cost(grade)
|
|
112
|
+
MINETTI_RUNNING_COEFFICIENTS.reduce(0.0) { |sum, coefficient| (sum * grade) + coefficient }
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EnvironmentalAdjuster
|
|
4
|
+
# Turns air temperature plus humidity into the effective temperature the heat
|
|
5
|
+
# curve is read at.
|
|
6
|
+
#
|
|
7
|
+
# The heat curve is keyed on air temperature, but heat stress depends on
|
|
8
|
+
# humidity too: sweat evaporates less in humid air. Wet-bulb globe temperature
|
|
9
|
+
# (WBGT) captures both. The Australian Bureau of Meteorology's simplified WBGT,
|
|
10
|
+
# for outdoor conditions with moderate sun and light wind, is
|
|
11
|
+
#
|
|
12
|
+
# WBGT = 0.567·Ta + 0.393·e + 3.94
|
|
13
|
+
# e = RH/100 · 6.105·exp(17.27·Ta / (237.7 + Ta)) (hPa)
|
|
14
|
+
#
|
|
15
|
+
# The effective temperature is the air temperature that, at
|
|
16
|
+
# REFERENCE_HUMIDITY, has the same WBGT as the real (temperature, humidity)
|
|
17
|
+
# pair: WBGT(T_eff, REFERENCE_HUMIDITY) = WBGT(T, RH). Solving that equation
|
|
18
|
+
# (rather than adding ΔWBGT / 0.567) keeps the reference air's own vapour
|
|
19
|
+
# pressure rising with temperature, as it does along the temperature-only
|
|
20
|
+
# curve; the shortcut would roughly double the humidity effect at 30 °C.
|
|
21
|
+
module Humidity
|
|
22
|
+
# Relative humidity (%) the temperature-only heat curve stands for. The
|
|
23
|
+
# simplified WBGT equals the air temperature at 51–56% RH between 20 °C and
|
|
24
|
+
# 35 °C, so at 50% the curve reads the same whether its input is taken as
|
|
25
|
+
# air temperature or as WBGT; 50% is also mid-range for the marathons
|
|
26
|
+
# behind the duration factor (El Helou et al. 2012: mean race-day RH
|
|
27
|
+
# 51–78%). humidity: 50 gives the same numbers as no humidity at all.
|
|
28
|
+
REFERENCE_HUMIDITY = 50.0
|
|
29
|
+
|
|
30
|
+
# Simplified WBGT coefficients (Australian Bureau of Meteorology)
|
|
31
|
+
WBGT_TEMPERATURE_COEFFICIENT = 0.567
|
|
32
|
+
WBGT_VAPOUR_PRESSURE_COEFFICIENT = 0.393
|
|
33
|
+
|
|
34
|
+
# Bisection: the bracket is ±40 °C around the air temperature (0–100% RH
|
|
35
|
+
# moves the effective temperature by far less) and 60 halvings take it
|
|
36
|
+
# below 1e-16 °C
|
|
37
|
+
BRACKET_CELSIUS = 40.0
|
|
38
|
+
BISECTION_STEPS = 60
|
|
39
|
+
|
|
40
|
+
# Lowest accepted dew point (°C). The Magnus formula has a pole at
|
|
41
|
+
# −237.7 °C, and no weather on Earth has a dew point anywhere near −100 °C.
|
|
42
|
+
MIN_DEW_POINT_CELSIUS = -100.0
|
|
43
|
+
|
|
44
|
+
module_function
|
|
45
|
+
|
|
46
|
+
# @param temp [Numeric, nil] air temperature in any unit (nil = none given)
|
|
47
|
+
# @param humidity [Object] relative humidity input
|
|
48
|
+
# @param dew_point [Object] dew point input
|
|
49
|
+
# @raise [ArgumentError] if the combination or a value is invalid
|
|
50
|
+
def check_inputs!(temp, humidity, dew_point)
|
|
51
|
+
return if humidity.nil? && dew_point.nil?
|
|
52
|
+
raise ArgumentError, 'Pass either humidity or dew_point, not both' if humidity && dew_point
|
|
53
|
+
raise ArgumentError, 'humidity and dew_point need a finite temperature' unless finite_number?(temp)
|
|
54
|
+
|
|
55
|
+
check_values!(humidity, dew_point)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def check_values!(humidity, dew_point)
|
|
59
|
+
unless valid_dew_point?(dew_point)
|
|
60
|
+
raise ArgumentError, "dew_point must be a finite number (got #{dew_point.inspect})"
|
|
61
|
+
end
|
|
62
|
+
return if valid_humidity?(humidity)
|
|
63
|
+
|
|
64
|
+
raise ArgumentError, "humidity must be a relative humidity between 0 and 100 (got #{humidity.inspect})"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# @param temp_c [Float] air temperature in °C
|
|
68
|
+
# @param humidity [Numeric, nil] relative humidity in %
|
|
69
|
+
# @param dew_point_c [Numeric, nil] dew point in °C (used when humidity is nil)
|
|
70
|
+
# @return [Float] effective temperature in °C, unrounded (round only for
|
|
71
|
+
# display, so that humidity: REFERENCE_HUMIDITY reads the curve at
|
|
72
|
+
# exactly the air temperature)
|
|
73
|
+
# @raise [ArgumentError] if the dew point is above the air temperature or
|
|
74
|
+
# below MIN_DEW_POINT_CELSIUS
|
|
75
|
+
def effective_temperature(temp_c, humidity: nil, dew_point_c: nil)
|
|
76
|
+
# The exact solution; bisection would land within an ulp of it, which a
|
|
77
|
+
# later rounding step can still tip over a boundary
|
|
78
|
+
return temp_c if humidity == REFERENCE_HUMIDITY
|
|
79
|
+
|
|
80
|
+
vapour = humidity ? humidity / 100.0 * saturation_vapour_pressure(temp_c) : dew_point_vapour(dew_point_c, temp_c)
|
|
81
|
+
temperature_at_reference_humidity(temp_c, vapour)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Saturation vapour pressure in hPa (the Magnus form the Bureau of
|
|
85
|
+
# Meteorology pairs with its simplified WBGT)
|
|
86
|
+
def saturation_vapour_pressure(temp_c)
|
|
87
|
+
6.105 * Math.exp(17.27 * temp_c / (237.7 + temp_c))
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# The temperature-and-humidity part of the simplified WBGT (the 3.94
|
|
91
|
+
# constant cancels out when two WBGTs are compared)
|
|
92
|
+
def wbgt_without_constant(temp_c, vapour_hpa)
|
|
93
|
+
(WBGT_TEMPERATURE_COEFFICIENT * temp_c) + (WBGT_VAPOUR_PRESSURE_COEFFICIENT * vapour_hpa)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Solves WBGT(x, REFERENCE_HUMIDITY) = WBGT(temp_c, vapour) for x. The left
|
|
97
|
+
# side rises strictly with x, so bisection converges.
|
|
98
|
+
def temperature_at_reference_humidity(temp_c, vapour_hpa)
|
|
99
|
+
target = wbgt_without_constant(temp_c, vapour_hpa)
|
|
100
|
+
low = temp_c - BRACKET_CELSIUS
|
|
101
|
+
high = temp_c + BRACKET_CELSIUS
|
|
102
|
+
BISECTION_STEPS.times do
|
|
103
|
+
mid = (low + high) / 2.0
|
|
104
|
+
reference_wbgt(mid) < target ? low = mid : high = mid
|
|
105
|
+
end
|
|
106
|
+
(low + high) / 2.0
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def reference_wbgt(temp_c)
|
|
110
|
+
wbgt_without_constant(temp_c, REFERENCE_HUMIDITY / 100.0 * saturation_vapour_pressure(temp_c))
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def dew_point_vapour(dew_point_c, temp_c)
|
|
114
|
+
if dew_point_c < MIN_DEW_POINT_CELSIUS
|
|
115
|
+
raise ArgumentError, "dew_point (#{dew_point_c} °C) is below #{MIN_DEW_POINT_CELSIUS} °C"
|
|
116
|
+
end
|
|
117
|
+
if dew_point_c > temp_c
|
|
118
|
+
raise ArgumentError, "dew_point (#{dew_point_c} °C) cannot be above the temperature (#{temp_c} °C)"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
saturation_vapour_pressure(dew_point_c)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def valid_dew_point?(dew_point)
|
|
125
|
+
dew_point.nil? || finite_number?(dew_point)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def valid_humidity?(humidity)
|
|
129
|
+
humidity.nil? || (finite_number?(humidity) && humidity.to_f.between?(0.0, 100.0))
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Real numbers only: Complex is Numeric too, and has no order to compare
|
|
133
|
+
def finite_number?(value)
|
|
134
|
+
value.is_a?(Numeric) && value.real? && value.to_f.finite?
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|