Skip to content

Commit 77129d1

Browse files
authored
fix: Ensure path provider clears start doc after run (#1221)
This change provides support for nested runs while using the document path provider. Any nested runs will write their data into the parent run until the parent run is closed. If prepare is called on a detector outside of a run, there is now a helpful error message.
1 parent 798c1e0 commit 77129d1

File tree

3 files changed

+160
-22
lines changed

3 files changed

+160
-22
lines changed

src/blueapi/core/context.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,8 @@ def __post_init__(self, configuration: ApplicationConfig | None):
123123

124124
path_provider = StartDocumentPathProvider()
125125
set_path_provider(path_provider)
126-
self.run_engine.subscribe(path_provider.update_run, "start")
126+
self.run_engine.subscribe(path_provider.run_start, "start")
127+
self.run_engine.subscribe(path_provider.run_stop, "stop")
127128

128129
def _update_scan_num(md: dict[str, Any]) -> int:
129130
scan = numtracker.create_scan(

src/blueapi/utils/path_provider.py

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from pathlib import Path
22

3-
from event_model import RunStart
3+
from event_model import RunStart, RunStop
44
from ophyd_async.core import PathInfo, PathProvider
55

66
DEFAULT_TEMPLATE = "{device_name}-{instrument}-{scan_id}"
@@ -17,15 +17,19 @@ class StartDocumentPathProvider(PathProvider):
1717
"""
1818

1919
def __init__(self) -> None:
20-
self._doc = {}
20+
self._doc: RunStart | None = None
2121

22-
def update_run(self, name: str, start_doc: RunStart) -> None:
23-
"""Cache a start document.
22+
def run_start(self, name: str, start_document: RunStart) -> None:
23+
if name == "start" and self._doc is None:
24+
self._doc = start_document
2425

25-
This can be plugged into the run engine's subscribe method.
26-
"""
27-
if name == "start":
28-
self._doc = start_doc
26+
def run_stop(self, name: str, stop_document: RunStop) -> None:
27+
if (
28+
name == "stop"
29+
and self._doc is not None
30+
and stop_document.get("run_start") == self._doc["uid"]
31+
):
32+
self._doc = None
2933

3034
def __call__(self, device_name: str | None = None) -> PathInfo:
3135
"""Returns the directory path and filename for a given data_session.
@@ -36,7 +40,14 @@ def __call__(self, device_name: str | None = None) -> PathInfo:
3640
3741
If you do not provide a data_session_directory it will default to "/tmp".
3842
"""
39-
template = self._doc.get("data_file_path_template", DEFAULT_TEMPLATE)
40-
sub_path = template.format_map(self._doc | {"device_name": device_name})
41-
data_session_directory = Path(self._doc.get("data_session_directory", "/tmp"))
42-
return PathInfo(directory_path=data_session_directory, filename=sub_path)
43+
if self._doc is None:
44+
raise AttributeError(
45+
"Start document not found. This call must be made inside a run."
46+
)
47+
else:
48+
template = self._doc.get("data_file_path_template", DEFAULT_TEMPLATE)
49+
sub_path = template.format_map(self._doc | {"device_name": device_name})
50+
data_session_directory = Path(
51+
self._doc.get("data_session_directory", "/tmp")
52+
)
53+
return PathInfo(directory_path=data_session_directory, filename=sub_path)

tests/unit_tests/utils/test_path_provider.py

Lines changed: 135 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from pathlib import PosixPath
22

33
import pytest
4-
from event_model.documents import RunStart
4+
from event_model import RunStart, RunStop
55
from ophyd_async.core import PathInfo
66

77
from blueapi.utils.path_provider import StartDocumentPathProvider
@@ -38,7 +38,7 @@ def test_start_document_path_provider_with_default_template_returns_correct_path
3838
start_doc_default_template: RunStart,
3939
):
4040
pp = StartDocumentPathProvider()
41-
pp.update_run(name="start", start_doc=start_doc_default_template)
41+
pp.run_start(name="start", start_document=start_doc_default_template)
4242
path = pp("det")
4343

4444
assert path == PathInfo(
@@ -80,7 +80,7 @@ def test_start_document_path_provider_with_custom_template_returns_correct_path_
8080
start_doc_custom_template: RunStart,
8181
):
8282
pp = StartDocumentPathProvider()
83-
pp.update_run(name="start", start_doc=start_doc_custom_template)
83+
pp.run_start(name="start", start_document=start_doc_custom_template)
8484
path = pp("det")
8585

8686
assert path == PathInfo(
@@ -120,7 +120,7 @@ def test_start_document_path_provider_fails_with_missing_instrument(
120120
start_doc_missing_instrument: RunStart,
121121
):
122122
pp = StartDocumentPathProvider()
123-
pp.update_run(name="start", start_doc=start_doc_missing_instrument)
123+
pp.run_start(name="start", start_document=start_doc_missing_instrument)
124124

125125
with pytest.raises(KeyError, match="'instrument'"):
126126
pp("det")
@@ -156,7 +156,7 @@ def test_start_document_path_provider_fails_with_missing_scan_id(
156156
start_doc_missing_scan_id: RunStart,
157157
):
158158
pp = StartDocumentPathProvider()
159-
pp.update_run(name="start", start_doc=start_doc_missing_scan_id)
159+
pp.run_start(name="start", start_document=start_doc_missing_scan_id)
160160

161161
with pytest.raises(KeyError, match="'scan_id'"):
162162
pp("det")
@@ -192,18 +192,144 @@ def test_start_document_path_provider_sets_data_session_directory_default_to_tmp
192192
start_doc_default_data_session_directory: RunStart,
193193
):
194194
pp = StartDocumentPathProvider()
195-
pp.update_run(name="start", start_doc=start_doc_default_data_session_directory)
195+
pp.run_start(name="start", start_document=start_doc_default_data_session_directory)
196196
path = pp("det")
197197

198198
assert path == PathInfo(
199199
directory_path=PosixPath("/tmp"), filename="det-p01-22", create_dir_depth=0
200200
)
201201

202202

203-
def test_start_document_path_provider_update_called_with_different_document_skips(
203+
@pytest.fixture
204+
def stop_doc_default_template() -> dict:
205+
return {
206+
"run_start": "27c48d2f-d8c6-4ac0-8146-fedf467ce11f",
207+
"time": 1741264732.96875,
208+
"uid": "401ad197-5456-4a7d-ba5b-9cf8ad38d914",
209+
"exit_status": "success",
210+
"reason": "",
211+
}
212+
213+
214+
def test_start_document_path_provider_run_start_called_with_different_document_skips(
215+
stop_doc_default_template: RunStop,
216+
):
217+
pp = StartDocumentPathProvider()
218+
pp.run_start(name="stop", start_document=stop_doc_default_template) # type: ignore
219+
220+
assert pp._doc is None
221+
222+
223+
def test_start_document_path_provider_run_stop_called_with_different_document_skips(
224+
start_doc_default_template: RunStart,
225+
):
226+
pp = StartDocumentPathProvider()
227+
pp.run_stop(name="start", stop_document=start_doc_default_template) # type: ignore
228+
229+
assert pp._doc is None
230+
231+
232+
@pytest.fixture
233+
def start_doc() -> dict:
234+
return {
235+
"uid": "fa2feced-4098-4c0e-869d-285d2a69c24a",
236+
"time": 1690463918.3893268,
237+
"versions": {"ophyd": "1.10.0", "bluesky": "1.13"},
238+
"data_session": "ab123",
239+
"instrument": "p02",
240+
"data_session_directory": "/p02/ab123",
241+
"scan_id": 50,
242+
"plan_type": "generator",
243+
"plan_name": "count",
244+
"detectors": ["det"],
245+
"num_points": 1,
246+
"num_intervals": 0,
247+
"plan_args": {
248+
"detectors": [
249+
"<ophyd_async.epics.adaravis._aravis.AravisDetector object at 0x7f74c02b8710>" # NOQA: E501
250+
],
251+
"num": 1,
252+
"delay": 0.0,
253+
},
254+
"hints": {"dimensions": [[["time"], "primary"]]},
255+
"shape": [1],
256+
}
257+
258+
259+
@pytest.fixture
260+
def stop_doc() -> dict:
261+
return {
262+
"run_start": "fa2feced-4098-4c0e-869d-285d2a69c24a",
263+
"time": 1690463920.3893268,
264+
"uid": "401ad197-5456-4a7d-ba5b-9cf8ad38d914",
265+
"exit_status": "success",
266+
"reason": "",
267+
"num_events": {"primary": 1},
268+
}
269+
270+
271+
def test_start_document_path_provider_start_doc_persists_until_stop_with_matching_id(
272+
start_doc: RunStart,
273+
stop_doc: RunStop,
274+
start_doc_default_template: RunStart,
275+
stop_doc_default_template: RunStop,
276+
):
277+
pp = StartDocumentPathProvider()
278+
pp.run_start(name="start", start_document=start_doc)
279+
280+
assert pp._doc == start_doc
281+
assert pp._doc["uid"] == "fa2feced-4098-4c0e-869d-285d2a69c24a" # type: ignore
282+
283+
pp.run_start(name="start", start_document=start_doc_default_template)
284+
assert pp._doc == start_doc
285+
assert pp._doc["uid"] == "fa2feced-4098-4c0e-869d-285d2a69c24a" # type: ignore
286+
287+
pp.run_stop(name="stop", stop_document=stop_doc_default_template)
288+
assert pp._doc == start_doc
289+
assert pp._doc["uid"] == "fa2feced-4098-4c0e-869d-285d2a69c24a" # type: ignore
290+
291+
pp.run_stop(name="stop", stop_document=stop_doc)
292+
assert pp._doc is None
293+
294+
295+
def test_start_document_path_provider_nested_runs_use_parent_run_info(
204296
start_doc_default_template: RunStart,
297+
stop_doc_default_template: RunStop,
298+
start_doc: RunStart,
299+
stop_doc: RunStop,
205300
):
206301
pp = StartDocumentPathProvider()
207-
pp.update_run(name="descriptor", start_doc=start_doc_default_template)
302+
pp.run_start(name="start", start_document=start_doc_default_template)
303+
parent_path_info = pp("det")
304+
305+
assert pp._doc == start_doc_default_template
306+
assert pp._doc["uid"] == "27c48d2f-d8c6-4ac0-8146-fedf467ce11f" # type: ignore
307+
assert parent_path_info == PathInfo(
308+
directory_path=PosixPath("/p01/ab123"),
309+
filename="det-p01-22",
310+
create_dir_depth=0,
311+
)
208312

209-
assert pp._doc == {}
313+
pp.run_start(name="start", start_document=start_doc)
314+
assert pp._doc == start_doc_default_template
315+
assert pp._doc["uid"] == "27c48d2f-d8c6-4ac0-8146-fedf467ce11f" # type: ignore
316+
317+
assert pp("det") == parent_path_info
318+
319+
pp.run_stop(name="stop", stop_document=stop_doc)
320+
assert pp._doc == start_doc_default_template
321+
assert pp._doc["uid"] == "27c48d2f-d8c6-4ac0-8146-fedf467ce11f" # type: ignore
322+
323+
assert pp("det") == parent_path_info
324+
325+
pp.run_stop(name="stop", stop_document=stop_doc_default_template)
326+
assert pp._doc is None
327+
328+
329+
def test_start_document_path_provider_called_without_start_raises():
330+
pp = StartDocumentPathProvider()
331+
with pytest.raises(
332+
AttributeError,
333+
match="Start document not found. This call must be made inside a run.",
334+
):
335+
pp("det")

0 commit comments

Comments
 (0)