Why My Linkage Optimizer Returned Infinity for 188 Generations
Generation 47: best: inf. Generation 48: best: inf. Generation 49: best: inf. I watched the terminal scroll for ten minutes, each line identical to the last, before accepting that something was fundamentally wrong. The genetic algorithm wasnât slowly converging or getting stuck in local minimaâit was finding zero valid solutions out of thousands of candidates.
The Context: Six-Bar Linkage Synthesis
Iâm working on a final project for my advanced mechanisms class that involves synthesizing six-bar linkages. Think of the mechanism that makes windshield wipers sweep in an arc, or how a folding chair collapsesâthese are linkages that convert rotation into specific motion paths. A six-bar linkage has six rigid links connected by joints, and by carefully choosing the link lengths and pivot positions, you can make a point on the mechanism trace almost any path you need.
My goal: find link lengths and pivot positions that make a coupler point travel between two specific target positions. This âtwo-position synthesisâ specifies where I want the mechanism to be at two key moments, and the optimizer searches for geometry that achieves both.
Hereâs a rough sketch of the linkage topology:
Ground (fixed)
A-----------G
| |
AB GH
| |
B-----C-----H
|
CD
|
D â coupler point (must hit target positions)
The optimization uses scipy.optimize.differential_evolution, a genetic algorithm exploring a 13-dimensional parameter space: two ground pivot locations and nine link lengths. With so many variables and hard geometric constraints, the algorithm can easily wander through regions where no valid configurations exist.
The Symptom: Every Solution Returns Infinity
After adding an early stopping feature to halt optimization when the error converged, I ran the synthesis and watched 188 generations pass with âbest: infâ on every line. Over 4,000 total evaluationsâevery single one returning infinity.
My fitness function used large penalty values to enforce constraints:
# Hard constraint: Start position must be within 0.01 units
if dist_start > 0.01:
error += 1000000.0 * (dist_start - 0.01)**2
# Hard constraint: All joints must stay in the [0,1] box
if any_joint_outside_box:
error += 500000.0
# Critical: No joint can have negative X coordinate
if any_negative_x:
error += 5000000.0
These penalties might seem arbitrary, but their purpose is to make constraint-violating solutions effectively infinitely worse than valid ones. A valid solution might have an error of 0.001 to 0.1, so adding millions ensures the optimizer always prefers valid geometry. When scipy reports âbest: inf,â every solution is violating something.
The Debugging Process
Claude helped me systematically investigate, ruling out several hypotheses before finding the real issue.
First hypothesis: bounds too restrictive. The parameter bounds looked reasonableâall positions in the unit square, all link lengths between 0.1 and 1.0. But this didnât explain why every combination failed.
Second hypothesis: numerical precision issues. Maybe the circle-circle intersection code was failing due to floating point errors? We added epsilon tolerances, but the problem persisted.
Third hypothesis: the target positions themselves. This is where things got interesting. I had specified a start position at (1, 0) and an end position at (0.1, 1)âopposite corners of the workspace. We added diagnostic counters to track exactly which constraints were failing:
def objective(self, x):
self.eval_count += 1
params = self.params_dict(x)
if not self.check_triangle_inequalities(params):
self.triangle_failures += 1
return 1e8
try:
theta_start, dist_start = self.find_angle_for_target(params, self.target_start)
except ValueError:
self.geometry_failures += 1
return 1e8 # Circles don't intersectâlinkage can't close
The results were illuminating:
Triangle inequality failures: 847
Circle intersection failures: 1203
Box constraint violations: 2156
Successful geometry evaluations: 0
The triangle inequality check catches a physical impossibility: for any three connected links forming a loop, the sum of any two lengths must exceed the third. If link AB is 0.2, BC is 0.3, and CA is 0.8, thereâs no way to connect themâ0.2 + 0.3 = 0.5 < 0.8.
But the real culprit was the combination of tight target positions and the box constraint. Reaching from (1, 0) to (0.1, 1) required the linkage to sweep through a large arc, inevitably pushing joints outside the [0, 1] box during motion.
The Fix: Graduated Penalties and Wider Bounds
The solution involved three concrete changes:
-
Increased population size from 10 to 25. More diverse initial candidates meant better coverage of the feasible region.
-
Widened link length bounds for connecting linksâ
L_ABfrom (0.1, 1.0) to (0.1, 1.5),L_CDto (0.1, 1.2). Longer links allow the mechanism to reach further without pushing intermediate joints outside the workspace. -
Graduated penalties instead of hard cutoffs:
# Old: hard cutoff
if any_joint_outside_box:
error += 500000.0
# New: graduated penalty based on violation magnitude
for joint in joints:
if joint.x < 0:
error += 100000.0 * abs(joint.x)
if joint.x > 1:
error += 50000.0 * (joint.x - 1)
After these changes, valid solutions appeared within 12 generations, with errors dropping from infinity to 0.0034.
The Key Insight
When every solution returns infinity, the algorithm has no gradient to follow. Itâs randomly jumping between equally invalid candidates with no way to tell which direction leads toward feasibility. Graduated penalties create a slope that guides the search toward valid regions.
Hard constraints should be reserved for true impossibilitiesâconfigurations that violate physics or break the simulation. Geometric preferences work better as graduated penalties proportional to the violation magnitude. A joint at x=-0.001 shouldnât be treated the same as one at x=-0.5.
Practical Takeaway
If your optimization is stuck returning infinity, add instrumentation to track which constraints are failing:
class ConstraintTracker:
def __init__(self):
self.failures = defaultdict(int)
def check(self, name, condition):
if not condition:
self.failures[name] += 1
return condition
# After running:
print(dict(tracker.failures))
# {'triangle_ineq': 847, 'circle_intersect': 1203, 'box_constraint': 2156}
This immediately tells you where to focus. In my case, seeing that box constraint violations outnumbered everything else pointed directly at the relationship between target positions and workspace limitsâand suggested that softening those constraints would let the optimizer find its footing.