diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 425e57d7..4404bc4f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: steps: - uses: actions/checkout@v3 - name: Install micromamba - uses: mamba-org/provision-with-micromamba@main + uses: mamba-org/setup-micromamba@v1 with: environment-file: environment.yml - name: Setup X virtual frame buffer diff --git a/conftest.py b/conftest.py deleted file mode 100644 index 6159b0d7..00000000 --- a/conftest.py +++ /dev/null @@ -1,222 +0,0 @@ -import subprocess -from subprocess import Popen, PIPE -import shutil -import time -from pathlib import Path -import os - -from qtpy import QtWidgets -from tiled.client import from_uri -from tiled.client.cache import Cache -import pytest -from unittest import mock -from ophyd.sim import ( - instantiate_fake_device, - make_fake_device, - fake_device_cache, - FakeEpicsSignal, -) -from pydm.data_plugins import add_plugin - -import haven -from haven.simulated_ioc import simulated_ioc -from haven import load_config, registry -from haven._iconfig import beamline_connected as _beamline_connected -from haven.instrument.stage import AerotechFlyer, AerotechStage -from haven.instrument.aps import ApsMachine -from haven.instrument.shutter import Shutter -from haven.instrument.camera import AravisDetector -from haven.instrument.delay import EpicsSignalWithIO -from haven.instrument.dxp import DxpDetectorBase, add_mcas as add_dxp_mcas -from haven.instrument.ion_chamber import IonChamber -from haven.instrument.xspress import Xspress3Detector, add_mcas as add_xspress_mcas -from firefly.application import FireflyApplication -from firefly.ophyd_plugin import OphydPlugin - -# from run_engine import RunEngineStub -from firefly.application import FireflyApplication - - -top_dir = Path(__file__).parent.resolve() -ioc_dir = top_dir / "tests" / "iocs" -haven_dir = top_dir / "src" / "haven" -test_dir = top_dir / "tests" - - -# Specify the configuration files to use for testing -os.environ["HAVEN_CONFIG_FILES"] = ",".join( - [ - f"{test_dir/'iconfig_testing.toml'}", - f"{haven_dir/'iconfig_default.toml'}", - ] -) - - -class FakeEpicsSignalWithIO(FakeEpicsSignal): - # An EPICS signal that simply uses the DG-645 convention of - # 'AO' being the setpoint and 'AI' being the read-back - _metadata_keys = EpicsSignalWithIO._metadata_keys - - def __init__(self, prefix, **kwargs): - super().__init__(f"{prefix}I", write_pv=f"{prefix}O", **kwargs) - - -fake_device_cache[EpicsSignalWithIO] = FakeEpicsSignalWithIO - - -@pytest.fixture() -def beamline_connected(): - with _beamline_connected(True): - yield - - -@pytest.fixture(scope="session") -def qapp_cls(): - return FireflyApplication - - -def pytest_configure(config): - app = QtWidgets.QApplication.instance() - assert app is None - app = FireflyApplication() - app = QtWidgets.QApplication.instance() - assert isinstance(app, FireflyApplication) - # # Create event loop for asyncio stuff - # loop = asyncio.new_event_loop() - # asyncio.set_event_loop(loop) - - -def tiled_is_running(port, match_command=True): - lsof = subprocess.run(["lsof", "-i", f":{port}", "-F"], capture_output=True) - assert lsof.stderr.decode() == "" - stdout = lsof.stdout.decode().split("\n") - is_running = len(stdout) >= 3 - if match_command: - is_running = is_running and stdout[3] == "ctiled" - return is_running - - -@pytest.fixture(scope="session") -def sim_tiled(): - """Start a tiled server using production data from 25-ID.""" - timeout = 20 - port = "8337" - - if tiled_is_running(port, match_command=False): - raise RuntimeError(f"Port {port} is already in use.") - tiled_bin = shutil.which("tiled") - process = Popen( - [ - tiled_bin, - "serve", - "pyobject", - "--public", - "--port", - str(port), - "haven.tests.tiled_example:tree", - ] - ) - # Wait for start to complete - for i in range(timeout): - if tiled_is_running(port): - break - time.sleep(1.0) - else: - # Timeout finished without startup or error - process.kill() - raise TimeoutError - # Prepare the client - client = from_uri(f"http://localhost:{port}", cache=Cache()) - try: - yield client - finally: - # Shut down - process.terminate() - # Wait for start to complete - for i in range(timeout): - if not tiled_is_running(port): - break - time.sleep(1.0) - else: - process.kill() - time.sleep(1) - - -@pytest.fixture() -def sim_registry(monkeypatch): - # mock out Ophyd connections so devices can be created - modules = [ - haven.instrument.fluorescence_detector, - haven.instrument.monochromator, - haven.instrument.ion_chamber, - haven.instrument.motor, - haven.instrument.device, - ] - for mod in modules: - monkeypatch.setattr(mod, "await_for_connection", mock.AsyncMock()) - monkeypatch.setattr( - haven.instrument.ion_chamber, "caget", mock.AsyncMock(return_value="I0") - ) - # Clean the registry so we can restore it later - registry = haven.registry - objects_by_name = registry._objects_by_name - objects_by_label = registry._objects_by_label - registry.clear() - # Run the test - yield registry - # Restore the previous registry components - registry._objects_by_name = objects_by_name - registry._objects_by_label = objects_by_label - - -@pytest.fixture() -def sim_ion_chamber(sim_registry): - FakeIonChamber = make_fake_device(IonChamber) - ion_chamber = FakeIonChamber( - prefix="scaler_ioc", name="I00", labels={"ion_chambers"}, ch_num=2 - ) - sim_registry.register(ion_chamber) - return ion_chamber - - -@pytest.fixture() -def I0(sim_registry): - """A fake ion chamber named 'I0' on scaler channel 2.""" - FakeIonChamber = make_fake_device(IonChamber) - ion_chamber = FakeIonChamber( - prefix="scaler_ioc", name="I0", labels={"ion_chambers"}, ch_num=2 - ) - sim_registry.register(ion_chamber) - return ion_chamber - - -@pytest.fixture() -def It(sim_registry): - """A fake ion chamber named 'It' on scaler channel 3.""" - FakeIonChamber = make_fake_device(IonChamber) - ion_chamber = FakeIonChamber( - prefix="scaler_ioc", name="It", labels={"ion_chambers"}, ch_num=3 - ) - sim_registry.register(ion_chamber) - return ion_chamber - - -@pytest.fixture(scope="session") -def pydm_ophyd_plugin(): - return add_plugin(OphydPlugin) - - -@pytest.fixture() -def ffapp(pydm_ophyd_plugin): - # Get an instance of the application - app = FireflyApplication.instance() - assert isinstance(app, FireflyApplication) - if app is None: - app = FireflyApplication() - # Set up the actions and other boildplate stuff - app.setup_window_actions() - app.setup_runengine_actions() - assert isinstance(app, FireflyApplication) - yield app - if hasattr(app, "_queue_thread"): - app._queue_thread.quit() diff --git a/poetry.lock b/poetry.lock deleted file mode 100644 index ae5d8159..00000000 --- a/poetry.lock +++ /dev/null @@ -1,8 +0,0 @@ -package = [] - -[metadata] -lock-version = "1.1" -python-versions = "*" -content-hash = "1be3afc87beb00627d70ba9087279825e941f05a562df12e972061262422d79a" - -[metadata.files] diff --git a/src/conftest.py b/src/conftest.py new file mode 100644 index 00000000..1ffdd468 --- /dev/null +++ b/src/conftest.py @@ -0,0 +1,327 @@ +from unittest import mock +import subprocess +import psutil +from pathlib import Path +import os + +from bluesky import RunEngine +import pytest +from ophyd import DynamicDeviceComponent as DDC, Kind +from ophyd.sim import ( + instantiate_fake_device, + make_fake_device, + fake_device_cache, + FakeEpicsSignal, +) +from pydm.data_plugins import add_plugin +from pytestqt.qt_compat import qt_api + +import haven +from haven._iconfig import beamline_connected as _beamline_connected +from haven.instrument.aerotech import AerotechStage +from haven.instrument.aps import ApsMachine +from haven.instrument.shutter import Shutter +from haven.instrument.camera import AravisDetector +from haven.instrument.delay import EpicsSignalWithIO +from haven.instrument.dxp import DxpDetector, add_mcas as add_dxp_mcas +from haven.instrument.ion_chamber import IonChamber +from haven.instrument.xspress import Xspress3Detector, add_mcas as add_xspress_mcas +from firefly.application import FireflyApplication +from firefly.main_window import FireflyMainWindow +from firefly.ophyd_plugin import OphydPlugin + + +top_dir = Path(__file__).parent.resolve() +haven_dir = top_dir / "haven" + + +# Specify the configuration files to use for testing +os.environ["HAVEN_CONFIG_FILES"] = ",".join( + [ + f"{haven_dir/'iconfig_testing.toml'}", + f"{haven_dir/'iconfig_default.toml'}", + ] +) + + +class FakeEpicsSignalWithIO(FakeEpicsSignal): + # An EPICS signal that simply uses the DG-645 convention of + # 'AO' being the setpoint and 'AI' being the read-back + _metadata_keys = EpicsSignalWithIO._metadata_keys + + def __init__(self, prefix, **kwargs): + super().__init__(f"{prefix}I", write_pv=f"{prefix}O", **kwargs) + + +fake_device_cache[EpicsSignalWithIO] = FakeEpicsSignalWithIO + + +@pytest.fixture() +def beamline_connected(): + with _beamline_connected(True): + yield + + +class RunEngineStub(RunEngine): + def __repr__(self): + return "" + + +@pytest.fixture() +def RE(event_loop): + return RunEngineStub(call_returns_result=True) + + +@pytest.fixture(scope="session") +def qapp_cls(): + return FireflyApplication + + +# def pytest_configure(config): +# app = QtWidgets.QApplication.instance() +# assert app is None +# app = FireflyApplication() +# app = QtWidgets.QApplication.instance() +# assert isinstance(app, FireflyApplication) +# # # Create event loop for asyncio stuff +# # loop = asyncio.new_event_loop() +# # asyncio.set_event_loop(loop) + + +def tiled_is_running(port, match_command=True): + lsof = subprocess.run(["lsof", "-i", f":{port}", "-F"], capture_output=True) + assert lsof.stderr.decode() == "" + stdout = lsof.stdout.decode().split("\n") + is_running = len(stdout) >= 3 + if match_command: + is_running = is_running and stdout[3] == "ctiled" + return is_running + + +def kill_process(process_name): + processes = [] + for proc in psutil.process_iter(): + # check whether the process name matches + if proc.name() == process_name: + proc.kill() + processes.append(proc) + # Wait for them all the terminate + [proc.wait(timeout=5) for proc in processes] + + +@pytest.fixture() +def sim_registry(monkeypatch): + # mock out Ophyd connections so devices can be created + modules = [ + haven.instrument.fluorescence_detector, + haven.instrument.monochromator, + haven.instrument.ion_chamber, + haven.instrument.motor, + haven.instrument.device, + ] + for mod in modules: + monkeypatch.setattr(mod, "await_for_connection", mock.AsyncMock()) + monkeypatch.setattr( + haven.instrument.ion_chamber, "caget", mock.AsyncMock(return_value="I0") + ) + # Clean the registry so we can restore it later + registry = haven.registry + objects_by_name = registry._objects_by_name + objects_by_label = registry._objects_by_label + registry.clear() + # Run the test + yield registry + # Restore the previous registry components + registry._objects_by_name = objects_by_name + registry._objects_by_label = objects_by_label + + +@pytest.fixture() +def sim_ion_chamber(sim_registry): + FakeIonChamber = make_fake_device(IonChamber) + ion_chamber = FakeIonChamber( + prefix="scaler_ioc", name="I00", labels={"ion_chambers"}, ch_num=2 + ) + sim_registry.register(ion_chamber) + return ion_chamber + + +@pytest.fixture() +def I0(sim_registry): + """A fake ion chamber named 'I0' on scaler channel 2.""" + FakeIonChamber = make_fake_device(IonChamber) + ion_chamber = FakeIonChamber( + prefix="scaler_ioc", name="I0", labels={"ion_chambers"}, ch_num=2 + ) + sim_registry.register(ion_chamber) + return ion_chamber + + +@pytest.fixture() +def It(sim_registry): + """A fake ion chamber named 'It' on scaler channel 3.""" + FakeIonChamber = make_fake_device(IonChamber) + ion_chamber = FakeIonChamber( + prefix="scaler_ioc", name="It", labels={"ion_chambers"}, ch_num=3 + ) + sim_registry.register(ion_chamber) + return ion_chamber + + +@pytest.fixture() +def sim_camera(sim_registry): + FakeCamera = make_fake_device(AravisDetector) + camera = FakeCamera(name="s255id-gige-A", labels={"cameras", "area_detectors"}) + camera.pva.pv_name._readback = "255idSimDet:Pva1:Image" + # Registry with the simulated registry + sim_registry.register(camera) + yield camera + + +class DxpVortex(DxpDetector): + mcas = DDC( + add_dxp_mcas(range_=[0, 1, 2, 3]), + kind=Kind.normal | Kind.hinted, + default_read_attrs=[f"mca{i}" for i in [0, 1, 2, 3]], + default_configuration_attrs=[f"mca{i}" for i in [0, 1, 2, 3]], + ) + + +@pytest.fixture() +def dxp(sim_registry): + FakeDXP = make_fake_device(DxpVortex) + vortex = FakeDXP(name="vortex_me4", labels={"xrf_detectors", "detectors"}) + sim_registry.register(vortex) + # vortex.net_cdf.dimensions.set([1477326, 1, 1]) + yield vortex + + +class Xspress3Vortex(Xspress3Detector): + mcas = DDC( + add_xspress_mcas(range_=[0, 1, 2, 3]), + kind=Kind.normal | Kind.hinted, + default_read_attrs=[f"mca{i}" for i in [0, 1, 2, 3]], + default_configuration_attrs=[f"mca{i}" for i in [0, 1, 2, 3]], + ) + + +@pytest.fixture() +def xspress(sim_registry): + FakeXspress = make_fake_device(Xspress3Vortex) + vortex = FakeXspress(name="vortex_me4", labels={"xrf_detectors"}) + sim_registry.register(vortex) + yield vortex + + +@pytest.fixture() +def aerotech(): + Stage = make_fake_device( + AerotechStage, + ) + stage = Stage( + "255id", + delay_prefix="255id:DG645", + pv_horiz=":m1", + pv_vert=":m2", + name="aerotech", + ) + return stage + + +@pytest.fixture() +def aerotech_flyer(aerotech): + flyer = aerotech.horiz + flyer.user_setpoint._limits = (0, 1000) + flyer.send_command = mock.MagicMock() + yield flyer + + +@pytest.fixture() +def aps(sim_registry): + aps = instantiate_fake_device(ApsMachine, name="APS") + sim_registry.register(aps) + yield aps + + +@pytest.fixture() +def shutters(sim_registry): + FakeShutter = make_fake_device(Shutter) + kw = dict( + prefix="_prefix", + open_pv="_prefix", + close_pv="_prefix2", + state_pv="_prefix2", + labels={"shutters"}, + ) + shutters = [ + FakeShutter(name="Shutter A", **kw), + FakeShutter(name="Shutter C", **kw), + ] + # Registry with the simulated registry + for shutter in shutters: + sim_registry.register(shutter) + yield shutters + + +@pytest.fixture(scope="session") +def pydm_ophyd_plugin(): + return add_plugin(OphydPlugin) + + +qs_status = { + "msg": "RE Manager v0.0.18", + "items_in_queue": 0, + "items_in_history": 0, + "running_item_uid": None, + "manager_state": "idle", + "queue_stop_pending": False, + "worker_environment_exists": False, + "worker_environment_state": "closed", + "worker_background_tasks": 0, + "re_state": None, + "pause_pending": False, + "run_list_uid": "4f2d48cc-980d-4472-b62b-6686caeb3833", + "plan_queue_uid": "2b99ccd8-f69b-4a44-82d0-947d32c5d0a2", + "plan_history_uid": "9af8e898-0f00-4e7a-8d97-0964c8d43f47", + "devices_existing_uid": "51d8b88d-7457-42c4-b67f-097b168be96d", + "plans_existing_uid": "65f11f60-0049-46f5-9eb3-9f1589c4a6dd", + "devices_allowed_uid": "a5ddff29-917c-462e-ba66-399777d2442a", + "plans_allowed_uid": "d1e907cd-cb92-4d68-baab-fe195754827e", + "plan_queue_mode": {"loop": False}, + "task_results_uid": "159e1820-32be-4e01-ab03-e3478d12d288", + "lock_info_uid": "c7fe6f73-91fc-457d-8db0-dfcecb2f2aba", + "lock": {"environment": False, "queue": False}, +} + + +@pytest.fixture(scope="session") +def ffapp(pydm_ophyd_plugin, qapp_cls, qapp_args, pytestconfig): + # Get an instance of the application + app = qt_api.QtWidgets.QApplication.instance() + if app is None: + # New Application + global _ffapp_instance + _ffapp_instance = qapp_cls(qapp_args) + app = _ffapp_instance + name = pytestconfig.getini("qt_qapp_name") + app.setApplicationName(name) + # Make sure there's at least one Window, otherwise things get weird + if getattr(app, "_dummy_main_window", None) is None: + # Set up the actions and other boildplate stuff + app.setup_window_actions() + app.setup_runengine_actions() + app._dummy_main_window = FireflyMainWindow() + # Sanity check to make sure a QApplication was not created by mistake + assert isinstance(app, FireflyApplication) + # Yield the finalized application object + try: + yield app + finally: + if hasattr(app, "_queue_thread"): + app._queue_thread.quit() + app._queue_thread.wait(msecs=5000) + + +# holds a global QApplication instance created in the qapp fixture; keeping +# this reference alive avoids it being garbage collected too early +_ffapp_instance = None diff --git a/src/firefly/application.py b/src/firefly/application.py index 161792b0..f775d72c 100644 --- a/src/firefly/application.py +++ b/src/firefly/application.py @@ -110,6 +110,8 @@ def __init__(self, display="status", use_main_window=False, *args, **kwargs): def __del__(self): if hasattr(self, "_queue_thread"): self._queue_thread.quit() + self._queue_thread.wait(msecs=5000) + assert not self._queue_thread.isRunning() def _setup_window_action(self, action_name: str, text: str, slot: QtCore.Slot): action = QtWidgets.QAction(self) @@ -226,6 +228,25 @@ def setup_runengine_actions(self): action.setCheckable(True) action.setIcon(icon) setattr(self, name, action) + # Actions that control how the queue operates + actions = [ + # Attr, object name, text + ("queue_autoplay_action", "queue_autoplay_action", "&Autoplay"), + ( + "queue_open_environment_action", + "queue_open_environment_action", + "&Open Environment", + ), + ] + for attr, obj_name, text in actions: + action = QAction() + action.setObjectName(obj_name) + action.setText(text) + setattr(self, attr, action) + # Customize some specific actions + self.queue_autoplay_action.setCheckable(True) + self.queue_autoplay_action.setChecked(True) + self.queue_open_environment_action.setCheckable(True) def _prepare_device_windows(self, device_label: str, attr_name: str): """Generic routine to be called for individual classes of devices. @@ -303,24 +324,27 @@ def prepare_motor_windows(self): action.triggered.connect(slot) self.motor_window_slots.append(slot) - def prepare_queue_client(self, start_thread: bool = True, api=None): + def prepare_queue_client(self, api=None): """Set up the QueueClient object that talks to the queue server. Parameters ========== api queueserver API. Used for testing. - start_thread - Whether to start the newly create queue client thread. """ if api is None: api = queueserver_api() - client = QueueClient(api=api) - thread = QueueClientThread(client=client) + # Create a thread in which the api can run + thread = getattr(self, "_queue_thread", None) + if thread is None: + thread = QueueClientThread() + self._queue_thread = thread + # Create the client object + client = QueueClient(api=api, autoplay_action=self.queue_autoplay_action, open_environment_action=self.queue_open_environment_action) client.moveToThread(thread) + thread.timer.timeout.connect(client.update) self._queue_client = client - self._queue_thread = thread # Connect actions to slots for controlling the queueserver self.pause_runengine_action.triggered.connect( partial(client.request_pause, defer=True) @@ -343,13 +367,11 @@ def prepare_queue_client(self, start_thread: bool = True, api=None): client.manager_state_changed.connect(self.queue_manager_state_changed) client.re_state_changed.connect(self.queue_re_state_changed) client.devices_changed.connect(self.queue_devices_changed) - self.queue_autoplay_action = client.autoplay_action self.queue_autoplay_action.toggled.connect( self.check_queue_status_action.trigger ) - self.queue_open_environment_action = client.open_environment_action # Start the thread - if start_thread: + if not thread.isRunning(): thread.start() def enable_queue_controls(self, re_state): @@ -370,7 +392,11 @@ def enable_queue_controls(self, re_state): self.halt_runengine_action, ] # Decide which signals to enable - if re_state == "idle": + unknown_re_state = re_state is None or re_state.strip() == "" + if unknown_re_state: + # Unknown state, no button should work + enabled_signals = [] + elif re_state == "idle": enabled_signals = [self.start_queue_action] elif re_state == "paused": enabled_signals = [ @@ -476,7 +502,7 @@ def show_ion_chamber_window(self, *args, device): device_name = device.name.replace(" ", "_") self.show_window( FireflyMainWindow, - ui_dir / "ion_chamber.ui", + ui_dir / "ion_chamber.py", name=f"FireflyMainWindow_ion_chamber_{device_name}", macros={"IC": device.name}, ) @@ -557,6 +583,9 @@ def show_bss_window(self): @QtCore.Slot(bool) def set_open_environment_action_state(self, is_open: bool): - self.queue_open_environment_action.blockSignals(True) - self.queue_open_environment_action.setChecked(is_open) - self.queue_open_environment_action.blockSignals(False) + """Update the readback value for opening the queueserver environment.""" + action = self.queue_open_environment_action + if action is not None: + action.blockSignals(True) + action.setChecked(is_open) + action.blockSignals(False) diff --git a/src/firefly/ion_chamber.py b/src/firefly/ion_chamber.py new file mode 100644 index 00000000..4c5b9756 --- /dev/null +++ b/src/firefly/ion_chamber.py @@ -0,0 +1,10 @@ +import warnings + +import haven +from firefly import display + + +class IonChamberDisplay(display.FireflyDisplay): + + def ui_filename(self): + return "ion_chamber.ui" diff --git a/src/firefly/queue_client.py b/src/firefly/queue_client.py index 89a3402a..d98bf935 100644 --- a/src/firefly/queue_client.py +++ b/src/firefly/queue_client.py @@ -23,22 +23,28 @@ def queueserver_api(): class QueueClientThread(QThread): + """A thread for handling the queue client. + + Every *poll_time* seconds, the timer will emit its *timeout* + signal. You can connect a slot to the + :py:cls:`QueueClientThread.timer.timeout()` signal. + + """ timer: QTimer - def __init__(self, *args, client, **kwargs): - self.client = client + def __init__(self, *args, poll_time=1000, **kwargs): super().__init__(*args, **kwargs) - # Timer for polling the queueserver self.timer = QTimer() - self.timer.timeout.connect(self.client.update) - self.timer.start(1000) def quit(self, *args, **kwargs): - self.timer.stop() - # del self.timer - # del self.client + if hasattr(self, "timer"): + self.timer.stop() super().quit(*args, **kwargs) + def start(self, *args, **kwargs): + super().start(*args, **kwargs) + self.timer.start(1000) + class QueueClient(QObject): api: REManagerAPI @@ -58,34 +64,20 @@ class QueueClient(QObject): # Actions for changing the queue settings in menubars autoplay_action: QAction open_environment_action: QAction - close_environment_action: QAction - def __init__(self, *args, api, **kwargs): + def __init__(self, *args, api, autoplay_action, open_environment_action, **kwargs): self.api = api super().__init__(*args, **kwargs) + # Set up actions coming from the parent + self.autoplay_action = autoplay_action + self.open_environment_action = open_environment_action self.setup_actions() + self._last_queue_status = {} def setup_actions(self): - actions = [ - # Attr, object name, text, checkable - ("autoplay_action", "queue_autoplay_action", "&Autoplay"), - ( - "open_environment_action", - "queue_open_environment_action", - "&Open Environment", - ), - ] - for attr, obj_name, text in actions: - action = QAction() - action.setObjectName(obj_name) - action.setText(text) - setattr(self, attr, action) - # Customize some specific actions - self.autoplay_action.setCheckable(True) - self.autoplay_action.setChecked(True) - self.open_environment_action.setCheckable(True) # Connect actions to signal handlers - self.open_environment_action.triggered.connect(self.open_environment) + if self.open_environment_action is not None: + self.open_environment_action.triggered.connect(self.open_environment) def open_environment(self): to_open = self.open_environment_action.isChecked() @@ -94,6 +86,7 @@ def open_environment(self): else: api_call = self.api.environment_close result = api_call() + print(result, to_open) if result["success"]: self.environment_opened.emit(to_open) else: @@ -146,7 +139,7 @@ def add_queue_item(self, item): new_length = result["qsize"] self.length_changed.emit(result["qsize"]) # Automatically run the queue if this is the first item - # from pprint import pprint + print(self.autoplay_action.isChecked()) if self.autoplay_action.isChecked(): self.start_queue() else: @@ -219,16 +212,14 @@ def _check_queue_status(self, force: bool = False): if force: log.debug(f"Forcing queue server status update: {new_status}") for key, signal in signals_to_check: - has_changed = ( - self._last_queue_status is None - or new_status[key] != self._last_queue_status[key] - ) - if has_changed or force: + is_new = key not in self._last_queue_status + has_changed = new_status[key] != self._last_queue_status.get(key) + if is_new or has_changed or force: signal.emit(new_status[key]) # Check for new available devices if ( new_status["devices_allowed_uid"] - != self._last_queue_status["devices_allowed_uid"] + != self._last_queue_status.get("devices_allowed_uid") ): self.update_devices() # check the whole status to see if it's changed diff --git a/src/firefly/run_browser.py b/src/firefly/run_browser.py index 3d0e0e65..59bd0765 100644 --- a/src/firefly/run_browser.py +++ b/src/firefly/run_browser.py @@ -1,6 +1,7 @@ import logging import datetime as dt from typing import Sequence +import warnings import yaml from httpx import HTTPStatusError, PoolTimeout from contextlib import contextmanager @@ -13,6 +14,7 @@ from pyqtgraph import PlotItem, GraphicsLayoutWidget, PlotWidget, PlotDataItem import qtawesome as qta from matplotlib.colors import TABLEAU_COLORS +from pydantic.error_wrappers import ValidationError from firefly import display, FireflyApplication from firefly.run_client import DatabaseWorker @@ -78,7 +80,6 @@ class RunBrowserDisplay(display.FireflyDisplay): filters_changed = Signal(dict) def __init__(self, root_node=None, args=None, macros=None, **kwargs): - # self.prepare_run_client(root_node=root_node) super().__init__(args=args, macros=macros, **kwargs) self.start_run_client(root_node=root_node) @@ -120,8 +121,9 @@ def update_combobox_items(self, fields): ("plan_name", self.ui.filter_plan_combobox), ("edge", self.ui.filter_edge_combobox), ]: - cb.clear() - cb.addItems(fields[field_name]) + if field_name in fields.keys(): + cb.clear() + cb.addItems(fields[field_name]) def customize_ui(self): self.load_models() @@ -278,8 +280,8 @@ def calculate_ydata( if use_grad: y = np.gradient(y, x_data) y_string = f"d({y_string})/d[{r_signal}]" - except TypeError: - msg = f"Could not calculate transformation." + except TypeError as exc: + msg = f"Could not calculate transformation: {exc}" log.warning(msg) raise exceptions.InvalidTransformation(msg) return y, y_string @@ -290,6 +292,9 @@ def load_run_data(self, run, x_signal, y_signal, r_signal, use_reference=True): f"Empty signal name requested: x='{x_signal}', y='{y_signal}', r='{r_signal}'" ) raise exceptions.EmptySignalName + signals = [x_signal, y_signal] + if use_reference: + signals.append(r_signal) try: data = run["primary"]["data"] y_data = data[y_signal] @@ -303,6 +308,9 @@ def load_run_data(self, run, x_signal, y_signal, r_signal, use_reference=True): msg = f"Cannot find key {e} in {run}." log.warning(msg) raise exceptions.SignalNotFound(msg) + except ValidationError: + print("Pydantic error:", run) + raise return x_data, y_data, r_data def multiplot_items(self, n_cols: int = 3): @@ -339,7 +347,6 @@ def update_multi_plot(self, *args): n_cols = 3 runs = self._db_worker.selected_runs for run in runs: - # print(run.metadata['start']['uid'], run['primary']['data']) data = run["primary"]["data"].read(all_signals) try: xdata = data[x_signal] diff --git a/src/firefly/run_client.py b/src/firefly/run_client.py index 47a785f7..95cf65dd 100644 --- a/src/firefly/run_client.py +++ b/src/firefly/run_client.py @@ -144,7 +144,6 @@ def load_all_runs(self): raise else: self.db_op_ended.emit([]) - self.all_runs_changed.emit(all_runs) @Slot(list) diff --git a/tests/test_application.py b/src/firefly/tests/test_application.py similarity index 79% rename from tests/test_application.py rename to src/firefly/tests/test_application.py index ac4a5626..a9411692 100644 --- a/tests/test_application.py +++ b/src/firefly/tests/test_application.py @@ -10,24 +10,25 @@ from firefly.queue_client import QueueClient from firefly.application import REManagerAPI -from firefly.main_window import FireflyMainWindow -def test_setup(queue_app): - queue_app.setup_window_actions() - queue_app.setup_runengine_actions() +def test_setup(ffapp): api = MagicMock() - FireflyMainWindow() - queue_app.prepare_queue_client(api=api) + try: + ffapp.prepare_queue_client(api=api) + finally: + ffapp._queue_thread.quit() + ffapp._queue_thread.wait(msecs=5000) -def test_setup2(queue_app): +def test_setup2(ffapp): """Verify that multiple tests can use the app without crashing.""" - queue_app.setup_window_actions() - queue_app.setup_runengine_actions() api = MagicMock() - FireflyMainWindow() - queue_app.prepare_queue_client(api=api) + try: + ffapp.prepare_queue_client(api=api) + finally: + ffapp._queue_thread.quit() + ffapp._queue_thread.wait(msecs=5000) def test_queue_actions_enabled(ffapp, qtbot): @@ -70,3 +71,11 @@ def test_queue_actions_enabled(ffapp, qtbot): assert not ffapp.resume_runengine_action.isEnabled() assert not ffapp.abort_runengine_action.isEnabled() assert not ffapp.halt_runengine_action.isEnabled() + # Pretend the queue is in an unknown state (maybe the environment is closed) + with qtbot.waitSignal(ffapp.queue_re_state_changed): + ffapp.queue_re_state_changed.emit(None) + + +@pytest.mark.xfail +def test_prepare_queue_client(ffapp): + assert False, "Write tests for prepare_queue_client." diff --git a/tests/test_area_detector_display.py b/src/firefly/tests/test_area_detector_display.py similarity index 96% rename from tests/test_area_detector_display.py rename to src/firefly/tests/test_area_detector_display.py index efd504c2..50d9c8e8 100644 --- a/tests/test_area_detector_display.py +++ b/src/firefly/tests/test_area_detector_display.py @@ -3,7 +3,6 @@ import pydm from unittest import mock -from firefly.main_window import FireflyMainWindow from firefly.area_detector_viewer import AreaDetectorViewerDisplay from haven.instrument.camera import load_cameras @@ -29,7 +28,6 @@ def test_open_area_detector_viewer_actions(ffapp, qtbot, sim_camera): def test_image_plotting(ffapp, qtbot, sim_camera): - FireflyMainWindow() display = AreaDetectorViewerDisplay(macros={"AD": sim_camera.name}) assert isinstance(display.image_view, pyqtgraph.ImageView) assert isinstance(display.image_channel, pydm.PyDMChannel) @@ -55,7 +53,6 @@ def test_image_plotting(ffapp, qtbot, sim_camera): def test_caqtdm_window(ffapp, sim_camera): - FireflyMainWindow() display = AreaDetectorViewerDisplay(macros={"AD": sim_camera.name}) display._open_caqtdm_subprocess = mock.MagicMock() # Launch the caqtdm display diff --git a/tests/test_bss_display.py b/src/firefly/tests/test_bss_display.py similarity index 98% rename from tests/test_bss_display.py rename to src/firefly/tests/test_bss_display.py index 43f58500..c0ae6a4e 100644 --- a/tests/test_bss_display.py +++ b/src/firefly/tests/test_bss_display.py @@ -6,7 +6,6 @@ from haven.instrument.aps import load_aps from firefly.bss import BssDisplay -from firefly.main_window import FireflyMainWindow @pytest.fixture() @@ -169,7 +168,6 @@ def test_bss_proposal_updating(qtbot, ffapp, bss_api, sim_registry): def test_bss_proposals(ffapp, bss_api): - window = FireflyMainWindow() display = BssDisplay(api=bss_api) # Check values api_proposal = bss_api.getCurrentProposals()[0] @@ -196,7 +194,6 @@ def test_bss_esaf_model(qtbot, ffapp, bss_api): def test_bss_esaf_updating(qtbot, ffapp, bss_api, sim_registry): load_aps() - window = FireflyMainWindow() display = BssDisplay(api=bss_api) bss = sim_registry.find(name="bss") # Set some base-line values on the IOC @@ -222,7 +219,6 @@ def test_bss_esaf_updating(qtbot, ffapp, bss_api, sim_registry): def test_bss_esafs(ffapp, bss_api): - window = FireflyMainWindow() display = BssDisplay(api=bss_api) # Check values api_esaf = bss_api.getCurrentEsafs()[0] diff --git a/tests/test_cameras_display.py b/src/firefly/tests/test_cameras_display.py similarity index 91% rename from tests/test_cameras_display.py rename to src/firefly/tests/test_cameras_display.py index de92d70c..3289021b 100644 --- a/tests/test_cameras_display.py +++ b/src/firefly/tests/test_cameras_display.py @@ -6,7 +6,6 @@ from pydm.widgets.channel import PyDMChannel from qtpy import QtWidgets, QtGui, QtCore -from firefly.main_window import FireflyMainWindow from firefly.cameras import CamerasDisplay from firefly.camera import CameraDisplay, DetectorStates @@ -16,10 +15,6 @@ def test_embedded_displays(qtbot, ffapp, sim_registry, sim_camera): """Test that the embedded displays get loaded.""" - FireflyMainWindow() - # Set up fake cameras - # camera = haven.Camera(prefix="camera_ioc:", name="Camera A", labels={"cameras"}) - # sim_registry.register(camera) # Load the display display = CamerasDisplay() # Check that the embedded display widgets get added correctly @@ -36,7 +31,6 @@ def test_camera_channel_status(qtbot, ffapp): status PV. """ - FireflyMainWindow() display = CameraDisplay(macros=macros) # Check that the pydm connections have been made to EPICS assert isinstance(display.detector_state, PyDMChannel) @@ -44,7 +38,6 @@ def test_camera_channel_status(qtbot, ffapp): def test_set_status_byte(qtbot, ffapp): - FireflyMainWindow() display = CameraDisplay(macros=macros) display.show() # All devices are disconnected @@ -77,7 +70,6 @@ def test_set_status_byte(qtbot, ffapp): def test_camera_viewer_button(qtbot, ffapp, ioc_area_detector, mocker): action = QtWidgets.QAction(ffapp) ffapp.camera_actions.append(action) - FireflyMainWindow() display = CameraDisplay(macros=macros) display.show() # Click the button diff --git a/tests/test_count_window.py b/src/firefly/tests/test_count_window.py similarity index 100% rename from tests/test_count_window.py rename to src/firefly/tests/test_count_window.py diff --git a/tests/test_detector_list.py b/src/firefly/tests/test_detector_list.py similarity index 87% rename from tests/test_detector_list.py rename to src/firefly/tests/test_detector_list.py index a132adc6..f604dc62 100644 --- a/tests/test_detector_list.py +++ b/src/firefly/tests/test_detector_list.py @@ -3,13 +3,13 @@ from firefly.detector_list import DetectorListView -def test_detector_model(ffapp, sim_registry, sim_vortex): +def test_detector_model(ffapp, dxp): view = DetectorListView() assert hasattr(view, "detector_model") assert view.detector_model.item(0).text() == "vortex_me4" -def test_selected_detectors(ffapp, sim_vortex, qtbot): +def test_selected_detectors(ffapp, dxp, qtbot): """Do we get the list of detectors after they have been selected?""" # No detectors selected, so empty list view = DetectorListView() diff --git a/tests/test_energy_display.py b/src/firefly/tests/test_energy_display.py similarity index 74% rename from tests/test_energy_display.py rename to src/firefly/tests/test_energy_display.py index f492b970..d67ba188 100644 --- a/tests/test_energy_display.py +++ b/src/firefly/tests/test_energy_display.py @@ -5,21 +5,26 @@ from qtpy import QtWidgets, QtCore from bluesky_queueserver_api import BPlan from apstools.devices.aps_undulator import ApsUndulator +from ophyd.sim import make_fake_device import haven from haven.instrument.monochromator import load_monochromator from haven.instrument.energy_positioner import load_energy_positioner -from firefly.main_window import FireflyMainWindow from firefly.energy import EnergyDisplay +FakeMonochromator = make_fake_device(haven.instrument.monochromator.Monochromator) +FakeEnergyPositioner = make_fake_device(haven.instrument.energy_positioner.EnergyPositioner) +FakeUndulator = make_fake_device(ApsUndulator) + + def test_mono_caqtdm_macros(qtbot, ffapp, sim_registry): # Create fake device mono = sim_registry.register( - haven.instrument.monochromator.Monochromator("mono_ioc", name="monochromator") + FakeMonochromator("mono_ioc", name="monochromator") ) sim_registry.register( - haven.instrument.energy_positioner.EnergyPositioner( + FakeEnergyPositioner( mono_pv="mono_ioc:Energy", id_offset_pv="mono_ioc:ID_offset", id_tracking_pv="mono_ioc:ID_tracking", @@ -27,12 +32,10 @@ def test_mono_caqtdm_macros(qtbot, ffapp, sim_registry): name="energy", ) ) - undulator = ApsUndulator("id_ioc:", name="undulator", labels={"xray_sources"}) + undulator = FakeUndulator("id_ioc:", name="undulator", labels={"xray_sources"}) + undulator.energy.pvname = "id_ioc:Energy" sim_registry.register(undulator) # Load display - ffapp.setup_window_actions() - ffapp.setup_runengine_actions() - FireflyMainWindow() display = EnergyDisplay() display.launch_caqtdm = mock.MagicMock() # Check that the various caqtdm calls set up the right macros @@ -51,11 +54,11 @@ def test_mono_caqtdm_macros(qtbot, ffapp, sim_registry): def test_id_caqtdm_macros(qtbot, ffapp, sim_registry): # Create fake device - mono = haven.instrument.monochromator.Monochromator( + mono = FakeMonochromator( "mono_ioc", name="monochromator" ) sim_registry.register( - haven.instrument.energy_positioner.EnergyPositioner( + FakeEnergyPositioner( mono_pv="mono_ioc:Energy", id_offset_pv="mono_ioc:ID_offset", id_tracking_pv="mono_ioc:ID_tracking", @@ -63,12 +66,9 @@ def test_id_caqtdm_macros(qtbot, ffapp, sim_registry): name="energy", ) ) - undulator = ApsUndulator("id_ioc:", name="undulator", labels={"xray_sources"}) + undulator = FakeUndulator("id_ioc:", name="undulator", labels={"xray_sources"}) sim_registry.register(undulator) # Load display - ffapp.setup_window_actions() - ffapp.setup_runengine_actions() - FireflyMainWindow() display = EnergyDisplay() display.launch_caqtdm = mock.MagicMock() # Check that the various caqtdm calls set up the right macros @@ -82,11 +82,11 @@ def test_id_caqtdm_macros(qtbot, ffapp, sim_registry): def test_move_energy(qtbot, ffapp, sim_registry): - mono = haven.instrument.monochromator.Monochromator( + mono = FakeMonochromator( "mono_ioc", name="monochromator" ) sim_registry.register( - haven.instrument.energy_positioner.EnergyPositioner( + FakeEnergyPositioner( mono_pv="mono_ioc:Energy", id_offset_pv="mono_ioc:ID_offset", id_tracking_pv="mono_ioc:ID_tracking", @@ -95,9 +95,6 @@ def test_move_energy(qtbot, ffapp, sim_registry): ) ) # Load display - ffapp.setup_window_actions() - ffapp.setup_runengine_actions() - FireflyMainWindow() disp = EnergyDisplay() # Click the set energy button btn = disp.ui.set_energy_button @@ -113,21 +110,13 @@ def check_item(item): qtbot.mouseClick(btn, QtCore.Qt.LeftButton) -def test_predefined_energies(qtbot, ffapp, ioc_mono, sim_registry): - load_monochromator( - config={ - "monochromator": { - "ioc": "mono_ioc", - }, - } - ) - load_energy_positioner() +def test_predefined_energies(qtbot, ffapp, sim_registry): # Create fake device - mono = haven.instrument.monochromator.Monochromator( + mono = FakeMonochromator( "mono_ioc", name="monochromator" ) sim_registry.register( - haven.instrument.energy_positioner.EnergyPositioner( + FakeEnergyPositioner( mono_pv="mono_ioc:Energy", id_offset_pv="mono_ioc:ID_offset", id_tracking_pv="mono_ioc:ID_tracking", @@ -136,10 +125,7 @@ def test_predefined_energies(qtbot, ffapp, ioc_mono, sim_registry): ) ) # Set up the required Application state - ffapp.setup_window_actions() - ffapp.setup_runengine_actions() # Load display - FireflyMainWindow() disp = EnergyDisplay() # Check that the combo box was populated combo_box = disp.ui.edge_combo_box diff --git a/tests/test_main_window.py b/src/firefly/tests/test_main_window.py similarity index 72% rename from tests/test_main_window.py rename to src/firefly/tests/test_main_window.py index 7727cdd3..e8c021de 100644 --- a/tests/test_main_window.py +++ b/src/firefly/tests/test_main_window.py @@ -7,36 +7,36 @@ from firefly.application import FireflyApplication -def test_navbar(queue_app): +def test_navbar(ffapp): window = PlanMainWindow() # Check navbar actions on the app - assert hasattr(queue_app, "pause_runengine_action") + assert hasattr(ffapp, "pause_runengine_action") # Check that the navbar actions are set up properly assert hasattr(window.ui, "navbar") navbar = window.ui.navbar # Navigation actions are removed assert window.ui.actionHome not in navbar.actions() # Run engine actions have been added to the navbar - assert queue_app.pause_runengine_action in navbar.actions() - assert queue_app.start_queue_action in navbar.actions() + assert ffapp.pause_runengine_action in navbar.actions() + assert ffapp.start_queue_action in navbar.actions() -def test_navbar_autohide(queue_app, qtbot): +def test_navbar_autohide(ffapp, qtbot): """Test that the queue navbar is only visible when plans are queued.""" window = PlanMainWindow() window.show() navbar = window.ui.navbar # Pretend the queue has some things in it - with qtbot.waitSignal(queue_app.queue_length_changed): - queue_app.queue_length_changed.emit(3) + with qtbot.waitSignal(ffapp.queue_length_changed): + ffapp.queue_length_changed.emit(3) assert navbar.isVisible() # Make the queue be empty - with qtbot.waitSignal(queue_app.queue_length_changed): - queue_app.queue_length_changed.emit(0) + with qtbot.waitSignal(ffapp.queue_length_changed): + ffapp.queue_length_changed.emit(0) assert not navbar.isVisible() -def test_add_menu_action(queue_app): +def test_add_menu_action(ffapp): window = FireflyMainWindow() # Check that it's not set up with the right menu yet assert not hasattr(window, "actionMake_Salad") @@ -50,12 +50,12 @@ def test_add_menu_action(queue_app): assert action.objectName() == "actionMake_Salad" -def test_customize_ui(queue_app): +def test_customize_ui(ffapp): window = FireflyMainWindow() assert hasattr(window.ui, "menuScans") -def test_show_message(queue_app): +def test_show_message(ffapp): window = FireflyMainWindow() status_bar = window.statusBar() # Send a message diff --git a/tests/test_motor_menu.py b/src/firefly/tests/test_motor_menu.py similarity index 95% rename from tests/test_motor_menu.py rename to src/firefly/tests/test_motor_menu.py index e1607f0a..01f72469 100644 --- a/tests/test_motor_menu.py +++ b/src/firefly/tests/test_motor_menu.py @@ -32,6 +32,7 @@ def test_motor_menu(fake_motors, qtbot, ffapp): # Check that the menu items have been created assert hasattr(window.ui, "menuPositioners") assert len(ffapp.motor_actions) == 3 + window.destroy() def test_open_motor_window(fake_motors, monkeypatch, ffapp): @@ -39,7 +40,6 @@ def test_open_motor_window(fake_motors, monkeypatch, ffapp): ffapp.setup_window_actions() ffapp.setup_runengine_actions() # Simulate clicking on the menu action (they're in alpha order) - window = FireflyMainWindow() action = ffapp.motor_actions[2] action.trigger() # See if the window was created @@ -47,5 +47,3 @@ def test_open_motor_window(fake_motors, monkeypatch, ffapp): assert motor_3_name in ffapp.windows.keys() macros = ffapp.windows[motor_3_name].display_widget().macros() assert macros["MOTOR"] == "motorC" - # Clean up - window.close() diff --git a/tests/test_ophyd_connection.py b/src/firefly/tests/test_ophyd_connection.py similarity index 99% rename from tests/test_ophyd_connection.py rename to src/firefly/tests/test_ophyd_connection.py index b45ea760..89234e5c 100644 --- a/tests/test_ophyd_connection.py +++ b/src/firefly/tests/test_ophyd_connection.py @@ -11,7 +11,6 @@ from haven import HavenMotor from firefly.ophyd_plugin import Connection as OphydConnection, OphydPlugin -from firefly.main_window import FireflyMainWindow class DummyObject(QtCore.QObject): diff --git a/tests/test_queue_button.py b/src/firefly/tests/test_queue_button.py similarity index 84% rename from tests/test_queue_button.py rename to src/firefly/tests/test_queue_button.py index 330a09fe..63434ac2 100644 --- a/tests/test_queue_button.py +++ b/src/firefly/tests/test_queue_button.py @@ -1,7 +1,7 @@ from firefly.queue_button import QueueButton -def test_queue_button_style(queue_app): +def test_queue_button_style(ffapp): """Does the queue button change color/icon based.""" btn = QueueButton() # Initial style should be disabled and plain @@ -13,7 +13,7 @@ def test_queue_button_style(queue_app): "items_in_queue": 0, "re_state": "idle", } - queue_app.queue_status_changed.emit(queue_state) + ffapp.queue_status_changed.emit(queue_state) assert btn.isEnabled() assert "rgb(25, 135, 84)" in btn.styleSheet() assert btn.text() == "Run" @@ -23,7 +23,7 @@ def test_queue_button_style(queue_app): "items_in_queue": 0, "re_state": "running", } - queue_app.queue_status_changed.emit(queue_state) + ffapp.queue_status_changed.emit(queue_state) assert btn.isEnabled() assert "rgb(0, 123, 255)" in btn.styleSheet() assert btn.text() == "Add to Queue" diff --git a/tests/test_queue_client.py b/src/firefly/tests/test_queue_client.py similarity index 79% rename from tests/test_queue_client.py rename to src/firefly/tests/test_queue_client.py index f0fa0408..70f54126 100644 --- a/tests/test_queue_client.py +++ b/src/firefly/tests/test_queue_client.py @@ -7,13 +7,13 @@ from bluesky import RunEngine, plans as bp from qtpy.QtCore import QThread from qtpy.QtTest import QSignalSpy +from qtpy.QtWidgets import QAction from bluesky_queueserver_api import BPlan from bluesky_queueserver_api.zmq import REManagerAPI from pytestqt.exceptions import TimeoutError from firefly.queue_client import QueueClient from firefly.application import REManagerAPI -from firefly.main_window import FireflyMainWindow qs_status = { @@ -216,93 +216,86 @@ } -def test_setup(ffapp): - ffapp.setup_window_actions() - ffapp.setup_runengine_actions() +@pytest.fixture() +def client(): + # Create a fake API with known responses api = MagicMock() - ffapp.prepare_queue_client(api=api) - FireflyMainWindow() + api.queue_start.return_value = {"success": True} + api.status.return_value = qs_status + api.queue_start.return_value = {"success": True,} + api.devices_allowed.return_value = {"success": True, "devices_allowed": {}} + api.environment_open.return_value = {"success": True} + api.environment_close.return_value = {"success": True} + # Create the client using the fake API + autoplay_action = QAction() + autoplay_action.setCheckable(True) + open_environment_action = QAction() + open_environment_action.setCheckable(True) + client = QueueClient(api=api, autoplay_action=autoplay_action, open_environment_action=open_environment_action) + yield client -def test_queue_re_control(ffapp): +def test_queue_re_control(client): """Test if the run engine can be controlled from the queue client.""" - api = MagicMock() - api.queue_start.return_value = {"success": True} - ffapp.setup_window_actions() - ffapp.setup_runengine_actions() - ffapp.prepare_queue_client(api=api) - window = FireflyMainWindow() - window.show() + api = client.api # Try and pause the run engine - ffapp.pause_runengine_action.trigger() + client.request_pause(defer=True) # Check if the API paused - time.sleep(0.1) api.re_pause.assert_called_once_with(option="deferred") # Pause the run engine now! api.reset_mock() - ffapp.pause_runengine_now_action.trigger() + client.request_pause(defer=False) # Check if the API paused now - time.sleep(0.1) api.re_pause.assert_called_once_with(option="immediate") # Start the queue api.reset_mock() - ffapp.start_queue_action.trigger() + client.start_queue() # Check if the queue started - time.sleep(0.1) api.queue_start.assert_called_once() -def test_run_plan(ffapp, qtbot): +def test_run_plan(client, qtbot): """Test if a plan can be queued in the queueserver.""" - ffapp.setup_window_actions() - ffapp.setup_runengine_actions() - api = MagicMock() + api = client.api api.item_add.return_value = {"success": True, "qsize": 2} - api.queue_start.return_value = {"success": True} - ffapp.prepare_queue_client(api=api) - FireflyMainWindow() # Send a plan with qtbot.waitSignal( - ffapp.queue_length_changed, timeout=1000, check_params_cb=lambda l: l == 2 + client.length_changed, timeout=1000, check_params_cb=lambda l: l == 2 ): - ffapp.queue_item_added.emit({}) + client.add_queue_item({}) # Check if the API sent it api.item_add.assert_called_once_with(item={}) -def test_autoplay(queue_app, qtbot): +def test_autoplay(client, qtbot): """Test how queuing a plan starts the runengine.""" - FireflyMainWindow() - api = queue_app._queue_client.api - # Send a plan - plan = BPlan("set_energy", energy=8333) - queue_app._queue_client.add_queue_item(plan) - api.item_add.assert_called_once() - # Check the queue was started - api.queue_start.assert_called_once() + api = client.api # Check that it doesn't start the queue if the autoplay action is off - api.reset_mock() - queue_app._queue_client.autoplay_action.trigger() - queue_app._queue_client.add_queue_item(plan) - # Check that the queue wasn't started + plan = BPlan("set_energy", energy=8333) + client.add_queue_item(plan) assert not api.queue_start.called + # Check the queue was started now that autoplay is on + client.autoplay_action.toggle() + client.add_queue_item(plan) + api.queue_start.assert_called_once() -def test_check_queue_status(queue_app, qtbot): +def test_check_queue_status(client, qtbot): # Check that the queue length is changed signals = [ - queue_app.queue_status_changed, - queue_app.queue_environment_opened, - queue_app.queue_environment_state_changed, - queue_app.queue_re_state_changed, - queue_app.queue_manager_state_changed, + client.status_changed, + client.environment_opened, + client.environment_state_changed, + client.re_state_changed, + client.manager_state_changed, ] with qtbot.waitSignals(signals): - queue_app._queue_client.check_queue_status() + client.check_queue_status() + return # Check that it isn't emitted a second time with pytest.raises(TimeoutError): with qtbot.waitSignals(signals, timeout=10): - queue_app._queue_client.check_queue_status() + client.check_queue_status() # Now check a non-empty length queue new_status = qs_status.copy() new_status.update( @@ -318,40 +311,40 @@ def test_check_queue_status(queue_app, qtbot): # "plan_queue_uid": "f682e6fa-983c-4bd8-b643-b3baec2ec764", } ) - queue_app._queue_client.api.status.return_value = new_status + client.api.status.return_value = new_status with qtbot.waitSignals(signals): - queue_app._queue_client.check_queue_status() + client.check_queue_status() -def test_open_environment(queue_app, qtbot): +def test_open_environment(client, qtbot): """Check that the 'open environment' action sends the right command to the queue. """ - api = queue_app._queue_client.api + api = client.api # Open the environment - queue_app.queue_open_environment_action.setChecked(False) - with qtbot.waitSignal(queue_app.queue_environment_opened) as blocker: - queue_app.queue_open_environment_action.trigger() + client.open_environment_action.setChecked(False) + print(client.open_environment_action.isCheckable()) + with qtbot.waitSignal(client.environment_opened) as blocker: + client.open_environment_action.trigger() assert blocker.args == [True] assert api.environment_open.called # Close the environment - with qtbot.waitSignal(queue_app.queue_environment_opened) as blocker: - queue_app.queue_open_environment_action.trigger() + with qtbot.waitSignal(client.environment_opened) as blocker: + client.open_environment_action.trigger() assert blocker.args == [False] assert api.environment_close.called -def test_devices_available(queue_app, qtbot): +def test_devices_available(client, qtbot): """Check that the queue client provides a list of devices that can be used in plans. - + """ - api = queue_app._queue_client.api + api = client.api api.devices_allowed.return_value = devices_allowed - client = queue_app._queue_client # Ask for updated list of devices - with qtbot.waitSignal(queue_app.queue_devices_changed) as blocker: + with qtbot.waitSignal(client.devices_changed) as blocker: client.update_devices() # Check that the data have the right form devices = blocker.args[0] diff --git a/tests/test_run_browser.py b/src/firefly/tests/test_run_browser.py similarity index 79% rename from tests/test_run_browser.py rename to src/firefly/tests/test_run_browser.py index 68576028..4e43cdb0 100644 --- a/tests/test_run_browser.py +++ b/src/firefly/tests/test_run_browser.py @@ -7,6 +7,12 @@ from qtpy.QtCore import Qt from pyqtgraph import PlotItem, PlotWidget import numpy as np +import pandas as pd +from tiled.adapters.mapping import MapAdapter +from tiled.adapters.array import ArrayAdapter +from tiled.adapters.xarray import DatasetAdapter +from tiled.server.app import build_app +from tiled.client import Context, from_context from haven import tiled_client from firefly.main_window import PlanMainWindow @@ -17,36 +23,93 @@ log = logging.getLogger(__name__) -httpx_reason = ( - "v0.1.0a106 of tiled client broke the run_browser" - "giving an httpx.PoolTimeout exception. " - "Happens when calling ``run['primary']['data'] on " - "in *run_browser.py* ln 294" -) - - -# pytest.skip(reason=httpx_reason, allow_module_level=True) - - def wait_for_runs_model(display, qtbot): with qtbot.waitSignal(display.runs_model_changed): pass -@pytest.fixture() -def client(sim_tiled): - return sim_tiled["255id_testing"] +run1 = pd.DataFrame( + { + "energy_energy": np.linspace(8300, 8400, num=100), + "It_net_counts": np.abs(np.sin(np.linspace(0, 4 * np.pi, num=100))), + "I0_net_counts": np.linspace(1, 2, num=100), + } +) +hints = { + "energy": {"fields": ["energy_energy", "energy_id_energy_readback"]}, +} + +bluesky_mapping = { + "7d1daf1d-60c7-4aa7-a668-d1cd97e5335f": MapAdapter( + { + "primary": MapAdapter( + { + "data": DatasetAdapter.from_dataset(run1.to_xarray()), + }, + metadata={"descriptors": [{"hints": hints}]}, + ), + }, + metadata={ + "plan_name": "xafs_scan", + "start": { + "plan_name": "xafs_scan", + "uid": "7d1daf1d-60c7-4aa7-a668-d1cd97e5335f", + "hints": {"dimensions": [[["energy_energy"], "primary"]]}, + }, + }, + ), + "9d33bf66-9701-4ee3-90f4-3be730bc226c": MapAdapter( + { + "primary": MapAdapter( + { + "data": DatasetAdapter.from_dataset(run1.to_xarray()), + }, + metadata={"descriptors": [{"hints": hints}]}, + ), + }, + metadata={ + "start": { + "plan_name": "rel_scan", + "uid": "9d33bf66-9701-4ee3-90f4-3be730bc226c", + "hints": {"dimensions": [[["pitch2"], "primary"]]}, + } + }, + ), +} + + +mapping = { + "255id_testing": MapAdapter(bluesky_mapping), +} + +tree = MapAdapter(mapping) + + +@pytest.fixture(scope="module") +def client(): + app = build_app(tree) + with Context.from_app(app) as context: + client = from_context(context) + yield client["255id_testing"] + + +def test_client_fixture(client): + """Does the client fixture load without stalling the test runner?""" + pass @pytest.fixture() -def display(client, qtbot, ffapp): +def display(ffapp, client, qtbot): display = RunBrowserDisplay(root_node=client) wait_for_runs_model(display, qtbot) - yield display - display._thread.quit() + try: + yield display + finally: + display._thread.quit() + display._thread.wait(msecs=5000) -def test_run_viewer_action(ffapp, monkeypatch, sim_tiled): +def test_run_viewer_action(ffapp, monkeypatch): monkeypatch.setattr(ffapp, "create_window", MagicMock()) assert hasattr(ffapp, "show_run_browser_action") ffapp.show_run_browser_action.trigger() @@ -88,7 +151,6 @@ def test_metadata(qtbot, display): assert "xafs_scan" in text -@pytest.mark.skip(reason=httpx_reason) def test_1d_plot_signals(client, display): # Check that the 1D plot was created plot_widget = display.ui.plot_1d_view @@ -130,7 +192,6 @@ def test_1d_plot_signal_memory(client, display): assert cb.currentText() == "energy_id_energy_readback" -@pytest.mark.skip(reason=httpx_reason) def test_1d_hinted_signals(client, display): display.ui.plot_1d_hints_checkbox.setChecked(True) # Check that the 1D plot was created @@ -151,7 +212,7 @@ def test_1d_hinted_signals(client, display): ), f"unhinted signal found in {combobox.objectName()}." -@pytest.mark.skip(reason=httpx_reason) +@pytest.mark.skip(reason="Need to figure out why tiled fails with this test.") def test_update_1d_plot(client, display, qtbot): run = client.values()[0] run_data = run["primary"]["data"].read() @@ -184,9 +245,7 @@ def test_update_1d_plot(client, display, qtbot): np.testing.assert_almost_equal(ydata, expected_ydata) -@pytest.mark.skip(reason=httpx_reason) def test_update_multi_plot(client, display, qtbot): - print("Current text", display.ui.multi_signal_x_combobox.currentText()) run = client.values()[0] run_data = run["primary"]["data"].read() expected_xdata = run_data.energy_energy @@ -208,7 +267,6 @@ def test_update_multi_plot(client, display, qtbot): # np.testing.assert_almost_equal(ydata, expected_ydata) -@pytest.mark.skip(reason=httpx_reason) def test_filter_controls(client, display, qtbot): # Does editing text change the filters? display.ui.filter_user_combobox.setCurrentText("") @@ -242,7 +300,6 @@ def test_filter_controls(client, display, qtbot): } -@pytest.mark.skip(reason=httpx_reason) def test_filter_runs(client, qtbot): worker = DatabaseWorker(root_node=client) worker._filters["plan"] = "xafs_scan" @@ -253,7 +310,6 @@ def test_filter_runs(client, qtbot): assert len(runs) == 1 -@pytest.mark.skip(reason=httpx_reason) def test_distinct_fields(client, qtbot, display): worker = DatabaseWorker(root_node=client) with qtbot.waitSignal(worker.distinct_fields_changed) as blocker: diff --git a/src/firefly/tests/test_tiled_server.py b/src/firefly/tests/test_tiled_server.py new file mode 100644 index 00000000..12a0d8cb --- /dev/null +++ b/src/firefly/tests/test_tiled_server.py @@ -0,0 +1,38 @@ +"""Tests to check that the simulated tiled client works properly.""" + +import pytest + +import numpy +from tiled.adapters.mapping import MapAdapter +from tiled.adapters.array import ArrayAdapter +from tiled.adapters.xarray import DatasetAdapter +from tiled.server.app import build_app +from tiled.client import Context, from_context + + +simple_tree = MapAdapter( + { + "a": ArrayAdapter.from_array( + numpy.arange(10), metadata={"apple": "red", "animal": "dog"} + ), + "b": ArrayAdapter.from_array( + numpy.arange(10), metadata={"banana": "yellow", "animal": "dog"} + ), + "c": ArrayAdapter.from_array( + numpy.arange(10), metadata={"cantalope": "orange", "animal": "cat"} + ), + } +) + + +@pytest.fixture(scope="module") +def client(): + app = build_app(simple_tree) + with Context.from_app(app) as context: + client = from_context(context) + yield client + + +def test_client_fixture(client): + """Does the client fixture load without stalling the test runner?""" + pass diff --git a/tests/test_xafs_scan.py b/src/firefly/tests/test_xafs_scan.py similarity index 87% rename from tests/test_xafs_scan.py rename to src/firefly/tests/test_xafs_scan.py index 3d8ea423..8ea85762 100644 --- a/tests/test_xafs_scan.py +++ b/src/firefly/tests/test_xafs_scan.py @@ -1,13 +1,10 @@ import pytest -from firefly.main_window import FireflyMainWindow from firefly.xafs_scan import XafsScanDisplay def test_region_number(qtbot): """Does changing the region number affect the UI?""" - window = FireflyMainWindow() - qtbot.addWidget(window) disp = XafsScanDisplay() qtbot.addWidget(disp) # Check that the display has the right number of rows to start with @@ -19,8 +16,6 @@ def test_region_number(qtbot): def test_region(qtbot): """Does changing the region ui respond the way it should.""" - window = FireflyMainWindow() - qtbot.addWidget(window) disp = XafsScanDisplay() qtbot.addWidget(disp) # Does the k-space checkbox enable the k-weight edit line @@ -31,8 +26,6 @@ def test_region(qtbot): def test_E0_checkbox(qtbot): """Does selecting the E0 checkbox adjust the UI properly?""" - window = FireflyMainWindow() - qtbot.addWidget(window) disp = XafsScanDisplay() qtbot.addWidget(disp) # K-space checkboxes should be disabled when E0 is unchecked diff --git a/tests/test_xrf_detector_display.py b/src/firefly/tests/test_xrf_detector_display.py similarity index 99% rename from tests/test_xrf_detector_display.py rename to src/firefly/tests/test_xrf_detector_display.py index 01d6e809..2f41f2b6 100644 --- a/tests/test_xrf_detector_display.py +++ b/src/firefly/tests/test_xrf_detector_display.py @@ -6,7 +6,6 @@ import pytest from qtpy import QtCore -from firefly.main_window import FireflyMainWindow from firefly.xrf_detector import XRFDetectorDisplay, XRFPlotWidget from firefly.xrf_roi import XRFROIDisplay @@ -20,7 +19,6 @@ def xrf_display(ffapp, request): # Figure out which detector we're using det = request.getfixturevalue(request.param) # Create the display - FireflyMainWindow() display = XRFDetectorDisplay(macros={"DEV": det.name}) # Set sensible starting values spectra = np.random.default_rng(seed=0).integers( @@ -66,7 +64,6 @@ def test_roi_element_comboboxes(ffapp, qtbot, xrf_display): @pytest.mark.parametrize("det_fixture", ["dxp", "xspress"]) def test_roi_selection(ffapp, qtbot, det_fixture, request): det = request.getfixturevalue(det_fixture) - FireflyMainWindow() display = XRFROIDisplay(macros={"DEV": det.name, "NUM": 2, "MCA": 2, "ROI": 2}) # Unchecked box should be bland assert "background" not in display.styleSheet() diff --git a/src/haven/__init__.py b/src/haven/__init__.py index 723e0f8b..7ea95c2d 100644 --- a/src/haven/__init__.py +++ b/src/haven/__init__.py @@ -2,11 +2,6 @@ __version__ = "0.1.0" -# Allow for nested asyncio event loops -import nest_asyncio - -nest_asyncio.apply() - # Top-level imports from .catalog import load_catalog, load_result, load_data, tiled_client # noqa: F401 from .energy_ranges import ERange, KRange, merge_ranges # noqa: F401 diff --git a/tests/iconfig_testing.toml b/src/haven/iconfig_testing.toml similarity index 90% rename from tests/iconfig_testing.toml rename to src/haven/iconfig_testing.toml index e1552f84..513ddbfa 100644 --- a/tests/iconfig_testing.toml +++ b/src/haven/iconfig_testing.toml @@ -11,9 +11,10 @@ name = "s25id-gige-A" description = "GigE Vision A" prefix = "255idgigeA" -[stage.Aerotech] +[aerotech_stage.aerotech] -prefix = "vme_crate_ioc" +prefix = "255idc" +delay_prefix = "255idc:DG645" pv_vert = ":m1" pv_horiz = ":m2" diff --git a/src/haven/instrument/aerotech.py b/src/haven/instrument/aerotech.py new file mode 100644 index 00000000..47dc4de3 --- /dev/null +++ b/src/haven/instrument/aerotech.py @@ -0,0 +1,613 @@ +import threading +import time +import logging +import asyncio +import math +from typing import Generator, Dict +from datetime import datetime, timedelta +from collections import OrderedDict + +from ophyd import ( + Device, + FormattedComponent as FCpt, + EpicsMotor, + Component as Cpt, + Signal, + SignalRO, + Kind, + EpicsSignal, + flyers, +) +from ophyd.status import SubscriptionStatus, AndStatus, StatusBase +from apstools.synApps.asyn import AsynRecord +import pint +import numpy as np + +from .delay import DG645Delay +from .stage import XYStage +from .instrument_registry import registry +from .._iconfig import load_config +from ..exceptions import InvalidScanParameters +from .device import await_for_connection, aload_devices, make_device + + +log = logging.getLogger(__name__) + +ureg = pint.UnitRegistry() + + +class AerotechFlyer(EpicsMotor, flyers.FlyerInterface): + """Allow an Aerotech stage to fly-scan via the Ophyd FlyerInterface. + + Set *start_position*, *stop_position*, and *step_size* in units of + the motor record (.EGU), and *dwell_time* in seconds. Then the + remaining components will be calculated accordingly. + + All position or distance components are assumed to be in motor + record engineering units, unless preceded with "encoder_", in + which case they are in units of encoder pulses based on the + encoder resolution. + + The following diagram describes how the various components relate + to each other. Distances are not to scale:: + + ┌─ encoder_window_start ┌─ encoder_window_stop + │ │ + │ |┄┄┄| *step_size* │ + │ │ │ encoder_step_size │ + │ │ │ │ + Window: ├┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┤ + + Pulses: ┄┄┄┄┄╨───╨───╨───╨───╨───╨───╨───╨┄┄┄┄┄ + │ │ │ │ │ └─ taxi_end + │ │ └─ *start_position* │ └─ pso_end + │ └─ pso_start └─ *end position* + └─ taxi_start + + Parameters + ========== + axis + The label used by the aerotech controller to refer to this + axis. Examples include "@0" or "X". + encoder + The number of the encoder to track when fly-scanning with this + device. + + Components + ========== + start_position + User-requested center of the first scan pixel. + stop_position + User-requested center of the last scan pixel. This is not + guaranteed and may be adjusted to match the encoder resolution + of the stage. + step_size + How much space desired between points. This is not guaranteed + and may be adjusted to match the encoder resolution of the + stage. + dwell_time + How long to take, in seconds, moving between points. + slew_speed + How fast to move the stage. Calculated from the remaining + components. + taxi_start + The starting point for motor movement during flying, accounts + for needed acceleration of the motor. + taxi_end + The target point for motor movement during flying, accounts + for needed acceleration of the motor. + pso_start + The motor position corresponding to the first PSO pulse. + pso_end + The motor position corresponding to the last PSO pulse. + encoder_step_size + The number of encoder counts for each pixel. + encoder_window_start + The start of the window within which PSO pulses may be emitted, + in encoder counts. Should be slightly wider than the actual PSO + range. + encoder_window_end + The end of the window within which PSO pulses may be emitted, + in encoder counts. Should be slightly wider than the actual PSO + range. + + """ + + axis: str + pixel_positions: np.ndarray = None + # Internal encoder in the Ensemble to track for flying + encoder: int + encoder_direction: int = 1 + encoder_window_min: int = -8388607 + encoder_window_max: int = 8388607 + + # Extra motor record components + encoder_resolution = Cpt(EpicsSignal, ".ERES", kind=Kind.config) + + # Desired fly parameters + start_position = Cpt(Signal, name="start_position", kind=Kind.config) + end_position = Cpt(Signal, name="end_position", kind=Kind.config) + step_size = Cpt(Signal, name="step_size", value=1, kind=Kind.config) + dwell_time = Cpt(Signal, name="dwell_time", value=1, kind=Kind.config) + + # Calculated signals + slew_speed = Cpt(Signal, value=1, kind=Kind.config) + taxi_start = Cpt(Signal, kind=Kind.config) + taxi_end = Cpt(Signal, kind=Kind.config) + pso_start = Cpt(Signal, kind=Kind.config) + pso_end = Cpt(Signal, kind=Kind.config) + encoder_step_size = Cpt(Signal, kind=Kind.config) + encoder_window_start = Cpt(Signal, kind=Kind.config) + encoder_window_end = Cpt(Signal, kind=Kind.config) + encoder_use_window = Cpt(Signal, value=False, kind=Kind.config) + + # Status signals + flying_complete = Cpt(Signal, kind=Kind.omitted) + ready_to_fly = Cpt(Signal, kind=Kind.omitted) + + def __init__(self, *args, axis: str, encoder: int, **kwargs): + super().__init__(*args, **kwargs) + self.axis = axis + self.encoder = encoder + # Set up auto-calculations for the flyer + self.motor_egu.subscribe(self._update_fly_params) + self.start_position.subscribe(self._update_fly_params) + self.end_position.subscribe(self._update_fly_params) + self.step_size.subscribe(self._update_fly_params) + self.dwell_time.subscribe(self._update_fly_params) + self.encoder_resolution.subscribe(self._update_fly_params) + self.acceleration.subscribe(self._update_fly_params) + + def kickoff(self): + """Start a flyer + + The status object return is marked as done once flying + has started. + + Returns + ------- + kickoff_status : StatusBase + Indicate when flying has started. + + """ + + def flight_check(*args, old_value, value, **kwargs) -> bool: + return not bool(old_value) and bool(value) + + # Status object is complete when flying has started + self.ready_to_fly.set(False).wait() + status = SubscriptionStatus(self.ready_to_fly, flight_check) + # Taxi the motor + th = threading.Thread(target=self.taxi) + th.start() + # Record time of fly start of scan + self.starttime = time.time() + self._taxi_thread = th # Prevents garbage collection + return status + + def complete(self): + """Wait for flying to be complete. + + This can either be a question ("are you done yet") or a + command ("please wrap up") to accommodate flyers that have a + fixed trajectory (ex. high-speed raster scans) or that are + passive collectors (ex MAIA or a hardware buffer). + + In either case, the returned status object should indicate when + the device is actually finished flying. + + Returns + ------- + complete_status : StatusBase + Indicate when flying has completed + """ + + # Prepare a callback to check when the motor has stopped moving + def check_flying(*args, old_value, value, **kwargs) -> bool: + "Check if flying is complete." + return bool(value) + + # Status object is complete when flying has started + self.flying_complete.set(False).wait() + status = SubscriptionStatus(self.flying_complete, check_flying) + # Iniate the fly scan + th = threading.Thread(target=self.fly) + th.start() + self._fly_thread = th # Prevents garbage collection + return status + + def collect(self) -> Generator[Dict, None, None]: + """Retrieve data from the flyer as proto-events + Yields + ------ + event_data : dict + Must have the keys {'time', 'timestamps', 'data'}. + + """ + # np array of pixel location + pixels = self.pixel_positions + # time of scans start taken at Kickoff + starttime = self.starttime + # time of scans at movement stop + endtime = self.endtime + # grab necessary for calculation + accel_time = self.acceleration.get() + dwell_time = self.dwell_time.get() + step_size = self.step_size.get() + slew_speed = step_size / dwell_time + motor_accel = slew_speed / accel_time + # Calculate the time it takes for taxi to reach first pixel + extrataxi = ((0.5 * ((slew_speed**2) / (2 * motor_accel))) / slew_speed) + ( + dwell_time / 2 + ) + taxi_time = accel_time + extrataxi + # Create np array of times for each pixel in seconds since epoch + startpixeltime = starttime + taxi_time + endpixeltime = endtime - taxi_time + scan_time = endpixeltime - startpixeltime + timestamps1 = np.linspace( + startpixeltime, startpixeltime + scan_time, num=len(pixels) + ) + timestamps = [round(ts, 8) for ts in timestamps1] + for value, ts in zip(pixels, timestamps): + yield { + "data": {self.name: value, self.user_setpoint.name: value}, + "timestamps": {self.name: ts, self.user_setpoint.name: ts}, + "time": ts, + } + + def describe_collect(self): + """Describe details for the collect() method""" + desc = OrderedDict() + desc.update(self.describe()) + return {"positions": desc} + + def fly(self): + # Start the trajectory + destination = self.taxi_end.get() + log.debug(f"Flying to {destination}.") + flight_status = self.move(destination, wait=True) + # Wait for the landing + self.disable_pso() + self.flying_complete.set(True).wait() + # Record end time of flight + self.endtime = time.time() + + def taxi(self): + # import pdb; pdb.set_trace() + self.disable_pso() + # Initalize the PSO + # Move motor to the scan start point + self.move(self.pso_start.get(), wait=True) + # Arm the PSO + self.enable_pso() + self.arm_pso() + # Move the motor to the taxi position + taxi_start = self.taxi_start.get() + log.debug(f"Taxiing to {taxi_start}.") + self.move(taxi_start, wait=True) + # Set the speed on the motor + self.velocity.set(self.slew_speed.get()).wait() + # Set timing on the delay for triggering detectors, etc + self.parent.delay.channel_C.delay.put(0) + self.parent.delay.output_CD.polarity.put(self.parent.delay.polarities.NEGATIVE) + # Count-down timer + # for i in range(10, 0, -1): + # print(f"{i}...", end="", flush=True) + # time.sleep(1) + # print("Go!") + self.ready_to_fly.set(True) + + def stage(self, *args, **kwargs): + self.ready_to_fly.set(False).wait() + self.flying_complete.set(False).wait() + self.starttime = None + self.endtime = None + # Save old veolcity to be restored after flying + self.old_velocity = self.velocity.get() + super().stage(*args, **kwargs) + + def unstage(self, *args, **kwargs): + self.velocity.set(self.old_velocity).wait() + return super().unstage(*args, **kwargs) + + def move(self, position, wait=True, *args, **kwargs): + motor_status = super().move(position, wait=wait, *args, **kwargs) + + def check_readback(*args, old_value, value, **kwargs) -> bool: + "Check if taxiing is complete and flying has begun." + has_arrived = np.isclose(value, position, atol=0.001) + log.debug( + f"Checking readback: {value=}, target: {position}, {has_arrived=}" + ) + return has_arrived + + # Status object is complete motor reaches target value + readback_status = SubscriptionStatus(self.user_readback, check_readback) + # Prepare the combined status object + status = motor_status & readback_status + if wait: + status.wait() + return readback_status + + @property + def motor_egu_pint(self): + egu = ureg(self.motor_egu.get()) + return egu + + def _update_fly_params(self, *args, **kwargs): + """Calculate new fly-scan parameters based on signal values. + + Implementation courtesy of Alan Kastengren. + + Computes several parameters describing the fly scan motion. + These include the actual start position of the motor, the + actual distance (in encoder counts and distance) between PSO + pulses, the end position of the motor, and where PSO pulses + are expected to occcur. This code ensures that each PSO delta + is an integer number of encoder counts, since this is how the + PSO triggering works in hardware. + + These calculations are for MCS scans, where for N bins we need + N+1 pulses. + + Several fields are set in the class: + + direction + 1 if we are moving positive in user coordinates, −1 if + negative + overall_sense + is our fly motion + or - with respect to encoder counts + taxi_start + The starting point for motor movement during flying, accounts + for needed acceleration of the motor. + taxi_end + The target point for motor movement during flying, accounts + for needed acceleration of the motor. + pso_start + The motor position corresponding to the first PSO pulse. + pso_end + The motor position corresponding to the last PSO pulse. + pso_zero + The motor position where the PSO counter is set to zero. + encoder_step_size + The number of encoder counts for each pixel. + encoder_window_start + The start of the window within which PSO pulses may be emitted, + in encoder counts. Should be slightly wider than the actual PSO + range. + encoder_window_end + The end of the window within which PSO pulses may be emitted, + in encoder counts. Should be slightly wider than the actual PSO + pixel_positions + array of places where pixels are, should occur calculated from + encoder counts then translated to motor positions + """ + window_buffer = 5 + # Grab any neccessary signals for calculation + egu = self.motor_egu.get() + start_position = self.start_position.get() + end_position = self.end_position.get() + dwell_time = self.dwell_time.get() + step_size = self.step_size.get() + encoder_resolution = self.encoder_resolution.get() + accel_time = self.acceleration.get() + # Check for sane values + if dwell_time == 0: + log.warning( + f"{self} dwell_time is zero. Could not update fly scan parameters." + ) + return + if encoder_resolution == 0: + log.warning( + f"{self} encoder resolution is zero. Could not update fly scan parameters." + ) + return + if accel_time <= 0: + log.warning( + f"{self} acceleration is non-positive. Could not update fly scan parameters." + ) + return + # Determine the desired direction of travel and overal sense + # +1 when moving in + encoder direction, -1 if else + direction = 1 if start_position < end_position else -1 + overall_sense = direction * self.encoder_direction + # Calculate the step size in encoder steps + encoder_step_size = int(step_size / encoder_resolution) + # PSO start/end should be located to where req. start/end are + # in between steps. Also doubles as the location where slew + # speed must be met. + pso_start = start_position - (direction * (step_size / 2)) + pso_end = end_position + (direction * (step_size / 2)) + # Determine taxi distance to accelerate to req speed, v^2/(2*a) = d + # x1.5 for safety margin + slew_speed = step_size / dwell_time + motor_accel = slew_speed / accel_time + taxi_dist = slew_speed**2 / (2 * motor_accel) * 1.5 + taxi_start = pso_start - (direction * taxi_dist) + taxi_end = pso_end + (direction * taxi_dist) + # Actually taxi to the first PSO position before the taxi position + encoder_taxi_start = (taxi_start - pso_start) / encoder_resolution + if overall_sense > 0: + rounder = math.floor + else: + rounder = math.ceil + encoder_taxi_start = ( + rounder(encoder_taxi_start / encoder_step_size) * encoder_step_size + ) + taxi_start = pso_start + encoder_taxi_start * encoder_resolution + # Calculate encoder counts within the requested window of the scan + encoder_window_start = 0 # round(pso_start / encoder_resolution) + encoder_distance = (pso_end - pso_start) / encoder_resolution + encoder_window_end = round(encoder_window_start + encoder_distance) + # Widen the bound a little to make sure we capture the pulse + encoder_window_start -= overall_sense * window_buffer + encoder_window_end += overall_sense * window_buffer + + # Check for values outside of the window range for this controller + def is_valid_window(value): + return self.encoder_window_min < value < self.encoder_window_max + + window_range = [encoder_window_start, encoder_window_end] + encoder_use_window = all([is_valid_window(v) for v in window_range]) + # Create np array of PSO positions in encoder counts + _pso_step = encoder_step_size * overall_sense + _pso_end = encoder_distance + 0.5 * _pso_step + encoder_pso_positions = np.arange(0, _pso_end, _pso_step) + # Transform from PSO positions from encoder counts to engineering units + pso_positions = (encoder_pso_positions * encoder_resolution) + pso_start + # Tranforms from pulse positions to pixel centers + pixel_positions = (pso_positions[1:] + pso_positions[:-1]) / 2 + # Set all the calculated variables + [ + stat.wait() + for stat in [ + self.encoder_step_size.set(encoder_step_size), + self.pso_start.set(pso_start), + self.pso_end.set(pso_end), + self.slew_speed.set(slew_speed), + self.taxi_start.set(taxi_start), + self.taxi_end.set(taxi_end), + self.encoder_window_start.set(encoder_window_start), + self.encoder_window_end.set(encoder_window_end), + self.encoder_use_window.set(encoder_use_window), + ] + ] + self.encoder_pso_positions = encoder_pso_positions + self.pso_positions = pso_positions + self.pixel_positions = pixel_positions + + def send_command(self, cmd: str): + """Send commands directly to the aerotech ensemble controller. + + Returns + ======= + status + The Ophyd status object for this write. + + """ + status = self.parent.asyn.ascii_output.set(cmd, settle_time=0.1) + status.wait() + return status + + def disable_pso(self): + self.send_command(f"PSOCONTROL {self.axis} OFF") + + def check_flyscan_bounds(self): + """Check that the fly-scan params are sane at the scan start and end. + + This checks to make sure no spurious pulses are expected from taxiing. + + """ + end_points = [(self.taxi_start, self.pso_start), (self.taxi_end, self.pso_end)] + step_size = self.step_size.get() + for taxi, pso in end_points: + # Make sure we're not going to have extra pulses + taxi_distance = abs(taxi.get() - pso.get()) + if taxi_distance > (1.1 * step_size): + raise InvalidScanParameters( + f"Scan parameters for {taxi}, {pso}, {self.step_size} would produce extra pulses without a window." + ) + + def enable_pso(self): + num_axis = 1 + use_window = self.encoder_use_window.get() + if not use_window: + self.check_flyscan_bounds() + # Erase any previous PSO control + self.send_command(f"PSOCONTROL {self.axis} RESET") + # Set the output to occur from the I/O terminal on the + # controller + self.send_command(f"PSOOUTPUT {self.axis} CONTROL {num_axis}") + # Set a pulse 10 us long, 20 us total duration, so 10 us + # on, 10 us off + self.send_command(f"PSOPULSE {self.axis} TIME 20, 10") + # Set the pulses to only occur in a specific window + if use_window: + self.send_command(f"PSOOUTPUT {self.axis} PULSE WINDOW MASK") + else: + self.send_command(f"PSOOUTPUT {self.axis} PULSE") + # Set which encoder we will use. 3 = the MXH (encoder + # multiplier) input. For Ensemble lab, 6 is horizontal encoder + self.send_command(f"PSOTRACK {self.axis} INPUT {self.encoder}") + # Set the distance between pulses in encoder counts + self.send_command( + f"PSODISTANCE {self.axis} FIXED {self.encoder_step_size.get()}" + ) + # Which encoder is being used to calculate whether we are + # in the window. + if use_window: + self.send_command(f"PSOWINDOW {self.axis} {num_axis} INPUT {self.encoder}") + # Calculate window function parameters. Must be in encoder + # counts, and is referenced from the stage location where + # we arm the PSO + window_range = ( + self.encoder_window_start.get(), + self.encoder_window_end.get(), + ) + self.send_command( + f"PSOWINDOW {self.axis} {num_axis} RANGE " + f"{min(window_range)},{max(window_range)}" + ) + + def arm_pso(self): + self.send_command(f"PSOCONTROL {self.axis} ARM") + + +class AerotechStage(XYStage): + """An XY stage for an Aerotech stage with fly-scanning capabilities. + + Parameters + ========== + + pv_vert + The suffix to the PV for the vertical motor. + pv_horiz + The suffix to the PV for the horizontal motor. + """ + + horiz = FCpt( + AerotechFlyer, + "{prefix}{pv_horiz}", + axis="@0", + encoder=6, + labels={"motors", "flyers"}, + ) + vert = FCpt( + AerotechFlyer, + "{prefix}{pv_vert}", + axis="@1", + encoder=7, + labels={"motors", "flyers"}, + ) + asyn = Cpt(AsynRecord, ":asynEns", name="async", labels={"asyns"}) + # A digital delay generator for providing a gate signal + delay = FCpt(DG645Delay, "{delay_prefix}:", kind=Kind.config) + + def __init__(self, *args, delay_prefix, **kwargs): + self.delay_prefix = delay_prefix + super().__init__(*args, **kwargs) + + +def load_aerotech_stage_coros(config=None): + """Provide co-routines for loading Aerotech stages defined in the + configuration files. + + """ + if config is None: + config = load_config() + for name, stage_data in config.get("aerotech_stage", {}).items(): + yield make_device( + AerotechStage, + name=name, + prefix=stage_data["prefix"], + delay_prefix=stage_data["delay_prefix"], + pv_vert=stage_data["pv_vert"], + pv_horiz=stage_data["pv_horiz"], + ) + + +def load_aerotech_stages(config=None): + """Load the XY stages defined in the config ``[stage]`` section.""" + asyncio.run(aload_devices(*load_aerotech_stage_coros(config=config))) + diff --git a/src/haven/instrument/dxp.py b/src/haven/instrument/dxp.py index 063f8ccb..a9c89502 100644 --- a/src/haven/instrument/dxp.py +++ b/src/haven/instrument/dxp.py @@ -439,7 +439,7 @@ async def make_dxp_device(device_name, prefix, num_elements): Cls, prefix=f"{prefix}:", name=device_name, - labels={"xrf_detectors", "fluorescence_detectors"}, + labels={"xrf_detectors", "fluorescence_detectors", "detectors"}, ) diff --git a/src/haven/instrument/ion_chamber.py b/src/haven/instrument/ion_chamber.py index 7a63bcfa..195694aa 100644 --- a/src/haven/instrument/ion_chamber.py +++ b/src/haven/instrument/ion_chamber.py @@ -77,7 +77,7 @@ def preamp(self): # Move up a step in the hierarchy device = device.parent # If we get here, there's no pre-amp - raise Attribute(f"No ancestor of {self} has a pre-amp.") + raise AttributeError(f"No ancestor of {self} has a pre-amp.") def inverse(self, value): """Calculate the current given a output voltage.""" @@ -610,7 +610,7 @@ async def load_ion_chamber( try: ion_chamber.voltmeter.differential.set(1).wait(timeout=1) except OpException as exc: - msg = f"Could not set voltmeter {self.name} channel differential state: {exc}" + msg = f"Could not set voltmeter {ion_chamber.name} channel differential state: {exc}" log.warning(msg) warnings.warn(msg) return ion_chamber diff --git a/src/haven/instrument/lerix.py b/src/haven/instrument/lerix.py index c041b813..f9a8d52c 100644 --- a/src/haven/instrument/lerix.py +++ b/src/haven/instrument/lerix.py @@ -7,7 +7,7 @@ from .._iconfig import load_config from .instrument_registry import registry -from .device import await_for_connection, aload_devices +from .device import await_for_connection, aload_devices, make_device log = logging.getLogger(__name__) @@ -74,6 +74,7 @@ def forward(self, pseudo_pos): z1 = D * np.sin(theta - alpha) * np.cos(theta + alpha) z2 = D * np.sin(theta - alpha) * np.cos(theta - alpha) z = z1 + z2 + print(x, y, z1, z) return self.RealPosition( x=x, y=y, @@ -162,24 +163,24 @@ class LERIXSpectrometer(Device): ) -async def make_lerix_device(name: str, x_pv: str, y_pv: str, z_pv: str, z1_pv: str): - dev = RowlandPositioner( - name=name, - x_motor_pv=x_pv, - y_motor_pv=y_pv, - z_motor_pv=z_pv, - z1_motor_pv=z1_pv, - labels={"lerix_spectrometers"}, - ) - pvs = ", ".join((x_pv, y_pv, z_pv, z1_pv)) - try: - await await_for_connection(dev) - except TimeoutError as exc: - log.warning(f"Could not connect to LERIX spectrometer: {name} ({pvs})") - else: - log.info(f"Created area detector: {name} ({pvs})") - registry.register(dev) - return dev +# async def make_lerix_device(name: str, x_pv: str, y_pv: str, z_pv: str, z1_pv: str): +# dev = RowlandPositioner( +# name=name, +# x_motor_pv=x_pv, +# y_motor_pv=y_pv, +# z_motor_pv=z_pv, +# z1_motor_pv=z1_pv, +# labels={"lerix_spectrometers"}, +# ) +# pvs = ", ".join((x_pv, y_pv, z_pv, z1_pv)) +# try: +# await await_for_connection(dev) +# except TimeoutError as exc: +# log.warning(f"Could not connect to LERIX spectrometer: {name} ({pvs})") +# else: +# log.info(f"Created area detector: {name} ({pvs})") +# registry.register(dev) +# return dev def load_lerix_spectrometer_coros(config=None): @@ -192,12 +193,14 @@ def load_lerix_spectrometer_coros(config=None): # Create spectrometers for name, cfg in config.get("lerix", {}).items(): rowland = cfg["rowland"] - yield make_lerix_device( + yield make_device( + RowlandPositioner, name=name, - x_pv=rowland["x_motor_pv"], - y_pv=rowland["y_motor_pv"], - z_pv=rowland["z_motor_pv"], - z1_pv=rowland["z1_motor_pv"], + x_motor_pv=rowland["x_motor_pv"], + y_motor_pv=rowland["y_motor_pv"], + z_motor_pv=rowland["z_motor_pv"], + z1_motor_pv=rowland["z1_motor_pv"], + labels={"lerix_spectromoters"}, ) diff --git a/src/haven/instrument/motor.py b/src/haven/instrument/motor.py index 06fcae5d..5cf40186 100644 --- a/src/haven/instrument/motor.py +++ b/src/haven/instrument/motor.py @@ -91,20 +91,21 @@ async def load_motor(prefix: str, motor_num: int, ioc_name: str = None): pv = f"{prefix}:m{motor_num+1}" # Get motor names config = load_config() - if not config["beamline"]["is_connected"]: - # Just use a generic name - name = f"{prefix}_m{motor_num+1}" - else: - # Get the motor name from the description PV - try: - name = await caget(f"{pv}.DESC") - except asyncio.exceptions.TimeoutError: + # Get the motor name from the description PV + try: + name = await caget(f"{pv}.DESC") + print(name) + except asyncio.exceptions.TimeoutError: + if not config["beamline"]["is_connected"]: + # Beamline is not connected, so just use a generic name + name = f"{prefix}_m{motor_num+1}" + else: # Motor is unreachable, so skip it log.warning(f"Could not connect to motor: {pv}") return - else: - log.debug(f"Resolved motor {pv} to '{name}'") - + else: + log.debug(f"Resolved motor {pv} to '{name}'") + # Create the motor device if name == f"motor {motor_num+1}": # It's an unnamed motor, so skip it diff --git a/src/haven/instrument/stage.py b/src/haven/instrument/stage.py index 1734513d..8e882fb6 100644 --- a/src/haven/instrument/stage.py +++ b/src/haven/instrument/stage.py @@ -30,14 +30,12 @@ from .device import await_for_connection, aload_devices, make_device -__all__ = ["XYStage", "AerotechFlyer", "AerotechStage", "load_stages"] +__all__ = ["XYStage", "load_stages"] log = logging.getLogger(__name__) -ureg = pint.UnitRegistry() - @registry.register class XYStage(Device): @@ -72,559 +70,6 @@ def __init__( super().__init__(prefix, labels=labels, *args, **kwargs) -class AerotechFlyer(EpicsMotor, flyers.FlyerInterface): - """Allow an Aerotech stage to fly-scan via the Ophyd FlyerInterface. - - Set *start_position*, *stop_position*, and *step_size* in units of - the motor record (.EGU), and *dwell_time* in seconds. Then the - remaining components will be calculated accordingly. - - All position or distance components are assumed to be in motor - record engineering units, unless preceded with "encoder_", in - which case they are in units of encoder pulses based on the - encoder resolution. - - The following diagram describes how the various components relate - to each other. Distances are not to scale:: - - ┌─ encoder_window_start ┌─ encoder_window_stop - │ │ - │ |┄┄┄| *step_size* │ - │ │ │ encoder_step_size │ - │ │ │ │ - Window: ├┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┤ - - Pulses: ┄┄┄┄┄╨───╨───╨───╨───╨───╨───╨───╨┄┄┄┄┄ - │ │ │ │ │ └─ taxi_end - │ │ └─ *start_position* │ └─ pso_end - │ └─ pso_start └─ *end position* - └─ taxi_start - - Parameters - ========== - axis - The label used by the aerotech controller to refer to this - axis. Examples include "@0" or "X". - encoder - The number of the encoder to track when fly-scanning with this - device. - - Components - ========== - start_position - User-requested center of the first scan pixel. - stop_position - User-requested center of the last scan pixel. This is not - guaranteed and may be adjusted to match the encoder resolution - of the stage. - step_size - How much space desired between points. This is not guaranteed - and may be adjusted to match the encoder resolution of the - stage. - dwell_time - How long to take, in seconds, moving between points. - slew_speed - How fast to move the stage. Calculated from the remaining - components. - taxi_start - The starting point for motor movement during flying, accounts - for needed acceleration of the motor. - taxi_end - The target point for motor movement during flying, accounts - for needed acceleration of the motor. - pso_start - The motor position corresponding to the first PSO pulse. - pso_end - The motor position corresponding to the last PSO pulse. - encoder_step_size - The number of encoder counts for each pixel. - encoder_window_start - The start of the window within which PSO pulses may be emitted, - in encoder counts. Should be slightly wider than the actual PSO - range. - encoder_window_end - The end of the window within which PSO pulses may be emitted, - in encoder counts. Should be slightly wider than the actual PSO - range. - - """ - - axis: str - pixel_positions: np.ndarray = None - # Internal encoder in the Ensemble to track for flying - encoder: int - encoder_direction: int = 1 - encoder_window_min: int = -8388607 - encoder_window_max: int = 8388607 - - # Extra motor record components - encoder_resolution = Cpt(EpicsSignal, ".ERES", kind=Kind.config) - - # Desired fly parameters - start_position = Cpt(Signal, name="start_position", kind=Kind.config) - end_position = Cpt(Signal, name="end_position", kind=Kind.config) - step_size = Cpt(Signal, name="step_size", value=1, kind=Kind.config) - dwell_time = Cpt(Signal, name="dwell_time", value=1, kind=Kind.config) - - # Calculated signals - slew_speed = Cpt(Signal, value=1, kind=Kind.config) - taxi_start = Cpt(Signal, kind=Kind.config) - taxi_end = Cpt(Signal, kind=Kind.config) - pso_start = Cpt(Signal, kind=Kind.config) - pso_end = Cpt(Signal, kind=Kind.config) - encoder_step_size = Cpt(Signal, kind=Kind.config) - encoder_window_start = Cpt(Signal, kind=Kind.config) - encoder_window_end = Cpt(Signal, kind=Kind.config) - encoder_use_window = Cpt(Signal, value=False, kind=Kind.config) - - # Status signals - flying_complete = Cpt(Signal, kind=Kind.omitted) - ready_to_fly = Cpt(Signal, kind=Kind.omitted) - - def __init__(self, *args, axis: str, encoder: int, **kwargs): - super().__init__(*args, **kwargs) - self.axis = axis - self.encoder = encoder - # Set up auto-calculations for the flyer - self.motor_egu.subscribe(self._update_fly_params) - self.start_position.subscribe(self._update_fly_params) - self.end_position.subscribe(self._update_fly_params) - self.step_size.subscribe(self._update_fly_params) - self.dwell_time.subscribe(self._update_fly_params) - self.encoder_resolution.subscribe(self._update_fly_params) - self.acceleration.subscribe(self._update_fly_params) - - def kickoff(self): - """Start a flyer - - The status object return is marked as done once flying - has started. - - Returns - ------- - kickoff_status : StatusBase - Indicate when flying has started. - - """ - - def flight_check(*args, old_value, value, **kwargs) -> bool: - return not bool(old_value) and bool(value) - - # Status object is complete when flying has started - self.ready_to_fly.set(False).wait() - status = SubscriptionStatus(self.ready_to_fly, flight_check) - # Taxi the motor - th = threading.Thread(target=self.taxi) - th.start() - # Record time of fly start of scan - self.starttime = time.time() - self._taxi_thread = th # Prevents garbage collection - return status - - def complete(self): - """Wait for flying to be complete. - - This can either be a question ("are you done yet") or a - command ("please wrap up") to accommodate flyers that have a - fixed trajectory (ex. high-speed raster scans) or that are - passive collectors (ex MAIA or a hardware buffer). - - In either case, the returned status object should indicate when - the device is actually finished flying. - - Returns - ------- - complete_status : StatusBase - Indicate when flying has completed - """ - - # Prepare a callback to check when the motor has stopped moving - def check_flying(*args, old_value, value, **kwargs) -> bool: - "Check if flying is complete." - return bool(value) - - # Status object is complete when flying has started - self.flying_complete.set(False).wait() - status = SubscriptionStatus(self.flying_complete, check_flying) - # Iniate the fly scan - th = threading.Thread(target=self.fly) - th.start() - self._fly_thread = th # Prevents garbage collection - return status - - def collect(self) -> Generator[Dict, None, None]: - """Retrieve data from the flyer as proto-events - Yields - ------ - event_data : dict - Must have the keys {'time', 'timestamps', 'data'}. - - """ - # np array of pixel location - pixels = self.pixel_positions - # time of scans start taken at Kickoff - starttime = self.starttime - # time of scans at movement stop - endtime = self.endtime - # grab necessary for calculation - accel_time = self.acceleration.get() - dwell_time = self.dwell_time.get() - step_size = self.step_size.get() - slew_speed = step_size / dwell_time - motor_accel = slew_speed / accel_time - # Calculate the time it takes for taxi to reach first pixel - extrataxi = ((0.5 * ((slew_speed**2) / (2 * motor_accel))) / slew_speed) + ( - dwell_time / 2 - ) - taxi_time = accel_time + extrataxi - # Create np array of times for each pixel in seconds since epoch - startpixeltime = starttime + taxi_time - endpixeltime = endtime - taxi_time - scan_time = endpixeltime - startpixeltime - timestamps1 = np.linspace( - startpixeltime, startpixeltime + scan_time, num=len(pixels) - ) - timestamps = [round(ts, 8) for ts in timestamps1] - for value, ts in zip(pixels, timestamps): - yield { - "data": {self.name: value, self.user_setpoint.name: value}, - "timestamps": {self.name: ts, self.user_setpoint.name: ts}, - "time": ts, - } - - def describe_collect(self): - """Describe details for the collect() method""" - desc = OrderedDict() - desc.update(self.describe()) - return {"positions": desc} - - def fly(self): - # Start the trajectory - destination = self.taxi_end.get() - log.debug(f"Flying to {destination}.") - flight_status = self.move(destination, wait=True) - # Wait for the landing - self.disable_pso() - self.flying_complete.set(True).wait() - # Record end time of flight - self.endtime = time.time() - - def taxi(self): - # import pdb; pdb.set_trace() - self.disable_pso() - # Initalize the PSO - # Move motor to the scan start point - self.move(self.pso_start.get(), wait=True) - # Arm the PSO - self.enable_pso() - self.arm_pso() - # Move the motor to the taxi position - taxi_start = self.taxi_start.get() - log.debug(f"Taxiing to {taxi_start}.") - self.move(taxi_start, wait=True) - # Set the speed on the motor - self.velocity.set(self.slew_speed.get()).wait() - # Set timing on the delay for triggering detectors, etc - self.parent.delay.channel_C.delay.put(0) - self.parent.delay.output_CD.polarity.put(self.parent.delay.polarities.NEGATIVE) - # Count-down timer - # for i in range(10, 0, -1): - # print(f"{i}...", end="", flush=True) - # time.sleep(1) - # print("Go!") - self.ready_to_fly.set(True) - - def stage(self, *args, **kwargs): - self.ready_to_fly.set(False).wait() - self.flying_complete.set(False).wait() - self.starttime = None - self.endtime = None - # Save old veolcity to be restored after flying - self.old_velocity = self.velocity.get() - super().stage(*args, **kwargs) - - def unstage(self, *args, **kwargs): - self.velocity.set(self.old_velocity).wait() - return super().unstage(*args, **kwargs) - - def move(self, position, wait=True, *args, **kwargs): - motor_status = super().move(position, wait=wait, *args, **kwargs) - - def check_readback(*args, old_value, value, **kwargs) -> bool: - "Check if taxiing is complete and flying has begun." - has_arrived = np.isclose(value, position, atol=0.001) - log.debug( - f"Checking readback: {value=}, target: {position}, {has_arrived=}" - ) - return has_arrived - - # Status object is complete motor reaches target value - readback_status = SubscriptionStatus(self.user_readback, check_readback) - # Prepare the combined status object - status = motor_status & readback_status - if wait: - status.wait() - return readback_status - - @property - def motor_egu_pint(self): - egu = ureg(self.motor_egu.get()) - return egu - - def _update_fly_params(self, *args, **kwargs): - """Calculate new fly-scan parameters based on signal values. - - Implementation courtesy of Alan Kastengren. - - Computes several parameters describing the fly scan motion. - These include the actual start position of the motor, the - actual distance (in encoder counts and distance) between PSO - pulses, the end position of the motor, and where PSO pulses - are expected to occcur. This code ensures that each PSO delta - is an integer number of encoder counts, since this is how the - PSO triggering works in hardware. - - These calculations are for MCS scans, where for N bins we need - N+1 pulses. - - Several fields are set in the class: - - direction - 1 if we are moving positive in user coordinates, −1 if - negative - overall_sense - is our fly motion + or - with respect to encoder counts - taxi_start - The starting point for motor movement during flying, accounts - for needed acceleration of the motor. - taxi_end - The target point for motor movement during flying, accounts - for needed acceleration of the motor. - pso_start - The motor position corresponding to the first PSO pulse. - pso_end - The motor position corresponding to the last PSO pulse. - pso_zero - The motor position where the PSO counter is set to zero. - encoder_step_size - The number of encoder counts for each pixel. - encoder_window_start - The start of the window within which PSO pulses may be emitted, - in encoder counts. Should be slightly wider than the actual PSO - range. - encoder_window_end - The end of the window within which PSO pulses may be emitted, - in encoder counts. Should be slightly wider than the actual PSO - pixel_positions - array of places where pixels are, should occur calculated from - encoder counts then translated to motor positions - """ - window_buffer = 5 - # Grab any neccessary signals for calculation - egu = self.motor_egu.get() - start_position = self.start_position.get() - end_position = self.end_position.get() - dwell_time = self.dwell_time.get() - step_size = self.step_size.get() - encoder_resolution = self.encoder_resolution.get() - accel_time = self.acceleration.get() - # Check for sane values - if dwell_time == 0: - log.warning( - f"{self} dwell_time is zero. Could not update fly scan parameters." - ) - return - if encoder_resolution == 0: - log.warning( - f"{self} encoder resolution is zero. Could not update fly scan parameters." - ) - return - if accel_time <= 0: - log.warning( - f"{self} acceleration is non-positive. Could not update fly scan parameters." - ) - return - # Determine the desired direction of travel and overal sense - # +1 when moving in + encoder direction, -1 if else - direction = 1 if start_position < end_position else -1 - overall_sense = direction * self.encoder_direction - # Calculate the step size in encoder steps - encoder_step_size = int(step_size / encoder_resolution) - # PSO start/end should be located to where req. start/end are - # in between steps. Also doubles as the location where slew - # speed must be met. - pso_start = start_position - (direction * (step_size / 2)) - pso_end = end_position + (direction * (step_size / 2)) - # Determine taxi distance to accelerate to req speed, v^2/(2*a) = d - # x1.5 for safety margin - slew_speed = step_size / dwell_time - motor_accel = slew_speed / accel_time - taxi_dist = slew_speed**2 / (2 * motor_accel) * 1.5 - taxi_start = pso_start - (direction * taxi_dist) - taxi_end = pso_end + (direction * taxi_dist) - # Actually taxi to the first PSO position before the taxi position - encoder_taxi_start = (taxi_start - pso_start) / encoder_resolution - if overall_sense > 0: - rounder = math.floor - else: - rounder = math.ceil - encoder_taxi_start = ( - rounder(encoder_taxi_start / encoder_step_size) * encoder_step_size - ) - taxi_start = pso_start + encoder_taxi_start * encoder_resolution - # Calculate encoder counts within the requested window of the scan - encoder_window_start = 0 # round(pso_start / encoder_resolution) - encoder_distance = (pso_end - pso_start) / encoder_resolution - encoder_window_end = round(encoder_window_start + encoder_distance) - # Widen the bound a little to make sure we capture the pulse - encoder_window_start -= overall_sense * window_buffer - encoder_window_end += overall_sense * window_buffer - - # Check for values outside of the window range for this controller - def is_valid_window(value): - return self.encoder_window_min < value < self.encoder_window_max - - window_range = [encoder_window_start, encoder_window_end] - encoder_use_window = all([is_valid_window(v) for v in window_range]) - # Create np array of PSO positions in encoder counts - _pso_step = encoder_step_size * overall_sense - _pso_end = encoder_distance + 0.5 * _pso_step - encoder_pso_positions = np.arange(0, _pso_end, _pso_step) - # Transform from PSO positions from encoder counts to engineering units - pso_positions = (encoder_pso_positions * encoder_resolution) + pso_start - # Tranforms from pulse positions to pixel centers - pixel_positions = (pso_positions[1:] + pso_positions[:-1]) / 2 - # Set all the calculated variables - [ - stat.wait() - for stat in [ - self.encoder_step_size.set(encoder_step_size), - self.pso_start.set(pso_start), - self.pso_end.set(pso_end), - self.slew_speed.set(slew_speed), - self.taxi_start.set(taxi_start), - self.taxi_end.set(taxi_end), - self.encoder_window_start.set(encoder_window_start), - self.encoder_window_end.set(encoder_window_end), - self.encoder_use_window.set(encoder_use_window), - ] - ] - self.encoder_pso_positions = encoder_pso_positions - self.pso_positions = pso_positions - self.pixel_positions = pixel_positions - - def send_command(self, cmd: str): - """Send commands directly to the aerotech ensemble controller. - - Returns - ======= - status - The Ophyd status object for this write. - - """ - status = self.parent.asyn.ascii_output.set(cmd, settle_time=0.1) - status.wait() - return status - - def disable_pso(self): - self.send_command(f"PSOCONTROL {self.axis} OFF") - - def check_flyscan_bounds(self): - """Check that the fly-scan params are sane at the scan start and end. - - This checks to make sure no spurious pulses are expected from taxiing. - - """ - end_points = [(self.taxi_start, self.pso_start), (self.taxi_end, self.pso_end)] - step_size = self.step_size.get() - for taxi, pso in end_points: - # Make sure we're not going to have extra pulses - taxi_distance = abs(taxi.get() - pso.get()) - if taxi_distance > (1.1 * step_size): - raise InvalidScanParameters( - f"Scan parameters for {taxi}, {pso}, {self.step_size} would produce extra pulses without a window." - ) - - def enable_pso(self): - num_axis = 1 - use_window = self.encoder_use_window.get() - if not use_window: - self.check_flyscan_bounds() - # Erase any previous PSO control - self.send_command(f"PSOCONTROL {self.axis} RESET") - # Set the output to occur from the I/O terminal on the - # controller - self.send_command(f"PSOOUTPUT {self.axis} CONTROL {num_axis}") - # Set a pulse 10 us long, 20 us total duration, so 10 us - # on, 10 us off - self.send_command(f"PSOPULSE {self.axis} TIME 20, 10") - # Set the pulses to only occur in a specific window - if use_window: - self.send_command(f"PSOOUTPUT {self.axis} PULSE WINDOW MASK") - else: - self.send_command(f"PSOOUTPUT {self.axis} PULSE") - # Set which encoder we will use. 3 = the MXH (encoder - # multiplier) input. For Ensemble lab, 6 is horizontal encoder - self.send_command(f"PSOTRACK {self.axis} INPUT {self.encoder}") - # Set the distance between pulses in encoder counts - self.send_command( - f"PSODISTANCE {self.axis} FIXED {self.encoder_step_size.get()}" - ) - # Which encoder is being used to calculate whether we are - # in the window. - if use_window: - self.send_command(f"PSOWINDOW {self.axis} {num_axis} INPUT {self.encoder}") - # Calculate window function parameters. Must be in encoder - # counts, and is referenced from the stage location where - # we arm the PSO - window_range = ( - self.encoder_window_start.get(), - self.encoder_window_end.get(), - ) - self.send_command( - f"PSOWINDOW {self.axis} {num_axis} RANGE " - f"{min(window_range)},{max(window_range)}" - ) - - def arm_pso(self): - self.send_command(f"PSOCONTROL {self.axis} ARM") - - -class AerotechStage(XYStage): - """An XY stage for an Aerotech stage with fly-scanning capabilities. - - Parameters - ========== - - pv_vert - The suffix to the PV for the vertical motor. - pv_horiz - The suffix to the PV for the horizontal motor. - """ - - horiz = FCpt( - AerotechFlyer, - "{prefix}{pv_horiz}", - axis="@0", - encoder=6, - labels={"motors", "flyers"}, - ) - vert = FCpt( - AerotechFlyer, - "{prefix}{pv_vert}", - axis="@1", - encoder=7, - labels={"motors", "flyers"}, - ) - asyn = Cpt(AsynRecord, ":asynEns", name="async", labels={"asyns"}) - # A digital delay generator for providing a gate signal - delay = FCpt(DG645Delay, "{delay_prefix}:", kind=Kind.config) - - def __init__(self, *args, delay_prefix, **kwargs): - self.delay_prefix = delay_prefix - super().__init__(*args, **kwargs) - - def load_stage_coros(config=None): """Provide co-routines for loading the stages defined in the configuration files. @@ -640,15 +85,6 @@ def load_stage_coros(config=None): pv_vert=stage_data["pv_vert"], pv_horiz=stage_data["pv_horiz"], ) - for name, stage_data in config.get("aerotech_stage", {}).items(): - yield make_device( - AerotechStage, - name=name, - prefix=stage_data["prefix"], - delay_prefix=stage_data["delay_prefix"], - pv_vert=stage_data["pv_vert"], - pv_horiz=stage_data["pv_horiz"], - ) def load_stages(config=None): diff --git a/src/haven/instrument/xspress.py b/src/haven/instrument/xspress.py index 48a8a8b9..3dea0ca2 100644 --- a/src/haven/instrument/xspress.py +++ b/src/haven/instrument/xspress.py @@ -576,7 +576,7 @@ async def make_xspress_device(name, prefix, num_elements): Cls, name=name, prefix=f"{prefix}:", - labels={"xrf_detectors", "fluorescence_detectors"}, + labels={"xrf_detectors", "fluorescence_detectors", "detectors"}, ) diff --git a/tests/test_stages.py b/src/haven/tests/test_aerotech.py similarity index 82% rename from tests/test_stages.py rename to src/haven/tests/test_aerotech.py index b4bede4e..081bb599 100644 --- a/tests/test_stages.py +++ b/src/haven/tests/test_aerotech.py @@ -1,50 +1,30 @@ -import time from unittest import mock from collections import OrderedDict + +import numpy as np import pytest from ophyd import StatusBase -from ophyd.sim import instantiate_fake_device, make_fake_device -import numpy as np -from datetime import datetime -from haven import registry, exceptions -from haven.instrument import stage +from haven.instrument.aerotech import AerotechFlyer, AerotechStage, load_aerotech_stages, ureg +from haven import exceptions -def test_stage_init(): - stage_ = stage.XYStage( - "motor_ioc", pv_vert=":m1", pv_horiz=":m2", labels={"stages"}, name="aerotech" - ) - assert stage_.name == "aerotech" - assert stage_.vert.name == "aerotech_vert" - # Check registry of the stage and the individiual motors - registry.clear() - with pytest.raises(exceptions.ComponentNotFound): - registry.findall(label="motors") - with pytest.raises(exceptions.ComponentNotFound): - registry.findall(label="stages") - registry.register(stage_) - assert len(list(registry.findall(label="motors"))) == 2 - assert len(list(registry.findall(label="stages"))) == 1 - - -def test_load_aerotech_stage(monkeypatch): - monkeypatch.setattr(stage, "await_for_connection", mock.AsyncMock()) - stage.load_stages() +def test_load_aerotech_stage(sim_registry): + load_aerotech_stages() # Make sure these are findable - stage_ = registry.find(name="Aerotech") + stage_ = sim_registry.find(name="aerotech") assert stage_ is not None - vert_ = registry.find(name="Aerotech_vert") + vert_ = sim_registry.find(name="aerotech_vert") assert vert_ is not None -def test_aerotech_flyer(): - aeroflyer = stage.AerotechFlyer(name="aerotech_flyer", axis="@0", encoder=6) +def test_aerotech_flyer(sim_registry): + aeroflyer = AerotechFlyer(name="aerotech_flyer", axis="@0", encoder=6) assert aeroflyer is not None -def test_aerotech_stage(): - fly_stage = stage.AerotechStage( +def test_aerotech_stage(sim_registry): + fly_stage = AerotechStage( "motor_ioc", pv_vert=":m1", pv_horiz=":m2", @@ -56,8 +36,8 @@ def test_aerotech_stage(): assert fly_stage.asyn.ascii_output.pvname == "motor_ioc:asynEns.AOUT" -def test_aerotech_fly_params_forward(sim_aerotech_flyer): - flyer = sim_aerotech_flyer +def test_aerotech_fly_params_forward(aerotech_flyer): + flyer = aerotech_flyer # Set some example positions flyer.motor_egu.set("micron").wait() flyer.acceleration.set(0.5).wait() # sec @@ -84,8 +64,8 @@ def test_aerotech_fly_params_forward(sim_aerotech_flyer): np.testing.assert_allclose(flyer.pixel_positions, pixel) -def test_aerotech_fly_params_reverse(sim_aerotech_flyer): - flyer = sim_aerotech_flyer +def test_aerotech_fly_params_reverse(aerotech_flyer): + flyer = aerotech_flyer # Set some example positions flyer.motor_egu.set("micron").wait() flyer.acceleration.set(0.5).wait() # sec @@ -106,9 +86,9 @@ def test_aerotech_fly_params_reverse(sim_aerotech_flyer): assert flyer.encoder_window_end.get(use_monitor=False) == -10005 -def test_aerotech_fly_params_no_window(sim_aerotech_flyer): +def test_aerotech_fly_params_no_window(aerotech_flyer): """Test the fly scan params when the range is too large for the PSO window.""" - flyer = sim_aerotech_flyer + flyer = aerotech_flyer # Set some example positions flyer.motor_egu.set("micron").wait() flyer.acceleration.set(0.5).wait() # sec @@ -129,9 +109,9 @@ def test_aerotech_fly_params_no_window(sim_aerotech_flyer): assert flyer.encoder_use_window.get(use_monitor=False) is False -def test_aerotech_predicted_positions(sim_aerotech_flyer): +def test_aerotech_predicted_positions(aerotech_flyer): """Check that the fly-scan positions are calculated properly.""" - flyer = sim_aerotech_flyer + flyer = aerotech_flyer # Set some example positions flyer.motor_egu.set("micron").wait() flyer.acceleration.set(0.5).wait() # sec @@ -155,8 +135,8 @@ def test_aerotech_predicted_positions(sim_aerotech_flyer): np.testing.assert_allclose(flyer.pixel_positions, pixel_positions) -def test_enable_pso(sim_aerotech_flyer): - flyer = sim_aerotech_flyer +def test_enable_pso(aerotech_flyer): + flyer = aerotech_flyer # Set up scan parameters flyer.encoder_step_size.set(50).wait() # In encoder counts flyer.encoder_window_start.set(-5).wait() # In encoder counts @@ -178,8 +158,8 @@ def test_enable_pso(sim_aerotech_flyer): ] -def test_enable_pso_no_window(sim_aerotech_flyer): - flyer = sim_aerotech_flyer +def test_enable_pso_no_window(aerotech_flyer): + flyer = aerotech_flyer # Set up scan parameters flyer.encoder_step_size.set(50).wait() # In encoder counts flyer.encoder_window_start.set(-5).wait() # In encoder counts @@ -200,11 +180,11 @@ def test_enable_pso_no_window(sim_aerotech_flyer): ] -def test_pso_bad_window_forward(sim_aerotech_flyer): +def test_pso_bad_window_forward(aerotech_flyer): """Check for an exception when the window is needed but not enabled. I.e. when the taxi distance is larger than the encoder step size.""" - flyer = sim_aerotech_flyer + flyer = aerotech_flyer # Set up scan parameters flyer.encoder_resolution.set(1).wait() flyer.encoder_step_size.set( @@ -219,11 +199,11 @@ def test_pso_bad_window_forward(sim_aerotech_flyer): flyer.enable_pso() -def test_pso_bad_window_reverse(sim_aerotech_flyer): +def test_pso_bad_window_reverse(aerotech_flyer): """Check for an exception when the window is needed but not enabled. I.e. when the taxi distance is larger than the encoder step size.""" - flyer = sim_aerotech_flyer + flyer = aerotech_flyer # Set up scan parameters flyer.encoder_resolution.set(1).wait() flyer.step_size.set(5).wait() @@ -239,8 +219,8 @@ def test_pso_bad_window_reverse(sim_aerotech_flyer): flyer.enable_pso() -def test_arm_pso(sim_aerotech_flyer): - flyer = sim_aerotech_flyer +def test_arm_pso(aerotech_flyer): + flyer = aerotech_flyer assert not flyer.send_command.called flyer.arm_pso() assert flyer.send_command.called @@ -248,17 +228,17 @@ def test_arm_pso(sim_aerotech_flyer): assert command == "PSOCONTROL @0 ARM" -def test_motor_units(sim_aerotech_flyer): +def test_motor_units(aerotech_flyer): """Check that the motor and flyer handle enginering units properly.""" - flyer = sim_aerotech_flyer + flyer = aerotech_flyer flyer.motor_egu.set("micron").wait() unit = flyer.motor_egu_pint - assert unit == stage.ureg("1e-6 m") + assert unit == ureg("1e-6 m") -def test_kickoff(sim_aerotech_flyer): +def test_kickoff(aerotech_flyer): # Set up fake flyer with mocked fly method - flyer = sim_aerotech_flyer + flyer = aerotech_flyer flyer.taxi = mock.MagicMock() flyer.dwell_time.set(1.0) # Start flying @@ -273,9 +253,9 @@ def test_kickoff(sim_aerotech_flyer): assert type(flyer.starttime) == float -def test_complete(sim_aerotech_flyer): +def test_complete(aerotech_flyer): # Set up fake flyer with mocked fly method - flyer = sim_aerotech_flyer + flyer = aerotech_flyer flyer.move = mock.MagicMock() assert flyer.user_setpoint.get() == 0 flyer.taxi_end.set(10).wait() @@ -289,8 +269,8 @@ def test_complete(sim_aerotech_flyer): assert status.done -def test_collect(sim_aerotech_flyer): - flyer = sim_aerotech_flyer +def test_collect(aerotech_flyer): + flyer = aerotech_flyer # Set up needed parameters flyer.pixel_positions = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] flyer.starttime = 0 @@ -328,7 +308,7 @@ def test_collect(sim_aerotech_flyer): } -def test_describe_collect(sim_aerotech_flyer): +def test_describe_collect(aerotech_flyer): expected = { "positions": OrderedDict( [ @@ -354,11 +334,11 @@ def test_describe_collect(sim_aerotech_flyer): ) } - assert sim_aerotech_flyer.describe_collect() == expected + assert aerotech_flyer.describe_collect() == expected -def test_fly_motor_positions(sim_aerotech_flyer): - flyer = sim_aerotech_flyer +def test_fly_motor_positions(aerotech_flyer): + flyer = aerotech_flyer # Arbitrary rest position flyer.user_setpoint.set(255).wait() flyer.parent.delay.channel_C.delay.sim_put(1.5) @@ -389,9 +369,9 @@ def test_fly_motor_positions(sim_aerotech_flyer): assert flyer.parent.delay.output_CD.polarity.get(use_monitor=False) == 0 -def test_aerotech_move_status(sim_aerotech_flyer): +def test_aerotech_move_status(aerotech_flyer): """Check that the flyer only finishes when the readback value is reached.""" - flyer = sim_aerotech_flyer + flyer = aerotech_flyer status = flyer.move(100, wait=False) assert not status.done # To-Do: figure out how to make this be done in the fake device diff --git a/tests/test_align_motor.py b/src/haven/tests/test_align_motor.py similarity index 100% rename from tests/test_align_motor.py rename to src/haven/tests/test_align_motor.py diff --git a/tests/test_aps.py b/src/haven/tests/test_aps.py similarity index 100% rename from tests/test_aps.py rename to src/haven/tests/test_aps.py diff --git a/tests/test_area_detector.py b/src/haven/tests/test_area_detector.py similarity index 100% rename from tests/test_area_detector.py rename to src/haven/tests/test_area_detector.py diff --git a/tests/test_beam_properties.py b/src/haven/tests/test_beam_properties.py similarity index 100% rename from tests/test_beam_properties.py rename to src/haven/tests/test_beam_properties.py diff --git a/tests/test_camera.py b/src/haven/tests/test_camera.py similarity index 100% rename from tests/test_camera.py rename to src/haven/tests/test_camera.py diff --git a/src/haven/tests/test_catalogs.py b/src/haven/tests/test_catalogs.py deleted file mode 100644 index b6120c31..00000000 --- a/src/haven/tests/test_catalogs.py +++ /dev/null @@ -1,10 +0,0 @@ -from haven.catalog import tiled_client - - -def test_tiled_client(sim_tiled): - uri = sim_tiled.uri.split("/")[2] - uri = f"http://{uri}" - client = tiled_client(entry_node="255id_testing", uri=uri) - assert "7d1daf1d-60c7-4aa7-a668-d1cd97e5335f" in client.keys() - run = client["7d1daf1d-60c7-4aa7-a668-d1cd97e5335f"] - assert run.metadata["start"]["plan_name"] == "xafs_scan" diff --git a/tests/test_device.py b/src/haven/tests/test_device.py similarity index 100% rename from tests/test_device.py rename to src/haven/tests/test_device.py diff --git a/tests/test_energy_positioner.py b/src/haven/tests/test_energy_positioner.py similarity index 50% rename from tests/test_energy_positioner.py rename to src/haven/tests/test_energy_positioner.py index 24525dcf..46b2f540 100644 --- a/tests/test_energy_positioner.py +++ b/src/haven/tests/test_energy_positioner.py @@ -1,20 +1,27 @@ import time +import pytest import epics +from ophyd.sim import instantiate_fake_device from haven.instrument.energy_positioner import EnergyPositioner -def test_pseudo_to_real_positioner(ioc_mono, ioc_undulator): - positioner = EnergyPositioner( +@pytest.fixture() +def positioner(): + positioner = instantiate_fake_device( + EnergyPositioner, name="energy", - mono_pv=ioc_mono.pvs["energy"], - id_prefix=ioc_undulator.prefix.strip(":"), - id_tracking_pv=ioc_mono.pvs["id_tracking"], - id_offset_pv=ioc_mono.pvs["id_offset"], + mono_pv="255idMono", + id_prefix="255idID", + id_tracking_pv="255idMono:Tracking", + id_offset_pv="255idMono:Offset", ) - positioner.mono_energy.wait_for_connection() - positioner.id_energy.wait_for_connection() + positioner.mono_energy.user_setpoint._use_limits = False + return positioner + + +def test_pseudo_to_real_positioner(positioner): positioner.energy.set(10000, timeout=5.0) assert positioner.get(use_monitor=False).mono_energy.user_setpoint == 10000 positioner.id_offset.set(230) @@ -28,19 +35,12 @@ def test_pseudo_to_real_positioner(ioc_mono, ioc_undulator): assert positioner.get(use_monitor=False).id_energy.setpoint == expected_id_energy -def test_real_to_pseudo_positioner(ioc_mono, ioc_undulator): - positioner = EnergyPositioner( - name="energy", - mono_pv=ioc_mono.pvs["energy"], - id_prefix=ioc_undulator.prefix.strip(":"), - id_tracking_pv=ioc_mono.pvs["id_tracking"], - id_offset_pv=ioc_mono.pvs["id_offset"], - ) - positioner.wait_for_connection(timeout=10.0) +def test_real_to_pseudo_positioner(positioner): + positioner.mono_energy.user_readback.sim_put(5000.0) # Move the mono energy positioner - epics.caput(ioc_mono.pvs["energy"], 5000.0) - time.sleep(0.1) # Caproto breaks pseudopositioner status - assert epics.caget(ioc_mono.pvs["energy"], use_monitor=False) == 5000.0 + # epics.caput(ioc_mono.pvs["energy"], 5000.0) + # time.sleep(0.1) # Caproto breaks pseudopositioner status + # assert epics.caget(ioc_mono.pvs["energy"], use_monitor=False) == 5000.0 # assert epics.caget("mono_ioc:Energy.RBV") == 5000.0 # Check that the pseudo single is updated assert positioner.energy.get(use_monitor=False).readback == 5000.0 diff --git a/tests/test_energy_ranges.py b/src/haven/tests/test_energy_ranges.py similarity index 100% rename from tests/test_energy_ranges.py rename to src/haven/tests/test_energy_ranges.py diff --git a/tests/test_energy_xafs_scan.py b/src/haven/tests/test_energy_xafs_scan.py similarity index 97% rename from tests/test_energy_xafs_scan.py rename to src/haven/tests/test_energy_xafs_scan.py index c605628c..b9a73c30 100644 --- a/tests/test_energy_xafs_scan.py +++ b/src/haven/tests/test_energy_xafs_scan.py @@ -1,4 +1,5 @@ import pytest +import time from ophyd import sim import numpy as np @@ -79,9 +80,11 @@ def test_energy_scan_basics(mono_motor, id_gap_motor, energies, RE): energy_positioners=[mono_motor, id_gap_motor], time_positioners=[I0_exposure, It_exposure], ) - RE(scan) + result = RE(scan) # Check that the mono and ID gap ended up in the right position - assert mono_motor.get().readback == np.max(energies) + # time.sleep(1.0) + assert mono_motor.readback.get() == np.max(energies) + # assert mono_motor.get().readback == np.max(energies) assert id_gap_motor.get().readback == np.max(energies) assert I0_exposure.get().readback == exposure_time assert It_exposure.get().readback == exposure_time diff --git a/tests/test_fluorescence_detectors.py b/src/haven/tests/test_fluorescence_detectors.py similarity index 100% rename from tests/test_fluorescence_detectors.py rename to src/haven/tests/test_fluorescence_detectors.py diff --git a/tests/test_fly_plans.py b/src/haven/tests/test_fly_plans.py similarity index 96% rename from tests/test_fly_plans.py rename to src/haven/tests/test_fly_plans.py index dc870041..5a285a4e 100644 --- a/tests/test_fly_plans.py +++ b/src/haven/tests/test_fly_plans.py @@ -8,9 +8,9 @@ from haven.plans.fly import fly_scan, grid_fly_scan, FlyerCollector -def test_set_fly_params(sim_aerotech_flyer): +def test_set_fly_params(aerotech_flyer): """Does the plan set the parameters of the flyer motor.""" - flyer = sim_aerotech_flyer + flyer = aerotech_flyer # step size == 10 plan = fly_scan(detectors=[], flyer=flyer, start=-20, stop=30, num=6) messages = list(plan) @@ -30,9 +30,9 @@ def test_set_fly_params(sim_aerotech_flyer): assert new_step_size == 10 -def test_fly_scan_metadata(sim_aerotech_flyer, sim_ion_chamber): +def test_fly_scan_metadata(aerotech_flyer, sim_ion_chamber): """Does the plan set the parameters of the flyer motor.""" - flyer = sim_aerotech_flyer + flyer = aerotech_flyer md = {"spam": "eggs"} plan = fly_scan( detectors=[sim_ion_chamber], flyer=flyer, start=-20, stop=30, num=6, md=md @@ -240,8 +240,8 @@ def test_collector_collect(): assert events == expected_events -def test_fly_grid_scan(sim_aerotech_flyer): - flyer = sim_aerotech_flyer +def test_fly_grid_scan(aerotech_flyer): + flyer = aerotech_flyer stepper = sim.motor # step size == 10 plan = grid_fly_scan( @@ -278,9 +278,9 @@ def test_fly_grid_scan(sim_aerotech_flyer): assert flyer_end_positions == [30, -20, 30, -20, 30, -20, 30, -20, 30, -20, 30] -def test_fly_grid_scan_metadata(sim_aerotech_flyer, sim_ion_chamber): +def test_fly_grid_scan_metadata(aerotech_flyer, sim_ion_chamber): """Does the plan set the parameters of the flyer motor.""" - flyer = sim_aerotech_flyer + flyer = aerotech_flyer stepper = sim.motor md = {"spam": "eggs"} plan = grid_fly_scan( diff --git a/tests/test_heater.py b/src/haven/tests/test_heater.py similarity index 100% rename from tests/test_heater.py rename to src/haven/tests/test_heater.py diff --git a/tests/test_iconfig.py b/src/haven/tests/test_iconfig.py similarity index 100% rename from tests/test_iconfig.py rename to src/haven/tests/test_iconfig.py diff --git a/tests/test_iconfig.toml b/src/haven/tests/test_iconfig.toml similarity index 100% rename from tests/test_iconfig.toml rename to src/haven/tests/test_iconfig.toml diff --git a/tests/test_instrument_registry.py b/src/haven/tests/test_instrument_registry.py similarity index 100% rename from tests/test_instrument_registry.py rename to src/haven/tests/test_instrument_registry.py diff --git a/src/haven/tests/test_ion_chamber.py b/src/haven/tests/test_ion_chamber.py index 94424e25..5871163e 100644 --- a/src/haven/tests/test_ion_chamber.py +++ b/src/haven/tests/test_ion_chamber.py @@ -207,7 +207,7 @@ def test_flyscan_collect(sim_ion_chamber): flyer.mca.spectrum._readback = sim_data sim_times = np.asarray([12.0e7, 4.0e7, 4.0e7, 4.0e7, 4.0e7, 4.0e7]) flyer.mca_times.spectrum._readback = sim_times - flyer.frequency.set(1e7) + flyer.frequency.set(1e7).wait() # Ignore the first collected data point because it's during taxiing expected_data = sim_data[1:] # The real timestamps should be midway between PSO pulses diff --git a/tests/test_lerix.py b/src/haven/tests/test_lerix.py similarity index 83% rename from tests/test_lerix.py rename to src/haven/tests/test_lerix.py index ffa88851..2b3e0b66 100644 --- a/tests/test_lerix.py +++ b/src/haven/tests/test_lerix.py @@ -3,6 +3,7 @@ from epics import caget import pytest +from ophyd.sim import instantiate_fake_device from haven.instrument import lerix import haven @@ -70,8 +71,13 @@ def test_rowland_circle_forward(): @pytest.mark.xfail def test_rowland_circle_inverse(): - rowland = lerix.RowlandPositioner( - name="rowland", x_motor_pv="", y_motor_pv="", z_motor_pv="", z1_motor_pv="" + rowland = instantiate_fake_device( + lerix.RowlandPositioner, + name="rowland", + x_motor_pv="", + y_motor_pv="", + z_motor_pv="", + z1_motor_pv="", ) # Check one set of values result = rowland.inverse( @@ -115,10 +121,14 @@ def test_rowland_circle_inverse(): # )) -@pytest.mark.xfail -def test_rowland_circle_component(ioc_motor): - device = lerix.LERIXSpectrometer("255idVME", name="lerix") - device.wait_for_connection() +def test_rowland_circle_component(): + device = instantiate_fake_device( + lerix.LERIXSpectrometer, prefix="255idVME", name="lerix" + ) + device.rowland.x.user_setpoint._use_limits = False + device.rowland.y.user_setpoint._use_limits = False + device.rowland.z.user_setpoint._use_limits = False + device.rowland.z1.user_setpoint._use_limits = False # Set pseudo axes statuses = [ device.rowland.D.set(500.0), @@ -129,14 +139,10 @@ def test_rowland_circle_component(ioc_motor): time.sleep(0.1) # Check that the virtual axes were set result = device.rowland.get(use_monitor=False) - assert caget("255idVME:m1") == pytest.approx(500.0 * um_per_mm) - assert result.x.user_readback == pytest.approx(500.0 * um_per_mm) - assert caget("255idVME:m2") == pytest.approx(375.0 * um_per_mm) - assert result.y.user_readback == pytest.approx(375.0 * um_per_mm) - assert caget("255idVME:m3") == pytest.approx(216.50635094610968 * um_per_mm) - assert result.z.user_readback == pytest.approx(216.50635094610968 * um_per_mm) - assert caget("255idVME:m4") == pytest.approx(1.5308084989341912e-14 * um_per_mm) - assert result.z1.user_readback == pytest.approx(1.5308084989341912e-14 * um_per_mm) + assert result.x.user_setpoint == pytest.approx(500.0 * um_per_mm) + assert result.y.user_setpoint == pytest.approx(375.0 * um_per_mm) + assert result.z.user_setpoint == pytest.approx(216.50635094610968 * um_per_mm) + assert result.z1.user_setpoint == pytest.approx(1.5308084989341912e-14 * um_per_mm) def test_load_lerix_spectrometers(sim_registry): diff --git a/tests/test_mono_ID_calibration_plan.py b/src/haven/tests/test_mono_ID_calibration_plan.py similarity index 95% rename from tests/test_mono_ID_calibration_plan.py rename to src/haven/tests/test_mono_ID_calibration_plan.py index 409a4597..5840d4b2 100644 --- a/tests/test_mono_ID_calibration_plan.py +++ b/src/haven/tests/test_mono_ID_calibration_plan.py @@ -5,7 +5,6 @@ from lmfit.models import QuadraticModel from haven import mono_ID_calibration -from run_engine import RunEngineStub @pytest.fixture() @@ -49,13 +48,12 @@ def ion_chamber(sim_registry, id_motor): @pytest.mark.skip(reason="``haven.plans.align_motor`` is deprecated.") -def test_moves_energy(mono_motor, id_motor, ion_chamber, pitch2_motor, event_loop): +def test_moves_energy(mono_motor, id_motor, ion_chamber, pitch2_motor, event_loop, RE): """Simple test to ensure that the plan moves the mono and undulators to the right starting energy.""" # Execute the plan id_motor.set(8) fit_model = MagicMock() - RE = RunEngineStub() RE( mono_ID_calibration( energies=[8000], energy_motor=mono_motor, fit_model=fit_model @@ -89,13 +87,12 @@ def test_aligns_mono_energy(mono_motor, id_motor, ion_chamber, pitch2_motor): @pytest.mark.skip(reason="``haven.plans.align_motor`` is deprecated.") -def test_fitting_callback(mono_motor, id_motor, ion_chamber, pitch2_motor, event_loop): +def test_fitting_callback(mono_motor, id_motor, ion_chamber, pitch2_motor, event_loop, RE): fit_model = MagicMock() plan = mono_ID_calibration( energies=[8000, 9000], energy_motor=mono_motor, fit_model=fit_model ) # Execute the plan in the runengine - RE = RunEngineStub() result = RE(plan) # Check that the fitting results are available fit_model.fit.assert_called_once() diff --git a/src/haven/tests/test_monochromator.py b/src/haven/tests/test_monochromator.py new file mode 100644 index 00000000..c7b86783 --- /dev/null +++ b/src/haven/tests/test_monochromator.py @@ -0,0 +1,11 @@ +import time + +import epics + +from haven import Monochromator + + +def test_mono_energy_signal(): + mono = Monochromator("255idMono", name="monochromator") + # Check PVs are correct + mono.energy.user_readback.pvname == "255idMono:Energy.RBV" diff --git a/tests/test_motor.py b/src/haven/tests/test_motor.py similarity index 52% rename from tests/test_motor.py rename to src/haven/tests/test_motor.py index 405e7ffd..f3f5b124 100644 --- a/tests/test_motor.py +++ b/src/haven/tests/test_motor.py @@ -1,30 +1,24 @@ import epics -from haven.instrument.instrument_registry import registry from haven.instrument import motor -def test_load_vme_motors(ioc_motor, beamline_connected): - registry.clear() - # Set the IOC motor descriptions to known values - epics.caput("255idVME:m1.DESC", "SLT V Upper") - epics.caput("255idVME:m2.DESC", "SLT V Lower") - epics.caput("255idVME:m3.DESC", "SLT H Inbound") - assert epics.caget("255idVME:m1.DESC", use_monitor=False) == "SLT V Upper" - assert epics.caget("255idVME:m2.DESC", use_monitor=False) == "SLT V Lower" - assert epics.caget("255idVME:m3.DESC", use_monitor=False) == "SLT H Inbound" +def test_load_vme_motors(sim_registry, mocker): + # Mock the caget calls used to get the motor name + mocked_caget = mocker.patch.object(motor, "caget") + mocked_caget.side_effect = ["SLT V Upper", "SLT V Lower", "SLT H Inbound"] # Load the Ophyd motor definitions motor.load_all_motors() # Were the motors imported correctly - motors = list(registry.findall(label="motors")) + motors = list(sim_registry.findall(label="motors")) assert len(motors) == 3 - assert type(motors[0]) is motor.HavenMotor + # assert type(motors[0]) is motor.HavenMotor motor_names = [m.name for m in motors] assert "SLT V Upper" in motor_names assert "SLT V Lower" in motor_names assert "SLT H Inbound" in motor_names # Check that the IOC name is set in labels - motor1 = registry.find(name="SLT V Upper") + motor1 = sim_registry.find(name="SLT V Upper") assert "VME_crate" in motor1._ophyd_labels_ diff --git a/tests/test_plans.py b/src/haven/tests/test_plans.py similarity index 100% rename from tests/test_plans.py rename to src/haven/tests/test_plans.py diff --git a/tests/test_power_supply.py b/src/haven/tests/test_power_supply.py similarity index 100% rename from tests/test_power_supply.py rename to src/haven/tests/test_power_supply.py diff --git a/tests/test_preprocessors.py b/src/haven/tests/test_preprocessors.py similarity index 97% rename from tests/test_preprocessors.py rename to src/haven/tests/test_preprocessors.py index 671467fa..d399106c 100644 --- a/tests/test_preprocessors.py +++ b/src/haven/tests/test_preprocessors.py @@ -12,7 +12,7 @@ from haven.instrument.aps import EpicsBssDevice, load_aps, ApsMachine -def test_shutter_suspend_wrapper(sim_aps, sim_shutters, sim_registry): +def test_shutter_suspend_wrapper(aps, shutters, sim_registry): # Check that the run engine does not have any shutter suspenders # Currently this test is fragile since we might add non-shutter # suspenders in the future. @@ -41,7 +41,7 @@ def test_shutter_suspend_wrapper(sim_aps, sim_shutters, sim_registry): assert len(unsub_msgs) == 2 -def test_baseline_wrapper(sim_registry, sim_aps, event_loop): +def test_baseline_wrapper(sim_registry, aps, event_loop): # Create a test device motor_baseline = SynAxis(name="baseline_motor", labels={"motors", "baseline"}) sim_registry.register(motor_baseline) @@ -64,7 +64,7 @@ def test_baseline_wrapper(sim_registry, sim_aps, event_loop): assert "baseline_motor" in baseline_doc["data_keys"].keys() -def test_baseline_decorator(sim_registry, sim_aps): +def test_baseline_decorator(sim_registry, aps): """Similar to baseline wrapper test, but used as a decorator.""" # Create the decorated function before anything else func = baseline_decorator(devices="motors")(bp.count) @@ -89,7 +89,7 @@ def test_baseline_decorator(sim_registry, sim_aps): assert "baseline_motor" in baseline_doc["data_keys"].keys() -def test_metadata(sim_registry, sim_aps, monkeypatch): +def test_metadata(sim_registry, aps, monkeypatch): """Similar to baseline wrapper test, but used as a decorator.""" # Load devices bss = instantiate_fake_device(EpicsBssDevice, name="bss", prefix="255id:bss:") diff --git a/tests/test_run_engine.py b/src/haven/tests/test_run_engine.py similarity index 100% rename from tests/test_run_engine.py rename to src/haven/tests/test_run_engine.py diff --git a/tests/test_save_motor_positions.py b/src/haven/tests/test_save_motor_positions.py similarity index 92% rename from tests/test_save_motor_positions.py rename to src/haven/tests/test_save_motor_positions.py index 725ba036..613cfd89 100644 --- a/tests/test_save_motor_positions.py +++ b/src/haven/tests/test_save_motor_positions.py @@ -7,7 +7,6 @@ import datetime as dt from datetime import datetime -import epics import pytest from ophyd.sim import motor1, SynAxis, make_fake_device from ophyd import EpicsMotor, Signal, Component as Cpt @@ -165,22 +164,18 @@ def test_list_motor_positions(mongodb, capsys): assert captured.out == expected -def test_motor_position_e2e(mongodb, ioc_motor): +def test_motor_position_e2e(mongodb, sim_motor_registry): """Check that a motor position can be saved, then recalled using - simulated IOC. + a simulated motor. """ # Create an epics motor for setting values manually - pv = ioc_motor.pvs["m1"] - motor1 = EpicsMotor(pv, name="SLT V Upper") - motor1.wait_for_connection(timeout=20) - assert motor1.connected - registry.register(motor1) - registry.find(name="SLT V Upper") - epics.caput(pv, 504.6) - assert epics.caget(pv, use_monitor=False) == 504.6 - time.sleep(0.1) - assert motor1.get(use_monitor=False).user_readback == 504.6 + motor1 = sim_motor_registry.find(name="SLT V Upper") + # Set a fake value + motor1.set(504.6).wait(timeout=2) + # assert epics.caget(pv, use_monitor=False) == 504.6 + # time.sleep(0.1) + assert motor1.get().readback == 504.6 # Save motor position uid = save_motor_position( motor1, @@ -188,10 +183,8 @@ def test_motor_position_e2e(mongodb, ioc_motor): collection=mongodb.motor_positions, ) # Change to a different value - epics.caput(pv, 520) - time.sleep(0.1) - assert epics.caget(pv, use_monitor=False) == 520 - assert motor1.get(use_monitor=False).user_readback == 520 + motor1.set(520).wait(timeout=2) + assert motor1.get().readback == 520 # Recall the saved position and see if it complies plan = recall_motor_position(uid=uid, collection=mongodb.motor_positions) msg = next(plan) diff --git a/tests/test_scaler_triggering.py b/src/haven/tests/test_scaler_triggering.py similarity index 100% rename from tests/test_scaler_triggering.py rename to src/haven/tests/test_scaler_triggering.py diff --git a/tests/test_set_energy.py b/src/haven/tests/test_set_energy.py similarity index 100% rename from tests/test_set_energy.py rename to src/haven/tests/test_set_energy.py diff --git a/tests/test_shutter.py b/src/haven/tests/test_shutter.py similarity index 100% rename from tests/test_shutter.py rename to src/haven/tests/test_shutter.py diff --git a/tests/test_slits.py b/src/haven/tests/test_slits.py similarity index 100% rename from tests/test_slits.py rename to src/haven/tests/test_slits.py diff --git a/src/haven/tests/test_stages.py b/src/haven/tests/test_stages.py new file mode 100644 index 00000000..401fd68c --- /dev/null +++ b/src/haven/tests/test_stages.py @@ -0,0 +1,30 @@ +"""Tests for a generic X-Y stage.""" + + +import time +from unittest import mock +from collections import OrderedDict +import pytest +from ophyd.sim import instantiate_fake_device, make_fake_device +import numpy as np +from datetime import datetime + +from haven import registry, exceptions +from haven.instrument import stage + + +def test_stage_init(): + stage_ = stage.XYStage( + "motor_ioc", pv_vert=":m1", pv_horiz=":m2", labels={"stages"}, name="aerotech" + ) + assert stage_.name == "aerotech" + assert stage_.vert.name == "aerotech_vert" + # Check registry of the stage and the individiual motors + registry.clear() + with pytest.raises(exceptions.ComponentNotFound): + registry.findall(label="motors") + with pytest.raises(exceptions.ComponentNotFound): + registry.findall(label="stages") + registry.register(stage_) + assert len(list(registry.findall(label="motors"))) == 2 + assert len(list(registry.findall(label="stages"))) == 1 diff --git a/tests/test_xdi_writer.py b/src/haven/tests/test_xdi_writer.py similarity index 100% rename from tests/test_xdi_writer.py rename to src/haven/tests/test_xdi_writer.py diff --git a/tests/test_xray_source.py b/src/haven/tests/test_xray_source.py similarity index 100% rename from tests/test_xray_source.py rename to src/haven/tests/test_xray_source.py diff --git a/tests/test_xspress.py b/src/haven/tests/test_xspress.py similarity index 100% rename from tests/test_xspress.py rename to src/haven/tests/test_xspress.py diff --git a/src/haven/tests/tiled_example.py b/src/haven/tests/tiled_example.py deleted file mode 100644 index 7962fa53..00000000 --- a/src/haven/tests/tiled_example.py +++ /dev/null @@ -1,206 +0,0 @@ -import asyncio -import string -import sys -from datetime import timedelta - -import numpy -import numpy as np -import pandas -import pandas as pd -import sparse -import xarray - -from tiled.adapters.array import ArrayAdapter -from tiled.adapters.dataframe import DataFrameAdapter -from tiled.adapters.mapping import MapAdapter -from tiled.adapters.sparse import COOAdapter -from tiled.adapters.xarray import DatasetAdapter - -# print("Generating large example data...", file=sys.stderr) -# data = { -# "big_image": numpy.random.random((10_000, 10_000)), -# "small_image": numpy.random.random((300, 300)), -# "medium_image": numpy.random.random((1000, 1000)), -# "tiny_image": numpy.random.random((50, 50)), -# "tiny_cube": numpy.random.random((50, 50, 50)), -# "tiny_hypercube": numpy.random.random((50, 50, 50, 50, 50)), -# "high_entropy": numpy.random.random((100, 100)), -# "low_entropy": numpy.ones((100, 100)), -# "short_column": numpy.random.random(100), -# "tiny_column": numpy.random.random(10), -# "long_column": numpy.random.random(100_000), -# } -# temp = 15 + 8 * numpy.random.randn(2, 2, 3) -# precip = 10 * numpy.random.rand(2, 2, 3) -# lon = [[-99.83, -99.32], [-99.79, -99.23]] -# lat = [[42.25, 42.21], [42.63, 42.59]] - -# sparse_arr = numpy.random.random((100, 100)) -# sparse_arr[sparse_arr < 0.9] = 0 # fill most of the array with zeros - -run1 = pd.DataFrame( - { - "energy_energy": np.linspace(8300, 8400, num=100), - "It_net_counts": np.abs(np.sin(np.linspace(0, 4 * np.pi, num=100))), - "I0_net_counts": np.linspace(1, 2, num=100), - } -) - -print("Done generating example data.", file=sys.stderr) -hints = { - "energy": {"fields": ["energy_energy", "energy_id_energy_readback"]}, -} - -bluesky_mapping = { - "7d1daf1d-60c7-4aa7-a668-d1cd97e5335f": MapAdapter( - { - "primary": MapAdapter( - { - "data": DatasetAdapter.from_dataset(run1.to_xarray()), - }, - metadata={"descriptors": [{"hints": hints}]}, - ), - }, - metadata={ - "plan_name": "xafs_scan", - "start": { - "plan_name": "xafs_scan", - "uid": "7d1daf1d-60c7-4aa7-a668-d1cd97e5335f", - "hints": {"dimensions": [[["energy_energy"], "primary"]]}, - }, - }, - ), - "9d33bf66-9701-4ee3-90f4-3be730bc226c": MapAdapter( - { - "primary": MapAdapter( - { - "data": DatasetAdapter.from_dataset(run1.to_xarray()), - }, - metadata={"descriptors": [{"hints": hints}]}, - ), - }, - metadata={ - "start": { - "plan_name": "rel_scan", - "uid": "9d33bf66-9701-4ee3-90f4-3be730bc226c", - "hints": {"dimensions": [[["pitch2"], "primary"]]}, - } - }, - ), - # '1942a888-2627-43e6-ad36-82f0022e2c57': MapAdapter({ - # "start": { - # "plan_name": "xafs_scan" - # } - # }), - # 'bb1cd731-f180-40b5-8255-71a6a398e51c': MapAdapter({ - # "start": { - # "plan_name": "xafs_scan" - # } - # }), - # '4165b2bb-7df5-4a0e-8c3e-8221a9808bef': MapAdapter({ - # "start": { - # "plan_name": "xafs_scan" - # } - # }), - # 'e3131e5e-4ebf-458e-943b-62bcdbc0f6e0': MapAdapter({ - # "start": { - # "plan_name": "xafs_scan" - # } - # }), -} - -# mapping = { -# "big_image": ArrayAdapter.from_array(data["big_image"]), -# "small_image": ArrayAdapter.from_array(data["small_image"]), -# "medium_image": ArrayAdapter.from_array(data["medium_image"]), -# "sparse_image": COOAdapter.from_coo(sparse.COO(sparse_arr)), -# "tiny_image": ArrayAdapter.from_array(data["tiny_image"]), -# "tiny_cube": ArrayAdapter.from_array(data["tiny_cube"]), -# "tiny_hypercube": ArrayAdapter.from_array(data["tiny_hypercube"]), -# "short_table": DataFrameAdapter.from_pandas( -# pandas.DataFrame( -# { -# "A": data["short_column"], -# "B": 2 * data["short_column"], -# "C": 3 * data["short_column"], -# }, -# index=pandas.Index(numpy.arange(len(data["short_column"])), name="index"), -# ), -# npartitions=1, -# metadata={"animal": "dog", "color": "red"}, -# ), -# "long_table": DataFrameAdapter.from_pandas( -# pandas.DataFrame( -# { -# "A": data["long_column"], -# "B": 2 * data["long_column"], -# "C": 3 * data["long_column"], -# }, -# index=pandas.Index(numpy.arange(len(data["long_column"])), name="index"), -# ), -# npartitions=5, -# metadata={"animal": "dog", "color": "green"}, -# ), -# "wide_table": DataFrameAdapter.from_pandas( -# pandas.DataFrame( -# { -# letter: i * data["tiny_column"] -# for i, letter in enumerate(string.ascii_uppercase, start=1) -# }, -# index=pandas.Index(numpy.arange(len(data["tiny_column"])), name="index"), -# ), -# npartitions=1, -# metadata={"animal": "dog", "color": "red"}, -# ), -# "structured_data": MapAdapter( -# { -# "pets": ArrayAdapter.from_array( -# numpy.array( -# [("Rex", 9, 81.0), ("Fido", 3, 27.0)], -# dtype=[("name", "U10"), ("age", "i4"), ("weight", "f4")], -# ) -# ), -# "xarray_dataset": DatasetAdapter.from_dataset( -# xarray.Dataset( -# { -# "temperature": (["x", "y", "time"], temp), -# "precipitation": (["x", "y", "time"], precip), -# }, -# coords={ -# "lon": (["x", "y"], lon), -# "lat": (["x", "y"], lat), -# "time": pandas.date_range("2014-09-06", periods=3), -# }, -# ), -# ), -# }, -# metadata={"animal": "cat", "color": "green"}, -# ), -# "flat_array": ArrayAdapter.from_array(numpy.random.random(100)), -# "low_entropy": ArrayAdapter.from_array(data["low_entropy"]), -# "high_entropy": ArrayAdapter.from_array(data["high_entropy"]), -# # Below, an asynchronous task modifies this value over time. -# "dynamic": ArrayAdapter.from_array(numpy.zeros((3, 3))), -# } - -mapping = { - "255id_testing": MapAdapter(bluesky_mapping), -} - -tree = MapAdapter(mapping, entries_stale_after=timedelta(seconds=10)) - - -# async def increment_dynamic(): -# """ -# Change the value of the 'dynamic' node every 3 seconds. -# """ -# fill_value = 0 -# while True: -# fill_value += 1 -# mapping["dynamic"] = ArrayAdapter.from_array(fill_value * numpy.ones((3, 3))) -# await asyncio.sleep(3) - - -# # The server will run this on its event loop. We cannot start it *now* because -# # there is not yet a running event loop. -# tree.background_tasks.append(increment_dynamic) diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 00000000..08cfd054 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,7 @@ +# Tests + +The tests for Haven and Firefly have been moved to src/haven/tests and +src/firefly/tests respectively. + +The remaining test module, ``test_simulated_ioc.py``, is skipped, and +is here in case we want to start using the simulated IOCs again. \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index 79cdfea0..50f11dc6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,5 @@ import os +import warnings from pathlib import Path from unittest.mock import MagicMock from types import SimpleNamespace @@ -26,7 +27,7 @@ from haven.simulated_ioc import simulated_ioc from haven import load_config, registry from haven._iconfig import beamline_connected as _beamline_connected -from haven.instrument.stage import AerotechFlyer, AerotechStage +from haven.instrument.aerotech import AerotechFlyer, AerotechStage from haven.instrument.aps import ApsMachine from haven.instrument.shutter import Shutter from haven.instrument.camera import AravisDetector @@ -37,50 +38,12 @@ from firefly.application import FireflyApplication from firefly.ophyd_plugin import OphydPlugin from firefly.main_window import FireflyMainWindow -from run_engine import RunEngineStub -IOC_SCOPE = "function" +# IOC_SCOPE = "function" IOC_SCOPE = "session" -# Specify the configuration files to use for testing -os.environ["HAVEN_CONFIG_FILES"] = ",".join( - [ - f"{test_dir/'iconfig_testing.toml'}", - f"{haven_dir/'iconfig_default.toml'}", - ] -) - - -class FakeEpicsSignalWithIO(FakeEpicsSignal): - # An EPICS signal that simply uses the DG-645 convention of - # 'AO' being the setpoint and 'AI' being the read-back - _metadata_keys = EpicsSignalWithIO._metadata_keys - - def __init__(self, prefix, **kwargs): - super().__init__(f"{prefix}I", write_pv=f"{prefix}O", **kwargs) - - -fake_device_cache[EpicsSignalWithIO] = FakeEpicsSignalWithIO - - -def pytest_configure(config): - app = QtWidgets.QApplication.instance() - assert app is None - app = FireflyApplication() - app = QtWidgets.QApplication.instance() - assert isinstance(app, FireflyApplication) - # # Create event loop for asyncio stuff - # loop = asyncio.new_event_loop() - # asyncio.set_event_loop(loop) - - -@pytest.fixture(scope="session") -def qapp_cls(): - return FireflyApplication - - @pytest.fixture(scope=IOC_SCOPE) def ioc_undulator(request): prefix = "ID255:" @@ -215,26 +178,6 @@ def pydm_ophyd_plugin(): return add_plugin(OphydPlugin) -@pytest.fixture() -def ffapp(pydm_ophyd_plugin): - # Get an instance of the application - app = FireflyApplication.instance() - if app is None: - app = FireflyApplication() - app._dummy_main_window = FireflyMainWindow() - # Set up the actions and other boildplate stuff - app.setup_window_actions() - app.setup_runengine_actions() - assert isinstance(app, FireflyApplication) - try: - yield app - finally: - if hasattr(app, "_queue_thread"): - app._queue_thread.quit() - app.quit() - del app - - @pytest.fixture(scope=IOC_SCOPE) def ioc_motor(request): prefix = "255idVME:" @@ -333,158 +276,17 @@ def ioc_dxp(request): # Simulated devices -@pytest.fixture() -def sim_aps(sim_registry): - aps = instantiate_fake_device(ApsMachine, name="APS") - sim_registry.register(aps) - yield aps - - -@pytest.fixture() -def sim_shutters(sim_registry): - FakeShutter = make_fake_device(Shutter) - kw = dict( - prefix="_prefix", - open_pv="_prefix", - close_pv="_prefix2", - state_pv="_prefix2", - labels={"shutters"}, - ) - shutters = [ - FakeShutter(name="Shutter A", **kw), - FakeShutter(name="Shutter C", **kw), - ] - # Registry with the simulated registry - for shutter in shutters: - sim_registry.register(shutter) - yield shutters - - -@pytest.fixture() -def sim_camera(sim_registry): - FakeCamera = make_fake_device(AravisDetector) - camera = FakeCamera(name="s255id-gige-A", labels={"cameras", "area_detectors"}) - camera.pva.pv_name._readback = "255idSimDet:Pva1:Image" - # Registry with the simulated registry - sim_registry.register(camera) - yield camera - - -qs_status = { - "msg": "RE Manager v0.0.18", - "items_in_queue": 0, - "items_in_history": 0, - "running_item_uid": None, - "manager_state": "idle", - "queue_stop_pending": False, - "worker_environment_exists": False, - "worker_environment_state": "closed", - "worker_background_tasks": 0, - "re_state": None, - "pause_pending": False, - "run_list_uid": "4f2d48cc-980d-4472-b62b-6686caeb3833", - "plan_queue_uid": "2b99ccd8-f69b-4a44-82d0-947d32c5d0a2", - "plan_history_uid": "9af8e898-0f00-4e7a-8d97-0964c8d43f47", - "devices_existing_uid": "51d8b88d-7457-42c4-b67f-097b168be96d", - "plans_existing_uid": "65f11f60-0049-46f5-9eb3-9f1589c4a6dd", - "devices_allowed_uid": "a5ddff29-917c-462e-ba66-399777d2442a", - "plans_allowed_uid": "d1e907cd-cb92-4d68-baab-fe195754827e", - "plan_queue_mode": {"loop": False}, - "task_results_uid": "159e1820-32be-4e01-ab03-e3478d12d288", - "lock_info_uid": "c7fe6f73-91fc-457d-8db0-dfcecb2f2aba", - "lock": {"environment": False, "queue": False}, -} - - @pytest.fixture() def queue_app(ffapp): - queue_api = MagicMock() - queue_api.status.return_value = qs_status - queue_api.queue_start.return_value = {"success": True} - ffapp.setup_window_actions() - ffapp.setup_runengine_actions() - ffapp.prepare_queue_client(api=queue_api, start_thread=False) - - try: - yield ffapp - finally: - # print(self._queue_thread) - print("Exiting") - ffapp._queue_thread.quit() - ffapp._queue_thread.wait(5) - - -class DxpVortex(DxpDetector): - mcas = DDC( - add_dxp_mcas(range_=[0, 1, 2, 3]), - kind=Kind.normal | Kind.hinted, - default_read_attrs=[f"mca{i}" for i in [0, 1, 2, 3]], - default_configuration_attrs=[f"mca{i}" for i in [0, 1, 2, 3]], - ) - + """An application that is set up to interact (fakely) with the queue + server. -@pytest.fixture() -def dxp(sim_registry): - FakeDXP = make_fake_device(DxpVortex) - vortex = FakeDXP(name="vortex_me4", labels={"xrf_detectors"}) - sim_registry.register(vortex) - # vortex.net_cdf.dimensions.set([1477326, 1, 1]) - yield vortex + """ + warnings.warn("queue_app is deprecated, just use ffapp instead.") + return ffapp @pytest.fixture() def sim_vortex(dxp): + warnings.warn("sim_vortex is deprecated, just use ``dxp`` instead.") return dxp - - -class Xspress3Vortex(Xspress3Detector): - mcas = DDC( - add_xspress_mcas(range_=[0, 1, 2, 3]), - kind=Kind.normal | Kind.hinted, - default_read_attrs=[f"mca{i}" for i in [0, 1, 2, 3]], - default_configuration_attrs=[f"mca{i}" for i in [0, 1, 2, 3]], - ) - - -@pytest.fixture() -def xspress(sim_registry): - FakeXspress = make_fake_device(Xspress3Vortex) - vortex = FakeXspress(name="vortex_me4", labels={"xrf_detectors"}) - sim_registry.register(vortex) - yield vortex - - -@pytest.fixture() -def sim_aerotech(): - Stage = make_fake_device( - AerotechStage, - ) - stage = Stage( - "255id", - delay_prefix="255id:DG645", - pv_horiz=":m1", - pv_vert=":m2", - name="aerotech", - ) - return stage - - -@pytest.fixture() -def sim_aerotech_flyer(sim_aerotech): - flyer = sim_aerotech.horiz - flyer.user_setpoint._limits = (0, 1000) - flyer.send_command = mock.MagicMock() - # flyer.encoder_resolution.put(0.001) - # flyer.acceleration.put(1) - yield flyer - - -@pytest.fixture() -def RE(event_loop): - return RunEngineStub(call_returns_result=True) - - -@pytest.fixture() -def beamline_connected(): - with _beamline_connected(True): - yield diff --git a/tests/run_engine.py b/tests/run_engine.py deleted file mode 100644 index c3ca0bcb..00000000 --- a/tests/run_engine.py +++ /dev/null @@ -1,6 +0,0 @@ -from bluesky import RunEngine - - -class RunEngineStub(RunEngine): - def __repr__(self): - return "" diff --git a/tests/test_monochromator.py b/tests/test_monochromator.py deleted file mode 100644 index f0047c5f..00000000 --- a/tests/test_monochromator.py +++ /dev/null @@ -1,15 +0,0 @@ -import time - -import epics - -from haven import Monochromator - - -def test_mono_energy_signal(ioc_mono): - mono = Monochromator(ioc_mono.prefix.strip(":"), name="monochromator") - mono.wait_for_connection(timeout=20) - time.sleep(0.1) - # Change mono energy - mono.energy.set(5000) - # Check new value on the IOC - assert epics.caget(ioc_mono.pvs["energy"], use_monitor=False) == 5000 diff --git a/tests/test_simulated_ioc.py b/tests/test_simulated_ioc.py index 5dd8c11a..a0551ece 100644 --- a/tests/test_simulated_ioc.py +++ b/tests/test_simulated_ioc.py @@ -15,6 +15,7 @@ ioc_dir = Path(__file__).parent.resolve() / "iocs" +@pytest.mark.skip(reason="Simulated IOCs are deprecated.") def test_simulated_ioc(ioc_simple): assert caget(ioc_simple.pvs["B"], use_monitor=False) == 2.0 caput(ioc_simple.pvs["B"], 5) @@ -22,6 +23,7 @@ def test_simulated_ioc(ioc_simple): assert caget(ioc_simple.pvs["B"], use_monitor=False) == 5 +@pytest.mark.skip(reason="Simulated IOCs are deprecated.") def test_motor_ioc(ioc_motor): prefix = "255idVME:" # Check that the starting value is different than what we'll set it to @@ -34,14 +36,14 @@ def test_motor_ioc(ioc_motor): assert caget(f"{prefix}m1.VAL", use_monitor=False) == 4000.0 assert caget(f"{prefix}m1.RBV", use_monitor=False) == 4000.0 - +@pytest.mark.skip(reason="Simulated IOCs are deprecated.") def test_scaler_ioc(ioc_scaler): # Check that all the channels have the right counts for ch_num in range(1, 32): pv = f"255idVME:scaler1.S{ch_num}" assert caget(pv) is not None, pv - +@pytest.mark.skip(reason="Simulated IOCs are deprecated.") def test_mono_ioc(ioc_mono): # Test a regular motor caput("255idMono:ACS:m1", 0) @@ -86,6 +88,7 @@ def test_mono_ioc(ioc_mono): # assert pass_time < 4, msg +@pytest.mark.skip(reason="Simulated IOCs are deprecated.") def test_undulator_ioc(ioc_undulator): val = caget(ioc_undulator.pvs["energy"], use_monitor=False) assert val == 0.0 @@ -111,6 +114,7 @@ def test_undulator_ioc(ioc_undulator): # pass +@pytest.mark.skip(reason="Simulated IOCs are deprecated.") @pytest.mark.xfail def test_bss_ioc(ioc_bss): caput(ioc_bss.pvs["esaf_cycle"], "2023-2", wait=True) @@ -118,6 +122,7 @@ def test_bss_ioc(ioc_bss): assert val == "2023-2" +@pytest.mark.skip(reason="Simulated IOCs are deprecated.") def test_preamp_ioc(ioc_preamp): # Update PVs to recover from other tests caput(ioc_preamp.pvs["preamp1_sens_num"], "5") @@ -144,6 +149,7 @@ def test_preamp_ioc(ioc_preamp): ) +@pytest.mark.skip(reason="Simulated IOCs are deprecated.") def test_ptc10_ioc(ioc_ptc10): assert caput(ioc_ptc10.pvs["tc1_temperature"], 21.3) # Check that the values were set @@ -151,11 +157,12 @@ def test_ptc10_ioc(ioc_ptc10): assert caget(ioc_ptc10.pvs["pid1_voltage_rbv"], use_monitor=False) == 0 assert caget(ioc_ptc10.pvs["tc1_temperature"], use_monitor=False) == 21.3 - +@pytest.mark.skip(reason="Simulated IOCs are deprecated.") def test_area_detector_ioc(ioc_area_detector): assert caget(ioc_area_detector.pvs["cam_acquire_busy"], use_monitor=False) == 0 +@pytest.mark.skip(reason="Simulated IOCs are deprecated.") def test_dxp_ioc_mca_propogation(ioc_dxp): # See if settings propogate to the MCAs caput("255idDXP:PresetLive", 1.5) @@ -167,6 +174,7 @@ def test_dxp_ioc_mca_propogation(ioc_dxp): assert real_time == 2.5 +@pytest.mark.skip(reason="Simulated IOCs are deprecated.") def test_dxp_ioc_spectra(ioc_dxp): # Get the starting spectrum spectrum = caget("255idDXP:mca1.VAL")