Advanced Optimization Techniques
This tutorial covers advanced optimization techniques for linkage mechanisms using pylinkage’s Particle Swarm Optimization (PSO) and grid search capabilities.
Overview of Optimization
Linkage optimization finds the best geometric parameters (distances, angles) to achieve a desired motion. Pylinkage provides:
Particle Swarm Optimization (PSO): Efficient global optimization using swarm intelligence
Trials and Errors (Grid Search): Exhaustive search over a parameter grid
Defining a Fitness Function
The fitness function evaluates how well a linkage configuration meets your goals.
Use the @kinematic_minimization or @kinematic_maximization decorators.
Basic Fitness Function
import pylinkage as pl
@pl.kinematic_minimization
def fitness(loci, **kwargs):
"""Evaluate linkage fitness.
:param loci: Joint positions for each simulation step.
Structure: tuple[tuple[tuple[float, float], ...], ...]
- Outer tuple: simulation steps
- Middle tuple: joints at each step
- Inner tuple: (x, y) coordinates
:param kwargs: ``linkage``, ``params`` (the candidate constraints)
and ``init_pos`` (the joint positions the simulation started from).
:return: Fitness score (lower is better for minimization)
"""
# Get the locus (path) of the last joint
output_locus = [step[-1] for step in loci]
# Calculate your fitness metric: here, how far the path strays
# from a horizontal line.
ys = [y for _, y in output_locus]
return max(ys) - min(ys)
The decorated function has the signature every optimizer expects,
(linkage, params, init_pos). The decorator:
applies the candidate
paramswithlinkage.set_constraints();runs the simulation and passes the loci in;
catches
UnbuildableErrorand returns infinity (-inffor@kinematic_maximization), so impossible geometries lose without crashing the search.
Optimizers look for the highest score by default. A minimization
fitness therefore goes with order_relation=min, as below.
Working with Loci Data
Understanding the loci structure is key to writing good fitness functions:
@pl.kinematic_minimization
def analyze_loci(loci, **kwargs):
"""Example showing loci structure."""
# loci[step][joint] = (x, y)
# Get all positions of joint 0 (usually the crank)
crank_path = [step[0] for step in loci]
# Get all positions of the last joint (output)
output_path = [step[-1] for step in loci]
# Get positions at a specific step
positions_at_step_5 = loci[5] # All joint positions at step 5
# Calculate bounding box of output path
bbox = pl.bounding_box(output_path)
# bbox = (min_y, max_x, max_y, min_x)
# Score: the crank's highest point should stay under the output's
return max(y for _, y in crank_path) - bbox[2]
Example: Optimizing for Path Shape
Let’s optimize a four-bar linkage so its output traces a specific rectangular path:
import pylinkage as pl
from pylinkage.actuators import Crank
from pylinkage.components import Ground
from pylinkage.dyads import RRRDyad
from pylinkage.simulation import Linkage
def create_linkage():
"""Create the base linkage to optimize."""
A = Ground(0.0, 0.0, name="A")
D = Ground(3.0, 0.0, name="D")
crank = Crank(
anchor=A, radius=1.0, angular_velocity=0.31, name="Crank",
)
output = RRRDyad(
anchor1=crank.output, anchor2=D,
distance1=3.0, distance2=1.0, name="Output",
)
return Linkage([A, D, crank, output])
@pl.kinematic_minimization
def rectangle_fitness(loci, **kwargs):
"""Minimize distance from a target rectangle."""
output_path = [step[-1] for step in loci]
bbox = pl.bounding_box(output_path)
# Target rectangle: min_y=0, max_x=5, max_y=2, min_x=3
target = (0, 5, 2, 3)
# Sum of squared differences
return sum((actual - target_val) ** 2
for actual, target_val in zip(bbox, target))
# Run optimization
linkage = create_linkage()
# Generate search bounds around current constraints
bounds = pl.generate_bounds(linkage.get_constraints())
results = pl.particle_swarm_optimization(
eval_func=rectangle_fitness,
linkage=linkage,
bounds=bounds,
order_relation=min, # rectangle_fitness is an error to minimize
n_particles=30,
iterations=50,
)
# Every optimizer returns an Ensemble: the optimized linkage plus one
# Member per candidate kept, best first.
best = results[0]
print(f"Best score: {best.score}")
print(f"Best constraints: {best.dimensions}")
# Animate the best member: the Ensemble simulates it from the joint
# positions the search started from, then hands the loci to show_linkage
results.show(0)
# Apply the best constraints to the linkage itself
linkage.set_constraints(best.dimensions)
An optimum often sits right at the edge of what can be assembled, where a
four-bar has two mirror-image ways to close and a fine simulation from the
original joint positions may pick the wrong one. results.show() and
results[0].trajectory avoid the question by simulating the member the
way the optimizer scored it; prefer them to re-stepping the linkage by hand.
Particle Swarm Optimization Parameters
Fine-tune PSO behavior for better results:
results = pl.particle_swarm_optimization(
eval_func=rectangle_fitness,
linkage=linkage,
bounds=bounds,
# Number of particles in the swarm
n_particles=100, # More particles = better exploration, slower
# Number of iterations
iterations=200, # More iterations = better convergence, slower
# Starting position (optional)
center=None, # None: particles start uniformly within bounds
# Number of dimensions (usually auto-detected)
dimensions=None,
# Swarm dynamics
leader=3.0, # Pull towards the neighbourhood's best
follower=0.1, # Pull towards the particle's own best
inertia=0.6, # Momentum
neighbors=17, # Ring-topology neighbourhood size
# Order relation for optimization
order_relation=min, # min for minimization, max for maximization
verbose=False, # No progress bar
)
Generating Bounds
The generate_bounds function creates search ranges around current values:
constraints = linkage.get_constraints()
# Here: [1.0, 3.0, 1.0] — crank radius, coupler length, rocker length
bounds = pl.generate_bounds(constraints)
# Returns: (lower_bounds, upper_bounds)
# Default: values / 5 to values * 5
# Custom bounds
bounds = pl.generate_bounds(
constraints,
min_ratio=1.25, # Lower bound = value / 1.25
max_factor=1.25, # Upper bound = value * 1.25
)
# Or define bounds manually for precise control
bounds = (
[0.5, 2.0, 0.5], # Lower bounds
[2.0, 5.0, 2.0], # Upper bounds
)
Grid Search Optimization
For simpler problems or exhaustive search:
results = pl.trials_and_errors_optimization(
eval_func=rectangle_fitness,
linkage=linkage,
bounds=bounds,
divisions=6, # Points per dimension
n_results=5, # Members to keep
order_relation=min, # min or max
verbose=False,
)
print(f"{len(results)} candidates kept, best score {results[0].score:.3f}")
# Note: Grid search is O(divisions^n) where n = number of constraints
# Use sparingly for high-dimensional problems
Multi-Objective Optimization
For true multi-objective optimization with Pareto fronts, see Multi-Objective Optimization.
For simple cases where objectives can be combined with weights:
@pl.kinematic_minimization
def weighted_objectives(loci, **kwargs):
"""Combine objectives with weights (simple approach)."""
output_path = [step[-1] for step in loci]
# Objective 1: Match target bounding box
bbox = pl.bounding_box(output_path)
target = (0, 5, 2, 3)
shape_error = sum((a - t) ** 2 for a, t in zip(bbox, target))
# Objective 2: Minimize total mechanism size
all_points = [p for step in loci for p in step]
mech_bbox = pl.bounding_box(all_points)
mechanism_size = (mech_bbox[1] - mech_bbox[3]) * (mech_bbox[2] - mech_bbox[0])
# Weighted combination (requires choosing weights upfront)
return shape_error + 0.1 * mechanism_size
For exploring trade-offs without committing to weights, use
multi_objective_optimization() which returns the full Pareto front.
Constraint Preservation
Sometimes you want to optimize only certain constraints while keeping others fixed:
@pl.kinematic_minimization
def constrained_fitness(loci, params, **kwargs):
"""Fitness function that enforces additional constraints."""
# Penalize if the crank radius (first constraint of this
# linkage) is too short
if params[0] < 0.5:
return float('inf')
# Normal fitness calculation
output_path = [step[-1] for step in loci]
bbox = pl.bounding_box(output_path)
return sum((a - t) ** 2 for a, t in zip(bbox, (0, 5, 2, 3)))
Optimizing Initial Positions
Sometimes the issue isn’t the constraints but the initial joint positions:
# Save and restore initial positions
init_coords = linkage.get_coords()
# Optimize
results = pl.particle_swarm_optimization(
eval_func=rectangle_fitness,
linkage=linkage,
bounds=bounds,
order_relation=min,
n_particles=20,
iterations=20,
verbose=False,
)
# Apply results
linkage.set_constraints(results[0].dimensions)
linkage.set_coords(init_coords) # Restore initial positions
Visualizing Optimization Progress
Track optimization progress with the strider example pattern:
history = []
def tracking_fitness(linkage, constraints, initial_positions):
"""Wrapper that records every evaluation."""
score = rectangle_fitness(linkage, constraints, initial_positions)
history.append((score, list(constraints), initial_positions))
return score
# Run optimization with tracking
results = pl.particle_swarm_optimization(
tracking_fitness,
linkage,
bounds=bounds,
order_relation=min,
n_particles=20,
iterations=30,
verbose=False,
)
# Analyze history: one entry per particle per iteration, after the
# initial swarm
scores = [h[0] for h in history]
print(f"Evaluations: {len(scores)}, best score: {min(scores):.3f}")
first_iteration = scores[:20]
last_iteration = scores[-20:]
print(f"Best of first iteration: {min(first_iteration):.3f}")
print(f"Best of last iteration: {min(last_iteration):.3f}")
The pylinkage.visualizer PSO plots (parallel_coordinates_plot,
dashboard_layout, animate_dashboard) consume exactly this history,
grouped per iteration; see the strider example.
Async Optimization
For long-running optimizations, use the async version with progress callbacks:
import asyncio
from pylinkage.optimization import particle_swarm_optimization_async
async def optimize_with_progress():
def on_progress(progress):
if progress.current_iteration % 10 == 0:
print(
f"Iteration {progress.current_iteration}"
f"/{progress.total_iterations}: "
f"best = {progress.best_score}"
)
results = await particle_swarm_optimization_async(
eval_func=rectangle_fitness,
linkage=linkage,
bounds=bounds,
order_relation=min,
n_particles=20,
iterations=30,
on_progress=on_progress,
)
return results
# Run async optimization
results = asyncio.run(optimize_with_progress())
print(f"Async result: {results[0].score:.3f}")
The optimization runs in a thread-pool executor, so the event loop stays
free; on_progress receives an OptimizationProgress with
current_iteration, total_iterations, best_score and
is_complete.
Troubleshooting
Optimization converges to poor solutions:
Increase
n_particlesfor better explorationWiden the search bounds
Check if your fitness function correctly penalizes bad configurations
Many configurations are unbuildable:
Your bounds may include geometrically impossible regions
Narrow the bounds around known-good configurations
The
@kinematic_minimizationdecorator returnsinffor unbuildable configs
Optimization is too slow:
Reduce
n_particlesoriterationsUse coarser simulation (fewer steps in
linkage.step())Consider grid search for low-dimensional problems
Next Steps
See Example Scripts for complete optimization examples
Check
pylinkage.optimizationfor API detailsThe strider example demonstrates advanced PSO visualization techniques