Time for a new component, the bell crank. Not your regular crank, no, but a bell crank.

A bell crank is a component that is used to reverse direction, change direction approximately 90 degrees, or to change the magnitude of the motion of a push rod of some kind. Here are a couple of sketches:

sketches of rod motion changing direction

A bell crank will generally have two arms of possibleydifferent lengths, and a center of rotation. Pushing or pulling on one arm causes the crank to rotate around its center, causing the other arm to move, presumably pushing or pulling some other component.

Our ArcRod, the most recently implemented component, is intended to drive a bell crank, as we’ll see when this works.

Note
I am mindful that I’ve seen, or think I’ve seen, a failure of the ArcRod to track properly. While we could chase that issue, I am more interested in making some forward progress. This may turn out to be a mistake. We’ll see.

I think the BellCrank object will have four creation parameters, its center of rotation, two radii, one for each arm, and an angle, which we’ll take to be the positive angle in degrees from the driving arm to the driven arm. As is our habit when we’re somewhat on our game, we’ll start with a test. Maybe even a few of them.

class TestBellCrank:
    def test_bell_crank_exists(self):
        crank = BellCrank()
        assert crank is not None

As intended this fails. PyCharm will create a class for me.

class BellCrank:
    pass

Let’s put in the intended parameters, in that same test.

class TestBellCrank:
    def test_bell_crank_exists(self):
        c = vector(4,3)
        r1 = 1
        r2 = 0.5
        a = 90
        crank = BellCrank(
            center=c, 
            input_radius=r1,
            output_radius=r2, 
            angle=a)
        assert crank is not None

I’m not sure about those names. Naming is hard, but this will do for now. PyCharm notes that the class is not expecting all this information. It does not know what to do, but I do.

class BellCrank:
    def __init__(self):
        pass

Now PyCharm offers “change signature of init” as a thing we could do. I am inclined to agree. I don’t really quite like what it gives me:

class BellCrank:
    def __init__(self, center=c, input_radius=r1, output_radius=r2, angle=a):
        pass

It’s a start, though. I type this much:

class BellCrank:
    def __init__(self, *,center, input_radius, output_radius, angle):
        self._center = center

PyCharm prompts the rest, all in one go:

class BellCrank:
    def __init__(self, *,center, input_radius, output_radius, angle):
        self._center = center
        self._input_radius = input_radius
        self._output_radius = output_radius
        self._angle = angle

Test should be green. Indeed. Commit: initial BellCrank.

Some folks would argue that we should check the parameters. I claim that you and I can read the code and see that they are properly done. We’ll move along to an actual test. I think I’ll create a bell crank with convenient angles and radii and then update it and check the result.

As soon as I type the c in center=, PyCharm offers the rest of the creation line:

    def test_position(self):
        c = vector(0,0)
        r1 = 1
        r2 = 2
        a = 90
        crank = BellCrank(center=c, input_radius=r1, output_radius=r2, angle=a)

Now let’s compute a result. update provides a start and finish for the driving component. We’ll only be concerned with the finish, which will need to be within radius r1 of the center c. We might want to consider error checking, although we have not generally done that. Anyway, for now:

    def test_position(self):
        c = vector(0,0)
        r1 = 1
        r2 = 2
        a = 90
        crank = BellCrank(center=c, input_radius=r1, output_radius=r2, angle=a)
        p_start = None
        p_finish = vector(1, 0)
        start, finish = crank.update(p_start, p_finish)
        assert start == c
        assert finish == vector(0, 2)

I think that’s right. Let’s allow PyCharm to create the method and then we’ll fill it in. I think this is what we need:

    def update(self, p_start, p_finish):
        offset = p_finish - self._center
        rotation = math.atan2(offset.y, offset.x)
        full_angle = self._angle*math.pi/180 + rotation
        finish = vector(self._output_radius, 0).rotate_xy(full_angle)
        return self._center, finish

We compute the angle of the line between start and center, add that to our crank’s angle, and then rotate our output radius to that point. Let’s see if the test agrees. It does not:

Expected :v_Vector((0, 2, 0))
Actual   :v_Vector((1.2246467991473532e-16, 2.0, 0))

Rounding. Let’s have a little helper method.

    def test_position(self):
        c = vector(0,0)
        r1 = 1
        r2 = 2
        a = 90
        crank = BellCrank(center=c, input_radius=r1, output_radius=r2, angle=a)
        p_start = None
        p_finish = vector(1, 0)
        start, finish = crank.update(p_start, p_finish)
        assert start == c
        close_enough(finish, vector(0, 2))

def close_enough(result, expected):
    assert result.x == pytest.approx(expected.x, abs=1e-3)
    assert result.y == pytest.approx(expected.y, abs=1e-3)

I’m planning at least one more test, so it seemed reasonable to write that. It also seems reasonable to have that comparison made easier in general, but I’m not sure how to do that. Bigger fish to fry just now. Let’s do another test.

Note
I see that I should convert the input degrees to radians. Note made. Might remember, might notice later.
    def test_position_2(self):
        c = vector(1, 2)
        r1 = 1
        r2 = 2
        a = 90
        crank = BellCrank(center=c, input_radius=r1, output_radius=r2, angle=a)
        p_start = None
        sqrt2by2 = math.sqrt(2)/2
        p_finish = vector(sqrt2by2, sqrt2by2) + c
        start, finish = crank.update(p_start, p_finish)
        sqrt2 = math.sqrt(2)
        expected = vector(1-sqrt2, 2 + sqrt2)
        close_enough(finish, expected)

It took me too long to write this. I don’t know whether PyCharm or I set a to 180, but the calculation part is for 90 degrees. I didn’t notice that until I got around to printing everything that was going on. Anyway, it’s working, and I am confident that the numbers and code are right.

Commit: BellCrank calculations OK.

Now let’s do the drawing and check this baby out on the road.

Moved the class to its own file. After more fiddling than I wish to admit, I have this demo code:

linkage = Linkage()
linkage.add(
    FixedPoint(base),
    wheel := Wheel(),
    crank := Crank(radius=radius, lead_degrees=lead_angle),
    ConRod(length=rod_length, piston_angle=tilt_angle),
    Piston(length=piston_length, angle=tilt_angle)
)
linkage.parent = crank
linkage.add(SideRod(length=7))
linkage.parent = wheel
bell_radius_in = 1.0
bell_radius_out = 1.5
linkage.add(
    Crank(radius=0.5),
    ArcRod(
        length=5,
        target_position=bell_pos,
        target_radius=bell_radius_in,
        target_angle=135)
)
linkage.add(
    BellCrank(center=bell_pos, input_radius=bell_radius_in, output_radius=bell_radius_out, angle=-90)
)
linkage.apply(lambda c, s, f: c.init_draw(canvas, s, f))

Along the way I found that I had f and s reversed in the apply above, which left everything working except my BellCrank, which focused my attention on a supposed problem in BellCrank. Again, I drilled down to the bits before I noticed the issue.

Anyway, it looks good:

Summary

A bit ragged today. Some bad geometry in hand calculations and then a long-standing but until now harmless mistake in the apply caused me to spend more time digging in the wrong place, twice. Sometimes the bear nibbles you a bit.

But the BellCrank is clearly working, and the ArcRod is certainly working in the video shown. So far so good! A decent morning’s effort.

See you next time!

P.S. I came back to sketch some cranks, having forgotten to do them on my iPad. Was reminded to fix the angle to convert to radians right away. Start with this:

class BellCrank:
    def __init__(self, *,center, input_radius, output_radius, angle):
        self._center = center
        self._input_radius = input_radius
        self._output_radius = output_radius
        self._angle = angle
        self._driving = None
        self._line_driving = None
        self._line_driven = None

Rename the angle member to theta, our local convention for degrees vs radians:

class BellCrank:
    def __init__(self, *,center, input_radius, output_radius, angle):
        self._center = center
        self._input_radius = input_radius
        self._output_radius = output_radius
        self._theta = angle
        self._driving = None
        self._line_driving = None
        self._line_driven = None

Convert in init, remove conversions elsewhere:

    def update(self, p_start, p_finish):
        self._driving = p_finish
        offset = p_finish - self._center
        rotation = math.atan2(offset.y, offset.x)
        theta = self._theta * math.pi / 180
        full_angle = theta + rotation
        finish_offset = vector(self._output_radius, 0).rotate_xy(full_angle)
        finish = finish_offset + self._center
        return self._center, finish

Wind up with this:

class BellCrank:
    def __init__(self, *,center, input_radius, output_radius, angle):
        self._center = center
        self._input_radius = input_radius
        self._output_radius = output_radius
        self._theta = angle*math.pi/180.0
        self._driving = None
        self._line_driving = None
        self._line_driven = None

    def update(self, p_start, p_finish):
        self._driving = p_finish
        offset = p_finish - self._center
        rotation = math.atan2(offset.y, offset.x)
        full_angle = self._theta + rotation
        finish_offset = vector(self._output_radius, 0).rotate_xy(full_angle)
        finish = finish_offset + self._center
        return self._center, finish

Tests green. Sample motion still moves. Code better. Commit. See you next time!