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: Additional arguments (linkage, constraints, etc.)
    :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
    score = calculate_score(output_locus)
    return score

The decorator handles:

  • Setting up the linkage with candidate constraints

  • Running the simulation

  • Catching UnbuildableError and returning infinity

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)

    return some_score

Example: Optimizing for Path Shape

Let’s optimize a four-bar linkage so its output traces a specific rectangular path:

import pylinkage as pl


def create_linkage():
    """Create the base linkage to optimize."""
    crank = pl.Crank(
        x=0, y=1,
        joint0=(0, 0),
        angle=0.31,
        distance=1,
        name="Crank"
    )
    output = pl.Revolute(
        x=3, y=2,
        joint0=crank,
        joint1=(3, 0),
        distance0=3,
        distance1=1,
        name="Output"
    )
    return pl.Linkage(
        joints=(crank, output),
        order=(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_num_constraints())

results = pl.particle_swarm_optimization(
    eval_func=rectangle_fitness,
    linkage=linkage,
    bounds=bounds,
)

# Best result
best_score, best_constraints, best_coords = results[0]
print(f"Best score: {best_score}")

# Apply best constraints and visualize
linkage.set_num_constraints(best_constraints)
pl.show_linkage(linkage)

Particle Swarm Optimization Parameters

Fine-tune PSO behavior for better results:

results = pl.particle_swarm_optimization(
    eval_func=fitness_function,
    linkage=linkage,
    bounds=bounds,

    # Number of particles in the swarm
    n_particles=100,      # More particles = better exploration, slower

    # Number of iterations
    iters=200,            # More iterations = better convergence, slower

    # Starting position (optional)
    center=None,          # Use current linkage constraints as center

    # Number of dimensions (usually auto-detected)
    dimensions=None,

    # Order relation for optimization
    order_relation=min,   # min for minimization, max for maximization
)

Generating Bounds

The generate_bounds function creates search ranges around current values:

constraints = linkage.get_num_constraints()
# Example: [0.31, 1.0, 3.0, 1.0]

bounds = pl.generate_bounds(constraints)
# Returns: (lower_bounds, upper_bounds)
# Default: values * 0.5 to values * 2.0

# Custom bounds
bounds = pl.generate_bounds(
    constraints,
    min_ratio=0.8,    # Lower bound = value * 0.8
    max_ratio=1.2,    # Upper bound = value * 1.2
)

# Or define bounds manually for precise control
bounds = (
    [0.0, 0.5, 2.0, 0.5],    # Lower bounds
    [6.28, 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=fitness_function,
    linkage=linkage,
    divisions=20,          # Points per dimension
    order_relation=min,    # min or max
)

# Note: Grid search is O(divisions^n) where n = number of constraints
# Use sparingly for high-dimensional problems

Multi-Objective Optimization

Combine multiple objectives in your fitness function:

@pl.kinematic_minimization
def multi_objective_fitness(loci, **kwargs):
    """Optimize for both path shape and mechanism size."""
    output_path = [step[-1] for step in loci]
    crank_path = [step[0] 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
    return shape_error + 0.1 * mechanism_size

Constraint Preservation

Sometimes you want to optimize only certain constraints while keeping others fixed:

@pl.kinematic_minimization
def constrained_fitness(loci, linkage=None, constraints=None, **kwargs):
    """Fitness function that enforces additional constraints."""
    # Penalize if crank arm (constraint 1) is too short
    if constraints[1] < 0.5:
        return float('inf')

    # Normal fitness calculation
    output_path = [step[-1] for step in loci]
    return calculate_path_score(output_path)

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=fitness,
    linkage=linkage,
    bounds=bounds,
)

# Apply results
linkage.set_num_constraints(results[0][1])
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 optimization history."""
    # Your actual fitness calculation
    score = my_fitness(linkage, constraints, initial_positions)
    history.append((score, list(constraints), initial_positions))
    return score


# Run optimization with tracking
results = pl.particle_swarm_optimization(
    lambda *args: tracking_fitness(*args),
    linkage,
    bounds=bounds,
    n_particles=50,
    iters=100,
)

# Analyze history
scores = [h[0] for h in history]
print(f"Best score: {min(scores)}")
print(f"Score improvement: {scores[0]} -> {scores[-1]}")

Async Optimization

For long-running optimizations, use the async version with progress callbacks:

import asyncio


async def optimize_with_progress():
    def on_progress(iteration, best_score):
        print(f"Iteration {iteration}: best = {best_score}")

    results = await pl.particle_swarm_optimization_async(
        eval_func=fitness,
        linkage=linkage,
        bounds=bounds,
        progress_callback=on_progress,
    )
    return results


# Run async optimization
results = asyncio.run(optimize_with_progress())

Troubleshooting

Optimization converges to poor solutions:

  • Increase n_particles for better exploration

  • Widen 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_minimization decorator returns inf for unbuildable configs

Optimization is too slow:

  • Reduce n_particles or iters

  • Use coarser simulation (fewer steps in linkage.step())

  • Consider grid search for low-dimensional problems

Next Steps