AXLR / Catalog / health

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.

nametyperequireddefaultmeaning
agenumber | stringnoYears; needed unless max_hr is given. ≥ 5; ≤ 110
resting_hrnumber | stringnobpm; enables Karvonen zones. ≥ 20; ≤ 150
max_hrnumber | stringnoMeasured maximum, bpm. ≥ 60; ≤ 250
formulastringno"tanaka"one of "tanaka", "fox", "gellish"
methodstringnoDefault both with resting_hr, else percent_max. one of "karvonen", "percent_max", "both"
boundariesarray of numberno[50, 60, 70, 80, 90, 100]items ≥ 2; items ≤ 21
zone_namesarray of stringno
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