---
jupytext:
  text_representation:
    extension: .md
    format_name: myst
    format_version: 0.13
    jupytext_version: 1.17.1
kernelspec:
  name: python3
  display_name: Python 3 (ipykernel)
  language: python
---

# Simple example

```{code-cell} ipython3
:tags: [remove-cell]

import logging
import sys

import numpy as np
import xarray as xr

import matplotlib.pyplot as plt
from matplotlib.colors import LogNorm
from mpl_toolkits.axes_grid1 import ImageGrid

import phileas
from phileas import mock_instruments
from phileas.iteration.utility import (
    iteration_tree_to_xarray_parameters,
    flatten_datatree
)
from phileas.iteration import GeometricRange

logging.basicConfig(level=logging.INFO, stream=sys.stdout)
```

This pages uses a simple experiment to illustrate some of the basic features of
Phileas. It shows how to measure the values of a field at different spatial
positions.

## Experimental setup and drivers

This experiment is based on two *instruments*:
 - a probe is used to measure the amplitude of the electric field. It is
   connected to an oscilloscope.
 - A motorized stage is used to position the probe.

We want to plot different field maps, so for this we need to automate the
positioning of the probe and the measurement of the field. Two *drivers* are
used, allowing to control
 - the maximum amplitude of the oscilloscope. It must be high enough to avoid
   saturation, but low enough to prevent quantization noise. Different
   amplitudes are tried to solve this issue.
 - The motorized stage has thgrees of freedom, denoted $X$, $Y$ and $Z$, which
   also computer-controlled.

This example uses simulated instruments, availabe in the
`phileas.mock_instruments` module. They can be used as follows:

```{code-cell} ipython3
motors = mock_instruments.Motors()
probe = mock_instruments.ElectricFieldProbe(motors)
oscilloscope = mock_instruments.Oscilloscope(probe)
oscilloscope.amplitude = 1

print(f"Field value with {motors}: {oscilloscope.get_measurement()}")
motors.set_position(x=1, y=1.5)
print(f"Field value with {motors}: {oscilloscope.get_measurement()}")
```

```{code-cell} ipython3
probe.get_amplitude()
```

If we break this down,
 1. the drivers are instanciated,
 2. the instruments are configured,
 3. and finally, measurements are gathered.

We will now see how to use Phileas to automate these measurements for more
positions.

## Describing the experimental bench

The first step to drive the instruments is to describe what what instruments are
available on the bench, and how to connect to them. This gives Phileas all the
information required to instanciate the drivers. This is done by the *bench
configuration file*, which is a simple YAML file. In our case, as the
instruments are simulated, we can define it like this:

```{code-cell} ipython3
bench = """
simulated-motors:
    loader: phileas-mock_motors-phileas

simulated-oscilloscope:
    loader: phileas-mock_oscilloscope-phileas
    probe: electric-field-probe
    motors: simulated-motors
"""
```

This bench configuration states that two instruments, `simulated-motors` and
`simulated-oscilloscope` are available. Then, it tells the simulated
oscilloscope what probe is used, and how it is driven. Notice the `loader`
attribute : it is a special field, which is required by Phileas for
instantiating the drivers. It is explained in the following section.

:::{seealso}

[](/user_guide/bench_configuration_file) further describes the syntax of this
file.
:::

:::{warning}

Here, the content of the bench configuration file is stored in a Python `str`,
as it integrates easily with notebooks. Usually, it is preferred to store it in
a file, conventionally called `bench.yaml`.
:::

## Describing the instrument parameters


The instruments available on the bench can now be configured. This is the
purpose of the *experiment configuration file*. It is also a YAML file, which
describes
 - what instruments are required by the experiment,
 - how to configure them,
 - and is usually used to document the experiment itself, working as a lab
   notebook.

Let us define it as

```{code-cell} ipython3
experiment = """
description: Measure the electric field for a single probe position.

motors:
    interface: 2d-motors
    x: 1
    y: 1.5

oscilloscope:
    interface: oscilloscope
    amplitude: 1
"""
```

It contains two entries `motors` and `oscilloscope`, which are identified
as *instrument entries* by their `interface` key. It is a requirement, which
states that instrument `motors` must *be* a `3d-motors`. Phileas uses this
internally to match the required instrument with the available ones. Besides
the `interface` reserved keyword, each instrument entry describe its required
configuration.

Finally, this file can also contain arbitrary data, which is convenient to
document the experimental process. Here, the `description` entry simply states
the purpose of the experiment, but it could be much more detailed.

:::{seealso}

[](/user_guide/experiment_configuration_file) further describes the syntax of
this file.
:::

:::{warning}

Here, the content of the experiment configuration file is stored in a Python
`str`, as it integrates easily with notebooks. Usually, it is preferred to
store it in a file, conventionally called `experiment.yaml`.
:::

## Letting Phileas handle instruments with loaders

The bench configuration file describes the available instruments of a bench,
whereas the experiment configuration file requires some of them. Internally,
Phileas relies on *loaders* to match the requirements to the available
instruments.

A loader is a simple Python class, which
 - is identified by a name, which is used in the bench configuration file. In
   this example, loaders `phileas-mock_motors-phileas` and `phileas-mock_oscilloscope-phileas` are used.
 - Additionally, it has can support multiple interfaces, which are requested by
   the experiment configuration file. In this example, interfaces `2d-motors`
   and `oscilloscope` are used.

Here, loaders {py:class}`~phileas.mock_instruments.MotorsLoader` and
{py:class}`~phileas.mock_instruments.OscilloscopeLoaders` are internally used.
Phileas then uses them to
 - instanciate the corresponding drivers
 - and automatically configure the instruments, which is covered later in this
   example.

:::{seealso}

[](/user_guide/implementing_loaders) covers the details necessary for designing
your own loaders.
:::

## Putting it all together in a script

For now, we have declared the instruments available on our simulated bench, and
expressed what instruments are actually required by the experiment, and how to
configure them. We can then actually use Phileas to handle the instruments
initialization, which is done by an `ExperimentFactory`:

```{code-cell} ipython3
factory = phileas.ExperimentFactory(bench, experiment)
```

:::{warning}

Here, `bench` and `experiment` are `str`. If you store the configurations in
dedicated files, simply supply them as {py:class}`pathlib.Path` objects, such
as

```python3
factory = phileas.ExperimentFactory(Path("bench.yaml"), Path("experiment.yaml"))
```

:::

Phileas logs some of its internal operations. Here, we use them to monitor them.
Not how `motors` - the name of the instrument in the experiment
configuration - is matched with `simulated-motors` - the name of the
instrument on the bench. Similarly, `oscilloscope` is matched with
`simulated-oscilloscope`.

:::{note}

This separation in two levels allows to ease the replication of an experiment.
The useful information of an experiment, and what it exactly does, is in the
experiment configuration file, which should be portable. On the other hand, the
bench configuration file contains all the non-portable information which is
specific to a given bench. To replicate an experiment, only the bench
configuration file has to be adapted.
:::

Using the bench configuration file, and the loaders it requests, the drivers can
then be instantiated. They are then accessible in
`factory.experiment_instruments`.

```{code-cell} ipython3
factory.initiate_connections()
motors: mock_instruments.Motors = factory.experiment_instruments["motors"]
oscilloscope: mock_instruments.Oscilloscope = factory.experiment_instruments["oscilloscope"]
```

:::{note}

Notice the `oscilloscope: mock_instruments.Oscilloscope` type hint. It is not
required, yet it allows to document the type of `oscilloscope`, which
couldn't be statically determined otherwise. It is recommended to add type
hints if you want to benefit from the guarantees of a static type checker like
[mypy](https://mypy.readthedocs.io/en/stable/index.html) or
[pyright](https://github.com/microsoft/pyright).
:::


Finally, the instruments can be configured, and the measurements gathered:

```{code-cell} ipython3
factory.configure_experiment()
print(f"Field value with {motors}: {oscilloscope.get_measurement()}")
```

As the logs indicate, the motors and oscilloscope are properly configured to the
values specified in the experiment config file. This results in the same
measurement as in the initial example, with manual instruments configuration.

## Mapping the electric field value

Let us now go further, and record the field values for multiple probe positions.
Let us say that we want to regularly map the region with $0 \ge X, Y \ge 1$,
with $3$ different position along each axis. This can be done directly in the
experiment configuration file:

```{code-cell} ipython3
experiment = """
description: Measure the electric field in the region 0 <= X, Y <= 1.

motors:
    interface: 2d-motors
    x: !range
        start: 0
        end: 1
        steps: 3
    y: !range
        start: 0
        end: 1
        steps: 3

oscilloscope:
    interface: oscilloscope
    amplitude: 1
"""
```

Note how the `x` and`y` parameters of `motors` have been replaced by a `!range`
object. This is an example of a *custom YAML type*, that is used by Phileas to
represent multiple configurations with a single configuration file.

Let us introduce two types. First, a *data tree* represent the data that can be
stored in a usual YAML file. In other words, it is built with `list` and
`dict` objects, eventually storing numeric, boolean, string or null values. A
data tree thus represents a single configuration to be applied to an
instrument, or even the configuration of a whole experimental bench.

Now, Phileas introduces another similar structure : the *iteration tree*. Put
simply, an iteration tree represents a collection of data trees, with the
iteration order over it. Experiment configuration files are actually parsed to
iteration trees, so that they can represent all the configurations of a
(potentially complex) experiment.

The experiment iteration tree of a factory is available in its
`experiment_config` attribute, which can be iterated over like a usual Python
collection. This allows us to slightly modify the code of the last section to
apply multiple configurations to the instruments.

```{code-cell} ipython3
factory = phileas.ExperimentFactory(bench, experiment)
factory.initiate_connections()
motors: mock_instruments.Motors = factory.experiment_instruments["motors"]
oscilloscope: mock_instruments.Oscilloscope = factory.experiment_instruments["oscilloscope"]

for config in factory.experiment_config:
    factory.configure_experiment(config)
```

Yet, we notice that the oscilloscope amplitude is repeatedly configured, without
changing. Similarly, the `x` position only changes every 3 iterations. The
experiment configuration can be modified to perform *lazy iteration*: only
modified values are returned at each iteration.

```{code-cell} ipython3
lazy_configuration = (factory.experiment_config
    .with_params(["motors"], lazy=True)
    .with_params(["oscilloscope"], lazy=True)
)

for config in lazy_configuration:
    factory.configure_experiment(config)
```

{py:meth}`~phileas.iteration.IterationTree.with_params` modifies a parameter of
an entry of an iteration tree (here, one of its nodes), and returns it.

:::{seealso}

[](/user_guide/iteration_trees) extensively covers the capabilities offered by
iteration trees.
:::

## Configuring logging

Up to now, we have used the logs of Phileas in order to peek at its internal
operations. Let us clean the outputs by keeping only warnings.

```{code-cell} ipython3
phileas.logger.setLevel(logging.WARNING)
```

## Storing results with xarray

Let us now see how to store the results of the measurements, so that we can
proceed to the data analysis stage of the experiment.

Different Python data containers can be used, yet our example is perfect for
xarray ones. You can think of them as multi-dimensional arrays, where the
dimensions are annotated to make the container meaningfull.

```{seealso}

This page does not present xarray. See
[this page](https://docs.xarray.dev/en/stable/user-guide/terminology.html) for
terminology on its containers.
```


Phileas allows to simply prepare an xarray container for data storage.

```{code-cell} ipython3
coords, dims_name, dims_shape = iteration_tree_to_xarray_parameters(
    factory.experiment_config
)
results = xr.Dataset(
    data_vars=dict(
        electric_field=(dims_name, np.full(dims_shape, np.nan)),
    ),
    coords=coords
)
results
```

The `results` dataset contains a single variable, which is the measurement of
the electric field. See how most of the information of the experiment
configuration file is kept in the dataset, including both varying parameters
such as `motors.x` and fixed values such as `description`.


The acquisition loop can then be modified to incorporate measurements storage.

```{code-cell} ipython3
for config in factory.experiment_config:
    flat_config = {
        coord: v for coord, v in flatten_datatree(config).items() if coord in dims_name
    }
    factory.configure_experiment(config)
    results.electric_field.loc[flat_config] = oscilloscope.get_measurement()
```

The {py:func}`~phileas.iteration.utility.flatten_datatree` utility is used to
converted the hierarchical structure of a data tree to a flat structure which
can be used for indexing the xarray storage.

```{code-cell} ipython3
flat_config
```

Once the current experiment index is built, the results can be modified using
their `loc` modifier. Finally, `results` contains the values of the electric
field at the different requested positions.

```{code-cell} ipython3
results.electric_field
```

:::{seealso}

[](/user_guide/storing_results) covers additional ways of storing results.
:::

## Getting further in the parameters generation


We have seen how to acquire simple datasets, but the $3 \times 3$ grid used for
measurements up to now might not have a resolution suitable for data analysis.
Let us increase the number of measurements points, which will allow us to plot
the value of the electric field in space.

```{code-cell} ipython3
configs = (factory.experiment_config
    .with_params(["motors", "x"], start=0, end=1, steps=20)
    .with_params(["motors", "y"], start=0, end=1, steps=20)

)
coords, dims_name, dims_shape = iteration_tree_to_xarray_parameters(configs)
results = xr.Dataset(
    data_vars=dict(
        electric_field=(dims_name, np.full(dims_shape, np.nan)),
    ),
    coords=coords
)
results

for config in configs:
    flat_config = {
        coord: v for coord, v in flatten_datatree(config).items() if coord in dims_name
    }
    factory.configure_experiment(config)
    results.electric_field.loc[flat_config] = oscilloscope.get_measurement()

results
```

Notice how the size of the dimensions has increased to 20, as requested.
However, the acquisition code remained the same. We can now plot the
measurements:

```{code-cell} ipython3
results.electric_field.plot(x="motors.x", y="motors.y")
```

This figure exhibits some saturation: most of the measured values are fixed at
$0.5$. Let us have a look at the required configurations, using
{py:meth}`~phileas.iteration.IterationTree.to_pseudo_data_tree`, which converts
an iteration tree in a more readable format.

:::{seealso}

[This user guide section](project:/user_guide/iteration_trees.rst#pseudo-data-trees)
presents pseudo data trees more extensively.
:::

```{code-cell} ipython3
import pprint
pprint.pprint(configs.to_pseudo_data_tree())
```

The amplitude of the oscilloscope is configured to 1, and it does not have an
offset, so this means that the maximum value it can measure is 0.5. This
explains why we obtained saturated measurements. Let us vary the amplitude of
the oscilloscope in order to find a suitable value. Here, we use
{py:meth}`~phileas.iteration.IterationTree.replace_node` to replace the type of
a node in the iteration tree. This allows us to change the fixed amplitude
value of the oscilloscope with a geometrically distributed sequence.

```{code-cell} ipython3
configs = (configs
    .replace_node(["oscilloscope", "amplitude"], GeometricRange, start=1, end=10000, steps=3)
)
coords, dims_name, dims_shape = iteration_tree_to_xarray_parameters(configs)
results = xr.Dataset(
    data_vars=dict(
        electric_field=(dims_name, np.full(dims_shape, np.nan)),
    ),
    coords=coords
)

for config in configs:
    flat_config = {
        coord: v for coord, v in flatten_datatree(config).items() if coord in dims_name
    }
    factory.configure_experiment(config)
    results.electric_field.loc[flat_config] = oscilloscope.get_measurement()

results
```

The resulting data can finally be plotted to check what has just been acquired:

```{code-cell} ipython3
:tags: [hide-input]

# Prepare an image grid
fig = plt.figure(figsize=(18, 7))
grid = ImageGrid(
    fig,
    111,
    (1, len(results["oscilloscope.amplitude"])),
    axes_pad=0.2,
    cbar_location="bottom",
    cbar_mode="each",
    cbar_pad=None,
    aspect=False,
    cbar_size="3%",
)

for (lab, data), ax, cax in zip(
    results.electric_field.groupby("oscilloscope.amplitude"), grid, grid.cbar_axes
):
    contours = ax.contour(data.squeeze(), norm=LogNorm(), colors="w")
    ax.clabel(contours, inline=1, fontsize=10)
    im = ax.imshow(data.squeeze(), interpolation="nearest", norm=LogNorm())
    cax.colorbar(im)
    ax.set_xticks([])
    ax.set_yticks([])
    ax.set_title(f"Amplitude {data['oscilloscope.amplitude'].item():.0f}")
```

Using these measurements, the experimenter can now properly configure the
amplitude of his oscilloscope, in order to measure the phenomenon of interest.

## Conclusion and getting further

This example illustrates how Phileas work, and how you can prepare a simple
experiment with it. Notably, it splits an experiment in three parts:

 - the bench configuration file describes the *instruments that are available*
   on an experimental setup, and is not portable;
 - the experiment configuration file describes *what an experiment does*, and is
   usually what the experimenter works with;
 - the acquisition script handles everything else, and notably the measurements
   and the storage of their results.

The experiment configuration file and the acquisition script are meant to be
portable. This architecture reduces the efforts required to replicate an
experiment and share it with others.

Now that you have seen at a glance how to use Phileas, you can dive into more
advanced [examples](/examples/index).

You can go to the [user guide](/user_guide/index) to have detailed information
about specific features of Phileas.
