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
^^^^^^^^^^^^^^^^^^^^^^

.. code-block:: python

   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:

.. code-block:: python

   @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:

.. code-block:: python

   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:

.. code-block:: python

   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:

.. code-block:: python

   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:

.. code-block:: python

   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
----------------------------

For true multi-objective optimization with Pareto fronts, see :doc:`multi_objective`.

For simple cases where objectives can be combined with weights:

.. code-block:: python

   @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:

.. code-block:: python

   @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:

.. code-block:: python

   # 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:

.. code-block:: python

   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:

.. code-block:: python

   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
----------

- See :doc:`../examples/examples` for complete optimization examples
- Check :py:mod:`pylinkage.optimization` for API details
- The strider example demonstrates advanced PSO visualization techniques
