Skip to content

Commit b403b45

Browse files
authored
Merge branch 'main' into ticket195
2 parents d711d6a + 9b58361 commit b403b45

File tree

16 files changed

+212
-43
lines changed

16 files changed

+212
-43
lines changed

doc/architectural_decisions/006-where-to-put-code.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ Examples of devices and where we would put them under this model:
2525

2626
### `ibex_bluesky_core` devices
2727
- `BlockRw` and `SimpleDae`: `ibex_bluesky_core` as it's completely general / useful to all beamlines
28-
- `ReflParameter`: in `ibex_bluesky_core.devices.reflectrometry` because it's useful across all reflectometers
28+
- `ReflParameter`: in `ibex_bluesky_core.devices.reflectometry` because it's useful across all reflectometers
2929
- `Danfysik`: Try to push down "special" logic (i.e. polarity switching) into the IOC so that a standard `block_rw` works. Otherwise put in `ibex_bluesky_core.devices`
3030

3131
### `inst` scripts area devices

doc/dev/manual_system_testing.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ set "EPICS_CA_AUTO_ADDR_LIST=NO"
3232
set IBEX_BLUESKY_CORE_LOGS=c:\instrument\var\bluesky\tmp
3333
set IBEX_BLUESKY_CORE_OUTPUT=c:\instrument\var\bluesky\tmp
3434
```
35+
- Checking the prerequisites:
36+
37+
Ensure that any prerequisites are met before running the selected test. These can be located within the .py file.
38+
3539
- Run the test using:
3640
```
3741
python c:\instrument\dev\ibex_bluesky_core\manual_system_tests\the_test.py

doc/dev/troubleshooting.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,8 @@ This is because the **datatype of the underlying PV** does not match the **decla
240240
will not allow you to connect a `block_r(str, "some_block")` if `"some_block"` is a float-type PV. Every signal in
241241
`ophyd_async` is strongly typed.
242242

243+
If you're a developer, please ensure that you have followed the steps found on [`Manual System Testing`](/dev/manual_system_testing.md)
244+
243245
### Change how `set` on a device behaves
244246

245247
For a {py:obj}`writable block<ibex_bluesky_core.devices.block.BlockRw>`, a `write_config` argument can be specified.

src/ibex_bluesky_core/callbacks/__init__.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
HumanReadableFileCallback,
2222
)
2323
from ibex_bluesky_core.callbacks._fitting import LiveFit, LiveFitLogger
24-
from ibex_bluesky_core.callbacks._plotting import LivePlot, show_plot
24+
from ibex_bluesky_core.callbacks._plotting import LivePlot, PlotPNGSaver, show_plot
2525
from ibex_bluesky_core.callbacks._utils import get_default_output_path
2626
from ibex_bluesky_core.fitting import FitMethod
2727
from ibex_bluesky_core.utils import is_matplotlib_backend_qt
@@ -38,6 +38,7 @@
3838
"LiveFit",
3939
"LiveFitLogger",
4040
"LivePlot",
41+
"PlotPNGSaver",
4142
"get_default_output_path",
4243
"show_plot",
4344
]
@@ -67,6 +68,9 @@ def __init__( # noqa: PLR0912
6768
live_fit_logger_output_dir: str | PathLike[str] | None = None,
6869
live_fit_logger_postfix: str = "",
6970
human_readable_file_postfix: str = "",
71+
save_plot_to_png: bool = True,
72+
plot_png_output_dir: str | PathLike[str] | None = None,
73+
plot_png_postfix: str = "",
7074
live_fit_update_every: int | None = 1,
7175
live_plot_update_on_every_event: bool = True,
7276
) -> None:
@@ -127,9 +131,13 @@ def _inner():
127131
live_fit_logger_output_dir: the output directory for live fit logger.
128132
live_fit_logger_postfix: the postfix to add to live fit logger.
129133
human_readable_file_postfix: optional postfix to add to human-readable file logger.
134+
save_plot_to_png: whether to save the plot to a PNG file.
135+
plot_png_output_dir: the output directory for plotting PNG files.
136+
plot_png_postfix: the postfix to add to PNG plot files.
130137
live_fit_update_every: How often, in points, to recompute the fit. If None, do not compute until the end.
131138
live_plot_update_on_every_event: whether to show the live plot on every event, or just at the end.
132139
""" # noqa
140+
fig = None
133141
self._subs = []
134142
self._peak_stats = None
135143
self._live_fit = None
@@ -230,6 +238,16 @@ def start(self, doc: RunStart) -> None:
230238
update_on_every_event=live_plot_update_on_every_event,
231239
)
232240
)
241+
if save_plot_to_png and ax is not None:
242+
self._subs.append(
243+
PlotPNGSaver(
244+
x=x,
245+
y=y,
246+
ax=ax,
247+
output_dir=plot_png_output_dir,
248+
postfix=plot_png_postfix,
249+
)
250+
)
233251

234252
@property
235253
def live_fit(self) -> LiveFit:

src/ibex_bluesky_core/callbacks/_file_logger.py

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,7 @@
33
import csv
44
import logging
55
import os
6-
from datetime import datetime
76
from pathlib import Path
8-
from zoneinfo import ZoneInfo
97

108
from bluesky.callbacks import CallbackBase
119
from event_model.documents.event import Event
@@ -20,13 +18,13 @@
2018
MOTORS,
2119
NAME,
2220
PRECISION,
23-
RB,
2421
SEQ_NUM,
2522
START_TIME,
2623
TIME,
2724
UID,
2825
UNITS,
29-
UNKNOWN_RB,
26+
_get_rb_num,
27+
format_time,
3028
get_default_output_path,
3129
get_instrument,
3230
)
@@ -68,23 +66,19 @@ def start(self, doc: RunStart) -> None:
6866
self.output_dir.mkdir(parents=True, exist_ok=True)
6967
self.current_start_document = doc[UID]
7068

71-
datetime_obj = datetime.fromtimestamp(doc[TIME])
72-
title_format_datetime = datetime_obj.astimezone(ZoneInfo("UTC")).strftime(
73-
"%Y-%m-%d_%H-%M-%S"
74-
)
75-
rb_num = doc.get(RB, UNKNOWN_RB)
69+
rb_num = _get_rb_num(doc)
7670

7771
# motors is a tuple, we need to convert to a list to join the two below
7872
motors = list(doc.get(MOTORS, []))
7973

74+
formatted_time = format_time(doc)
75+
8076
self.filename = (
8177
self.output_dir
8278
/ f"{rb_num}"
8379
/ f"{get_instrument()}{'_' + '_'.join(motors) if motors else ''}_"
84-
f"{title_format_datetime}Z{self.postfix}.txt"
80+
f"{formatted_time}Z{self.postfix}.txt"
8581
)
86-
if rb_num == UNKNOWN_RB:
87-
logger.warning('No RB number found, saving to "%s"', UNKNOWN_RB)
8882
assert self.filename is not None
8983
logger.info("starting new file %s", self.filename)
9084

@@ -93,7 +87,6 @@ def start(self, doc: RunStart) -> None:
9387
]
9488
header_data = {k: v for k, v in doc.items() if k not in exclude_list}
9589

96-
formatted_time = datetime_obj.astimezone(ZoneInfo("UTC")).strftime("%Y-%m-%d %H:%M:%S")
9790
header_data[START_TIME] = formatted_time
9891

9992
# make sure the parent directory exists, create it if not

src/ibex_bluesky_core/callbacks/_fitting.py

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,7 @@
44
import logging
55
import os
66
import warnings
7-
from datetime import datetime
87
from pathlib import Path
9-
from zoneinfo import ZoneInfo
108

119
import numpy as np
1210
from bluesky.callbacks import CallbackBase
@@ -16,10 +14,9 @@
1614

1715
from ibex_bluesky_core.callbacks._utils import (
1816
DATA,
19-
RB,
20-
TIME,
2117
UID,
22-
UNKNOWN_RB,
18+
_get_rb_num,
19+
format_time,
2320
get_default_output_path,
2421
get_instrument,
2522
)
@@ -167,16 +164,13 @@ def start(self, doc: RunStart) -> None:
167164
doc (RunStart): The start bluesky document.
168165
169166
"""
170-
datetime_obj = datetime.fromtimestamp(doc[TIME])
171-
title_format_datetime = datetime_obj.astimezone(ZoneInfo("UTC")).strftime(
172-
"%Y-%m-%d_%H-%M-%S"
173-
)
167+
title_format_datetime = format_time(doc)
174168
self.output_dir.mkdir(parents=True, exist_ok=True)
175169
self.current_start_document = doc[UID]
176170
file = f"{get_instrument()}_{self.x}_{self.y}_{title_format_datetime}Z{self.postfix}.txt"
177-
rb_num = doc.get(RB, UNKNOWN_RB)
178-
if rb_num == UNKNOWN_RB:
179-
logger.warning('No RB number found, will save to "%s"', UNKNOWN_RB)
171+
172+
rb_num = _get_rb_num(doc)
173+
180174
self.filename = self.output_dir / f"{rb_num}" / file
181175

182176
def event(self, doc: Event) -> Event:

src/ibex_bluesky_core/callbacks/_plotting.py

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,29 @@
11
"""IBEX plotting callbacks."""
22

33
import logging
4+
import os
5+
from pathlib import Path
46
from typing import Any
57

68
import matplotlib
79
import matplotlib.pyplot as plt
810
from bluesky.callbacks import LivePlot as _DefaultLivePlot
911
from bluesky.callbacks.core import get_obj_fields, make_class_safe
12+
from bluesky.callbacks.mpl_plotting import QtAwareCallback
1013
from event_model import RunStop
1114
from event_model.documents import Event, RunStart
15+
from matplotlib.axes import Axes
16+
17+
from ibex_bluesky_core.callbacks._utils import (
18+
_get_rb_num,
19+
format_time,
20+
get_default_output_path,
21+
get_instrument,
22+
)
1223

1324
logger = logging.getLogger(__name__)
1425

15-
__all__ = ["LivePlot", "show_plot"]
26+
__all__ = ["LivePlot", "PlotPNGSaver", "show_plot"]
1627

1728

1829
def show_plot() -> None:
@@ -98,3 +109,53 @@ def stop(self, doc: RunStop) -> None:
98109
if not self.update_on_every_event:
99110
self.update_plot(force=True)
100111
show_plot()
112+
113+
114+
class PlotPNGSaver(QtAwareCallback):
115+
"""Save plots to PNG files on a run end."""
116+
117+
def __init__(
118+
self,
119+
x: str,
120+
y: str,
121+
ax: Axes,
122+
postfix: str,
123+
output_dir: str | os.PathLike[str] | None,
124+
) -> None:
125+
"""Initialise the PlotPNGSaver callback.
126+
127+
Args:
128+
x: The name of the signal for x.
129+
y: The name of the signal for y.
130+
ax: The subplot to save to a file.
131+
postfix: The file postfix.
132+
output_dir: The output directory for PNGs.
133+
134+
"""
135+
super().__init__()
136+
self.x = x
137+
self.y = y
138+
self.ax = ax
139+
self.postfix = postfix
140+
self.output_dir = Path(output_dir or get_default_output_path())
141+
self.filename = None
142+
143+
def start(self, doc: RunStart) -> None:
144+
self.filename = (
145+
self.output_dir
146+
/ f"{_get_rb_num(doc)}"
147+
/ f"{get_instrument()}_{self.x}_{self.y}_{format_time(doc)}Z{self.postfix}.png"
148+
)
149+
150+
def stop(self, doc: RunStop) -> None:
151+
"""Write the current plot to a PNG file.
152+
153+
Args:
154+
doc: The stop document.
155+
156+
"""
157+
if self.filename is None:
158+
raise ValueError("No filename specified for plot PNG")
159+
160+
self.filename.parent.mkdir(parents=True, exist_ok=True)
161+
self.ax.figure.savefig(self.filename, format="png") # pyright: ignore [reportAttributeAccessIssue]

src/ibex_bluesky_core/callbacks/_utils.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
1+
import logging
12
import os
3+
from datetime import datetime
24
from pathlib import Path
35
from platform import node
6+
from zoneinfo import ZoneInfo
7+
8+
from event_model import Event, RunStart, RunStop
9+
10+
logger = logging.getLogger(__name__)
411

512
OUTPUT_DIR_ENV_VAR = "IBEX_BLUESKY_CORE_OUTPUT"
613

@@ -31,3 +38,16 @@ def get_default_output_path() -> Path:
3138
if output_dir_env is None
3239
else Path(output_dir_env)
3340
)
41+
42+
43+
def format_time(doc: Event | RunStart | RunStop) -> str:
44+
datetime_obj = datetime.fromtimestamp(doc[TIME])
45+
title_format_datetime = datetime_obj.astimezone(ZoneInfo("UTC")).strftime("%Y-%m-%d_%H-%M-%S")
46+
return title_format_datetime
47+
48+
49+
def _get_rb_num(doc: Event | RunStart | RunStop) -> str:
50+
rb_num = doc.get(RB, UNKNOWN_RB)
51+
if rb_num == UNKNOWN_RB:
52+
logger.warning('No RB number found, saving to "%s"', UNKNOWN_RB)
53+
return rb_num

src/ibex_bluesky_core/devices/reflectometry.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,17 +101,22 @@ async def set(self, value: float) -> None:
101101
await asyncio.sleep(1.0)
102102

103103

104-
def refl_parameter(name: str, changing_timeout_s: float = 60.0) -> ReflParameter:
104+
def refl_parameter(
105+
name: str, changing_timeout_s: float = 60.0, has_redefine: bool = True
106+
) -> ReflParameter:
105107
"""Small wrapper around a reflectometry parameter device.
106108
107109
This automatically applies the current instrument's PV prefix.
108110
109111
Args:
110112
name: the reflectometry parameter name.
111113
changing_timeout_s: time to wait (seconds) for the CHANGING signal to go False after a set.
114+
has_redefine: whether this parameter can be redefined.
112115
113116
Returns a device pointing to a reflectometry parameter.
114117
115118
"""
116119
prefix = get_pv_prefix()
117-
return ReflParameter(prefix=prefix, name=name, changing_timeout_s=changing_timeout_s)
120+
return ReflParameter(
121+
prefix=prefix, name=name, changing_timeout_s=changing_timeout_s, has_redefine=has_redefine
122+
)

src/ibex_bluesky_core/devices/simpledae/_waiters.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from ophyd_async.core import (
99
Device,
1010
SignalR,
11+
soft_signal_rw,
1112
wait_for_value,
1213
)
1314

@@ -32,13 +33,14 @@ def __init__(self, value: T) -> None:
3233
value: the value to wait for
3334
3435
"""
35-
self._value: T = value
36+
self.finish_wait_at = soft_signal_rw(float, value)
3637

3738
async def wait(self, dae: "SimpleDae") -> None:
3839
"""Wait for signal to reach the user-specified value."""
3940
signal = self.get_signal(dae)
4041
logger.info("starting wait for signal %s", signal.source)
41-
await wait_for_value(signal, lambda v: v >= self._value, timeout=None)
42+
value = await self.finish_wait_at.get_value()
43+
await wait_for_value(signal, lambda v: v >= value, timeout=None)
4244
logger.info("completed wait for signal %s", signal.source)
4345

4446
def additional_readable_signals(self, dae: "SimpleDae") -> list[Device]:

0 commit comments

Comments
 (0)