diff --git a/INSTALL.rst b/INSTALL.rst index 5e18eac..98e0751 100644 --- a/INSTALL.rst +++ b/INSTALL.rst @@ -31,6 +31,3 @@ Initial Setup and Installation Troubleshooting --------------- - -Please note that for ATIP to function with Python 3.7 or later, you must -use Cothread>=2.16. diff --git a/docs/conf.py b/docs/conf.py index 63ca443..c5399dc 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -88,7 +88,6 @@ ("py:class", "obj"), ("py:class", "class"), ("py:class", "at.lattice.lattice_object.Lattice"), - ("py:class", "cothread.Event"), ] # Both the class’ and the __init__ method’s docstring are concatenated and diff --git a/pyproject.toml b/pyproject.toml index b32c4f9..7f3ce6b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ description = "Accelerator Toolbox Interface for Pytac" dependencies = [ "numpy", "scipy", - "pytac>=1.1.0", + "pytac>=2.0.0", "accelerator-toolbox>=0.8.0", "cothread", "softioc", @@ -35,6 +35,7 @@ dev = [ "pydata-sphinx-theme>=0.12", "pytest", "pytest-cov", + "pytest-asyncio", "testfixtures", "ruff", "sphinx-autobuild", @@ -73,6 +74,7 @@ addopts = """ filterwarnings = "error" # Doctest python code in docs, python code in src docstrings, test functions in tests testpaths = "docs src tests" +asyncio_mode = "auto" [tool.coverage.run] data_file = "/tmp/atip.coverage" diff --git a/src/atip/__main__.py b/src/atip/__main__.py index 137732d..3e83829 100644 --- a/src/atip/__main__.py +++ b/src/atip/__main__.py @@ -1,23 +1,40 @@ """Interface for ``python -m atip``.""" +import asyncio +import logging from argparse import ArgumentParser -from collections.abc import Sequence -from atip import __version__ +import atip __all__ = ["main"] -def main(args: Sequence[str] | None = None) -> None: +async def async_main() -> None: """Argument parser for the CLI.""" parser = ArgumentParser() parser.add_argument( "-v", "--version", action="version", - version=__version__, + version=atip.__version__, ) - parser.parse_args(args) + parser.add_argument( + "-t", + "--run-test", + help="Start an endless test of atip", + action="store_true", + ) + args = parser.parse_args() + + if args.run_test: + await atip.utils.test_atip() + + +def main() -> None: + logging.basicConfig() + logging.getLogger().setLevel(logging.DEBUG) + # Load the AT sim into the Pytac lattice. + asyncio.run(async_main()) if __name__ == "__main__": diff --git a/src/atip/load_sim.py b/src/atip/load_sim.py index 4759e7e..62f8997 100644 --- a/src/atip/load_sim.py +++ b/src/atip/load_sim.py @@ -12,7 +12,7 @@ SIMULATED_FIELDS = {"a1", "b0", "b1", "b2", "b3", "x", "y", "f", "x_kick", "y_kick"} -def load_from_filepath( +async def load_from_filepath( pytac_lattice, at_lattice_filepath, sim_params=None, @@ -36,9 +36,9 @@ def load_from_filepath( at_lattice = at.load.load_mat( at_lattice_filepath, name=pytac_lattice.name, - energy=pytac_lattice.get_value("energy", units=pytac.PHYS), + energy=await pytac_lattice.get_value("energy", units=pytac.PHYS), ) - return load( + return await load( pytac_lattice, at_lattice, sim_params, @@ -46,7 +46,7 @@ def load_from_filepath( ) -def load( +async def load( pytac_lattice, at_lattice, sim_params=None, @@ -72,7 +72,7 @@ def load( f"(AT:{len(at_lattice)} Pytac:{len(pytac_lattice)})." ) # Initialise an instance of the ATSimulator Object. - atsim = ATSimulator( + atsim = await ATSimulator.create( at_lattice, sim_params, callback, diff --git a/src/atip/sim_data_sources.py b/src/atip/sim_data_sources.py index b1e7226..6bf3775 100644 --- a/src/atip/sim_data_sources.py +++ b/src/atip/sim_data_sources.py @@ -125,7 +125,7 @@ def add_field(self, field): else: self._fields.append(field) - def get_value(self, field, handle=None, throw=True): + async def get_value(self, field, handle=None, throw=True): """Get the value for a field. Args: @@ -150,7 +150,7 @@ def get_value(self, field, handle=None, throw=True): # Wait for any outstanding calculations to conclude, to ensure they are # complete before a value is returned; if the wait times out then raise # an error message or log a warning according to the value of throw. - if not self._atsim.wait_for_calculations(): + if not await self._atsim.wait_for_calculations(): error_msg = "Check for completion of outstanding calculations timed out." if throw: raise ControlSystemException(error_msg) @@ -162,7 +162,7 @@ def get_value(self, field, handle=None, throw=True): else: raise FieldException(f"No field {field} on AT element {self._at_element}.") - def set_value(self, field, value, throw=None): + async def set_value(self, field, value, throw=None): """Set the value for a field. The field and value go onto the queue of changes on the ATSimulator to be passed to make_change when the queue is emptied. @@ -180,7 +180,7 @@ def set_value(self, field, value, throw=None): """ if field in self._fields: if field in self._set_field_funcs.keys(): - self._atsim.queue_set(self._make_change, field, value) + await self._atsim.queue_set(self._make_change, field, value) else: raise HandleException( f"Field {field} cannot be set on element data source {self}." @@ -415,7 +415,7 @@ def get_fields(self): """ return list(self._field_funcs.keys()) - def get_value(self, field, handle=None, throw=True): + async def get_value(self, field, handle=None, throw=True): """Get the value for a field on the Pytac lattice. Args: @@ -440,7 +440,7 @@ def get_value(self, field, handle=None, throw=True): # Wait for any outstanding calculations to conclude, to ensure they are # complete before a value is returned; if the wait times out then raise # an error message or log a warning according to the value of throw. - if not self._atsim.wait_for_calculations(): + if not await self._atsim.wait_for_calculations(): error_msg = "Check for completion of outstanding calculations timed out." if throw: raise ControlSystemException(error_msg) diff --git a/src/atip/simulator.py b/src/atip/simulator.py index ca8b61a..7a69568 100644 --- a/src/atip/simulator.py +++ b/src/atip/simulator.py @@ -1,12 +1,13 @@ """Module containing an interface with the AT simulator.""" +import asyncio +import concurrent import logging from dataclasses import dataclass from enum import StrEnum, auto from warnings import warn import at -import cothread import numpy from numpy.typing import ArrayLike from pytac.exceptions import DataSourceException, FieldException @@ -135,7 +136,7 @@ class ATSimulator: **Attributes** Attributes: - up_to_date (cothread.Event): A flag that indicates if the physics data + up_to_date (asyncio.Event): A flag that indicates if the physics data is up to date with all the changes made to the AT lattice. @@ -147,19 +148,32 @@ class ATSimulator: physics calculations. _lattice_data (LatticeData): calculated physics data function linopt (see at.lattice.linear.py). - _queue (cothread.EventQueue): A queue of changes to be applied to + _queue (asyncio.Queue): A queue of changes to be applied to the centralised lattice on the next recalculation cycle. - _paused (cothread.Event): A flag used to temporarily pause the + _paused (asyncio.Event): A flag used to temporarily pause the physics calculations. - _calculation_thread (cothread.Thread): A thread to check the queue - for new changes to the AT - lattice and recalculate the - physics data upon a change. + _calculation_task (asyncio.Task): A task to check the queue + for new changes to the AT + lattice and recalculate the + physics data upon a change. + _new_data_lock (asyncio.Lock): A lock which can be taken + to stop new caput callbacks + being added to the queue while + held. """ - def __init__( - self, + _loop: asyncio.BaseEventLoop + _queue: asyncio.Queue + _paused: asyncio.Event + _quit_thread: asyncio.Event + _up_to_date: asyncio.Event + _calculation_task: asyncio.Task + _new_data_lock: asyncio.Lock + + @classmethod + async def create( + cls, at_lattice, sim_params=None, callback=None, @@ -180,34 +194,40 @@ def __init__( **Methods:** """ + atsim = cls() if (not callable(callback)) and (callback is not None): raise TypeError( f"If passed, 'callback' should be callable, {callback} is not." ) - self._at_lat = at_lattice - self._rp = numpy.ones(len(at_lattice) + 1, dtype=bool) + atsim._at_lat = at_lattice + atsim._rp = numpy.ones(len(at_lattice) + 1, dtype=bool) if sim_params is None: sim_params = SimParams() - self._sim_params = sim_params + atsim._sim_params = sim_params - if self._sim_params.linopt == LinoptType.LINOPT6: - self._at_lat.enable_6d() + if atsim._sim_params.linopt == LinoptType.LINOPT6: + atsim._at_lat.enable_6d() # Initial phys data calculation. - self._lattice_data = calculate_optics(self._at_lat, self._rp, self._sim_params) - - # Threading stuff initialisation. - self._queue = cothread.EventQueue() - # Explicitly manage the cothread Events, so turn off auto_reset. - # These are False when reset, True when signalled. - self._paused = cothread.Event(auto_reset=False) - self._quit_thread = cothread.Event(auto_reset=False) - self.up_to_date = cothread.Event(auto_reset=False) - self.up_to_date.Signal() - self._calculation_thread = cothread.Spawn(self._recalculate_phys_data, callback) - - def queue_set(self, func, field, value): + atsim._lattice_data = calculate_optics( + atsim._at_lat, atsim._rp, atsim._sim_params + ) + + atsim._loop = asyncio.get_event_loop() # TODO: check a loop is running + atsim._queue = asyncio.Queue() + atsim._paused = asyncio.Event() + atsim._quit_thread = asyncio.Event() + atsim._up_to_date = asyncio.Event() + atsim._up_to_date.set() + atsim._new_data_lock = asyncio.Lock() + + atsim._calculation_task = asyncio.create_task( + atsim._recalculate_phys_data(callback) + ) # This task should last the lifetime of the program + return atsim + + async def queue_set(self, func, field, value): """Add a change to the queue, to be applied when the queue is emptied. Args: @@ -215,31 +235,50 @@ def queue_set(self, func, field, value): field (str): The field to be changed. value (float): The value to be set. """ - cothread.CallbackResult(self.up_to_date.Reset) - cothread.Callback(self._queue.Signal, (func, field, value)) - - def _gather_one_sample(self): + async with self._new_data_lock: + # Clear first otherwise it is possible to yield to another thread which will + # then think the lattice is up to date with the most recently accepted caput + # /lattice.set_value. when it isnt. + self._up_to_date.clear() + await self._queue.put((func, field, value)) + # If this flag gets cleared while we are recalculating, then it can cause + # everything to lock, so we setup a lock between this function and the + # recalculate function + logging.debug(f"Added task to async queue. qsize={self._queue.qsize()}") + + async def _gather_one_sample(self): """If the queue is empty Wait() yields until an item is added. When the queue is not empty the oldest change will be removed and applied to the AT lattice. """ - apply_change_method, field, value = self._queue.Wait() + logging.debug("Waiting for new item in queue") + apply_change_method, field, value = await self._queue.get() apply_change_method(field, value) + logging.debug("Processed item from queue") - def quit_calculation_thread(self, timeout=10): + async def cancel_calculation_task(self, timeout=10): """Quit the calculation thread after the current loop is complete.""" - cothread.CallbackResult(self._quit_thread.Signal) - self.trigger_calculation() - cothread.CallbackResult(self._calculation_thread.Wait, timeout) - # For some reason we have to wait a bit before we can clear the queue. - cothread.Sleep(0.1) - cothread.CallbackResult(self._queue.Reset) - - def _recalculate_phys_data(self, callback): - """Target function for the Cothread thread. Recalculates the physics + # TODO: Im not really sure what the purpose of this function is, as it + # kills all functionality in the virtac and there is no way to restart + # it? It also isnt called anywhere, although could be from the python shell + + # Do one last calculation and then wait 0.5 seconds to give pvs a chance + # to be updated + await self.trigger_calculation() + await self._quit_thread.set() + await asyncio.sleep(0.5) + tasks = asyncio.all_tasks() + for task in tasks: + await task.cancel() + + async def _recalculate_phys_data(self, callback): + """Run as a never ending asyncio task. Recalculates the physics data dependent on the status of the '_paused' flag and the length of the queue. The calculations only take place if '_paused' is False and - there are one or more changes on the queue. + there are one or more changes on the queue. After doing the recalculations, + we set _up_to_date flag to signal this and run any passed callback functions. + For VIRTAC, the passed callback is ATIPServer.update_pvs which updates all + softioc PVs with the fresh data. .. Note:: If an error or exception is raised in the running thread then it does not continue running so subsequent calculations are not @@ -254,37 +293,43 @@ def _recalculate_phys_data(self, callback): at.AtWarning: any error or exception that was raised in the thread, but as a warning. """ - # Using Cothread Event is only ~4% slower than a normal Boolean but much safer. - while not self._quit_thread: - logging.debug("Starting recalculation loop") - self._gather_one_sample() - while self._queue: - self._gather_one_sample() - if bool(self._paused) is False: - try: - self._lattice_data = calculate_optics( - self._at_lat, self._rp, self._sim_params - ) - except Exception as e: - warn(at.AtWarning(e), stacklevel=1) - logging.warning( - "PVs will not be updated due to simulation exception" - ) - continue - # Signal the up to date flag since the physics data is now up to date. - # We do this before the callback is executed in case the callback - # checks the flag. - self.up_to_date.Signal() - if callback is not None: - logging.debug("Executing callback function.") - callback() - logging.debug("Callback completed.") - # This could cause thread-locking issues if another application using - # cothreads is poorly written and running on the same machine, but this - # isn't the case with any of the software that currently runs against - # ATIP/virtac and it's required for cothread.CallbackResult to play nicely - # with Bluesky. So it's a calculated risk until we move away from Cothread. - cothread.Yield() + logging.debug("Starting recalculation loop") + while not self._quit_thread.is_set(): + await self._gather_one_sample() + while not self._queue.empty(): + await self._gather_one_sample() + logging.debug("Recaulculating simulation with new setpoints.") + if not self._paused.is_set(): + async with self._new_data_lock: + with concurrent.futures.ProcessPoolExecutor() as pool: + try: + self._lattice_data = await self._loop.run_in_executor( + pool, + calculate_optics, + self._at_lat, + self._rp, + self._sim_params, + ) + except Exception as e: + # If an error is found while doing the calculations we dont + # update lattice data. TODO: We currently update the pvs + # anyway but this wont do anything, so could be improved + warn(at.AtWarning(e), stacklevel=1) + logging.warning( + "PVs will not be updated due to simulation exception" + ) + continue + # Signal the up to date flag since the physics data is now up to + # date. We do this before the callback is executed in case the + # callback checks the flag. + self._up_to_date.set() + logging.debug("Simulation up to date.") + if callback is not None: + logging.debug( + f"Executing callback function: {callback.__name__}" + ) + await callback() + logging.debug("Callback completed.") def toggle_calculations(self): """Pause or unpause the physics calculations by setting or clearing the @@ -292,27 +337,25 @@ def toggle_calculations(self): .. Note:: This does not pause the emptying of the queue. """ - if self._paused: - cothread.CallbackResult(self._paused.Reset) + if self._paused.is_set(): + self._paused.clear() else: - cothread.CallbackResult(self._paused.Signal) + self._paused.set() def pause_calculations(self): """Pause the physics calculations by setting the _paused flag. .. Note:: This does not pause the emptying of the queue. """ - if not self._paused: - cothread.CallbackResult(self._paused.Signal) + if not self._paused.is_set(): + self._paused.set() def unpause_calculations(self): """Unpause the physics calculations by clearing the _paused flag.""" - if self._paused: - cothread.CallbackResult(self._paused.Reset) - if not self.up_to_date: - self.trigger_calculation() + if self._paused.is_set(): + self._paused.clear() - def trigger_calculation(self): + async def trigger_calculation(self): """Unpause the physics calculations and add a null item to the queue to trigger a recalculation. @@ -320,9 +363,9 @@ def trigger_calculation(self): that is up to the user. """ self.unpause_calculations() - self.queue_set(lambda *x: None, None, None) + await self.queue_set(lambda *x: None, None, None) - def wait_for_calculations(self, timeout=10): + async def wait_for_calculations(self, timeout=10): """Wait until the physics calculations have taken account of all changes to the AT lattice, i.e. the physics data is fully up to date. @@ -334,9 +377,9 @@ def wait_for_calculations(self, timeout=10): concluded, else True. """ try: - cothread.CallbackResult(self.up_to_date.Wait, timeout) + await asyncio.wait_for(self._up_to_date.wait(), timeout) return True - except cothread.Timedout: + except asyncio.exceptions.TimeoutError: return False # Get lattice related data: diff --git a/src/atip/utils.py b/src/atip/utils.py index c28771a..b9990ef 100644 --- a/src/atip/utils.py +++ b/src/atip/utils.py @@ -1,4 +1,6 @@ +import asyncio import os +import random import at import pytac @@ -29,7 +31,7 @@ def load_at_lattice(mode="I04", **kwargs): return at_lattice -def loader( +async def loader( mode="I04", sim_params=None, callback=None, @@ -55,9 +57,9 @@ def loader( at_lattice = load_at_lattice( mode, periodicity=1, - energy=pytac_lattice.get_value("energy", units=pytac.PHYS), + energy=await pytac_lattice.get_value("energy", units=pytac.PHYS), ) - lattice = atip.load_sim.load( + lattice = await atip.load_sim.load( pytac_lattice, at_lattice, sim_params, @@ -184,7 +186,7 @@ def toggle_thread(target): get_atsim(target).toggle_calculations() -def trigger_calc(target): +async def trigger_calc(target): """Manually trigger a recalculation of the physics data on the ATSimulator object of the given unified Pytac lattice. @@ -196,5 +198,25 @@ def trigger_calc(target): be extracted. """ atsim = get_atsim(target) - atsim.trigger_calculation() + await atsim.trigger_calculation() print("Recalculation manually triggered.") + + +async def test_atip(): + # Load the DIAD lattice from Pytac. + lat = pytac.load_csv.load("DIAD") + await atip.load_sim.load_from_filepath(lat, "../atip/src/atip/rings/DIAD.mat") + # Use the sim by default. + lat.set_default_data_source(pytac.SIM) + # The initial beam position is zero. + print(await lat.get_value("x")) + + # Get the first horizontal corrector magnet and set its current to 1A. + hcor1 = lat.get_elements("HSTR")[0] + while True: + kick: float = random.uniform(0, 2) + print(f"Applying x_kick of {kick}") + await hcor1.set_value("x_kick", kick, units=pytac.ENG) + # Now the x beam position has changed. + print(f"New data: {await lat.get_value('x')}") + await asyncio.sleep(1) diff --git a/tests/conftest.py b/tests/conftest.py index a5f2ffd..b2f553d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -59,7 +59,7 @@ def at_elem_preset(): @pytest.fixture(scope="session") def atlds(): - return atip.sim_data_sources.ATLatticeDataSource(mock.Mock()) + return atip.sim_data_sources.ATLatticeDataSource(mock.AsyncMock()) @pytest.fixture(scope="function", params=["I04"]) @@ -90,15 +90,20 @@ def lattice_filepath(request): @pytest.fixture() -def atsim(at_lattice): - return atip.simulator.ATSimulator(at_lattice) +async def atsim(at_lattice): + atsim = await atip.simulator.ATSimulator.create(at_lattice) + # We must mock run_in_executor to allow it to work with other mocked objects + atsim._loop.run_in_executor = mock.AsyncMock( + side_effect=lambda pool, func, *args: func(*args) + ) + return atsim @pytest.fixture() -def mocked_atsim(at_lattice): +async def mocked_atsim(at_lattice): length = len(at_lattice) + 1 base = numpy.ones((length, 4)) - atsim = atip.simulator.ATSimulator(at_lattice) + atsim = await atip.simulator.ATSimulator.create(at_lattice) atsim._at_lat = mock.PropertyMock(energy=5, circumference=(length * 0.1)) emitdata = [{"emitXY": numpy.array([1.4, 0.45])}] twiss = { @@ -119,17 +124,17 @@ def mocked_atsim(at_lattice): @pytest.fixture() -def ba_atsim(at_lattice): +async def ba_atsim(at_lattice): dr = at.elements.Drift("d1", 1) dr.BendingAngle = 9001 lat = [at.elements.Dipole("b1", 1, 1.3), at.elements.Dipole("b2", 1, -0.8)] - at_sim = atip.simulator.ATSimulator(at_lattice) + at_sim = await atip.simulator.ATSimulator.create(at_lattice) at_sim._at_lat = lat return at_sim @pytest.fixture() -def initial_phys_data(at_lattice): +def initial_phys_data(atsim, at_lattice): return { "tune": numpy.array([0.1823785, 0.2730096]), "chromaticity": numpy.array([2.05528097, 2.90000203]), @@ -138,7 +143,7 @@ def initial_phys_data(at_lattice): [8.04285115e-02, 1.82229041e-03, -4.66599806e-16, 9.68514718e-17] ), "s_pos": numpy.cumsum( - [0.0] + [getattr(elem, "Length", 0) for elem in at_lattice[:-1]] + [0.0] + [getattr(elem, "Length", 0) for elem in atsim._at_lat[:-1]] ), "alpha": numpy.array([0.41083373, 0.76826358]), "beta": numpy.array([11.41465744, 9.38580055]), diff --git a/tests/test_at_simulator_object.py b/tests/test_at_simulator_object.py index cf540a2..12d0c93 100644 --- a/tests/test_at_simulator_object.py +++ b/tests/test_at_simulator_object.py @@ -1,7 +1,7 @@ +import asyncio from unittest import mock import at -import cothread import numpy import pytest from pytac.exceptions import DataSourceException, FieldException @@ -66,81 +66,62 @@ def _check_initial_phys_data(atsim, initial_phys_data): def test_ATSimulator_creation(atsim, initial_phys_data): # Check initial state of flags. - assert not atsim._paused - assert atsim.up_to_date - assert len(atsim._queue) == 0 + assert not atsim._paused.is_set() + assert atsim._up_to_date.is_set() + assert atsim._queue.empty() # Check physics data is initially calculated correctly. _check_initial_phys_data(atsim, initial_phys_data) -def test_recalculate_phys_data_queue(atsim): +async def test_recalculate_phys_data_queue(atsim): elem_ds = mock.Mock() - assert atsim.up_to_date - atsim.queue_set(elem_ds._make_change, "a_field", 12) - assert not atsim.up_to_date - cothread.Sleep(0.1) + assert atsim._up_to_date.is_set() + await atsim.queue_set(elem_ds._make_change, "a_field", 12) + assert not atsim._up_to_date.is_set() + await atsim.wait_for_calculations() elem_ds._make_change.assert_called_once_with("a_field", 12) -def test_pause_calculations(atsim): +async def test_pause_calculations(atsim): elem_ds = mock.Mock() atsim.pause_calculations() - atsim.queue_set(elem_ds._make_change, "a_field", 12) - cothread.Sleep(0.1) + await atsim.queue_set(elem_ds._make_change, "a_field", 12) + await asyncio.sleep(0.1) # Queue emptied even though paused. - assert len(atsim._queue) == 0 + assert atsim._queue.empty() elem_ds._make_change.assert_called_once_with("a_field", 12) # Calculation not updated because paused. - assert not atsim.up_to_date - # Check we don't have to add another item to the queue to prompt a recalculation. + assert not atsim._up_to_date.is_set() atsim.unpause_calculations() - cothread.Sleep(0.1) + await atsim.trigger_calculation() + await atsim.wait_for_calculations() # Calculation now updated. - assert atsim.up_to_date - - -def test_quit_calculation_thread(atsim): - # Check our thread initially works - atsim._lattice_data = None - atsim.trigger_calculation() - assert atsim.wait_for_calculations() is True - assert len(atsim._queue) == 0 - assert atsim._lattice_data is not None - # Stop the calculation thread - assert len(atsim._queue) == 0 - atsim.quit_calculation_thread() - assert len(atsim._queue) == 0 - # Check our thread no longer works - atsim._lattice_data = None - atsim.trigger_calculation() - assert atsim.wait_for_calculations(2) is False - assert len(atsim._queue) == 1 - assert atsim._lattice_data is None - - -def test_gather_one_sample(atsim): + assert atsim._up_to_date.is_set() + + +async def test_gather_one_sample(atsim): # Stop the calculation thread from emptying the queue - atsim.quit_calculation_thread() + atsim.pause_calculations() + await asyncio.sleep(0.1) # Add something to the queue elem_ds = mock.Mock() - atsim.queue_set(elem_ds._make_change, "a_field", 12) - cothread.Sleep(0.1) + await atsim.queue_set(elem_ds._make_change, "a_field", 12) # Make sure it's on the queue and hasn't already been gathered - assert len(atsim._queue) == 1 + assert not atsim._queue.empty() elem_ds._make_change.assert_not_called() # Gather it off the queue and check that our mock change has been called correctly - atsim._gather_one_sample() - assert len(atsim._queue) == 0 + await atsim._gather_one_sample() + assert atsim._queue.empty() elem_ds._make_change.assert_called_once_with("a_field", 12) -def test_recalculate_phys_data(atsim, initial_phys_data): +async def test_recalculate_phys_data(atsim, initial_phys_data): _check_initial_phys_data(atsim, initial_phys_data) # Check that errors raised inside thread are converted to warnings. atsim._at_lat[4].PolynomB[0] = 1.0e10 - atsim.queue_set(mock.Mock(), "f", 0) + await atsim.queue_set(mock.Mock(), "f", 0) with pytest.warns(at.AtWarning): - atsim.wait_for_calculations() + await atsim.wait_for_calculations() atsim._at_lat[4].PolynomB[0] = 0.0 # Set corrector x_kick but on a sextupole as no correctors in test ring atsim._at_lat[7].PolynomB[0] = -7.0e-5 @@ -153,8 +134,8 @@ def test_recalculate_phys_data(atsim, initial_phys_data): # Set sextupole b2 atsim._at_lat[7].PolynomB[2] = 10 # Clear the flag and then wait for the calculations - atsim.queue_set(mock.Mock(), "f", 0) - atsim.wait_for_calculations() + await atsim.queue_set(mock.Mock(), "f", 0) + await atsim.wait_for_calculations() # Get the applicable physics data orbit = [atsim.get_orbit("x")[0], atsim.get_orbit("y")[0]] chrom = [atsim.get_chromaticity("x"), atsim.get_chromaticity("y")] @@ -169,60 +150,60 @@ def test_recalculate_phys_data(atsim, initial_phys_data): numpy.testing.assert_almost_equal(emit, [1.34308653e-10, 3.74339964e-13], decimal=3) -def test_ohmi_envelope_with_emittance_enabled(atsim, initial_phys_data): +async def test_ohmi_envelope_with_emittance_enabled(atsim): + atsim._at_lat.ohmi_envelope = mock.Mock() # Check emittance data is intially there assert len(atsim._lattice_data.emittance) == 3 assert atsim._sim_params.emittance # Check that ohmi_envelope is called when emittance is True - atsim._at_lat.ohmi_envelope = mock.Mock() - atsim.trigger_calculation() - cothread.Sleep(0.1) + await atsim.trigger_calculation() + await atsim.wait_for_calculations() atsim._at_lat.ohmi_envelope.assert_called_once() -def test_ohmi_envelope_with_emittance_disabled(atsim, initial_phys_data): +async def test_ohmi_envelope_with_emittance_disabled(atsim): atsim._sim_params = atip.simulator.SimParams(emittance=False) assert not atsim._sim_params.emittance atsim._at_lat.ohmi_envelope = mock.Mock() - atsim.trigger_calculation() - cothread.Sleep(0.1) + await atsim.trigger_calculation() + await atsim.wait_for_calculations() # Check that ohmi_envelope isn't called when emittance is False and that # there isn't any emittance data atsim._at_lat.ohmi_envelope.assert_not_called() assert len(atsim._lattice_data.emittance) == 0 -def test_toggle_calculations_and_wait_for_calculations(atsim, initial_phys_data): - assert not atsim._paused +async def test_toggle_calculations_and_wait_for_calculations(atsim, initial_phys_data): + assert not atsim._paused.is_set() atsim.toggle_calculations() - assert atsim._paused + assert atsim._paused.is_set() atsim.toggle_calculations() - assert not atsim._paused + assert not atsim._paused.is_set() # pause > make a change > check no calc > unpause > check calc atsim.toggle_calculations() # Kick quadrupole atsim._at_lat[4].PolynomB[1] = -0.8 - atsim.queue_set(mock.Mock(), "f", 0) - assert atsim.wait_for_calculations(2) is False + await atsim.queue_set(mock.Mock(), "f", 0) + assert await atsim.wait_for_calculations(2) is False _check_initial_phys_data(atsim, initial_phys_data) atsim.toggle_calculations() - atsim.queue_set(mock.Mock(), "f", 0) - assert atsim.wait_for_calculations() is True + await atsim.queue_set(mock.Mock(), "f", 0) + assert await atsim.wait_for_calculations() is True # Physics data has changed. with pytest.raises(AssertionError): _check_initial_phys_data(atsim, initial_phys_data) -def test_recalculate_phys_data_callback(at_lattice): +async def test_recalculate_phys_data_callback(at_lattice): # Check ATSimulator is created successfully if no callback. - atip.simulator.ATSimulator(at_lattice) + await atip.simulator.ATSimulator.create(at_lattice) # Check non-callable callback argument raises TypeError. with pytest.raises(TypeError): - atip.simulator.ATSimulator(at_lattice, callback="") - callback_func = mock.Mock() - atsim = atip.simulator.ATSimulator(at_lattice, callback=callback_func) - atsim.queue_set(mock.Mock(), "f", 0) - atsim.wait_for_calculations() + await atip.simulator.ATSimulator.create(at_lattice, callback="") + callback_func = mock.AsyncMock() + atsim = await atip.simulator.ATSimulator.create(at_lattice, callback=callback_func) + await atsim.queue_set(mock.Mock(), "f", 0) + await atsim.wait_for_calculations() callback_func.assert_called_once_with() diff --git a/tests/test_element_data_source.py b/tests/test_element_data_source.py index db0c5ca..5ef27b5 100644 --- a/tests/test_element_data_source.py +++ b/tests/test_element_data_source.py @@ -92,16 +92,16 @@ def test_elem_add_field_raises_FieldExceptions_correctly(at_elem, field): ateds.add_field(field) -def test_elem_get_value_handles_calculation_check_time_out_correctly(at_elem): - atsim = mock.Mock() +async def test_elem_get_value_handles_calculation_check_time_out_correctly(at_elem): + atsim = mock.AsyncMock() ateds = atip.sim_data_sources.ATElementDataSource(at_elem, 1, atsim, ["f"]) atsim.wait_for_calculations.return_value = False # Check fails, throw is True, so exception is raised. with pytest.raises(ControlSystemException): - ateds.get_value("f", throw=True) + await ateds.get_value("f", throw=True) # Check fails, throw is False, so warning is logged and value is returned. with LogCapture() as log: - assert ateds.get_value("f", throw=False) == 0 + assert await ateds.get_value("f", throw=False) == 0 log.check( ( "root", @@ -112,17 +112,19 @@ def test_elem_get_value_handles_calculation_check_time_out_correctly(at_elem): ) atsim.wait_for_calculations.return_value = True # Check doesn't fail, so doesn't raise error or warn and data is returned. - assert ateds.get_value("f", throw=True) == 0 - assert ateds.get_value("f", throw=False) == 0 + assert await ateds.get_value("f", throw=True) == 0 + assert await ateds.get_value("f", throw=False) == 0 @pytest.mark.parametrize("field", ["not_a_field", 1, [], "a1", "X_KICK"]) -def test_elem_get_value_raises_FieldException_if_nonexistent_field(at_elem, field): +async def test_elem_get_value_raises_FieldException_if_nonexistent_field( + at_elem, field +): ateds = atip.sim_data_sources.ATElementDataSource( - at_elem, 1, mock.Mock(), ["x_kick"] + at_elem, 1, mock.AsyncMock(), ["x_kick"] ) with pytest.raises(FieldException): - ateds.get_value(field) + await ateds.get_value(field) @pytest.mark.parametrize( @@ -137,56 +139,60 @@ def test_elem_get_value_raises_FieldException_if_nonexistent_field(at_elem, fiel ("b2", 42), ], ) -def test_elem_get_value(at_elem_preset, field, value): +async def test_elem_get_value(at_elem_preset, field, value): ateds = atip.sim_data_sources.ATElementDataSource( - at_elem_preset, 6, mock.Mock(), [field] + at_elem_preset, 6, mock.AsyncMock(), [field] ) - assert ateds.get_value(field) == value + assert await ateds.get_value(field) == value -def test_elem_get_orbit(at_elem_preset): - atsim = mock.Mock() - atsim.get_orbit.return_value = [27, 53, 741, 16, 12, 33] +async def test_elem_get_orbit(at_elem_preset): + atsim = mock.AsyncMock() + atsim.get_orbit = mock.Mock(return_value=[27, 53, 741, 16, 12, 33]) ateds = atip.sim_data_sources.ATElementDataSource( at_elem_preset, 6, atsim, ["x", "y"] ) - assert ateds.get_value("x") == 33 + assert await ateds.get_value("x") == 33 ateds._index = 3 - assert ateds.get_value("y") == 741 + assert await ateds.get_value("y") == 741 -def test_elem_get_value_on_Sextupole(): +async def test_elem_get_value_on_Sextupole(): s = at.elements.Sextupole( "S1", 0.1, PolynomA=[50, 0, 0, 0], PolynomB=[-10, 0, 0, 0] ) ateds = atip.sim_data_sources.ATElementDataSource( - s, 0, mock.Mock(), ["x_kick", "y_kick"] + s, 0, mock.AsyncMock(), ["x_kick", "y_kick"] ) - assert ateds.get_value("x_kick") == 1 - assert ateds.get_value("y_kick") == 5 + assert await ateds.get_value("x_kick") == 1 + assert await ateds.get_value("y_kick") == 5 @pytest.mark.parametrize("field", ["not_a_field", 1, [], "a1", "X_KICK"]) -def test_elem_set_value_raises_FieldException_if_nonexistant_field(at_elem, field): +async def test_elem_set_value_raises_FieldException_if_nonexistant_field( + at_elem, field +): ateds = atip.sim_data_sources.ATElementDataSource( - at_elem, 1, mock.Mock(), ["x_kick"] + at_elem, 1, mock.AsyncMock(), ["x_kick"] ) with pytest.raises(FieldException): - ateds.set_value(field, 0) + await ateds.set_value(field, 0) @pytest.mark.parametrize("field", ["x", "y"]) -def test_elem_set_orbit_raises_HandleException(at_elem, field): - ateds = atip.sim_data_sources.ATElementDataSource(at_elem, 1, mock.Mock(), [field]) +async def test_elem_set_orbit_raises_HandleException(at_elem, field): + ateds = atip.sim_data_sources.ATElementDataSource( + at_elem, 1, mock.AsyncMock(), [field] + ) with pytest.raises(HandleException): - ateds.set_value(field, 0) + await ateds.set_value(field, 0) @pytest.mark.parametrize("field", ["x_kick", "y_kick", "a1", "b0", "b1", "b2", "f"]) -def test_elem_set_value_adds_changes_to_queue(at_elem, field): - atsim = mock.Mock() +async def test_elem_set_value_adds_changes_to_queue(at_elem, field): + atsim = mock.AsyncMock() ateds = atip.sim_data_sources.ATElementDataSource(at_elem, 1, atsim, [field]) - ateds.set_value(field, 1) + await ateds.set_value(field, 1) assert len(atsim.queue_set.mock_calls) == 1 assert atsim.queue_set.mock_calls[0] == mock.call(ateds._make_change, field, 1) diff --git a/tests/test_lattice_data_source.py b/tests/test_lattice_data_source.py index 561d08f..9cfed9e 100644 --- a/tests/test_lattice_data_source.py +++ b/tests/test_lattice_data_source.py @@ -77,22 +77,22 @@ def test_lat_get_fields(atlds): @pytest.mark.parametrize("field", ["not_a_field", 1, [], "BETA", ["x", "y"]]) -def test_lat_get_value_raises_FieldException_if_nonexistent_field(atlds, field): +async def test_lat_get_value_raises_FieldException_if_nonexistent_field(atlds, field): with pytest.raises(FieldException): - atlds.get_value(field) + await atlds.get_value(field) -def test_lat_get_value_handles_calculation_check_time_out_correctly(): - atsim = mock.Mock() - atsim.get_dispersion.return_value = 2.5 +async def test_lat_get_value_handles_calculation_check_time_out_correctly(): + atsim = mock.AsyncMock() + atsim.get_dispersion = mock.Mock(return_value=2.5) atlds = atip.sim_data_sources.ATLatticeDataSource(atsim) atsim.wait_for_calculations.return_value = False # Check fails, throw is True, so exception is raised. with pytest.raises(ControlSystemException): - atlds.get_value("dispersion", throw=True) + await atlds.get_value("dispersion", throw=True) # Check fails, throw is False, so warning is logged and value is returned. with LogCapture() as log: - assert atlds.get_value("dispersion", throw=False) == 2.5 + assert await atlds.get_value("dispersion", throw=False) == 2.5 log.check( ( "root", @@ -103,24 +103,25 @@ def test_lat_get_value_handles_calculation_check_time_out_correctly(): ) atsim.wait_for_calculations.return_value = True # Check doesn't fail, so doesn't raise error or warn and data is returned. - assert atlds.get_value("dispersion", throw=True) == 2.5 - assert atlds.get_value("dispersion", throw=False) == 2.5 + assert await atlds.get_value("dispersion", throw=True) == 2.5 + assert await atlds.get_value("dispersion", throw=False) == 2.5 -def test_lat_get_value(): +async def test_lat_get_value(): """We don't need to test every value for get_value() as _field_funcs which it relys on has alreadly been tested for all fields.""" - atsim = mock.Mock() - atsim.get_dispersion.return_value = 2.5 + atsim = mock.AsyncMock() + atsim.get_dispersion = mock.Mock(return_value=2.5) + atsim.get_orbit = mock.Mock() atlds = atip.sim_data_sources.ATLatticeDataSource(atsim) - assert atlds.get_value("dispersion") == 2.5 - atlds.get_value("x") + assert await atlds.get_value("dispersion") == 2.5 + await atlds.get_value("x") atsim.get_orbit.assert_called_with("x") - atlds.get_value("phase_x") + await atlds.get_value("phase_x") atsim.get_orbit.assert_called_with("px") - atlds.get_value("y") + await atlds.get_value("y") atsim.get_orbit.assert_called_with("y") - atlds.get_value("phase_y") + await atlds.get_value("phase_y") atsim.get_orbit.assert_called_with("py") diff --git a/tests/test_load.py b/tests/test_load.py index 4cad808..7d5c990 100644 --- a/tests/test_load.py +++ b/tests/test_load.py @@ -18,8 +18,8 @@ def test_load_atip_lattice(request, at_lattice): @pytest.mark.parametrize("at_and_pytac_lattices", RINGMODES_TO_TEST, indirect=True) -def test_load_pytac_side(at_and_pytac_lattices): - lat = atip.load_sim.load(at_and_pytac_lattices[0], at_and_pytac_lattices[1]) +async def test_load_pytac_side(at_and_pytac_lattices): + lat = await atip.load_sim.load(at_and_pytac_lattices[0], at_and_pytac_lattices[1]) # Check lattice has simulator data source assert pytac.SIM in lat._data_source_manager._data_sources # Check all elements have simulator data source @@ -35,42 +35,46 @@ def test_load_pytac_side(at_and_pytac_lattices): [(mode, mode) for mode in RINGMODES_TO_TEST], indirect=True, ) -def test_load_atip_and_pytac_lattices(pytac_lattice, lattice_filepath): - atip.load_sim.load_from_filepath(pytac_lattice, lattice_filepath) +async def test_load_atip_and_pytac_lattices(pytac_lattice, lattice_filepath): + await atip.load_sim.load_from_filepath(pytac_lattice, lattice_filepath) @pytest.mark.parametrize("at_and_pytac_lattices", RINGMODES_TO_TEST, indirect=True) -def test_load_with_non_callable_callback_raises_TypeError(at_and_pytac_lattices): +async def test_load_with_non_callable_callback_raises_TypeError(at_and_pytac_lattices): with pytest.raises(TypeError): - atip.load_sim.load( + await atip.load_sim.load( at_and_pytac_lattices[0], at_and_pytac_lattices[1], callback="" ) @pytest.mark.parametrize("at_and_pytac_lattices", RINGMODES_TO_TEST, indirect=True) -def test_load_with_callback(at_and_pytac_lattices): - callback_func = mock.Mock() - lat = atip.load_sim.load( - at_and_pytac_lattices[0], at_and_pytac_lattices[1], callback=callback_func +async def test_load_with_callback(at_and_pytac_lattices): + callback_func = mock.AsyncMock() + lat = await atip.load_sim.load( + at_and_pytac_lattices[0], + at_and_pytac_lattices[1], + callback=callback_func, ) atsim = lat._data_source_manager._data_sources[pytac.SIM]._atsim - atip.utils.trigger_calc(at_and_pytac_lattices[0]) - atsim.wait_for_calculations() + await atip.utils.trigger_calc(at_and_pytac_lattices[0]) + await atsim.wait_for_calculations() callback_func.assert_called_once_with() -def test_load_raises_ValueError_if_incompatible_lattices(): +async def test_load_raises_ValueError_if_incompatible_lattices(): with pytest.raises(ValueError): - atip.load_sim.load([1], [1, 2]) # length mismatch + await atip.load_sim.load([1], [1, 2]) # length mismatch @mock.patch("atip.simulator.calculate_optics") -def test_load_from_filepath_with_default_sim_params( +async def test_load_from_filepath_with_default_sim_params( mocked_calc_optics, pytac_lattice, lattice_filepath, ): - pytac_lattice = atip.load_sim.load_from_filepath(pytac_lattice, lattice_filepath) + pytac_lattice = await atip.load_sim.load_from_filepath( + pytac_lattice, lattice_filepath + ) mocked_calc_optics.assert_called_with( mock.ANY, mock.ANY, atip.simulator.SimParams() @@ -89,7 +93,7 @@ def test_load_from_filepath_with_default_sim_params( ], ) @mock.patch("atip.simulator.calculate_optics") -def test_load_with_non_default_sim_params( +async def test_load_with_non_default_sim_params( mocked_calc_optics, pytac_lattice, lattice_filepath, @@ -105,7 +109,7 @@ def test_load_with_non_default_sim_params( radiation, ) - pytac_lattice = atip.load_sim.load_from_filepath( + pytac_lattice = await atip.load_sim.load_from_filepath( pytac_lattice, lattice_filepath, sim_params )