heart_rate_zones
Heart-rate training zones: maximum heart rate estimated by Tanaka (208 - 0.7 x age), Fox (220 - age) or Gellish (207 - 0.7 x age), or measured, and zones by heart-rate reserve (Karvonen, with resting heart rate) and by percentage of maximum, on default five-zone or custom boundaries. Exact arithmetic; every output carries a disclaimer: not medical advice, verify with a clinician.
Install
/tools install heart_rate_zones /tools install heart_rate_zones@1.0.0
In a console session, or let the model find it in normal mode: axlr_catalog describes it and axlr_install_tool asks for the same install card. The card reads catalog axlr · heart_rate_zones 1.0.0 · verified once the index signature, the manifest digest and every file digest check out. Then the model runs it with axlr_run_tool heart_rate_zones; each run is approved as a local_exec.
Arguments
One JSON object on stdin, validated against the schema before the program starts.
| name | type | required | default | meaning |
|---|---|---|---|---|
age | number | string | no | Years; needed unless max_hr is given. ≥ 5; ≤ 110 | |
resting_hr | number | string | no | bpm; enables Karvonen zones. ≥ 20; ≤ 150 | |
max_hr | number | string | no | Measured maximum, bpm. ≥ 60; ≤ 250 | |
formula | string | no | "tanaka" | one of "tanaka", "fox", "gellish" |
method | string | no | Default both with resting_hr, else percent_max. one of "karvonen", "percent_max", "both" | |
boundaries | array of number | no | [50, 60, 70, 80, 90, 100] | items ≥ 2; items ≤ 21 |
zone_names | array of string | no |
Input schema (JSON)
{
"type": "object",
"additionalProperties": false,
"properties": {
"age": {
"type": [
"number",
"string"
],
"minimum": 5,
"maximum": 110,
"description": "Years; needed unless max_hr is given"
},
"resting_hr": {
"type": [
"number",
"string"
],
"minimum": 20,
"maximum": 150,
"description": "bpm; enables Karvonen zones"
},
"max_hr": {
"type": [
"number",
"string"
],
"minimum": 60,
"maximum": 250,
"description": "Measured maximum, bpm"
},
"formula": {
"type": "string",
"enum": [
"tanaka",
"fox",
"gellish"
],
"default": "tanaka"
},
"method": {
"type": "string",
"enum": [
"karvonen",
"percent_max",
"both"
],
"description": "Default both with resting_hr, else percent_max"
},
"boundaries": {
"type": "array",
"minItems": 2,
"maxItems": 21,
"items": {
"type": "number",
"minimum": 0,
"maximum": 100
},
"default": [
50,
60,
70,
80,
90,
100
]
},
"zone_names": {
"type": "array",
"items": {
"type": "string"
}
}
}
}
Example
Input
{
"age": 40,
"resting_hr": 60
}
Output (stdout)
{
"max_hr": "180.0",
"heart_rate_reserve": "120.0",
"max_hr_estimates": {
"tanaka": "180.0",
"fox": "180.0",
"gellish": "179.0"
}
}
Files
The command the install card registers is python3 heart_rate_zones.py, run in the workspace with PATH and HOME as its only environment. Each file's SHA-256 is in the signed index; the console checks them before showing the card and again before writing.
heart_rate_zones.py · sha256 efecddbd907ed7e580eb862ff84040f75d262caaaaa1d31cce40cdfaf60c3b0c
#!/usr/bin/env python3
"""Heart-rate training zones: maximum heart rate estimates and zones by heart-rate reserve
(Karvonen) or by percentage of maximum heart rate.
Input (JSON on stdin):
age years (needed unless max_hr is given)
resting_hr beats per minute; enables Karvonen zones
max_hr measured maximum, bpm; overrides the estimate
formula "tanaka" | "fox" | "gellish" estimate to use, default tanaka
method "karvonen" | "percent_max" | "both" default both when resting_hr is given, else percent_max
boundaries ascending percentages, default [50, 60, 70, 80, 90, 100] (five zones)
zone_names optional names, one per zone
Estimates: Tanaka, Monahan & Seals 2001: 208 - 0.7 x age; Fox, Naughton & Haskell 1971:
220 - age; Gellish et al. 2007: 207 - 0.7 x age.
Karvonen et al. 1957: target = resting + (max - resting) x intensity.
"""
import json
import re
import sys
from decimal import ROUND_HALF_UP, Decimal
from fractions import Fraction
DISCLAIMER = ("Not medical advice: population formulas that can be off by 10 bpm or more for an individual. "
"Check with a qualified clinician before exercising, especially with a heart condition or medication.")
FORMULAS = {
"tanaka": (lambda age: 208 - Fraction(7, 10) * age, "Tanaka, Monahan & Seals 2001: 208 - 0.7 x age"),
"fox": (lambda age: 220 - age, "Fox, Naughton & Haskell 1971: 220 - age"),
"gellish": (lambda age: 207 - Fraction(7, 10) * age, "Gellish et al. 2007: 207 - 0.7 x age"),
}
DECIMAL = re.compile(r"\s*[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d{1,3})?\s*")
def fail(message):
print(json.dumps({"error": message, "disclaimer": DISCLAIMER}))
sys.exit(2)
def number(value, field, low, high):
if isinstance(value, bool) or not isinstance(value, (int, float, str)):
fail(f"{field} must be a number")
if isinstance(value, str) and not DECIMAL.fullmatch(value):
fail(f"{field} must be a number or a decimal string")
try:
result = Fraction(str(value).strip())
except (ValueError, ZeroDivisionError):
fail(f"{field} must be a number")
if abs(result) > 10 ** 12:
fail(f"{field} is too large")
if not low <= result <= high:
fail(f"{field} must be between {low} and {high}")
return result
def text(value):
d = Decimal(value.numerator) / Decimal(value.denominator)
return str(d.quantize(Decimal("0.1"), rounding=ROUND_HALF_UP))
def bpm(value):
return int((Decimal(value.numerator) / Decimal(value.denominator)).quantize(Decimal(1), rounding=ROUND_HALF_UP))
def zones(boundaries, names, low_of):
rows = []
for i in range(len(boundaries) - 1):
low, high = low_of(boundaries[i]), low_of(boundaries[i + 1])
rows.append({"zone": i + 1, "name": names[i] if names else None, "low_pct": float(boundaries[i]),
"high_pct": float(boundaries[i + 1]), "low_bpm": bpm(low), "high_bpm": bpm(high),
"low_exact": text(low), "high_exact": text(high)})
return rows
def main():
try:
request = json.load(sys.stdin)
except ValueError as error:
fail(f"stdin is not JSON: {error}")
if not isinstance(request, dict):
fail("input must be a JSON object")
extra = sorted(set(request) - {"age", "resting_hr", "max_hr", "formula", "method", "boundaries", "zone_names"})
if extra:
fail(f"unknown field(s): {', '.join(extra)}")
age = number(request["age"], "age", 5, 110) if request.get("age") is not None else None
formula = request.get("formula", "tanaka")
if not isinstance(formula, str) or formula not in FORMULAS:
fail("formula must be tanaka, fox or gellish")
estimates = {name: text(f(age)) for name, (f, _) in FORMULAS.items()} if age is not None else {}
if request.get("max_hr") is not None:
max_hr = number(request["max_hr"], "max_hr", 60, 250)
max_source = "measured (given)"
elif age is not None:
max_hr = FORMULAS[formula][0](age)
max_source = FORMULAS[formula][1]
else:
fail("give age or max_hr")
resting = number(request["resting_hr"], "resting_hr", 20, 150) if request.get("resting_hr") is not None else None
if resting is not None and resting >= max_hr:
fail("resting_hr must be below the maximum heart rate")
method = request.get("method", "both" if resting is not None else "percent_max")
if method not in ("karvonen", "percent_max", "both"):
fail("method must be karvonen, percent_max or both")
if method in ("karvonen", "both") and resting is None:
fail("Karvonen zones need resting_hr")
raw = request.get("boundaries", [50, 60, 70, 80, 90, 100])
if not isinstance(raw, list) or len(raw) < 2 or len(raw) > 21:
fail("boundaries must be a list of 2 to 21 percentages")
boundaries = [number(b, "boundaries[]", 0, 100) for b in raw]
if any(b >= c for b, c in zip(boundaries, boundaries[1:])):
fail("boundaries must be strictly ascending")
names = request.get("zone_names")
if names is not None and (not isinstance(names, list) or len(names) != len(boundaries) - 1
or not all(isinstance(n, str) for n in names)):
fail("zone_names must be a list of strings, one per zone (len(boundaries) - 1)")
result = {"age": float(age) if age is not None else None, "max_hr_estimates": estimates,
"max_hr": text(max_hr), "max_hr_source": max_source}
if resting is not None:
result["resting_hr"] = text(resting)
result["heart_rate_reserve"] = text(max_hr - resting)
if method in ("karvonen", "both"):
result["karvonen_zones"] = zones(boundaries, names, lambda p: resting + (max_hr - resting) * p / 100)
if method in ("percent_max", "both"):
result["percent_max_zones"] = zones(boundaries, names, lambda p: max_hr * p / 100)
result["notes"] = ["bpm values are rounded half up; *_exact keep one decimal."]
result["source"] = "Karvonen, Kentala & Mustala 1957 (heart-rate reserve); " + "; ".join(f[1] for f in FORMULAS.values())
result["disclaimer"] = DISCLAIMER
print(json.dumps(result))
if __name__ == "__main__":
try:
main()
except SystemExit:
raise
except Exception as error: # last resort: a usage error, never a traceback
fail(f"invalid input ({type(error).__name__})")
Where it is served
The registry serves only AXLR clients, so a browser gets 403; fetch with a User-Agent that starts with axlr:
curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/heart_rate_zones/1.0.0/manifest.json
curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/heart_rate_zones/1.0.0/heart_rate_zones.py