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.
@@ -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: Matthew Ely et al. (2007) "Impact of Weather on Marathon-Running Performance"
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
- # @return [Hash] hash with :total_penalty_percent and breakdown in :factors
21
- def calculate_penalty(temperature: nil, temperature_unit: :c, altitude: nil, time_seconds: nil)
22
- heat_penalty = calculate_heat_penalty(temperature, temperature_unit, time_seconds)
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
- total_penalty_percent: (heat_penalty + altitude_penalty).round(2),
27
- factors: {
28
- heat: heat_penalty,
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
- def calculate_heat_penalty(temp, unit, time_seconds)
75
- return 0.0 if temp.nil?
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
- temp_c = normalize_temperature(temp, unit)
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
- # Rule based on Matthew Ely (2007) heat degradation curve.
95
- # Scaled for piecewise linear interpolation to avoid jumps.
96
- if minutes <= 30
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)
@@ -20,6 +20,9 @@ class Calcpace
20
20
  end
21
21
  end
22
22
 
23
+ # Raised when versioned/static gem data fails an internal consistency check
24
+ class InvalidDataError < Error; end
25
+
23
26
  # Raised when an unsupported unit or unit conversion is requested
24
27
  #
25
28
  # @example conversion pair
@@ -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