From e110e7d291317cc82ac0e2258a03f9d59754b719 Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Wed, 21 Aug 2019 16:15:43 -0400 Subject: [PATCH 1/8] ENH: add IOC and ohpyd Device to act as a 'baton' --- nslsii/__init__.py | 21 +++++++++-- nslsii/baton.py | 85 ++++++++++++++++++++++++++++++++++++++++++++ nslsii/iocs/baton.py | 70 ++++++++++++++++++++++++++++++++++++ setup.py | 5 +++ 4 files changed, 179 insertions(+), 2 deletions(-) create mode 100644 nslsii/baton.py create mode 100644 nslsii/iocs/baton.py diff --git a/nslsii/__init__.py b/nslsii/__init__.py index b5d85732..76cfdcb8 100644 --- a/nslsii/__init__.py +++ b/nslsii/__init__.py @@ -13,7 +13,7 @@ def public(name): def configure_base(user_ns, broker_name, *, bec=True, epics_context=False, magics=True, mpl=True, - ophyd_logging=True, pbar=True): + ophyd_logging=True, pbar=True, baton=None): """ Perform base setup and instantiation of important objects. @@ -61,6 +61,12 @@ def configure_base(user_ns, broker_name, *, ophyd. pbar : boolean, optional True by default. Set false to skip ProgressBarManager. + baton : nslsii.baton.Baton, optional + Device to manage a baton and status IOC. Expected to have: + + - baton.acquire_baton + - baton.doc_callback + - baton.state_callback Returns ------- @@ -83,7 +89,18 @@ def configure_base(user_ns, broker_name, *, if 'RE' in user_ns: RE = user_ns['RE'] else: - RE = RunEngine(get_history()) + kwargs = {} + if baton is not None: + kwargs['acquire_baton'] = baton.acquire_baton + + RE = RunEngine(get_history(), **kwargs) + + if baton is not None: + for d in ['start', 'stop']: + tok = RE.subscribe(baton.doc_callback, d) + baton.tokens.append(tok) + RE.state_hook = baton.state_callback + ns['RE'] = RE # Set up SupplementalData. diff --git a/nslsii/baton.py b/nslsii/baton.py new file mode 100644 index 00000000..81aa41ee --- /dev/null +++ b/nslsii/baton.py @@ -0,0 +1,85 @@ +import platform +import os +import uuid +import atexit +from ophyd import Device, Component as Cpt, EpicsSignal + + +class Baton(Device): + """ + Ophyd object to wrap the "baton" IOC + + Examples + -------- + + >>>> b = Baton(PREFX, name='baton') + >>>> ip = get_ipython() + >>>> configure_base(ip.user_ns, 'chx', acquire_baton=b.acquire_baton) + + """ + + baton = Cpt(EpicsSignal, "baton", string=True) + host = Cpt(EpicsSignal, "host", string=True) + pid = Cpt(EpicsSignal, "pid") + last_uid = Cpt(EpicsSignal, "last_uid", string=True) + current_uid = Cpt(EpicsSignal, "current_uid", string=True) + state = Cpt(EpicsSignal, "state", string=True) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._clear_baton = None + self.tokens = [] + + def acquire_baton(self, steal_baton=False): + existing_baton = self.baton.get() + if existing_baton and not steal_baton: + old_host = self.host.get() + old_pid = self.pid.get() + raise RuntimeError( + f"There is already a RE claiming the baton. " + f"It was running on {old_host}:{old_pid}." + ) + + new_baton = str(uuid.uuid4()) + self.baton.put(new_baton) + self.host.put(platform.node()) + self.pid.put(os.getpid()) + + def check_baton(): + ioc_baton = self.baton.get() + if ioc_baton != new_baton: + ioc_host = self.host.get() + ioc_pid = self.pid.get() + raise RuntimeError( + f"This RE installed {new_baton} but the " + f"IOC has {ioc_baton}. " + f"The baton was intalled by {ioc_host}:{ioc_pid}" + ) + + self.install_clear_baton() + return check_baton + + def install_clear_baton(self): + if self._clear_baton is not None: + return + + def clear_baton(baton): + try: + self.baton.put("") + self.host.put("") + self.pid.put(0) + except Exception: + # if we fail in tear down 🤷 + pass + + atexit.register(clear_baton, self) + self._clear_baton = clear_baton + + def doc_callback(self, name, doc): + if name == "start": + self.current_uid.put(doc["uid"]) + elif name == "stop": + self.last_uid.put(doc["run_start"]) + + def state_callback(self, new, old): + self.state.put(new) diff --git a/nslsii/iocs/baton.py b/nslsii/iocs/baton.py new file mode 100644 index 00000000..33b31aaa --- /dev/null +++ b/nslsii/iocs/baton.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +from caproto.server import pvproperty, PVGroup +from caproto.server import ioc_arg_parser, run +from caproto import ChannelType +from textwrap import dedent + + +class IOC(PVGroup): + """ + A Baton IOC for managing + + """ + + baton = pvproperty( + value="", + dtype=ChannelType.STRING, + doc='"baton" for running RE', + mock_record="ai", + ) + host = pvproperty( + value="", + dtype=ChannelType.STRING, + doc="host name of computer running RE", + mock_record="ai", + ) + pid = pvproperty( + value=0, doc="pid of running RE on host", mock_record="ai" + ) + + last_uid = pvproperty( + value="", + dtype=ChannelType.STRING, + doc="Last finished uid.", + mock_record="ai", + ) + current_uid = pvproperty( + value="", + dtype=ChannelType.STRING, + doc="UID currently being collected.", + mock_record="ai", + ) + state = pvproperty( + value="unknown", + doc="current state of RE", + enum_strings=[ + "unknown", + "idle", + "running", + "pausing", + "paused", + "halting", + "stopping", + "aborting", + "suspending", + "panicked", + ], + dtype=ChannelType.ENUM, + mock_record="ai", + ) + + +if __name__ == "__main__": + + ioc_options, run_options = ioc_arg_parser( + default_prefix="XF31ID:", desc=dedent(IOC.__doc__) + ) + + ioc = IOC(**ioc_options) + + run(ioc.pvdb, **run_options) diff --git a/setup.py b/setup.py index 31de79ae..1674f086 100644 --- a/setup.py +++ b/setup.py @@ -27,4 +27,9 @@ description='Tools for data collection and analysis at NSLS-II', author='Brookhaven National Laboratory', install_requires=requirements, + entry_points={ + 'console_scripts': [ + 'baton-ioc = nslsii.iocs.baton:main', + ] + } ) From 99cdd9cc9fbe02a7d18229412b72760f85ffb437 Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Wed, 21 Aug 2019 19:02:09 -0400 Subject: [PATCH 2/8] MNT: adjust configure_base to not use history dict So it works with the threading in bs1.6 --- nslsii/__init__.py | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/nslsii/__init__.py b/nslsii/__init__.py index 76cfdcb8..b4670d69 100644 --- a/nslsii/__init__.py +++ b/nslsii/__init__.py @@ -80,20 +80,42 @@ def configure_base(user_ns, broker_name, *, >>>> configure_base(get_ipython().user_ns, 'chx'); """ ns = {} # We will update user_ns with this at the end. + import bluesky # Set up a RunEngine and use metadata backed by a sqlite file. from bluesky import RunEngine - from bluesky.utils import get_history # if RunEngine already defined grab it # useful when users make their own custom RunEngine if 'RE' in user_ns: RE = user_ns['RE'] else: kwargs = {} + if bluesky.__version__ < '1.6': + from bluesky.utils import get_history + md = get_history() + else: + from bluesky.utils import PersistentDict + + from pathlib import Path + import os + SEARCH_PATH = [] + ENV_VAR = 'BLUESKY_HISTORY_PATH' + if ENV_VAR in os.environ: + SEARCH_PATH.append(Path(os.environ[ENV_VAR]).expanduser()) + SEARCH_PATH.extend([ + Path('~/.config/bluesky/bluesky_history').expanduser(), + Path('/etc/bluesky/bluesky_history')]) + for path in SEARCH_PATH: + if path.exists(): + break + else: + path = SEARCH_PATH[0] + md = PersistentDict(path) + if baton is not None: kwargs['acquire_baton'] = baton.acquire_baton - RE = RunEngine(get_history(), **kwargs) + RE = RunEngine(md, **kwargs) if baton is not None: for d in ['start', 'stop']: From 6b1259d4ba4e701bb42b4447a7e5b9a00519f61f Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Wed, 21 Aug 2019 19:02:34 -0400 Subject: [PATCH 3/8] MNT: don't try to install the kicker for bs >= 1.6 --- nslsii/__init__.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nslsii/__init__.py b/nslsii/__init__.py index b4670d69..86b1e919 100644 --- a/nslsii/__init__.py +++ b/nslsii/__init__.py @@ -168,10 +168,10 @@ def configure_base(user_ns, broker_name, *, import matplotlib.pyplot as plt ns['plt'] = plt plt.ion() - - # Make plots update live while scans run. - from bluesky.utils import install_kicker - install_kicker() + if bluesky.__version__ < '1.6': + # Make plots update live while scans run. + from bluesky.utils import install_kicker + install_kicker() if epics_context: # Create a context in the underlying EPICS client. From 2068b3662284ad92adeaff30bdb009957ee5e463 Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Wed, 21 Aug 2019 19:43:58 -0400 Subject: [PATCH 4/8] ENH: tweak the Baton IOC a bit --- nslsii/__init__.py | 2 +- nslsii/baton.py | 13 ++++++++----- nslsii/iocs/baton.py | 11 +++++------ 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/nslsii/__init__.py b/nslsii/__init__.py index 86b1e919..34e757cd 100644 --- a/nslsii/__init__.py +++ b/nslsii/__init__.py @@ -118,7 +118,7 @@ def configure_base(user_ns, broker_name, *, RE = RunEngine(md, **kwargs) if baton is not None: - for d in ['start', 'stop']: + for d in ['start']: tok = RE.subscribe(baton.doc_callback, d) baton.tokens.append(tok) RE.state_hook = baton.state_callback diff --git a/nslsii/baton.py b/nslsii/baton.py index 81aa41ee..183b5e35 100644 --- a/nslsii/baton.py +++ b/nslsii/baton.py @@ -2,27 +2,31 @@ import os import uuid import atexit -from ophyd import Device, Component as Cpt, EpicsSignal +from ophyd import Device, Component as Cpt, EpicsSignal, EpicsSignalRO class Baton(Device): """ Ophyd object to wrap the "baton" IOC + This object has the methods that the RE needs to + install the baton and check it on every use, and update + the state while running. + Examples -------- >>>> b = Baton(PREFX, name='baton') >>>> ip = get_ipython() - >>>> configure_base(ip.user_ns, 'chx', acquire_baton=b.acquire_baton) + >>>> configure_base(ip.user_ns, 'temp', baton=b) """ baton = Cpt(EpicsSignal, "baton", string=True) host = Cpt(EpicsSignal, "host", string=True) pid = Cpt(EpicsSignal, "pid") - last_uid = Cpt(EpicsSignal, "last_uid", string=True) current_uid = Cpt(EpicsSignal, "current_uid", string=True) + current_scanid = Cpt(EpicsSignal, "current_scanid") state = Cpt(EpicsSignal, "state", string=True) def __init__(self, *args, **kwargs): @@ -78,8 +82,7 @@ def clear_baton(baton): def doc_callback(self, name, doc): if name == "start": self.current_uid.put(doc["uid"]) - elif name == "stop": - self.last_uid.put(doc["run_start"]) + self.current_scanid.put(doc.get("scan_id", -1)) def state_callback(self, new, old): self.state.put(new) diff --git a/nslsii/iocs/baton.py b/nslsii/iocs/baton.py index 33b31aaa..395c6518 100644 --- a/nslsii/iocs/baton.py +++ b/nslsii/iocs/baton.py @@ -27,18 +27,17 @@ class IOC(PVGroup): value=0, doc="pid of running RE on host", mock_record="ai" ) - last_uid = pvproperty( + current_uid = pvproperty( value="", dtype=ChannelType.STRING, doc="Last finished uid.", mock_record="ai", ) - current_uid = pvproperty( - value="", - dtype=ChannelType.STRING, - doc="UID currently being collected.", - mock_record="ai", + + current_scanid = pvproperty( + value=0, doc="Last finished scanid.", mock_record="ai" ) + state = pvproperty( value="unknown", doc="current state of RE", From f39c8dd8e5916b40bba89db9107c25f7e76ff670 Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Wed, 21 Aug 2019 19:44:37 -0400 Subject: [PATCH 5/8] ENH: add a ReadOnly Device to back a typhon object --- nslsii/baton.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/nslsii/baton.py b/nslsii/baton.py index 183b5e35..8b8c7e02 100644 --- a/nslsii/baton.py +++ b/nslsii/baton.py @@ -86,3 +86,19 @@ def doc_callback(self, name, doc): def state_callback(self, new, old): self.state.put(new) + + +class BatonDisplay(Device): + """ + Read-only Ophyd object to wrap the "baton" IOC. + + This is to populate a typhon screen. + """ + + baton = Cpt(EpicsSignalRO, "baton", string=True, kind="config") + host = Cpt(EpicsSignalRO, "host", string=True, kind="config") + pid = Cpt(EpicsSignalRO, "pid", kind="config") + + current_uid = Cpt(EpicsSignalRO, "current_uid", string=True) + current_scanid = Cpt(EpicsSignalRO, "current_scanid") + state = Cpt(EpicsSignalRO, "state", string=True, kind="hinted") From 51544495a0897b644be574eec310c692abecfcc3 Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Wed, 21 Aug 2019 19:52:45 -0400 Subject: [PATCH 6/8] ENH: also clear the state on the way out --- nslsii/baton.py | 1 + 1 file changed, 1 insertion(+) diff --git a/nslsii/baton.py b/nslsii/baton.py index 8b8c7e02..136fb49c 100644 --- a/nslsii/baton.py +++ b/nslsii/baton.py @@ -72,6 +72,7 @@ def clear_baton(baton): self.baton.put("") self.host.put("") self.pid.put(0) + self.state.put("unknown") except Exception: # if we fail in tear down 🤷 pass From 30250c2dd9a08b05d74b8c830f774f6a4daec8b6 Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Wed, 21 Aug 2019 20:35:51 -0400 Subject: [PATCH 7/8] FIX: make the baton-ioc entrypoint actually work --- nslsii/iocs/baton.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/nslsii/iocs/baton.py b/nslsii/iocs/baton.py index 395c6518..2e7a649a 100644 --- a/nslsii/iocs/baton.py +++ b/nslsii/iocs/baton.py @@ -58,8 +58,7 @@ class IOC(PVGroup): ) -if __name__ == "__main__": - +def main(): ioc_options, run_options = ioc_arg_parser( default_prefix="XF31ID:", desc=dedent(IOC.__doc__) ) @@ -67,3 +66,7 @@ class IOC(PVGroup): ioc = IOC(**ioc_options) run(ioc.pvdb, **run_options) + + +if __name__ == "__main__": + main() From 990075317fa7b743f86a33c8fc815126937964c6 Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Wed, 21 Aug 2019 20:36:08 -0400 Subject: [PATCH 8/8] TST: add tests --- nslsii/tests/test_baton.py | 84 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 nslsii/tests/test_baton.py diff --git a/nslsii/tests/test_baton.py b/nslsii/tests/test_baton.py new file mode 100644 index 00000000..76612099 --- /dev/null +++ b/nslsii/tests/test_baton.py @@ -0,0 +1,84 @@ +import uuid +import asyncio +from bluesky import RunEngine +from bluesky import plans as bps +from nslsii.baton import Baton +from nslsii import configure_base +import pytest +import subprocess +import os + + +@pytest.fixture(scope="function") +def baton_ioc(request): + stdout = subprocess.PIPE + stdin = None + prefix = f"{str(uuid.uuid4())[:6]}:" + # Start up an IOC based on the thermo_sim device in caproto.ioc_examples + ioc_process = subprocess.Popen( + ["baton-ioc", "--prefix", prefix, "--list-pvs"], + stdout=stdout, + stdin=stdin, + env=os.environ, + ) + + def kill_ioc(): + ioc_process.terminate() + + request.addfinalizer(kill_ioc) + return prefix + + +def test_baton(baton_ioc): + b = Baton(baton_ioc, name="b") + b.wait_for_connection(timeout=5) + b.read() + + assert b.baton.get() == "" + cb = b.acquire_baton() + assert b.baton.get() != "" + cb() + cb() + + with pytest.raises(RuntimeError): + b.acquire_baton() + + cb2 = b.acquire_baton(steal_baton=True) + with pytest.raises(RuntimeError): + cb() + + cb2() + + +def _inner_test(RE, b): + assert b.baton.get() != "" + for _ in range(5): + uid, = RE(bps.count([])) + assert b.current_uid.get() == uid + assert b.current_scanid.get() == RE.md["scan_id"] + + b.baton.put("") + with pytest.raises(RuntimeError): + RE([]) + + +def test_baton_RE(baton_ioc): + b = Baton(baton_ioc, name="b") + b.wait_for_connection(timeout=5) + assert b.baton.get() == "" + loop = asyncio.new_event_loop() + loop.set_debug(True) + RE = RunEngine({}, loop=loop, acquire_baton=b.acquire_baton) + RE.subscribe(b.doc_callback, "start") + RE.state_hook = b.state_callback + _inner_test(RE, b) + + +def test_configure_base(baton_ioc): + out = {} + b = Baton(baton_ioc, name="b") + b.wait_for_connection(timeout=5) + configure_base(out, "temp", baton=b, magics=False) + RE = out["RE"] + + _inner_test(RE, b)