diff --git a/docs/api/tune/tune.md b/docs/api/tune/tune.md
new file mode 100644
index 00000000..512f1c7e
--- /dev/null
+++ b/docs/api/tune/tune.md
@@ -0,0 +1 @@
+::: plugboard.tune
diff --git a/docs/examples/tutorials/tuning-a-process.md b/docs/examples/tutorials/tuning-a-process.md
new file mode 100644
index 00000000..071f76d9
--- /dev/null
+++ b/docs/examples/tutorials/tuning-a-process.md
@@ -0,0 +1,72 @@
+---
+tags:
+ - optimisation
+---
+Once you have built a model of your process, a common problem you might face is tuning its parameters. Plugboard includes a built-in optimisation utility based on [Ray Tune](https://docs.ray.io/en/latest/tune/index.html) and [Optuna](https://optuna.org/). Using this tool you can do things like:
+
+* Calibrate the parameters of a process model to match observed results; and
+* Optimise a process model to maximise or minimise its output.
+
+These capabilities are particularly useful when working with digital twins: for example given a model of a production line, you could use the tuner to work out how to maximise its output.
+
+!!! tip
+ By using Ray Tune, Plugboard allows you to run optimisations in parallel within a Ray cluster, allowing you to explore the parameter space quickly even when working with long simulations.
+
+## Define a model to optimise
+
+As a simple example, we'll create a simple 3-component model to calculate the maximum height of a [projectile](https://en.wikipedia.org/wiki/Projectile_motion#Displacement) launched at a given angle and velocity.
+```mermaid
+ flowchart LR
+ horizontal@{ shape: rounded, label: Iterator
**horizontal** } --> trajectory@{ shape: rounded, label: Trajectory
**trajectory** }
+ trajectory@{ shape: rounded, label: Trajectory
**trajectory** } --> max-height@{ shape: rounded, label: MaxHeight
**max-height** }
+```
+
+Running the model with different values of the angle and velocity parameters configured on the `Trajectory` component will result in different heights being found on the `MaxHeight` component at the end of the simulation. We will use the [`Tuner`][plugboard.tune.Tuner] class to explore this parameter space and maximise the projectile height.
+
+### Setting up the components
+
+We'll need the following components to implement the model above:
+```python
+--8<-- "examples/tutorials/006_optimisation/hello_tuner.py:components"
+```
+
+Instead of building a [`Process`][plugboard.process.Process] as we would normally do to run the model directly, we'll instead define the [`ProcessSpec`][plugboard.schemas.ProcessSpec] for the model.
+```python
+--8<-- "examples/tutorials/006_optimisation/hello_tuner.py:define_process"
+```
+
+## Setting up the Tuner
+
+Next, we set up a [`Tuner`][plugboard.tune.Tuner] object by configuring the `angle` and `velocity` arguments as floating point parameters, along with constraints.
+
+!!! info
+ Plugboard supports floating point, integer and categorical variables as tunable model parameters. See the definition of [`ParameterSpec`][plugboard.schemas.ParameterSpec] for details.
+
+When building the tuner, we also specify the number of optimisation samples and how many we will allow to run in parallel on Ray.
+```python
+--8<-- "examples/tutorials/006_optimisation/hello_tuner.py:run_tuner"
+```
+
+1. Set the objective, i.e. what we want our optimisation to target. In this case it is a field on the `max-height` component. This can be a list of objectives if you need to do multi-objective optimisation.
+2. List the tunable parameters here. The `field_type` can be `"arg"` or `"initial_value"`. This is also where you can specify constraints on the parameters.
+3. Set the number of trials to run. More trials will take longer, but may get closer to finding the true optimum.
+4. The level of concurrency to use in Ray.
+5. Whether to minimise or maximise the objective. This must be set as a list for multi-objective optimisation.
+
+Running this code will execute an optimisation job and print out information on each trial, along with the final optimisation result.
+
+!!! tip
+ Since [Optuna](https://optuna.org/) is used under the hood, you can configure the optional `algorithm` argument on the `Tuner` with additional configuration defined in [`OptunaSpec`][plugboard.schemas.OptunaSpec]. For example, the [`storage`](https://optuna.readthedocs.io/en/stable/reference/storages.html) argument allows you to save the optimisation results to a database or SQLite file. You can then use a tool like [Optuna Dashboard](https://optuna-dashboard.readthedocs.io/en/stable/getting-started.html) to study the optimisation output in more detail.
+
+## Using YAML config
+
+Plugboard's YAML config supports an optional `tune` section, allowing you to define optimisation jobs alongside your model configuration:
+```yaml
+--8<-- "examples/tutorials/006_optimisation/model-with-tuner.yaml"
+```
+
+1. As usual, this section defines the [`Process`][plugboard.process.Process]. It can also be replaced by a path to another YAML file.
+2. This section is optional, and configures the [`Tuner`][plugboard.tune.Tuner].
+3. Parameters need to reference a type, so that Plugboard knows the type of parameter to build.
+
+Now run `plugboard process tune model-with-tuner.yaml` to execute the optimisation job from the CLI.
diff --git a/examples/tutorials/006_optimisation/hello_tuner.py b/examples/tutorials/006_optimisation/hello_tuner.py
new file mode 100644
index 00000000..1bdb51ad
--- /dev/null
+++ b/examples/tutorials/006_optimisation/hello_tuner.py
@@ -0,0 +1,124 @@
+"""Optimisation demonstration."""
+
+# fmt: off
+import typing as _t
+
+from plugboard.component import Component, IOController as IO
+from plugboard.process import ProcessBuilder
+from plugboard.schemas import ComponentArgsDict, ProcessSpec, ProcessArgsSpec, ObjectiveSpec
+from plugboard.schemas.tune import FloatParameterSpec
+from plugboard.tune import Tuner
+import math
+
+
+# --8<-- [start:components]
+class Iterator(Component):
+ """Creates a sequence of x values."""
+
+ io = IO(outputs=["x"])
+
+ def __init__(self, iters: int, **kwargs: _t.Unpack[ComponentArgsDict]) -> None:
+ super().__init__(**kwargs)
+ self._iters = iters
+
+ async def init(self) -> None:
+ self._seq = iter(range(self._iters))
+
+ async def step(self) -> None:
+ try:
+ self.x = next(self._seq)
+ except StopIteration:
+ await self.io.close()
+
+
+class Trajectory(Component):
+ """Computes the height of a projectile."""
+
+ io = IO(inputs=["x"], outputs=["y"])
+
+ def __init__(
+ self, angle: float = 30, velocity: float = 20, **kwargs: _t.Unpack[ComponentArgsDict]
+ ) -> None:
+ super().__init__(**kwargs)
+ self._angle_radians = math.radians(angle)
+ self._v0 = velocity
+
+ async def step(self) -> None:
+ self._logger.info("Calculating trajectory", x=self.x)
+ self.y = self.x * math.tan(self._angle_radians) - (9.81 * self.x**2) / (
+ 2 * self._v0**2 * math.cos(self._angle_radians) ** 2
+ )
+
+
+class MaxHeight(Component):
+ """Record the maximum height achieved."""
+
+ io = IO(inputs=["y"], outputs=["max_y"])
+
+ def __init__(self, **kwargs: _t.Unpack[ComponentArgsDict]) -> None:
+ super().__init__(**kwargs)
+ self.max_y: float = 0
+
+ async def step(self) -> None:
+ self.max_y = max(self.y, self.max_y)
+# --8<-- [end:components]
+
+
+if __name__ == "__main__":
+ # --8<-- [start:define_process]
+ process_spec = ProcessSpec(
+ args=ProcessArgsSpec(
+ components=[
+ {"type": "hello_tuner.Iterator", "args": {"name": "horizontal", "iters": 100}},
+ {
+ "type": "hello_tuner.Trajectory",
+ "args": {"name": "trajectory", "angle": 30, "velocity": 20},
+ },
+ {"type": "hello_tuner.MaxHeight", "args": {"name": "max-height"}},
+ ],
+ connectors=[
+ {"source": "horizontal.x", "target": "trajectory.x"},
+ {"source": "trajectory.y", "target": "max-height.y"},
+ ],
+ ),
+ type="plugboard.process.LocalProcess",
+ )
+ # Check that the process spec can be built
+ _ = ProcessBuilder.build(spec=process_spec)
+ # --8<-- [end:define_process]
+ # --8<-- [start:run_tuner]
+ tuner = Tuner(
+ objective=ObjectiveSpec( # (1)!
+ object_type="component",
+ object_name="max-height",
+ field_type="field",
+ field_name="max_y",
+ ),
+ parameters=[
+ FloatParameterSpec( # (2)!
+ object_type="component",
+ object_name="trajectory",
+ field_type="arg",
+ field_name="angle",
+ lower=0,
+ upper=90,
+ ),
+ FloatParameterSpec(
+ object_type="component",
+ object_name="trajectory",
+ field_type="arg",
+ field_name="velocity",
+ lower=0,
+ upper=100,
+ ),
+ ],
+ num_samples=40, # (3)!
+ max_concurrent=4, # (4)!
+ mode="max", # (5)!
+ )
+ result = tuner.run(spec=process_spec)
+ print(
+ f"Best parameters: angle={result.config['trajectory.angle']}, velocity={result.config['trajectory.velocity']}"
+ )
+ print(f"Best max height: {result.metrics['max-height.max_y']}")
+ # --8<-- [end:run_tuner]
diff --git a/examples/tutorials/006_optimisation/model-with-tuner.yaml b/examples/tutorials/006_optimisation/model-with-tuner.yaml
new file mode 100644
index 00000000..00509e64
--- /dev/null
+++ b/examples/tutorials/006_optimisation/model-with-tuner.yaml
@@ -0,0 +1,46 @@
+plugboard:
+ process: # (1)!
+ args:
+ components:
+ - type: hello_tuner.Iterator
+ args:
+ name: horizontal
+ iters: 100
+ - type: hello_tuner.Trajectory
+ args:
+ name: trajectory
+ angle: 25
+ velocity: 20
+ - type: hello_tuner.MaxHeight
+ args:
+ name: max-height
+ connectors:
+ - source: horizontal.x
+ target: trajectory.x
+ - source: trajectory.y
+ target: max-height.y
+ tune: # (2)!
+ args:
+ objective:
+ object_name: max-height
+ field_type: field
+ field_name: max_y
+ parameters:
+ - type: ray.tune.uniform # (3)!
+ object_type: component
+ object_name: trajectory
+ field_type: arg
+ field_name: angle
+ lower: 0
+ upper: 90
+ - type: ray.tune.uniform
+ object_type: component
+ object_name: trajectory
+ field_type: arg
+ field_name: velocity
+ lower: 0
+ upper: 100
+ num_samples: 40
+ mode: max
+ max_concurrent: 4
+
\ No newline at end of file
diff --git a/mkdocs.yaml b/mkdocs.yaml
index 3a16c115..5a25aa30 100644
--- a/mkdocs.yaml
+++ b/mkdocs.yaml
@@ -113,6 +113,7 @@ nav:
- More components: examples/tutorials/more-components.md
- Running in parallel: examples/tutorials/running-in-parallel.md
- Event-driven models: examples/tutorials/event-driven-models.md
+ - Tuning a process: examples/tutorials/tuning-a-process.md
- Configuration: usage/configuration.md
- Topics: usage/topics.md
- Demos:
@@ -133,6 +134,7 @@ nav:
- process: api/process/process.md
- schemas: api/schemas/schemas.md
- state: api/state/state.md
+ - tune: api/tune/tune.md
- utils:
- api/utils/index.md
- settings: api/utils/settings/settings.md
diff --git a/plugboard/schemas/__init__.py b/plugboard/schemas/__init__.py
index 0c897829..7a1c2a1a 100644
--- a/plugboard/schemas/__init__.py
+++ b/plugboard/schemas/__init__.py
@@ -21,7 +21,10 @@
from .process import ProcessArgsDict, ProcessArgsSpec, ProcessSpec
from .state import StateBackendArgsDict, StateBackendArgsSpec, StateBackendSpec
from .tune import (
+ CategoricalParameterSpec,
Direction,
+ FloatParameterSpec,
+ IntParameterSpec,
ObjectiveSpec,
OptunaSpec,
ParameterSpec,
@@ -32,6 +35,7 @@
__all__ = [
+ "CategoricalParameterSpec",
"ComponentSpec",
"ComponentArgsDict",
"ComponentArgsSpec",
@@ -44,6 +48,8 @@
"ConnectorSpec",
"Direction",
"Entity",
+ "FloatParameterSpec",
+ "IntParameterSpec",
"IODirection",
"ObjectiveSpec",
"OptunaSpec",
diff --git a/plugboard/schemas/tune.py b/plugboard/schemas/tune.py
index c0564a6c..72527ea8 100644
--- a/plugboard/schemas/tune.py
+++ b/plugboard/schemas/tune.py
@@ -118,8 +118,10 @@ class CategoricalParameterSpec(BaseFieldSpec):
IntParameterSpec,
CategoricalParameterSpec,
]
+"""A union type for all parameter specifications."""
Direction = _t.Literal["min", "max"]
+"""A type for the direction of optimisation."""
class TuneArgsDict(_t.TypedDict):