Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add IBEX plotting callback #18

Merged
merged 4 commits into from
Aug 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions doc/plotting.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Plotting

Bluesky has good integration with `matplotlib` for data visualization, and data from scans
may be easily plotted using the `LivePlot` callback.

`ibex_bluesky_core` provides a thin wrapper over bluesky's default `LivePlot` callback,
which ensures that plots are promptly displayed in IBEX.

In order to use the wrapper, import `LivePlot` from `ibex_bluesky_core` rather than
`bluesky` directly:
```
from ibex_bluesky_core.callbacks.plotting import LivePlot
```

## Configuration

A range of configuration options for `LivePlot` are available - see the
[bluesky `LivePlot` documentation](https://blueskyproject.io/bluesky/main/callbacks.html#bluesky.callbacks.mpl_plotting.LivePlot)
for more details about available options.

The `LivePlot` object allows an arbitrary set of matplotlib `Axes` to be passed in, onto
which it will plot. This can be used to configure properties which are not directly exposed
on the `LivePlot` object, for example log-scaled axes.

See the [matplotlib `Axes` documentation](https://matplotlib.org/stable/api/_as_gen/matplotlib.axes.Axes.html)
for a full range of options on how to configure an `Axes` object.

Below is a full example showing how to use standard `matplotlib` & `bluesky` functionality
to plot a scan with a logarithmically-scaled y-axis:

```python
import matplotlib.pyplot as plt
from ibex_bluesky_core.callbacks.plotting import LivePlot
# Create a new figure to plot onto.
plt.figure()
# Make a new set of axes on that figure
ax = plt.gca()
# Set the y-scale to logarithmic
ax.set_yscale("log")
# Use the above axes in a LivePlot callback
plot_callback = LivePlot(y="y_variable", x="x_variable", ax=ax)
```

The `plot_callback` object can then be subscribed to the run engine, using either:
- An explicit callback when calling the run engine: `RE(some_plan(), plot_callback)`
- Be subscribed in a plan using `@subs_decorator` from bluesky **(recommended)**
- Globally attached to the run engine using `RE.subscribe(plot_callback)`
* Not recommended, not all scans will use the same variables and a plot setup that works
for one scan is unlikely to be optimal for a different type of scan.

By subsequently re-using the same `ax` object in later scans, rather than creating a new
`ax` object for each scan, two scans can be "overplotted" with each other for comparison.
31 changes: 21 additions & 10 deletions doc/set_up_dev_environment.md
Original file line number Diff line number Diff line change
@@ -1,43 +1,54 @@
### Local development
## Local development

Checkout the repository locally
### Checkout the repository locally

```
cd c:\Instrument\Dev
git clone https://github.com/ISISComputingGroup/ibex_bluesky_core.git
```

Create & activate a python virtual environment (windows):
### Create & activate a python virtual environment (windows):

```
python -m venv .venv
.venv\Scripts\activate
```

Install the library & dev dependencies in editable mode:
### Install the library & dev dependencies in editable mode:
```
python -m pip install -e .[dev]
```

Run the unit tests:
### Run the unit tests:
```
python -m pytest
```

Run lints:
### Run lints:
```
ruff format --check
ruff check
pyright
```

Run the 'demo' plan:
### Run the 'demo' plan

Option 1: from a terminal:

```
python src\ibex_bluesky_core\demo_plan.py
```
python -c "from ibex_bluesky_core.demo_plan import run_demo_plan;run_demo_plan()"

Option 2: from an interactive shell (e.g. PyDEV in the GUI):

```python
from ibex_bluesky_core.run_engine import get_run_engine
from ibex_bluesky_core.demo_plan import demo_plan
RE = get_run_engine()
RE(demo_plan())
```

If PVs for the demo plan don't connect, ensure that:
**If PVs for the demo plan don't connect, ensure that:**
- Set MYPVPREFIX
```
set MYPVPREFIX=TE:NDWXXXX:
Expand All @@ -49,4 +60,4 @@ set "EPICS_CA_AUTO_ADDR_LIST=NO"
```
- You have an IBEX server running with a DAE in setup state, which can begin a simulated run
- You have a readable & writable block named "mot" in the current configuration pointing at
the type of block expected by `run_demo_plan`
the type of block expected by `demo_plan`
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ classifiers = [
dependencies = [
"bluesky",
"ophyd-async[ca]",
"matplotlib",
]

[project.optional-dependencies]
Expand All @@ -52,6 +53,7 @@ dev = [
"pytest",
"pytest-asyncio",
"pytest-cov",
"pyqt6", # For dev testing with matplotlib's qt backend.
]

[project.urls]
Expand Down
32 changes: 32 additions & 0 deletions src/ibex_bluesky_core/callbacks/plotting.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"""IBEX plotting callbacks."""

import logging

import matplotlib
import matplotlib.pyplot as plt
from bluesky.callbacks import LivePlot as _DefaultLivePlot
from bluesky.callbacks.core import make_class_safe
from event_model.documents import Event, RunStart

logger = logging.getLogger(__name__)


@make_class_safe(logger=logger) # pyright: ignore (pyright doesn't understand this decorator)
class LivePlot(_DefaultLivePlot):
"""Live plot, customized for IBEX."""

def _show_plot(self) -> None:
# Play nicely with the "normal" backends too - only force show if we're
# actually using our custom backend.
if "genie_python" in matplotlib.get_backend():
plt.show()

def start(self, doc: RunStart) -> None:
"""Process an start document (delegate to superclass, then show the plot)."""
super().start(doc)
self._show_plot()

def event(self, doc: Event) -> None:
"""Process an event document (delegate to superclass, then show the plot)."""
super().event(doc)
self._show_plot()
41 changes: 23 additions & 18 deletions src/ibex_bluesky_core/demo_plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,40 +3,36 @@
from typing import Generator

import bluesky.plan_stubs as bps
import matplotlib
import matplotlib.pyplot as plt
from bluesky.callbacks import LiveTable
from bluesky.preprocessors import run_decorator
from bluesky.preprocessors import run_decorator, subs_decorator
from bluesky.utils import Msg
from ophyd_async.plan_stubs import ensure_connected

from ibex_bluesky_core.callbacks.plotting import LivePlot
from ibex_bluesky_core.devices import get_pv_prefix
from ibex_bluesky_core.devices.block import BlockRwRbv, block_rw_rbv
from ibex_bluesky_core.devices.block import block_rw_rbv
from ibex_bluesky_core.devices.dae import Dae
from ibex_bluesky_core.run_engine import get_run_engine

__all__ = ["run_demo_plan", "demo_plan"]
__all__ = ["demo_plan"]


def run_demo_plan() -> None:
"""Run the demo plan, including setup which would usually be done outside the plan.

You will need a DAE in a state which can begin, and a settable & readable
floating-point block named "mot".

Run using:
>>> from ibex_bluesky_core.demo_plan import run_demo_plan
>>> run_demo_plan()
"""
RE = get_run_engine()
def demo_plan() -> Generator[Msg, None, None]:
"""Demonstration plan which moves a block and reads the DAE."""
prefix = get_pv_prefix()
block = block_rw_rbv(float, "mot")
dae = Dae(prefix)
RE(demo_plan(block, dae), LiveTable(["mot", "DAE"]))


def demo_plan(block: BlockRwRbv[float], dae: Dae) -> Generator[Msg, None, None]:
"""Demonstration plan which moves a block and reads the DAE."""
yield from ensure_connected(block, dae, force_reconnect=True)

@subs_decorator(
[
LivePlot(y=dae.name, x=block.name, marker="x", linestyle="none"),
LiveTable([block.name, dae.name]),
]
)
@run_decorator(md={})
def _inner() -> Generator[Msg, None, None]:
# A "simple" acquisition using trigger_and_read.
Expand All @@ -54,3 +50,12 @@ def _inner() -> Generator[Msg, None, None]:
yield from bps.save()

yield from _inner()


if __name__ == "__main__":
if "genie_python" not in matplotlib.get_backend():
matplotlib.use("qtagg")
plt.ion()
RE = get_run_engine()
RE(demo_plan())
input("plan complete, press return to continue.")
14 changes: 12 additions & 2 deletions src/ibex_bluesky_core/run_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from functools import cache
from threading import Event

import matplotlib
from bluesky.run_engine import RunEngine
from bluesky.utils import DuringTask

Expand Down Expand Up @@ -60,12 +61,21 @@ def get_run_engine() -> RunEngine:
- https://nsls-ii.github.io/bluesky/run_engine_api.html
"""
loop = asyncio.new_event_loop()

# Only log *very* slow callbacks (in asyncio debug mode)
# Fitting/plotting can take more than the 100ms default.
loop.slow_callback_duration = 500

# See https://github.com/bluesky/bluesky/pull/1770 for details
# We don't need to use our custom _DuringTask if matplotlib is
# configured to use Qt.
dt = None if "qt" in matplotlib.get_backend() else _DuringTask()

RE = RunEngine(
loop=loop,
during_task=_DuringTask(),
during_task=dt,
call_returns_result=True, # Will be default in a future bluesky version.
)
RE.record_interruptions = True

log_callback = DocLoggingCallback()
RE.subscribe(log_callback)
Expand Down
Empty file added tests/callbacks/__init__.py
Empty file.
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,12 @@ def basic_plan() -> Generator[Msg, None, None]:
result: RunEngineResult = RE(basic_plan())
filepath = log_location / f"{result.run_start_uids[0]}.log"

for i in range(0, 3):
for i in range(0, 2):
assert m.call_args_list[i].args == (filepath, "a")
# Checks that the file is opened 3 times, for open, descriptor then stop
# Checks that the file is opened 2 times, for open and then stop

handle = m()
document = json.loads(handle.write.mock_calls[-1].args[0])

# In the stop document to be written, check that the run is successful
assert document["document"]["exit_status"] == "success"
# In the stop document to be written, check that the run is successful with no interruptions
assert document["document"]["num_events"]["interruptions"] == 0
44 changes: 44 additions & 0 deletions tests/callbacks/test_plotting_callback.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
from typing import Any
from unittest.mock import MagicMock, patch

from ibex_bluesky_core.callbacks.plotting import LivePlot


def test_ibex_plot_callback_calls_show_on_start():
# Testing matplotlib is a bit horrible.
# The best we can do realistically is check that any arguments get passed through to
# the underlying functions properly.

lp = LivePlot(y=MagicMock(), x=MagicMock())

with (
patch("ibex_bluesky_core.callbacks.plotting.plt.show") as mock_plt_show,
patch("ibex_bluesky_core.callbacks.plotting._DefaultLivePlot.start") as mock_start,
patch("ibex_bluesky_core.callbacks.plotting._DefaultLivePlot.event") as mock_event,
patch("ibex_bluesky_core.callbacks.plotting.matplotlib.get_backend") as mock_get_backend,
):
mock_get_backend.return_value = "simulated_genie_python_matplotlib_backed"
sentinel: Any = object()
lp.start(sentinel)
mock_start.assert_called_once_with(sentinel)
mock_plt_show.assert_called_once()

lp.event(sentinel)
mock_event.assert_called_once_with(sentinel)
assert mock_plt_show.call_count == 2


def test_show_plot_only_shows_if_backend_is_genie():
lp = LivePlot(y=MagicMock(), x=MagicMock())

with (
patch("ibex_bluesky_core.callbacks.plotting.plt.show") as mock_plt_show,
patch("ibex_bluesky_core.callbacks.plotting.matplotlib.get_backend") as mock_get_backend,
):
mock_get_backend.return_value = "qtagg"
lp._show_plot()
mock_plt_show.assert_not_called()

mock_get_backend.return_value = "simulated_genie_python_backend"
lp._show_plot()
mock_plt_show.assert_called_once()
Loading