Multi-Objective Optimization
============================

This tutorial covers true multi-objective optimization using NSGA-II/NSGA-III
algorithms to find Pareto-optimal solutions when optimizing linkages with
competing objectives.

When to Use Multi-Objective Optimization
----------------------------------------

Use multi-objective optimization when you have competing goals that cannot
be combined into a single metric without losing important trade-off information:

- **Path accuracy vs. transmission angle**: A linkage that follows a path
  perfectly might have poor force transmission characteristics.
- **Compactness vs. range of motion**: Smaller mechanisms may have limited
  travel.
- **Speed vs. smoothness**: Fast motion may introduce jerky accelerations.

Unlike weighted-sum approaches (combining objectives into one score),
multi-objective optimization returns the entire **Pareto front**—the set of
solutions where improving one objective necessarily worsens another.

Installation
------------

Multi-objective optimization requires the ``pymoo`` library:

.. code-block:: bash

   pip install pylinkage[moo]

Basic Example
-------------

Let's optimize a four-bar linkage for both path accuracy and transmission angle:

.. code-block:: python

   import pylinkage as pl
   from pylinkage.actuators import Crank
   from pylinkage.components import Ground
   from pylinkage.dyads import RRRDyad
   from pylinkage.simulation import Linkage
   from pylinkage.optimization import (
       multi_objective_optimization,
       kinematic_minimization,
   )


   # Create the linkage
   def create_fourbar():
       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=2.0, name="Output",
       )
       return Linkage([A, D, crank, output])


   # Objective 1: Minimize path error
   @kinematic_minimization
   def path_error(loci, **kwargs):
       """Distance from output path to target circle."""
       output_path = [step[-1] for step in loci]
       target_center = (4, 1)
       target_radius = 1.5

       error = 0.0
       for x, y in output_path:
           dist = ((x - target_center[0])**2 + (y - target_center[1])**2)**0.5
           error += (dist - target_radius)**2
       return error / len(output_path)


   # Objective 2: Minimize transmission angle deviation from 90 degrees
   @kinematic_minimization
   def transmission_penalty(loci, linkage=None, **kwargs):
       """Penalize poor transmission angles (coupler-rocker angle far from 90°)."""
       analysis = linkage.analyze_transmission()
       return analysis.max_deviation


   # Run multi-objective optimization
   linkage = create_fourbar()
   bounds = pl.generate_bounds(linkage.get_constraints())

   results = multi_objective_optimization(
       objectives=[path_error, transmission_penalty],
       linkage=linkage,
       bounds=bounds,
       objective_names=["Path Error", "Transmission Penalty"],
       n_generations=30,
       pop_size=40,
       seed=42,
       verbose=False,
   )

   print(f"Found {len(results)} Pareto-optimal solutions")

Understanding the Pareto Front
------------------------------

Like every optimizer, ``multi_objective_optimization`` returns an
``Ensemble``; here its members are the non-dominated solutions, and it
carries one score column per objective, named as requested:

.. code-block:: python

   # Iterate over solutions
   for member in results:
       print(f"Scores: {member.scores}")
       print(f"Constraints: {member.dimensions}")

   # Scores as numpy arrays, one per objective
   print(f"Objectives: {list(results.scores)}")
   path_errors = results.scores["Path Error"]  # shape (n_solutions,)

   # Rank along one objective
   most_accurate = results.rank("Path Error")[0]
   print(f"Most accurate path: {most_accurate.scores}")

The front's own tools live on ``ParetoFront``; ``to_pareto_front()``
converts the Ensemble when you need them:

.. code-block:: python

   pareto = results.to_pareto_front()

   # Get scores as one array, shape (n_solutions, n_objectives)
   scores = pareto.scores_array()

   # Number of objectives
   print(f"Objectives: {pareto.n_objectives}")

Pareto Dominance
^^^^^^^^^^^^^^^^

A solution **dominates** another if it is at least as good in all objectives
and strictly better in at least one:

.. code-block:: python

   sol_a = pareto[0]
   sol_b = pareto[1]

   if sol_a.dominates(sol_b):
       print("Solution A is strictly better than B")
   else:
       print("Solutions are non-dominated (trade-offs)")

Visualizing Trade-Offs
----------------------

Plot the Pareto front to understand the trade-offs:

2D Pareto Front
^^^^^^^^^^^^^^^

.. code-block:: python

   import matplotlib.pyplot as plt

   # Default 2D plot
   fig = pareto.plot()
   plt.savefig("pareto_front.png")
   plt.show()

   # Custom styling
   fig = pareto.plot(s=100, alpha=0.8, c='blue')
   plt.show()

With three objectives, pass three indices — ``plot(objective_indices=(0, 1,
2))`` — for a 3D scatter; see the three-objective example below.

Selecting a Solution
--------------------

Best Compromise
^^^^^^^^^^^^^^^

Select a balanced solution using normalized weighted sum:

.. code-block:: python

   # Equal weight on all objectives
   best = pareto.best_compromise()

   # Custom weights (prioritize path error)
   best = pareto.best_compromise(weights=[0.8, 0.2])

   # Apply to linkage
   linkage.set_constraints(best.dimensions)
   linkage.set_coords(best.initial_positions)
   pl.show_linkage(linkage)

Filtering Solutions
^^^^^^^^^^^^^^^^^^^

Reduce the Pareto front to a manageable subset using crowding distance:

.. code-block:: python

   # Keep 10 well-distributed solutions
   filtered = pareto.filter(max_solutions=10)

   print(f"Reduced from {len(pareto)} to {len(filtered)} solutions")

Hypervolume Indicator
^^^^^^^^^^^^^^^^^^^^^

Measure the quality of the Pareto front:

.. code-block:: python

   # Reference point should be worse than all solutions
   reference = scores.max(axis=0) * 1.1

   hv = pareto.hypervolume(reference_point=reference)
   print(f"Hypervolume: {hv}")

Advanced Usage
--------------

Combining with Transmission Analysis
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

Use the built-in transmission angle analysis:

.. code-block:: python

   @kinematic_minimization
   def transmission_objective(loci, linkage=None, **kwargs):
       """Use built-in transmission analysis."""
       analysis = linkage.analyze_transmission()
       # Minimize deviation of the mean from the ideal 90 degrees
       return abs(90 - analysis.mean_angle)


   @kinematic_minimization
   def worst_transmission(loci, linkage=None, **kwargs):
       """Minimize the worst transmission angle."""
       analysis = linkage.analyze_transmission()
       # Return how far the worst angle is from acceptable range
       if analysis.min_angle < 40:
           return 40 - analysis.min_angle
       if analysis.max_angle > 140:
           return analysis.max_angle - 140
       return 0

Three-Objective Example
^^^^^^^^^^^^^^^^^^^^^^^

.. code-block:: python

   @kinematic_minimization
   def compactness(loci, **kwargs):
       """Minimize mechanism bounding box area."""
       all_points = [p for step in loci for p in step]
       bbox = pl.bounding_box(all_points)
       width = bbox[1] - bbox[3]  # max_x - min_x
       height = bbox[2] - bbox[0]  # max_y - min_y
       return width * height


   results = multi_objective_optimization(
       objectives=[path_error, transmission_penalty, compactness],
       linkage=create_fourbar(),
       bounds=bounds,
       objective_names=["Path Error", "Transmission", "Size"],
       algorithm="nsga2",
       n_generations=30,
       pop_size=40,
       seed=42,
       verbose=False,
   )

   # 3D visualization
   fig = results.to_pareto_front().plot(objective_indices=(0, 1, 2))

Algorithm Selection
-------------------

NSGA-II (Default)
^^^^^^^^^^^^^^^^^

Best for 2-3 objectives:

.. code-block:: python

   results = multi_objective_optimization(
       objectives=[path_error, transmission_penalty],
       linkage=create_fourbar(),
       bounds=bounds,
       algorithm="nsga2",  # Default
       n_generations=20,
       pop_size=40,
       seed=42,
       verbose=False,
   )

NSGA-III
^^^^^^^^

Better for many objectives (4+); it spreads the population over
reference directions instead of crowding distance:

.. code-block:: python

   results = multi_objective_optimization(
       objectives=[path_error, transmission_penalty, compactness],
       linkage=create_fourbar(),
       bounds=bounds,
       algorithm="nsga3",
       n_generations=20,
       pop_size=40,
       seed=42,
       verbose=False,
   )
   print(f"NSGA-III kept {len(results)} solutions")

Reproducibility
^^^^^^^^^^^^^^^

Set a random seed for reproducible results:

.. code-block:: python

   results = multi_objective_optimization(
       objectives=[path_error, transmission_penalty],
       linkage=linkage,
       bounds=bounds,
       seed=42,
   )

Comparison: Single vs Multi-Objective
-------------------------------------

.. list-table::
   :header-rows: 1
   :widths: 25 40 35

   * - Approach
     - When to Use
     - Trade-offs
   * - **Weighted Sum**
     - Clear priority between objectives
     - Single solution; weights are subjective
   * - **Multi-Objective**
     - Exploring trade-offs; no clear priority
     - Multiple solutions; requires selection

Troubleshooting
---------------

**Empty Pareto front:**

- All candidate solutions may be unbuildable
- Widen the search bounds
- Increase population size

**Poor convergence:**

- Increase ``n_generations``
- Increase ``pop_size``
- Check that objectives have similar scales

**Slow optimization:**

- Reduce ``pop_size`` and ``n_generations``
- Simplify fitness functions
- Use fewer simulation steps

Next Steps
----------

- See :doc:`advanced_optimization` for single-objective techniques
- See :doc:`sensitivity_analysis` for understanding parameter sensitivity
- Check :py:mod:`pylinkage.optimization` for API reference
