Defects!!
This is why I drew the pictures, but I am still Very Disappointed to find defects in my ConRod. Further disappointment follows.
I knew the tests were weak, but that’s because I had reason to be confident in the code, having worked so hard on it. Apparently working hard is not the answer to being free of error. Who knew? Other than YT, of course. I learn that lesson repeatedly.
My plan for the day includes these possibilities, in no particular order:
- Rig up a better moving display that makes it easier to see correct and erroneous behavior;
- Rig the object itself to check its results and barf out key information when, inevitably, the results do not measure up;
- Read the code carefully, looking for sign inversions;
- Try to remain open to the possibility that my transformation scheme doesn’t really work as I think it does;
- Write more tests with carefully checked values;
- Make it easier to check results, in aid of writing those tests.
I decided to set up a separate test from my few existing ones, sort of a “working” test that I’ll modify and whack on until I like, not just the results, but the way the test itself is formed. Then, if all goes well, I can use it as the shape of as many tests as I feel I need.
I modify the drawing test that showed me the defect, and I think it’s worth looking at:
The vertical guide line is just there to show where the defined tilt angle is, the line along which the rod end should travel. The red dot is placed at the point of furthest extension along the vertical guide line. And it appears that the target point, where the diagonal line points, is 90 degrees out of phase, early. The tilt angle is 90.
I feel sure that we have a test for this case:
def test_tilt_90(self):
center = Vector((0, 0, 0))
radius = 1
length=5
tilt_angle = 90
angle_degrees = 0
wheel = Wheel(constraint=center, angle_degrees=angle_degrees)
con_rod = ConRod(
constraint=wheel,
radius=radius,
length=length,
tilt_angle=tilt_angle,)
crank = con_rod.crank_position()
target = con_rod.constrained(0)
dist = crank.distance(target)
print(f'{crank=}, {target=}, {dist=}')
test_y = center.y + math.sqrt(24)
assert target.y == pytest.approx(test_y,abs=0.001)
Slow realization
In what follows watch me slowly realize that my casual testing has led me astray, and that in fact I don’t have good tests or good code.
This test passes, but I think it’s wrong. At wheel angle zero and tilt angle 90, the … wait. We haven’t accounted for all the variability.
The wheel has control with its angle. The end point is to be found on a line along the tilt angle, because that’s where the power piston is going to be positioned. But where is the crank position relative to the wheel angle? It, too can be offset. Maybe I knew that and just hadn’t accommodated it?
Anyway, the current scheme is assuming that the crank is positioned at the tilt angle relative to wheel angle zero. That’s not valid, but it should work anyway.
The wheel returns crank offset and position like this:
class Wheel:
def crank_offset(self, radius, tilt_radians):
effective_angle = self._angle_radians + tilt_radians
return VtVector((radius, 0, 0)).rotate_xy(effective_angle)
def crank_position(self, radius, tilt_radians):
return self.crank_offset(radius, tilt_radians) + self.constraint
self.constraint is the wheel center. So, OK, the starting point of the conrod will, for now, lead the wheel by 90. But if that’s the case …
Let’s stop supplying tilt angle when we get the control positions, moving the driven end of the rod back to wheel angle zero.
Darkness grows
I’m struggling here. Not sure why, this is simple geometry. Very tempted to start over with ConRod.
Way more printing than I’m proud of and what I think I see is that I’m working with rotated and unrotated values indiscriminately.
Reality dawns
There’s nothing for it but to go back to the drawing board. I think the issue has to do with fetching the crank info from the wheel, but be that as it may, I really need to do it from scratch. Debugging is never going to give me the confidence I need after all this, no matter how many tests I write.
Struggling on the hook
Unless … unless I get a really clear picture of the solution and it translates clearly into the code we have. Anyway, I’m going back to paper and pencil (well iPad and Pencil), but I’ll leave the current code in for now.
Grr. This is embarrassing.
Later That Day …
I drew a careful picture with careful notes. Well, careful enough to use, not to publish:

And then I wrote a function to do the basic horizontal view calculation of the desired triangle, together with much better tests.
class TestConRodLogic:
def con_rod_solve(self, crank_position, length):
x,y = crank_position.x, crank_position.y
x_projection = math.sqrt(length*length - y*y)
return VtVector((x+x_projection,0,0))
def test_basic_function(self):
effective_ca = VtVector((1,0,0))
length = 5
constrained_offset = self.con_rod_solve(effective_ca, length)
assert constrained_offset == VtVector((6, 0, 0))
def test_basic_function_90(self):
effective_ca = VtVector((0, 1, 0))
length = 5
constrained_offset = self.con_rod_solve(effective_ca, length)
root24 = math.sqrt(24)
assert constrained_offset == VtVector((root24, 0, 0))
def test_basic_function_180(self):
effective_ca = VtVector((-1, 0, 0))
length = 5
constrained_offset = self.con_rod_solve(effective_ca, length)
expected = 4
assert constrained_offset == VtVector((expected, 0, 0))
def test_basic_function_135(self):
theta = 135*math.pi/180
base = VtVector((1,0,0))
effective_ca = base.rotate_xy(theta)
length = 5
constrained_offset = self.con_rod_solve(effective_ca, length)
root2by2 = math.sqrt(2)/2
print(f'{root2by2*root2by2=}')
expected = math.sqrt(24.5) - root2by2
assert constrained_offset == VtVector((expected, 0, 0))
Nothing like 30 lines of test for a three-line function, but I have been burned, and I want to be really clear about what works, in anticipation of something going wrong.
That was yesterday. What about today?
Reflection
The scribbled red note “Do everything with offsets!” reminds me that the math is really easy at the origin with the axis of motion of the con-rod horizontal. So the plan, and I want to be more explicit about it, is to solve the problem like this:
- Translate the problem to the origin if necessary;
- Rotate the problem so that the thrust line is horizontal;
- Solve the triangle with the code above;
- Rotate the solution back where it was;
- Translate the problem back to where it was if necessary.
This is a standard solution procedure, common in mathematics. We transform the problem with a series of “affine” transforms, solve the problem and transform back with the inverse transforms:
HardSolution(P) = EasySolution(T*R*P)*R-1*T-1
I am confident that this will work if implemented correctly. (There is a non-zero probability that my analysis is wrong, but I don’t think so. I used to be good at this.)
Our current function is this:
def con_rod_solve(self, crank_position, length):
x,y = crank_position.x, crank_position.y
x_projection = math.sqrt(length*length - y*y)
return VtVector((x+x_projection,0,0))
A better word for crank_position would be crank_offset. This will remind us that it is the difference between the true crank position in space and the wheel center position in space. I’ll do the rename.
Red test serves as reminder
Additionally, the 135 test fails, off by one in the 15th decimal place. That reminds me that I really want a much more robust test anyway.
I extract a verify method:
def test_basic_function_135(self):
theta = 135*math.pi/180
base = VtVector((1,0,0))
effective_ca = base.rotate_xy(theta)
length = 5
constrained_offset = self.con_rod_solve(effective_ca, length)
root2by2 = math.sqrt(2)/2
# print(f'{root2by2*root2by2=}')
expected = math.sqrt(24.5) - root2by2
self.verify(constrained_offset, VtVector((expected, 0, 0)))
def verify(self, expected, actual):
assert expected == actual
Same results, of course. But now:
def test_basic_function_135(self):
theta = 135*math.pi/180
base = VtVector((1,0,0))
effective_ca = base.rotate_xy(theta)
length = 5
constrained_offset = self.con_rod_solve(effective_ca, length)
root2by2 = math.sqrt(2)/2
# print(f'{root2by2*root2by2=}')
expected = math.sqrt(24.5) - root2by2
self.verify(constrained_offset, VtVector((expected, 0, 0)), theta, length)
def verify(self, expected, actual, theta, length):
assert expected.x == pytest.approx(actual.x)
assert expected.y == pytest.approx(actual.y)
crank_offset = VtVector((1,0,0)).rotate_xy(theta)
assert crank_offset.distance(expected) == pytest.approx(length)
I can check the x and y and also check that the distance is close enough to the provided length. I go back and backfill verify into the other tests.
Isn’t that overkill?
It is serious overkill for a three line function. I did it because I have skimped on the tests for the current ConRod, and I want to do much better this time around. So, in this small space with a well-understood situation, I wanted to practice writing an extensive verify function. That builds confidence and a sense of “it’s not that hard”, which will help me in the next phase. For which it is now time.
Transform Time?
We have a simple function that, given a crank offset and length, returns the zero-based vector for the point at the given length along the x axis. What we need is to accommodate the motion of a connecting rod given:
- A wheel, which has a non-zero origin;
- The radius of the crank’s wheel end;
- The crank’s lead angle, relative to the wheel’s inherent angle;
- The piston angle, relative to the wheel’s origin angle (0);
- The length of the connecting rod.
Our mission is to provide a point at the piston angle from the wheel origin, such that it is the given length from the crank’s position. We will also need the crank position. Both these points must be real points relative to the wheel origin. That is, not offsets, but actual coordinates.
We want to sneak up on this problem, because we (OK I) have demonstrated that I can mess it up if I try to go in too big a bite. So what is our next small step?
The current function, con_rod_solve, returns a zero-based point. The point we really want will be that plus the wheel’s origin.
We can have a function that returns more than one value. How about one that returns both ends of the crank, given the wheel origin and whatever other information we may provide.
Let’s do another test to drive out another function. I’ll promote con_rod_solve to the top level of the file so that I can reuse it. PyCharm wants to do that anyway.
Now a new test class, after not too much work:
def con_rod_ends(crank_radius, crank_angle, length):
# zero-based, horizontal
crank_offset = vector(crank_radius,0).rotate_xy(crank_angle)
constrained_end = con_rod_solve(crank_offset, length)
return crank_offset, constrained_end
class TestConRodWithOrigin:
def verify(self, v1, v2):
assert v1.x == pytest.approx(v2.x)
assert v1.y == pytest.approx(v2.y)
def test_something(self):
origin = vector(2,3)
theta = 135*math.pi/180
radius = 1
length = 5
start, finish = con_rod_ends(radius, theta, length)
root2by2 = math.sqrt(2)/2
exp_start = vector(-root2by2, root2by2)
exp_x = math.sqrt(24.5) - root2by2
exp_finish = vector(exp_x,0)
self.verify(start, exp_start)
self.verify(finish, exp_finish)
It’s not doing the origin part yet but the calculation seems to be correct and we have a start at a comprehensive verify in place. I have things to do today, so we’ll pause here.
Reflection
I feel better about this. I have a test scaffold in place, and a function con_rod_ends that is moving in the right direction.
Yes, I think I could just sit down and carefully write this out, or type in the whole function. Experience yesterday suggests a more incremental approach, and I enjoy kind of pushing the code in the direction I want it to go, a bit like making something out of clay. Mush it around, add a little more clay, mush it around some more.
This way seems to give me better results. YMMV.
See you next time!