Skip to content

Commit d38dd64

Browse files
Taxusptmmccomiskeyclaude
authored
feat: support custom bpm heart-rate range in create_run_workout (#224)
Named Garmin zones (Z1-Z5) don't always match a real training target - e.g. a 136-148 bpm Zone 2 goal straddles Garmin's Z2 (118-137) and Z3 (138-157). Previously create_run_workout only accepted hr_zone, so the watch would show "in range" for the whole zone (up to 157 bpm) even when the actual target ceiling was lower, silently misleading the in-workout HR feedback. Add optional hr_min/hr_max params that build a custom bpm-range target (targetValueOne/targetValueTwo) instead of a zoneNumber, matching a range Garmin Connect already supports natively but this tool didn't expose. hr_zone remains the default and is ignored when a range is given. Verified against the live Garmin Connect API: the custom range round-trips correctly with no zoneNumber set. Co-authored-by: Michael McComiskey <michael.mccomiskey@wunderkind.co> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 0195fa0 commit d38dd64

3 files changed

Lines changed: 125 additions & 6 deletions

File tree

src/garmin_mcp/workout_builders.py

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,22 +45,54 @@ def _zone_number(zone: str) -> int:
4545
raise ValueError(f"Invalid hr_zone '{zone}'. Use Z1-Z5 or 1-5.")
4646

4747

48+
def _hr_target(
49+
hr_zone: str,
50+
hr_min: Optional[int],
51+
hr_max: Optional[int],
52+
) -> tuple:
53+
"""Resolve HR target fields and a short description suffix.
54+
55+
Returns (target_extra_fields, description_suffix). If hr_min/hr_max are
56+
both given, builds a custom bpm-range target (targetValueOne/targetValueTwo)
57+
instead of a named Garmin zone. A custom range and a named zone are mutually
58+
exclusive -- if a range is given, hr_zone is ignored.
59+
"""
60+
if hr_min is not None or hr_max is not None:
61+
if hr_min is None or hr_max is None:
62+
raise ValueError("hr_min and hr_max must both be provided together.")
63+
if hr_min >= hr_max:
64+
raise ValueError(f"hr_min ({hr_min}) must be less than hr_max ({hr_max}).")
65+
return (
66+
{"targetValueOne": float(hr_min), "targetValueTwo": float(hr_max)},
67+
f"{hr_min}-{hr_max}bpm",
68+
)
69+
zone = _zone_number(hr_zone)
70+
return ({"zoneNumber": zone}, f"Z{zone}")
71+
72+
4873
def build_run_json(
4974
name: str,
5075
run_seconds: int,
5176
warmup_min: int,
5277
cooldown_min: int,
5378
hr_zone: str = "Z3",
79+
hr_min: Optional[int] = None,
80+
hr_max: Optional[int] = None,
5481
) -> dict:
55-
"""Build the Garmin Connect JSON for a continuous run workout."""
56-
zone = _zone_number(hr_zone)
82+
"""Build the Garmin Connect JSON for a continuous run workout.
83+
84+
Targets a named heart-rate zone (hr_zone) by default. Pass hr_min and
85+
hr_max together to target an exact custom bpm range instead (e.g. a
86+
136-148 bpm range that doesn't line up with any single Garmin zone).
87+
"""
88+
hr_target_fields, hr_desc = _hr_target(hr_zone, hr_min, hr_max)
5789
run_display = (
5890
f"{run_seconds // 60}m" if run_seconds % 60 == 0 else f"{run_seconds}s"
5991
)
6092
return {
6193
"workoutName": name,
6294
"description": (
63-
f"{warmup_min}m warmup + {run_display} run Z{zone} + {cooldown_min}m cooldown"
95+
f"{warmup_min}m warmup + {run_display} run {hr_desc} + {cooldown_min}m cooldown"
6496
),
6597
"sportType": {"sportTypeId": 1, "sportTypeKey": "running"},
6698
"workoutSegments": [{
@@ -80,11 +112,11 @@ def build_run_json(
80112
"type": "ExecutableStepDTO",
81113
"stepOrder": 2,
82114
"stepType": {"stepTypeId": 3, "stepTypeKey": "interval"},
83-
"description": f"Run {run_seconds}s Z{zone}",
115+
"description": f"Run {run_seconds}s {hr_desc}",
84116
"endCondition": {"conditionTypeId": 2, "conditionTypeKey": "time"},
85117
"endConditionValue": float(run_seconds),
86118
"targetType": {"workoutTargetTypeId": 4, "workoutTargetTypeKey": "heart.rate.zone"},
87-
"zoneNumber": zone,
119+
**hr_target_fields,
88120
},
89121
{
90122
"type": "ExecutableStepDTO",
@@ -363,17 +395,28 @@ async def create_run_workout(
363395
warmup_min: int,
364396
cooldown_min: int,
365397
hr_zone: str = "Z3",
398+
hr_min: Optional[int] = None,
399+
hr_max: Optional[int] = None,
366400
) -> str:
367401
"""Create a continuous run workout and upload it to Garmin Connect.
368402
369403
Builds a single uninterrupted run interval with warmup and cooldown walks.
370404
405+
Targets a named Garmin heart-rate zone by default. Named zones (Z1-Z5)
406+
don't line up with every real training target -- e.g. a 136-148 bpm
407+
Zone 2 goal straddles Garmin's Z2 (118-137) and Z3 (138-157). Pass
408+
hr_min and hr_max together to target that exact bpm range instead;
409+
the watch will then show "in range" only for the range you actually
410+
want, not a whole zone that over- or under-shoots it.
411+
371412
Args:
372413
name: Workout name (e.g. "Step 8 - 30min continuous")
373414
run_seconds: Duration of the run in seconds
374415
warmup_min: Warmup walk duration in minutes
375416
cooldown_min: Cooldown walk duration in minutes
376-
hr_zone: Target heart-rate zone (Z1-Z5, default Z3)
417+
hr_zone: Target heart-rate zone (Z1-Z5, default Z3). Ignored if hr_min/hr_max are given.
418+
hr_min: Optional custom target heart rate range, minimum bpm (must be given with hr_max)
419+
hr_max: Optional custom target heart rate range, maximum bpm (must be given with hr_min)
377420
"""
378421
try:
379422
workout_json = build_run_json(
@@ -382,6 +425,8 @@ async def create_run_workout(
382425
warmup_min=warmup_min,
383426
cooldown_min=cooldown_min,
384427
hr_zone=hr_zone,
428+
hr_min=hr_min,
429+
hr_max=hr_max,
385430
)
386431
result = garmin_client.upload_workout(workout_json)
387432

tests/integration/test_workout_builders_tools.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,38 @@ async def test_create_run_workout_success(app_with_builders, mock_garmin_client)
166166
mock_garmin_client.upload_workout.assert_called_once()
167167

168168

169+
@pytest.mark.asyncio
170+
async def test_create_run_workout_custom_hr_range(app_with_builders, mock_garmin_client):
171+
"""hr_min/hr_max on the tool call produce a custom bpm-range target, not a zoneNumber."""
172+
mock_garmin_client.upload_workout.return_value = {
173+
"workoutId": 1111111111,
174+
"workoutName": "Base run - 7/20",
175+
}
176+
177+
result = await app_with_builders.call_tool(
178+
"create_run_workout",
179+
{
180+
"name": "Base run - 7/20",
181+
"run_seconds": 1440,
182+
"warmup_min": 5,
183+
"cooldown_min": 5,
184+
"hr_min": 136,
185+
"hr_max": 148,
186+
},
187+
)
188+
189+
assert result is not None
190+
payload = json.loads(result[0][0].text)
191+
assert payload["status"] == "success"
192+
193+
uploaded_json = mock_garmin_client.upload_workout.call_args[0][0]
194+
interval_step = uploaded_json["workoutSegments"][0]["workoutSteps"][1]
195+
assert interval_step["targetType"]["workoutTargetTypeKey"] == "heart.rate.zone"
196+
assert interval_step["targetValueOne"] == 136.0
197+
assert interval_step["targetValueTwo"] == 148.0
198+
assert "zoneNumber" not in interval_step
199+
200+
169201
@pytest.mark.asyncio
170202
async def test_create_run_workout_exception(app_with_builders, mock_garmin_client):
171203
"""create_run_workout returns an error string when the API raises an exception."""

tests/unit/test_workout_builders.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,48 @@ def test_build_run_json_structure():
7171
assert steps[2]["endConditionValue"] == 300.0
7272

7373

74+
def test_build_run_json_custom_hr_range():
75+
"""hr_min/hr_max should produce a custom bpm-range target, not a zoneNumber."""
76+
result = build_run_json(
77+
name="Base run - custom range",
78+
run_seconds=1440,
79+
warmup_min=5,
80+
cooldown_min=5,
81+
hr_min=136,
82+
hr_max=148,
83+
)
84+
steps = result["workoutSegments"][0]["workoutSteps"]
85+
interval_step = steps[1]
86+
assert interval_step["targetType"]["workoutTargetTypeKey"] == "heart.rate.zone"
87+
assert interval_step["targetValueOne"] == 136.0
88+
assert interval_step["targetValueTwo"] == 148.0
89+
assert "zoneNumber" not in interval_step
90+
assert "136-148bpm" in result["description"]
91+
92+
93+
def test_build_run_json_custom_hr_range_requires_both_bounds():
94+
with pytest.raises(ValueError, match="hr_min and hr_max must both be provided together"):
95+
build_run_json(
96+
name="Bad range",
97+
run_seconds=1440,
98+
warmup_min=5,
99+
cooldown_min=5,
100+
hr_min=136,
101+
)
102+
103+
104+
def test_build_run_json_custom_hr_range_rejects_inverted_bounds():
105+
with pytest.raises(ValueError, match="must be less than"):
106+
build_run_json(
107+
name="Bad range",
108+
run_seconds=1440,
109+
warmup_min=5,
110+
cooldown_min=5,
111+
hr_min=148,
112+
hr_max=136,
113+
)
114+
115+
74116
def test_build_strength_json_structure():
75117
result = build_strength_json(
76118
name="Full Body A",

0 commit comments

Comments
 (0)