1"""Running pace calculator.
2
3Pure offline arithmetic. No network calls, no API keys, no scraping.
4Computes pace, finish time, distance, split tables, race-time predictions
5and VDOT training paces.
6"""
7
8from __future__ import annotations
9
10import math
11import re
12
13from apify import Actor
14
15M_PER_MILE = 1609.344
16M_PER_KM = 1000.0
17
18UNIT_TO_METERS = {
19 'km': M_PER_KM,
20 'kilometer': M_PER_KM,
21 'kilometre': M_PER_KM,
22 'kilometers': M_PER_KM,
23 'kilometres': M_PER_KM,
24 'k': M_PER_KM,
25 'mi': M_PER_MILE,
26 'mile': M_PER_MILE,
27 'miles': M_PER_MILE,
28 'm': 1.0,
29 'meter': 1.0,
30 'metre': 1.0,
31 'meters': 1.0,
32 'metres': 1.0,
33 'yd': 0.9144,
34 'yard': 0.9144,
35 'yards': 0.9144,
36 'ft': 0.3048,
37 'feet': 0.3048,
38}
39
40UNIT_LABEL = {M_PER_KM: 'km', M_PER_MILE: 'mi', 1.0: 'm', 0.9144: 'yd', 0.3048: 'ft'}
41
42
43NAMED_DISTANCES = {
44 '100m': 100.0,
45 '200m': 200.0,
46 '400m': 400.0,
47 '800m': 800.0,
48 '1000m': 1000.0,
49 '1k': 1000.0,
50 '1500m': 1500.0,
51 'mile': M_PER_MILE,
52 '1mile': M_PER_MILE,
53 '1600m': 1600.0,
54 '2k': 2000.0,
55 '2mile': 2 * M_PER_MILE,
56 '3k': 3000.0,
57 '3000m': 3000.0,
58 '5k': 5000.0,
59 '5000m': 5000.0,
60 '4mile': 4 * M_PER_MILE,
61 '8k': 8000.0,
62 '5mile': 5 * M_PER_MILE,
63 '10k': 10000.0,
64 '10000m': 10000.0,
65 '12k': 12000.0,
66 '15k': 15000.0,
67 '10mile': 10 * M_PER_MILE,
68 '20k': 20000.0,
69 'half': 21097.5,
70 'halfmarathon': 21097.5,
71 'halfmarathon13.1': 21097.5,
72 '25k': 25000.0,
73 '30k': 30000.0,
74 '20mile': 20 * M_PER_MILE,
75 'marathon': 42195.0,
76 'full': 42195.0,
77 '50k': 50000.0,
78 '50mile': 50 * M_PER_MILE,
79 '100k': 100000.0,
80 '100mile': 100 * M_PER_MILE,
81}
82
83
84PREDICTION_TARGETS = [
85 ('800 m', 800.0),
86 ('1500 m', 1500.0),
87 ('1 mile', M_PER_MILE),
88 ('3 km', 3000.0),
89 ('5 km', 5000.0),
90 ('8 km', 8000.0),
91 ('5 mile', 5 * M_PER_MILE),
92 ('10 km', 10000.0),
93 ('12 km', 12000.0),
94 ('15 km', 15000.0),
95 ('10 mile', 10 * M_PER_MILE),
96 ('20 km', 20000.0),
97 ('Half marathon', 21097.5),
98 ('25 km', 25000.0),
99 ('30 km', 30000.0),
100 ('Marathon', 42195.0),
101 ('50 km', 50000.0),
102]
103
104
105TRAINING_ZONES = [
106 ('Easy (E)', 0.70, 0.59, 0.74),
107 ('Marathon (M)', 0.84, 0.75, 0.84),
108 ('Threshold (T)', 0.88, 0.83, 0.88),
109 ('Interval (I)', 0.98, 0.95, 1.00),
110 ('Repetition (R)', 1.08, 1.05, 1.20),
111]
112
113
114class CalcError(Exception):
115 """Raised when a single calculation cannot be completed."""
116
117
118
119
120
121
122
123def parse_duration(value: object, field: str, bare_is_minutes: bool = False) -> float:
124 """Parse a duration into seconds.
125
126 Accepts "3:45:00", "22:30", "1h30m15s", "90m", "45s" and plain numbers.
127 A plain number is seconds, or minutes when bare_is_minutes is set.
128 """
129 if value is None or value == '':
130 raise CalcError(f'{field} is required')
131
132 if isinstance(value, (int, float)) and not isinstance(value, bool):
133 seconds = float(value) * (60.0 if bare_is_minutes else 1.0)
134 if seconds <= 0:
135 raise CalcError(f'{field} must be greater than zero')
136 return seconds
137
138 text = str(value).strip().lower().replace(',', '.')
139 if not text:
140 raise CalcError(f'{field} is required')
141
142 if ':' in text:
143 parts = text.split(':')
144 if len(parts) > 3:
145 raise CalcError(f'could not read {field} "{value}"')
146 try:
147 numbers = [float(p) for p in parts]
148 except ValueError as exc:
149 raise CalcError(f'could not read {field} "{value}"') from exc
150 seconds = 0.0
151 for number in numbers:
152 seconds = seconds * 60.0 + number
153 else:
154 matches = re.findall(r'(\d+(?:\.\d+)?)\s*(h|hr|hrs|hour|hours|m|min|mins|minute|minutes|s|sec|secs|second|seconds)?', text)
155 matches = [m for m in matches if m[0] != '']
156 if not matches:
157 raise CalcError(f'could not read {field} "{value}"')
158 seconds = 0.0
159 saw_unit = False
160 for number_text, unit in matches:
161 number = float(number_text)
162 if unit in ('h', 'hr', 'hrs', 'hour', 'hours'):
163 seconds += number * 3600.0
164 saw_unit = True
165 elif unit in ('m', 'min', 'mins', 'minute', 'minutes'):
166 seconds += number * 60.0
167 saw_unit = True
168 elif unit in ('s', 'sec', 'secs', 'second', 'seconds'):
169 seconds += number
170 saw_unit = True
171 else:
172 seconds += number * (60.0 if bare_is_minutes else 1.0)
173 if not saw_unit and len(matches) > 1:
174 raise CalcError(f'could not read {field} "{value}"')
175
176 if seconds <= 0:
177 raise CalcError(f'{field} must be greater than zero')
178 return seconds
179
180
181def normalise_unit(unit: object, default: float = M_PER_KM) -> float:
182 if unit is None or unit == '':
183 return default
184 key = str(unit).strip().lower().rstrip('.')
185 key = key.replace('min/', '').replace('per ', '').replace('/', '')
186 if key in UNIT_TO_METERS:
187 return UNIT_TO_METERS[key]
188 raise CalcError(f'unknown unit "{unit}"')
189
190
191def parse_distance(value: object, unit: object, field: str = 'distance') -> tuple[float, float]:
192 """Return (meters, meters_per_unit) for a distance input."""
193 if value is None or value == '':
194 raise CalcError(f'{field} is required')
195
196 if isinstance(value, (int, float)) and not isinstance(value, bool):
197 factor = normalise_unit(unit)
198 meters = float(value) * factor
199 if meters <= 0:
200 raise CalcError(f'{field} must be greater than zero')
201 return meters, factor
202
203 text = str(value).strip().lower()
204 key = re.sub(r'[\s\-_.]', '', text)
205 if key in NAMED_DISTANCES:
206 meters = NAMED_DISTANCES[key]
207 factor = M_PER_MILE if 'mile' in key else M_PER_KM
208 if meters < 1000:
209 factor = 1.0
210 return meters, factor
211
212 match = re.match(r'^(\d+(?:[.,]\d+)?)\s*([a-z/]*)$', text)
213 if not match:
214 raise CalcError(f'could not read {field} "{value}"')
215 number = float(match.group(1).replace(',', '.'))
216 factor = normalise_unit(match.group(2) or unit)
217 meters = number * factor
218 if meters <= 0:
219 raise CalcError(f'{field} must be greater than zero')
220 return meters, factor
221
222
223def parse_speed(value: object, unit: object) -> float:
224 """Return seconds per meter from a speed value."""
225 if isinstance(value, str):
226 value = value.strip().replace(',', '.')
227 try:
228 speed = float(value)
229 except (TypeError, ValueError) as exc:
230 raise CalcError(f'could not read speed "{value}"') from exc
231 if speed <= 0:
232 raise CalcError('speed must be greater than zero')
233 key = str(unit or 'kph').strip().lower().replace(' ', '')
234 if key in ('kph', 'km/h', 'kmh', 'kmph', 'kilometersperhour'):
235 meters_per_second = speed * M_PER_KM / 3600.0
236 elif key in ('mph', 'mi/h', 'milesperhour'):
237 meters_per_second = speed * M_PER_MILE / 3600.0
238 elif key in ('m/s', 'mps', 'meterspersecond'):
239 meters_per_second = speed
240 else:
241 raise CalcError(f'unknown speed unit "{unit}"')
242 return 1.0 / meters_per_second
243
244
245
246
247
248
249
250def format_duration(seconds: float, force_hours: bool = False) -> str:
251 if seconds is None:
252 return None
253 total = int(round(seconds))
254 hours, remainder = divmod(total, 3600)
255 minutes, secs = divmod(remainder, 60)
256 if hours or force_hours:
257 return f'{hours}:{minutes:02d}:{secs:02d}'
258 return f'{minutes}:{secs:02d}'
259
260
261def round_to(value: float, digits: int = 3) -> float:
262 if value is None:
263 return None
264 return round(float(value), digits)
265
266
267
268
269
270
271
272def vo2_from_velocity(velocity: float) -> float:
273 """Oxygen cost in ml/kg/min for a velocity in m/min (Daniels and Gilbert)."""
274 return -4.60 + 0.182258 * velocity + 0.000104 * velocity * velocity
275
276
277def velocity_from_vo2(vo2: float) -> float:
278 """Inverse of vo2_from_velocity, returns m/min."""
279 a = 0.000104
280 b = 0.182258
281 c = -4.60 - vo2
282 discriminant = b * b - 4 * a * c
283 if discriminant <= 0:
284 raise CalcError('intensity out of range')
285 return (-b + math.sqrt(discriminant)) / (2 * a)
286
287
288def percent_max_for_time(minutes: float) -> float:
289 return 0.8 + 0.1894393 * math.exp(-0.012778 * minutes) + 0.2989558 * math.exp(-0.1932605 * minutes)
290
291
292def vdot_for(meters: float, seconds: float) -> float:
293 minutes = seconds / 60.0
294 velocity = meters / minutes
295 return vo2_from_velocity(velocity) / percent_max_for_time(minutes)
296
297
298def time_for_vdot(meters: float, target_vdot: float) -> float:
299 """Seconds needed to cover meters at the given VDOT. Bisection on time."""
300 low, high = 1.0, 60.0 * 60.0 * 30.0
301 for _ in range(120):
302 mid = (low + high) / 2.0
303 if vdot_for(meters, mid) > target_vdot:
304 low = mid
305 else:
306 high = mid
307 return (low + high) / 2.0
308
309
310def riegel_time(base_meters: float, base_seconds: float, target_meters: float, exponent: float = 1.06) -> float:
311 return base_seconds * (target_meters / base_meters) ** exponent
312
313
314def cameron_time(base_meters: float, base_seconds: float, target_meters: float) -> float:
315 def factor(miles: float) -> float:
316 return 13.49681 - 0.048865 * miles + 2.438936 / (miles ** 0.7905)
317
318 base_miles = base_meters / M_PER_MILE
319 target_miles = target_meters / M_PER_MILE
320 return (base_seconds / base_miles) * (factor(base_miles) / factor(target_miles)) * target_miles
321
322
323
324
325
326
327
328def pace_fields(meters: float, seconds: float) -> dict:
329 km = meters / M_PER_KM
330 miles = meters / M_PER_MILE
331 return {
332 'pacePerKm': format_duration(seconds / km),
333 'pacePerKmSeconds': round_to(seconds / km, 2),
334 'pacePerMile': format_duration(seconds / miles),
335 'pacePerMileSeconds': round_to(seconds / miles, 2),
336 'pacePer400m': format_duration(seconds / (meters / 400.0)),
337 'speedKph': round_to(km / (seconds / 3600.0), 3),
338 'speedMph': round_to(miles / (seconds / 3600.0), 3),
339 'speedMps': round_to(meters / seconds, 3),
340 }
341
342
343def base_row(index: int, calc_type: str, row_type: str, label: str) -> dict:
344 return {
345 'inputIndex': index,
346 'type': calc_type,
347 'rowType': row_type,
348 'label': label,
349 'distance': None,
350 'unit': None,
351 'distanceKm': None,
352 'distanceMiles': None,
353 'distanceMeters': None,
354 'time': None,
355 'timeSeconds': None,
356 'pacePerKm': None,
357 'pacePerKmSeconds': None,
358 'pacePerMile': None,
359 'pacePerMileSeconds': None,
360 'pacePer400m': None,
361 'speedKph': None,
362 'speedMph': None,
363 'speedMps': None,
364 'splitNumber': None,
365 'splitTime': None,
366 'cumulativeTime': None,
367 'cumulativeTimeSeconds': None,
368 'vdot': None,
369 'vo2max': None,
370 'intensityPercent': None,
371 'paceRange': None,
372 'predictionMethod': None,
373 'timeRiegel': None,
374 'timeCameron': None,
375 'timeVdot': None,
376 'caloriesBurned': None,
377 'error': None,
378 }
379
380
381def fill_distance(row: dict, meters: float, factor: float) -> None:
382 row['distance'] = round_to(meters / factor, 4)
383 row['unit'] = UNIT_LABEL.get(factor, 'm')
384 row['distanceKm'] = round_to(meters / M_PER_KM, 4)
385 row['distanceMiles'] = round_to(meters / M_PER_MILE, 4)
386 row['distanceMeters'] = round_to(meters, 2)
387
388
389def fill_performance(row: dict, meters: float, seconds: float, weight_kg: float | None) -> None:
390 row['time'] = format_duration(seconds, force_hours=seconds >= 3600)
391 row['timeSeconds'] = round_to(seconds, 2)
392 row.update(pace_fields(meters, seconds))
393 if weight_kg:
394 row['caloriesBurned'] = int(round(weight_kg * (meters / M_PER_KM) * 1.036))
395
396
397def add_vdot(row: dict, meters: float, seconds: float) -> float | None:
398 """Attach VDOT and VO2 columns. Returns the VDOT, or None when out of range."""
399 minutes = seconds / 60.0
400 if not 2.0 <= minutes <= 300.0:
401 return None
402 try:
403 value = vdot_for(meters, seconds)
404 except (ValueError, ZeroDivisionError):
405 return None
406 if not math.isfinite(value) or value <= 0:
407 return None
408 row['vdot'] = round_to(value, 1)
409 row['vo2max'] = round_to(vo2_from_velocity(meters / minutes), 1)
410 return value
411
412
413
414
415
416
417
418def build_splits(index: int, calc: dict, meters: float, factor: float, seconds: float,
419 label: str, max_splits: int, weight_kg: float | None) -> list[dict]:
420 unit_label = UNIT_LABEL.get(factor, 'm')
421 split_every_raw = calc.get('splitEvery')
422 if split_every_raw in (None, '', 0):
423 split_every = 1.0 if factor in (M_PER_KM, M_PER_MILE) else 400.0
424 else:
425 try:
426 split_every = float(split_every_raw)
427 except (TypeError, ValueError) as exc:
428 raise CalcError(f'could not read splitEvery "{split_every_raw}"') from exc
429 if split_every <= 0:
430 raise CalcError('splitEvery must be greater than zero')
431
432 step_meters = split_every * factor
433 count = int(math.ceil(meters / step_meters - 1e-9))
434 if count > max_splits:
435 raise CalcError(f'{count} splits exceeds maxSplits ({max_splits}), increase splitEvery or maxSplits')
436
437 strategy = str(calc.get('strategy') or 'even').strip().lower()
438 if strategy not in ('even', 'negative', 'positive'):
439 raise CalcError(f'unknown strategy "{strategy}"')
440 try:
441 spread = float(calc.get('strategyPercent') or 2.0) / 100.0
442 except (TypeError, ValueError) as exc:
443 raise CalcError('could not read strategyPercent') from exc
444
445 lengths = []
446 covered = 0.0
447 for _ in range(count):
448 piece = min(step_meters, meters - covered)
449 lengths.append(piece)
450 covered += piece
451
452 if strategy == 'even' or count == 1 or spread == 0:
453 factors = [1.0] * count
454 else:
455 start, end = (1.0 + spread, 1.0 - spread)
456 if strategy == 'positive':
457 start, end = end, start
458 factors = [start + (end - start) * (i / (count - 1)) for i in range(count)]
459
460 raw = [length * f for length, f in zip(lengths, factors)]
461 scale = seconds / sum(raw)
462
463 rows = []
464 cumulative = 0.0
465 for i, (length, weighted) in enumerate(zip(lengths, raw), start=1):
466 split_seconds = weighted * scale
467 cumulative += split_seconds
468 row = base_row(index, calc.get('type', 'splits'), 'split', f'{label} split {i}')
469 fill_distance(row, length, factor)
470 row['splitNumber'] = i
471 row['splitTime'] = format_duration(split_seconds, force_hours=split_seconds >= 3600)
472 row['time'] = row['splitTime']
473 row['timeSeconds'] = round_to(split_seconds, 2)
474 row['cumulativeTime'] = format_duration(cumulative, force_hours=cumulative >= 3600)
475 row['cumulativeTimeSeconds'] = round_to(cumulative, 2)
476 row.update(pace_fields(length, split_seconds))
477 if weight_kg:
478 row['caloriesBurned'] = int(round(weight_kg * (length / M_PER_KM) * 1.036))
479 rows.append(row)
480 _ = unit_label
481 return rows
482
483
484def run_calculation(index: int, calc: dict, defaults: dict) -> list[dict]:
485 calc_type = str(calc.get('type') or 'pace').strip().lower()
486 label = str(calc.get('label') or '').strip()
487 weight_raw = calc.get('weightKg', defaults.get('weightKg'))
488 weight_kg = None
489 if weight_raw not in (None, '', 0):
490 try:
491 weight_kg = float(weight_raw)
492 except (TypeError, ValueError) as exc:
493 raise CalcError(f'could not read weightKg "{weight_raw}"') from exc
494
495 include_splits = calc.get('splits')
496 if include_splits is None:
497 include_splits = defaults.get('includeSplits', True)
498 max_splits = int(defaults.get('maxSplits') or 200)
499
500 if calc_type == 'convert':
501 if calc.get('pace') not in (None, ''):
502 unit_factor = normalise_unit(calc.get('paceUnit'), M_PER_KM)
503 seconds_per_meter = parse_duration(calc.get('pace'), 'pace', bare_is_minutes=True) / unit_factor
504 elif calc.get('speed') not in (None, ''):
505 seconds_per_meter = parse_speed(calc.get('speed'), calc.get('speedUnit'))
506 else:
507 raise CalcError('convert needs either pace or speed')
508 reference = M_PER_KM
509 row = base_row(index, calc_type, 'summary', label or 'Pace conversion')
510 row.update(pace_fields(reference, seconds_per_meter * reference))
511 return [row]
512
513 if calc_type in ('pace', 'splits'):
514 meters, factor = parse_distance(calc.get('distance'), calc.get('unit'))
515 if calc.get('time') not in (None, ''):
516 seconds = parse_duration(calc.get('time'), 'time')
517 elif calc.get('pace') not in (None, ''):
518 unit_factor = normalise_unit(calc.get('paceUnit'), factor)
519 seconds = parse_duration(calc.get('pace'), 'pace', bare_is_minutes=True) / unit_factor * meters
520 elif calc.get('speed') not in (None, ''):
521 seconds = parse_speed(calc.get('speed'), calc.get('speedUnit')) * meters
522 else:
523 raise CalcError('pace needs time, pace or speed')
524
525 name = label or f'{round_to(meters / factor, 4):g} {UNIT_LABEL.get(factor, "m")}'
526 row = base_row(index, calc_type, 'summary', name)
527 fill_distance(row, meters, factor)
528 fill_performance(row, meters, seconds, weight_kg)
529 add_vdot(row, meters, seconds)
530 rows = [row]
531 if include_splits or calc_type == 'splits':
532 rows.extend(build_splits(index, calc, meters, factor, seconds, name, max_splits, weight_kg))
533 return rows
534
535 if calc_type == 'time':
536 meters, factor = parse_distance(calc.get('distance'), calc.get('unit'))
537 if calc.get('pace') not in (None, ''):
538 unit_factor = normalise_unit(calc.get('paceUnit'), factor)
539 seconds = parse_duration(calc.get('pace'), 'pace', bare_is_minutes=True) / unit_factor * meters
540 elif calc.get('speed') not in (None, ''):
541 seconds = parse_speed(calc.get('speed'), calc.get('speedUnit')) * meters
542 else:
543 raise CalcError('time needs pace or speed')
544
545 name = label or f'{round_to(meters / factor, 4):g} {UNIT_LABEL.get(factor, "m")}'
546 row = base_row(index, calc_type, 'summary', name)
547 fill_distance(row, meters, factor)
548 fill_performance(row, meters, seconds, weight_kg)
549 add_vdot(row, meters, seconds)
550 rows = [row]
551 if include_splits:
552 rows.extend(build_splits(index, calc, meters, factor, seconds, name, max_splits, weight_kg))
553 return rows
554
555 if calc_type == 'distance':
556 seconds = parse_duration(calc.get('time'), 'time')
557 if calc.get('pace') not in (None, ''):
558 unit_factor = normalise_unit(calc.get('paceUnit'), M_PER_KM)
559 seconds_per_meter = parse_duration(calc.get('pace'), 'pace', bare_is_minutes=True) / unit_factor
560 elif calc.get('speed') not in (None, ''):
561 seconds_per_meter = parse_speed(calc.get('speed'), calc.get('speedUnit'))
562 else:
563 raise CalcError('distance needs pace or speed')
564 meters = seconds / seconds_per_meter
565 factor = normalise_unit(calc.get('unit'), M_PER_KM)
566 row = base_row(index, calc_type, 'summary', label or 'Distance covered')
567 fill_distance(row, meters, factor)
568 fill_performance(row, meters, seconds, weight_kg)
569 add_vdot(row, meters, seconds)
570 return [row]
571
572 if calc_type in ('predict', 'training'):
573 meters, factor = parse_distance(calc.get('distance'), calc.get('unit'))
574 seconds = parse_duration(calc.get('time'), 'time')
575 name = label or f'{round_to(meters / factor, 4):g} {UNIT_LABEL.get(factor, "m")} in {format_duration(seconds, seconds >= 3600)}'
576
577 summary = base_row(index, calc_type, 'summary', name)
578 fill_distance(summary, meters, factor)
579 fill_performance(summary, meters, seconds, weight_kg)
580 vdot = add_vdot(summary, meters, seconds)
581 rows = [summary]
582
583 if calc_type == 'predict':
584 method = str(calc.get('predictionMethod') or defaults.get('predictionMethod') or 'riegel').strip().lower()
585 if method not in ('riegel', 'cameron', 'vdot', 'average'):
586 raise CalcError(f'unknown predictionMethod "{method}"')
587 try:
588 exponent = float(calc.get('riegelExponent') or defaults.get('riegelExponent') or 1.06)
589 except (TypeError, ValueError) as exc:
590 raise CalcError('could not read riegelExponent') from exc
591
592 for target_label, target_meters in PREDICTION_TARGETS:
593 riegel = riegel_time(meters, seconds, target_meters, exponent)
594 cameron = None
595 if 400.0 <= target_meters <= 50000.0 and 400.0 <= meters <= 50000.0:
596 try:
597 cameron = cameron_time(meters, seconds, target_meters)
598 except (ValueError, ZeroDivisionError):
599 cameron = None
600 vdot_prediction = time_for_vdot(target_meters, vdot) if vdot else None
601
602 options = {'riegel': riegel, 'cameron': cameron, 'vdot': vdot_prediction}
603 if method == 'average':
604 usable = [v for v in options.values() if v]
605 headline = sum(usable) / len(usable)
606 else:
607 headline = options.get(method) or riegel
608
609 row = base_row(index, calc_type, 'prediction', target_label)
610 fill_distance(row, target_meters, M_PER_MILE if 'mile' in target_label.lower() else (M_PER_KM if target_meters >= 1000 else 1.0))
611 fill_performance(row, target_meters, headline, weight_kg)
612 row['predictionMethod'] = method
613 row['timeRiegel'] = format_duration(riegel, riegel >= 3600)
614 row['timeCameron'] = format_duration(cameron, cameron >= 3600) if cameron else None
615 row['timeVdot'] = format_duration(vdot_prediction, vdot_prediction >= 3600) if vdot_prediction else None
616 if vdot:
617 row['vdot'] = round_to(vdot, 1)
618 rows.append(row)
619 return rows
620
621 if not vdot:
622 raise CalcError('training paces need a performance between 2 and 300 minutes')
623 for zone_label, primary, low, high in TRAINING_ZONES:
624 velocity = velocity_from_vo2(primary * vdot)
625 fast = velocity_from_vo2(high * vdot)
626 slow = velocity_from_vo2(low * vdot)
627 row = base_row(index, calc_type, 'training', zone_label)
628 row.update(pace_fields(M_PER_KM, 60.0 * M_PER_KM / velocity))
629 row['intensityPercent'] = round_to(primary * 100, 1)
630 row['paceRange'] = (
631 f'{format_duration(60.0 * M_PER_KM / fast)} to {format_duration(60.0 * M_PER_KM / slow)} /km'
632 )
633 row['vdot'] = round_to(vdot, 1)
634 rows.append(row)
635 return rows
636
637 raise CalcError(
638 f'unknown type "{calc_type}", use pace, time, distance, splits, predict, training or convert'
639 )
640
641
642
643
644
645
646
647async def main() -> None:
648 async with Actor:
649 actor_input = await Actor.get_input() or {}
650 calculations = actor_input.get('calculations') or []
651 if isinstance(calculations, dict):
652 calculations = [calculations]
653 if not calculations:
654 raise ValueError('No calculations supplied. Add at least one item to "calculations".')
655
656 defaults = {
657 'includeSplits': actor_input.get('includeSplits', True),
658 'maxSplits': actor_input.get('maxSplits', 200),
659 'predictionMethod': actor_input.get('predictionMethod', 'riegel'),
660 'riegelExponent': actor_input.get('riegelExponent', 1.06),
661 'weightKg': actor_input.get('weightKg'),
662 }
663
664 results: list[dict] = []
665 for index, calc in enumerate(calculations):
666 if not isinstance(calc, dict):
667 row = base_row(index, 'unknown', 'error', str(calc)[:80])
668 row['error'] = 'each calculation must be an object'
669 results.append(row)
670 continue
671 try:
672 results.extend(run_calculation(index, calc, defaults))
673 except CalcError as exc:
674 row = base_row(index, str(calc.get('type') or 'pace'), 'error', str(calc.get('label') or '')[:80])
675 row['error'] = str(exc)
676 results.append(row)
677 Actor.log.warning(f'Calculation {index} failed: {exc}')
678 except Exception as exc:
679 row = base_row(index, str(calc.get('type') or 'pace'), 'error', str(calc.get('label') or '')[:80])
680 row['error'] = f'unexpected error: {exc}'
681 results.append(row)
682 Actor.log.exception(f'Calculation {index} raised')
683
684 Actor.log.info(f'Pushing {len(results)} rows from {len(calculations)} calculations')
685
686
687 await Actor.push_data(results)