We need an easier way to define a Linkage.

I think I have to invent a DSL (Domain Specific Language) in Python, or at least a smooth way to define a Linkage. To review, a Component is an instance of one of various concrete classes, FixedPoint, Wheel, Crank, etc. Each class has a common member, parent, and one or more other values specifying its details such as radius or length. One will tend to specify them root up, FixedPoint() a -> Wheel() b -> Crank() c. but then a new item wants to be the cuild of an earlier item, or a new one, such as FixedPoint() d, b -> Crank() e -> Rod(d) f. FixedPoint d is a parameter to the Rod f, not its parent. Its parent is Wheel b. (It is possible that the parameter to f should not be a FixedPoint, but a simple vector, but I think it has to be an actual component for reasons not useful here.) The Components are kept in a smart collection of class Linkage.

The Component does not know its parent, that info is in the Linkage. The Linkage iterates the collection passing standard parameters to each Component, parent before child, collecting from each parent the parameters to be sent to each child so that it can produce its own result to be passed to its children.

I guess the thing to do is to create a test file and try to type in something that would be easy to type, and see what can be done to make it work. I have a couple of starting ideas: returning the Linkage from its add commands, and using the walrus operator.

The first thing I discover is that I have a broken test. I was sure I was running on green tests. My guess is that it’s a mis-configured test and I’m going to skip it for now.

Here’s my initial DSL test:

class TestDSL:
    def test_adding(self):
        assert False

OK, let’s first copy in the code we use to build the graphical display linkage, and then try to improve it.

class TestDSL:
    def test_adding_old(self):
        radius = 1
        tilt_angle = 45
        lead_angle = 15
        rod_length = 5
        piston_length = 2
        components = Linkage()
        base = vector(6, 3)
        fixed_point = FixedPoint(base)
        components.add(fixed_point, None)
        wheel = Wheel()
        components.add(wheel, fixed_point)
        crank = Crank(radius=radius, lead_degrees=lead_angle)
        components.add(crank, wheel)
        con_rod = ConRod(length=rod_length, piston_angle=tilt_angle)
        components.add(con_rod,crank)
        piston = Piston(length=piston_length, angle=tilt_angle)
        components.add(piston, con_rod)
        side_rod = SideRod(length=7)
        components.add(side_rod, crank)
        off = vector(math.sqrt(12.5), math.sqrt(12.5))
        bell_pos = base + off
        print(f'{off=} {base=} {bell_pos=} {base.distance(bell_pos)=}')
        bell_point = FixedPoint(bell_pos)
        components.add(bell_point, None)
        arc_crank = Crank(radius=0.5)
        components.add(arc_crank, wheel)
        arc_rod = ArcRod(
            length=5,
            target_position=bell_pos,
            target_radius=0.55,
            target_angle=135)
        components.add(arc_rod, arc_crank)
        assert len(components._elements) == 9

That’s just pasted from the linkage display and it adds 9 components as advertised. Let’s posit a new adding method add_component that adds the provided new component, assigning the immediately previous component as the new component’s parent if one is not provided.

No, in fact let’s see if we can just change the existing add to do that.

class Linkage:
    class Element:
        def __init__(self, component, parent):
            self.component = component
            self.parent = parent
            self.start = None
            self.finish = None

    def __init__(self):
        self._elements = dict()
        self._previous = None

    def add(self, component, parent=None):
        if parent is None:
            parent = self._previous
        le = self.Element(component, parent)
        self._elements[component] = le
        self._previous = component
        return component

This seems to work as intended. I note that we are providing the component itself as the parent. We could instead use the Element as the parent, which might be better. We’ll keep that in mind. I think I want to have the current test’s linkage builder as a function so that I can use it in other tests as a point of comparison, since it is “known” to be right.

Next Day …

I did create a test using the new style:

    def test_compare_old_new(self):
        old_components = original_build()
        radius = 1
        tilt_angle = 45
        lead_angle = 15
        rod_length = 5
        piston_length = 2
        new_components = Linkage()
        base = vector(6, 3)
        fixed_point = FixedPoint(base)
        new_components.add(fixed_point)
        wheel = Wheel()
        new_components.add(wheel)
        crank = Crank(radius=radius, lead_degrees=lead_angle)
        new_components.add(crank)
        con_rod = ConRod(length=rod_length, piston_angle=tilt_angle)
        new_components.add(con_rod)
        piston = Piston(length=piston_length, angle=tilt_angle)
        new_components.add(piston)
        side_rod = SideRod(length=7)
        new_components.add(side_rod, crank)
        off = vector(math.sqrt(12.5), math.sqrt(12.5))
        bell_pos = base + off
        bell_point = FixedPoint(bell_pos)
        new_components.add(bell_point)
        arc_crank = Crank(radius=0.5)
        new_components.add(arc_crank, wheel)
        arc_rod = ArcRod(
            length=5,
            target_position=bell_pos,
            target_radius=0.55,
            target_angle=135)
        new_components.add(arc_rod)
        assert len(new_components._elements) == 9
        for old, new in zip(old_components._elements, new_components._elements):
            assert old == new

I extracted the code from the original test into a function, so that I could use it here. As you can see, my plan was just to compare the new linkage’s elements to the old one, and if they match, we’re good.

That plan was not quite as fine as it might have been. Components don’t have the ability to be compared for equality, other than by identity. So they would all need an __eq__ method. Not too hard. But to go into the elements dictionary they would also need a __hash__ method. Again, not difficult, but a bit tricky. And it would require us to add two methods to each component, forever, just to support our tests.

So that’s right out. At the point that I recognized that issue, I had other things to do anyway, so I bailed out. This morning I’m going to roll back the components and write a legitimate test for the changes to Linkage.

That test was too much of a story test anyway. Tell you what, we’ll roll back linkage as well and see if we can do this right.

What change(s) are we trying to make to Linkage? On every add:

  1. Remember the most recently-added component;
  2. When the parent parameter to add is None, use the most recently-added component as the parent;
  3. Otherwise use the provided parent;
  4. Return the component, so it can be saved as a future parent if needed.

Let’s just test-drive those features. We’ll use existing components, although in principle we could use any objects. It seems better to me to use the real thing.

    def test_parent_accepted(self):
        linkage = Linkage()
        fixed = FixedPoint(position=vector(2,3))
        wheel = Wheel(angle_degrees=45)
        linkage.add(wheel, fixed)
        element = linkage._elements[wheel]
        assert element.component == wheel
        assert element.parent == fixed

We check that our wheel has the right parent. That parent was in fact unknown to the linkage. So that works, for now. Of course, we’re in the version of add that doesn’t do any of this new stuff. Fine. This test will break if we do something wrong. ANother test, this time for implied parent:

    def test_implied_parent(self):
        linkage = Linkage()
        fixed = FixedPoint(position=vector(6,7))
        wheel = Wheel()
        linkage.add(fixed)
        linkage.add(wheel)
        element = linkage._elements[wheel]
        assert element.parent == fixed
        fixed_element = linkage._elements[fixed]
        assert fixed_element.parent is None

PyCharm assures me gently that this won’t even compile, because add expects a parent. We are here to fix that:

class Linkage:
    def add(self, component, parent=None):
        le = self.Element(component, parent)
        self._elements[component] = le

Now it’ll compile, and I expect the test to fail.

Expected :<src.fixed_point.FixedPoint object at 0x106acc260>
Actual   :None

Right. The implied parent, fixed, was not set into the wheel. We are here to implement that.

class Linkage:
    def __init__(self):
        self._elements = dict()
        self._previous = None

    def add(self, component, parent=None):
        if parent is None:
            parent = self._previous
        le = self.Element(component, parent)
        self._elements[component] = le
        self._previous = component

That passes. We can do that a bit more idiomatically, I think, but let’s commit this: two tests working.

Now:

    def add(self, component, parent=None):
        le = self.Element(component, parent or self._previous)
        self._elements[component] = le
        self._previous = component

Tests run. Commit: tidying.

We have another test to write, causing us to return the component for reuse.

    def test_component_returned(self):
        linkage = Linkage()
        fixed = FixedPoint(position=vector(2,11))
        returned = linkage.add(fixed)
        assert returned == fixed

Test fails, as we return None. Fix:

    def add(self, component, parent=None):
        le = self.Element(component, parent or self._previous)
        self._elements[component] = le
        self._previous = component
        return component

Green. Commit: add returns component.

I think what I’ll do now is just use the new facilities in the tk main that displays a linkage. It starts like this:

radius = 1
tilt_angle = 45
lead_angle = 15
rod_length = 5
piston_length = 2
components = Linkage()
base = vector(6, 3)
fixed_point = FixedPoint(base)
components.add(fixed_point, None)
wheel = Wheel()
components.add(wheel, fixed_point)
crank = Crank(radius=radius, lead_degrees=lead_angle)
components.add(crank, wheel)
con_rod = ConRod(length=rod_length, piston_angle=tilt_angle)
components.add(con_rod,crank)
piston = Piston(length=piston_length, angle=tilt_angle)
components.add(piston, con_rod)
side_rod = SideRod(length=7)
components.add(side_rod, crank)
off = vector(math.sqrt(12.5), math.sqrt(12.5))
bell_pos = base + off
bell_point = FixedPoint(bell_pos)
components.add(bell_point, None)
arc_crank = Crank(radius=0.5)
components.add(arc_crank, wheel)
arc_rod = ArcRod(
    length=5,
    target_position=bell_pos,
    target_radius=0.55,
    target_angle=135)
components.add(arc_rod, arc_crank)

I’m a bit concerned about that bell_point. We don’t use it. But it raises the question: is it possible that we will ever really want to force a component’s parent to None? We’ll table that concern for now.

I’m going to rename components to linkage, then start simplifying. Mostly that will consist of inlining a component creation, and removing the parent parameter from add. It will work better if I remove the parent params first: otherwise the inline will try to do it twice.,

My first pass gives me this:

radius = 1
tilt_angle = 45
lead_angle = 15
rod_length = 5
piston_length = 2
base = vector(6, 3)
linkage = Linkage()
linkage.add(FixedPoint(base))
wheel = Wheel()
linkage.add(wheel)
crank = Crank(radius=radius, lead_degrees=lead_angle)
linkage.add(crank)
linkage.add(ConRod(length=rod_length, piston_angle=tilt_angle))
linkage.add(Piston(length=piston_length, angle=tilt_angle))
linkage.add(SideRod(length=7), crank)
off = vector(math.sqrt(12.5), math.sqrt(12.5))
bell_pos = base + off
# bell_point = FixedPoint(bell_pos)
# linkage.add(bell_point)
linkage.add(Crank(radius=0.5), wheel)
arc_rod = ArcRod(
    length=5,
    target_position=bell_pos,
    target_radius=0.55,
    target_angle=135)
linkage.add(arc_rod)

Much more compact. I commented out the bell_point and it seems not to be needed. I’ll remove that. And the two step lines, like this:

wheel = Wheel()
linkage.add(wheel)

That’s there because we refer to wheel later, down right before we do the arc rod: we reuse the wheel for a second crank. But add now returns the component, so we can do this, by hand:

wheel = linkage.add(Wheel())

That draws correctly. Change the other lines. I move the regular main rod crank up, for a better picture.

radius = 1
tilt_angle = -45
lead_angle = 15
rod_length = 5
piston_length = 2
base = vector(6, 7)
off = vector(math.sqrt(12.5), math.sqrt(12.5))
bell_pos = base + off

linkage = Linkage()
linkage.add(FixedPoint(base))
wheel = linkage.add(Wheel())
crank = linkage.add(Crank(radius=radius, lead_degrees=lead_angle))
linkage.add(ConRod(length=rod_length, piston_angle=tilt_angle))
linkage.add(Piston(length=piston_length, angle=tilt_angle))
linkage.add(SideRod(length=7), crank)
linkage.add(Crank(radius=0.5), wheel)
arc_rod = ArcRod(
    length=5,
    target_position=bell_pos,
    target_radius=0.55,
    target_angle=135)
linkage.add(arc_rod)

And here’s the result:

Alt: Video shows a main rod, side rod and arc rod all in motion as intended.

Full disclosure: in my first attempt at separating the arc rod from the main rod, I moved the arc target upward leaving the main down. It seemed not to be connecting correctly, but I may have set the angles wrong. We’ll check that soon. I remain confident that the logic and code are correct, but I have been confident in the past and had things not be as I thought. So we’ll check.

Summary

We set out to make linkage creation easier. I think that’s accomplished. But there may be improvements.

It is possible that we’ll decide that the code lines are too busy or too long with the creation of the component inlined as they are. That might lead to a different arrangement, perhaps where we create all the components in a batch and then add them in a batch.

Hm, maybe we’d even have an add_batch method? add_branch to the tree? Perhaps we should link the components together more explicitly? Maybe there’s a scheme that is much better.

We’ll find out. Our current scheme is pleasant, because it is backward compatible: you can still express all the parents if you wish to.

If there’s a lesson to be re-re-re-relearned, it is that small steps are better. My three little tests this morning were easier to write, made checking easier, and drove out the aspects of the new add one at a time.

Very nice. How many more times will I have to learn this? Thousands, I hope, once a day for a long long time. (I reserve the right to change my mind.)

Presumably there will be many linkages made with this thing. So making linkage definition easier is a good thing to have done, and if we can make it even easier, we will do so.

See you next time!