From 2d9590f0ececa9b1420937d2301385d9737e901f Mon Sep 17 00:00:00 2001 From: Phil Smith Date: Thu, 11 Sep 2025 13:44:04 +0000 Subject: [PATCH 01/18] Refactored tests and added ringmode 48 Added a single place to define the ringmodes to tests. Created a combined fixture for loading both a pytac and atip lattice. Added a new test which tests just loading of the atip lattice. --- tests/conftest.py | 4 ++-- tests/test_load.py | 30 +++++++++++++++++------------- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index a5f2ffd..951a8d7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -63,7 +63,7 @@ def atlds(): @pytest.fixture(scope="function", params=["I04"]) -def at_and_pytac_lattices(request): +def load_at_and_pytac_lattices(request): lattices = [] lattices.append(load_csv.load(request.param, cs.ControlSystem())) lattices.append(atip.utils.load_at_lattice(request.param)) @@ -81,7 +81,7 @@ def at_lattice(request): @pytest.fixture(scope="function", params=["I04"]) -def lattice_filepath(request): +def get_lattice_filepath(request): here = os.path.dirname(__file__) filepath = os.path.realpath( os.path.join(here, f"../src/atip/rings/{request.param}.mat") diff --git a/tests/test_load.py b/tests/test_load.py index 4cad808..4a6ab8b 100644 --- a/tests/test_load.py +++ b/tests/test_load.py @@ -17,9 +17,11 @@ def test_load_atip_lattice(request, at_lattice): assert at_lattice.name == request.node.callspec.params["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]) +@pytest.mark.parametrize("load_at_and_pytac_lattices", RINGMODES_TO_TEST, indirect=True) +def test_load_pytac_side(load_at_and_pytac_lattices): + lat = atip.load_sim.load( + load_at_and_pytac_lattices[0], load_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 @@ -31,30 +33,32 @@ def test_load_pytac_side(at_and_pytac_lattices): @pytest.mark.parametrize( - ["pytac_lattice", "lattice_filepath"], + ["pytac_lattice", "get_lattice_filepath"], [(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) +def test_load_atip_and_pytac_lattices(pytac_lattice, get_lattice_filepath): + atip.load_sim.load_from_filepath(pytac_lattice, get_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): +@pytest.mark.parametrize("load_at_and_pytac_lattices", RINGMODES_TO_TEST, indirect=True) +def test_load_with_non_callable_callback_raises_TypeError(load_at_and_pytac_lattices): with pytest.raises(TypeError): atip.load_sim.load( - at_and_pytac_lattices[0], at_and_pytac_lattices[1], callback="" + load_at_and_pytac_lattices[0], load_at_and_pytac_lattices[1], "" ) -@pytest.mark.parametrize("at_and_pytac_lattices", RINGMODES_TO_TEST, indirect=True) -def test_load_with_callback(at_and_pytac_lattices): +@pytest.mark.parametrize("load_at_and_pytac_lattices", RINGMODES_TO_TEST, indirect=True) +def test_load_with_callback(load_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 + load_at_and_pytac_lattices[0], + load_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]) + atip.utils.trigger_calc(load_at_and_pytac_lattices[0]) atsim.wait_for_calculations() callback_func.assert_called_once_with() From bb751e0b8ddd2e3f0acf144b1dc3a80fc3aa60d3 Mon Sep 17 00:00:00 2001 From: Phil Smith Date: Mon, 22 Sep 2025 11:27:30 +0000 Subject: [PATCH 02/18] Test fixes for previous changes --- tests/conftest.py | 4 ++-- tests/test_load.py | 30 ++++++++++++++---------------- 2 files changed, 16 insertions(+), 18 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 951a8d7..a5f2ffd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -63,7 +63,7 @@ def atlds(): @pytest.fixture(scope="function", params=["I04"]) -def load_at_and_pytac_lattices(request): +def at_and_pytac_lattices(request): lattices = [] lattices.append(load_csv.load(request.param, cs.ControlSystem())) lattices.append(atip.utils.load_at_lattice(request.param)) @@ -81,7 +81,7 @@ def at_lattice(request): @pytest.fixture(scope="function", params=["I04"]) -def get_lattice_filepath(request): +def lattice_filepath(request): here = os.path.dirname(__file__) filepath = os.path.realpath( os.path.join(here, f"../src/atip/rings/{request.param}.mat") diff --git a/tests/test_load.py b/tests/test_load.py index 4a6ab8b..2629855 100644 --- a/tests/test_load.py +++ b/tests/test_load.py @@ -17,11 +17,9 @@ def test_load_atip_lattice(request, at_lattice): assert at_lattice.name == request.node.callspec.params["at_lattice"] -@pytest.mark.parametrize("load_at_and_pytac_lattices", RINGMODES_TO_TEST, indirect=True) -def test_load_pytac_side(load_at_and_pytac_lattices): - lat = atip.load_sim.load( - load_at_and_pytac_lattices[0], load_at_and_pytac_lattices[1] - ) +@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]) # Check lattice has simulator data source assert pytac.SIM in lat._data_source_manager._data_sources # Check all elements have simulator data source @@ -33,32 +31,32 @@ def test_load_pytac_side(load_at_and_pytac_lattices): @pytest.mark.parametrize( - ["pytac_lattice", "get_lattice_filepath"], + ["pytac_lattice", "lattice_filepath"], [(mode, mode) for mode in RINGMODES_TO_TEST], indirect=True, ) -def test_load_atip_and_pytac_lattices(pytac_lattice, get_lattice_filepath): - atip.load_sim.load_from_filepath(pytac_lattice, get_lattice_filepath) +def test_load_atip_and_pytac_lattices(pytac_lattice, lattice_filepath): + atip.load_sim.load_from_filepath(pytac_lattice, lattice_filepath) -@pytest.mark.parametrize("load_at_and_pytac_lattices", RINGMODES_TO_TEST, indirect=True) -def test_load_with_non_callable_callback_raises_TypeError(load_at_and_pytac_lattices): +@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): with pytest.raises(TypeError): atip.load_sim.load( - load_at_and_pytac_lattices[0], load_at_and_pytac_lattices[1], "" + at_and_pytac_lattices[0], at_and_pytac_lattices[1], callback="" ) -@pytest.mark.parametrize("load_at_and_pytac_lattices", RINGMODES_TO_TEST, indirect=True) -def test_load_with_callback(load_at_and_pytac_lattices): +@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( - load_at_and_pytac_lattices[0], - load_at_and_pytac_lattices[1], + 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(load_at_and_pytac_lattices[0]) + atip.utils.trigger_calc(at_and_pytac_lattices[0]) atsim.wait_for_calculations() callback_func.assert_called_once_with() From c6bdf9c2763e60bc3b2d0d475211a8bdb689943c Mon Sep 17 00:00:00 2001 From: Phil Smith Date: Thu, 2 Oct 2025 10:00:02 +0000 Subject: [PATCH 03/18] Switch tests to use I04 instead of deleted HMBA --- tests/conftest.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index a5f2ffd..81e5943 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -129,7 +129,7 @@ def ba_atsim(at_lattice): @pytest.fixture() -def initial_phys_data(at_lattice): +def initial_phys_data(atsim): return { "tune": numpy.array([0.1823785, 0.2730096]), "chromaticity": numpy.array([2.05528097, 2.90000203]), @@ -138,7 +138,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]), From 2ff4acbd52e5dc9a4029eeebece5dea3de2c557f Mon Sep 17 00:00:00 2001 From: Phil Smith Date: Wed, 30 Apr 2025 16:45:28 +0000 Subject: [PATCH 04/18] Switched main codebase from cothread to asyncio and aioca Added: Awaits and asyncio, switch from cothread queue to asyncio queue, caput/cage/camonitor from aioca instead of cothread, changed how the code is run (now uses asyncio.run()), made some necessary changes to events and when they are set/cleared, removed most references to cothread besides a few in the documentation which will be updated seperately and those in create_csv.py which will be updated later --- INSTALL.rst | 3 - docs/conf.py | 1 - src/atip/load_sim.py | 10 +-- src/atip/sim_data_sources.py | 12 +-- src/atip/simulator.py | 168 ++++++++++++++++++++--------------- src/atip/utils.py | 8 +- 6 files changed, 113 insertions(+), 89 deletions(-) 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/src/atip/load_sim.py b/src/atip/load_sim.py index 4759e7e..5aa1fc4 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( 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..1e6835a 100644 --- a/src/atip/simulator.py +++ b/src/atip/simulator.py @@ -1,12 +1,12 @@ """Module containing an interface with the AT simulator.""" +import asyncio 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 +135,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 +147,27 @@ 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. """ - 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 + + @classmethod + async def create( + cls, at_lattice, sim_params=None, callback=None, @@ -180,6 +188,7 @@ def __init__( **Methods:** """ + self = cls() if (not callable(callback)) and (callback is not None): raise TypeError( f"If passed, 'callback' should be callable, {callback} is not." @@ -197,17 +206,19 @@ def __init__( # 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): + self._loop = asyncio.get_event_loop() # TODO: check a loop is running + self._queue = asyncio.Queue() + self._paused = asyncio.Event() + self._quit_thread = asyncio.Event() + self._up_to_date = asyncio.Event() + self._up_to_date.set() + + self._calculation_task = asyncio.create_task( + self._recalculate_phys_data(callback) + ) # This task should last the lifetime of the program + return self + + 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 +226,45 @@ 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)) + 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()}") - def _gather_one_sample(self): + 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 +279,39 @@ 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: + 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(): try: self._lattice_data = 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.Signal() + # 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("Executing callback function.") - callback() + logging.debug(f"Executing callback function: {callback.__name__}") + await 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() + # After this point we assume new setpoints have made the data stale. We + # cant clear this flag in queue_set() as the callbacks can depend on + # this being set. + self._up_to_date.clear() def toggle_calculations(self): """Pause or unpause the physics calculations by setting or clearing the @@ -292,37 +319,38 @@ 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) + # TODO: These dont currently get called anyway, maybe add a pv to call them? + if not self._paused.is_set(): + self._paused.set() - def unpause_calculations(self): + async 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(): + await self._paused.clear() + if not self._up_to_date: + await self.trigger_calculation() - 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. .. Note:: This method does not wait for the recalculation to complete, that is up to the user. """ - self.unpause_calculations() + await self.unpause_calculations() 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 +362,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..b584fb6 100644 --- a/src/atip/utils.py +++ b/src/atip/utils.py @@ -29,7 +29,7 @@ def load_at_lattice(mode="I04", **kwargs): return at_lattice -def loader( +async def loader( mode="I04", sim_params=None, callback=None, @@ -51,13 +51,13 @@ def loader( pytac.lattice.Lattice: A Pytac lattice object with the simulator data source loaded. """ - pytac_lattice = pytac.load_csv.load(mode, symmetry=24) + pytac_lattice = await pytac.load_csv.load(mode, symmetry=24) 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, From 34fb4f5c5d4a1113f705756954839ac23b182675 Mon Sep 17 00:00:00 2001 From: Phil Smith Date: Wed, 30 Apr 2025 16:51:50 +0000 Subject: [PATCH 05/18] Run recalculation in seperate process CPU intensive recalculations are now done on a seperate cpu core. This means can now handle CA requests at the same time as doing the calculation. Fresh values will only be output to records after recalculation is done while the up_to_date event is set. So the output pvs will still be to date with the last processed pv setpoint. There may still be setpoints in the queue waiting to be processed, so the output data will not be up to date with these --- src/atip/simulator.py | 32 +++++++++++++++++++------------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/src/atip/simulator.py b/src/atip/simulator.py index 1e6835a..c86b332 100644 --- a/src/atip/simulator.py +++ b/src/atip/simulator.py @@ -1,6 +1,7 @@ """Module containing an interface with the AT simulator.""" import asyncio +import concurrent import logging from dataclasses import dataclass from enum import StrEnum, auto @@ -286,19 +287,24 @@ async def _recalculate_phys_data(self, callback): await self._gather_one_sample() logging.debug("Recaulculating simulation with new setpoints.") if not self._paused.is_set(): - try: - self._lattice_data = 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 + 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. From c3d5f7f3535f906abc27dd819218067c14f16cc2 Mon Sep 17 00:00:00 2001 From: Phil Smith Date: Wed, 30 Apr 2025 17:18:01 +0000 Subject: [PATCH 06/18] Add atip test which can be run from the CLI Add test functionality to atip as well as configuration to run it from vscode. This means that something actually happens when you run the 'atip' command if you pass '-t'. This makes it easier to test atip without virtac. Also changed vscode default CA ports to 806*. --- src/atip/__main__.py | 39 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/src/atip/__main__.py b/src/atip/__main__.py index 137732d..d652280 100644 --- a/src/atip/__main__.py +++ b/src/atip/__main__.py @@ -1,5 +1,8 @@ """Interface for ``python -m atip``.""" +import asyncio +import logging +import random from argparse import ArgumentParser from collections.abc import Sequence @@ -8,7 +11,7 @@ __all__ = ["main"] -def main(args: Sequence[str] | None = None) -> None: +async def async_main(args: Sequence[str] | None = None) -> None: """Argument parser for the CLI.""" parser = ArgumentParser() parser.add_argument( @@ -17,7 +20,39 @@ def main(args: Sequence[str] | None = None) -> None: action="version", version=__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: + # Load the DIAD lattice from Pytac. + lat = await 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) + + +def main(args: Sequence[str] | None = None) -> None: + logging.basicConfig() + logging.getLogger().setLevel(logging.DEBUG) + # Load the AT sim into the Pytac lattice. + asyncio.run(async_main(args)) if __name__ == "__main__": From b56ac30c45f1b0dfc9ef01956ee1cc6b621c0b46 Mon Sep 17 00:00:00 2001 From: Phil Smith Date: Thu, 1 May 2025 16:07:15 +0000 Subject: [PATCH 07/18] Reworked async locks --- src/atip/simulator.py | 86 +++++++++++++++++++++++++------------------ 1 file changed, 51 insertions(+), 35 deletions(-) diff --git a/src/atip/simulator.py b/src/atip/simulator.py index c86b332..f370954 100644 --- a/src/atip/simulator.py +++ b/src/atip/simulator.py @@ -157,6 +157,10 @@ class ATSimulator: 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. """ _loop: asyncio.BaseEventLoop @@ -165,6 +169,7 @@ class ATSimulator: _quit_thread: asyncio.Event _up_to_date: asyncio.Event _calculation_task: asyncio.Task + _new_data_lock: asyncio.Lock @classmethod async def create( @@ -213,6 +218,7 @@ async def create( self._quit_thread = asyncio.Event() self._up_to_date = asyncio.Event() self._up_to_date.set() + self._new_data_lock = asyncio.Lock() self._calculation_task = asyncio.create_task( self._recalculate_phys_data(callback) @@ -227,11 +233,16 @@ async def queue_set(self, func, field, value): field (str): The field to be changed. value (float): The value to be set. """ - 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 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 @@ -287,37 +298,42 @@ async def _recalculate_phys_data(self, callback): await self._gather_one_sample() logging.debug("Recaulculating simulation with new setpoints.") if not self._paused.is_set(): - 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, + 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__}" ) - 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.") - # After this point we assume new setpoints have made the data stale. We - # cant clear this flag in queue_set() as the callbacks can depend on - # this being set. - self._up_to_date.clear() + # For Virtac this function calls update_pvs() which gets data + # from the pytac datasource to update the softioc pvs with. The + # data source is sim_data_sources.py and its get_value() + # function waits on the wait_for_calculation() function which + # waits for the up_to_date flag which currently will always be + # set, so this process is pointless. + await callback() + logging.debug("Callback completed.") def toggle_calculations(self): """Pause or unpause the physics calculations by setting or clearing the From abf78c02f362427685257874a069008e7f2c2300 Mon Sep 17 00:00:00 2001 From: Phil Smith Date: Thu, 9 Oct 2025 15:06:30 +0000 Subject: [PATCH 08/18] Await the create class method instead of init --- src/atip/load_sim.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/atip/load_sim.py b/src/atip/load_sim.py index 5aa1fc4..62f8997 100644 --- a/src/atip/load_sim.py +++ b/src/atip/load_sim.py @@ -72,7 +72,7 @@ async def load( f"(AT:{len(at_lattice)} Pytac:{len(pytac_lattice)})." ) # Initialise an instance of the ATSimulator Object. - atsim = await ATSimulator( + atsim = await ATSimulator.create( at_lattice, sim_params, callback, From b5a2373379aa5d9701aa7b0ba937716eec3d532e Mon Sep 17 00:00:00 2001 From: Phil Smith Date: Fri, 2 Oct 2026 09:42:04 +0000 Subject: [PATCH 09/18] Import pytac and atip in __main__ --- src/atip/__main__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/atip/__main__.py b/src/atip/__main__.py index d652280..af5f1c0 100644 --- a/src/atip/__main__.py +++ b/src/atip/__main__.py @@ -6,7 +6,9 @@ from argparse import ArgumentParser from collections.abc import Sequence -from atip import __version__ +import pytac + +import atip __all__ = ["main"] @@ -18,7 +20,7 @@ async def async_main(args: Sequence[str] | None = None) -> None: "-v", "--version", action="version", - version=__version__, + version=atip.__version__, ) parser.add_argument( "-t", From 371a89c9ae9ae0358db4ffde22f0676e242b38cf Mon Sep 17 00:00:00 2001 From: Phil Smith Date: Tue, 6 Oct 2026 10:22:52 +0000 Subject: [PATCH 10/18] Save cls() object to atsim instead of self --- src/atip/simulator.py | 40 +++++++++++++++++++++------------------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/src/atip/simulator.py b/src/atip/simulator.py index f370954..8f3c919 100644 --- a/src/atip/simulator.py +++ b/src/atip/simulator.py @@ -194,36 +194,38 @@ async def create( **Methods:** """ - self = cls() + 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) - - self._loop = asyncio.get_event_loop() # TODO: check a loop is running - self._queue = asyncio.Queue() - self._paused = asyncio.Event() - self._quit_thread = asyncio.Event() - self._up_to_date = asyncio.Event() - self._up_to_date.set() - self._new_data_lock = asyncio.Lock() - - self._calculation_task = asyncio.create_task( - self._recalculate_phys_data(callback) + 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 self + return atsim async def queue_set(self, func, field, value): """Add a change to the queue, to be applied when the queue is emptied. From d05a16e619cb254c08ee0fb6e7a2e60d980595d0 Mon Sep 17 00:00:00 2001 From: Phil Smith Date: Tue, 6 Oct 2026 10:35:45 +0000 Subject: [PATCH 11/18] Move atip validation test code to utils --- src/atip/__main__.py | 19 +------------------ src/atip/utils.py | 22 ++++++++++++++++++++++ 2 files changed, 23 insertions(+), 18 deletions(-) diff --git a/src/atip/__main__.py b/src/atip/__main__.py index af5f1c0..c224722 100644 --- a/src/atip/__main__.py +++ b/src/atip/__main__.py @@ -2,7 +2,6 @@ import asyncio import logging -import random from argparse import ArgumentParser from collections.abc import Sequence @@ -31,23 +30,7 @@ async def async_main(args: Sequence[str] | None = None) -> None: args = parser.parse_args() if args.run_test: - # Load the DIAD lattice from Pytac. - lat = await 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) + await atip.utils.test_atip() def main(args: Sequence[str] | None = None) -> None: diff --git a/src/atip/utils.py b/src/atip/utils.py index b584fb6..20bcd74 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 @@ -198,3 +200,23 @@ def trigger_calc(target): atsim = get_atsim(target) atsim.trigger_calculation() print("Recalculation manually triggered.") + + +async def test_atip(): + # Load the DIAD lattice from Pytac. + lat = await 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) From 2341d09c96d90988e826009d5188f91c2387d733 Mon Sep 17 00:00:00 2001 From: Phil Smith Date: Tue, 6 Oct 2026 10:36:49 +0000 Subject: [PATCH 12/18] Remove direct passing of args in __main__ This is not needed as we use argparse --- src/atip/__main__.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/atip/__main__.py b/src/atip/__main__.py index c224722..3e83829 100644 --- a/src/atip/__main__.py +++ b/src/atip/__main__.py @@ -3,16 +3,13 @@ import asyncio import logging from argparse import ArgumentParser -from collections.abc import Sequence - -import pytac import atip __all__ = ["main"] -async def async_main(args: Sequence[str] | None = None) -> None: +async def async_main() -> None: """Argument parser for the CLI.""" parser = ArgumentParser() parser.add_argument( @@ -33,11 +30,11 @@ async def async_main(args: Sequence[str] | None = None) -> None: await atip.utils.test_atip() -def main(args: Sequence[str] | None = None) -> None: +def main() -> None: logging.basicConfig() logging.getLogger().setLevel(logging.DEBUG) # Load the AT sim into the Pytac lattice. - asyncio.run(async_main(args)) + asyncio.run(async_main()) if __name__ == "__main__": From daa1ef65885f0695a3d698cbc497912d625dfb53 Mon Sep 17 00:00:00 2001 From: Phil Smith Date: Wed, 7 Oct 2026 11:09:36 +0000 Subject: [PATCH 13/18] Fix functions used to interact with the atip calculation loop --- src/atip/simulator.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/atip/simulator.py b/src/atip/simulator.py index 8f3c919..643eb3f 100644 --- a/src/atip/simulator.py +++ b/src/atip/simulator.py @@ -353,16 +353,13 @@ def pause_calculations(self): .. Note:: This does not pause the emptying of the queue. """ - # TODO: These dont currently get called anyway, maybe add a pv to call them? if not self._paused.is_set(): self._paused.set() - async def unpause_calculations(self): + def unpause_calculations(self): """Unpause the physics calculations by clearing the _paused flag.""" if self._paused.is_set(): - await self._paused.clear() - if not self._up_to_date: - await self.trigger_calculation() + self._paused.clear() async def trigger_calculation(self): """Unpause the physics calculations and add a null item to the queue to @@ -371,8 +368,8 @@ async def trigger_calculation(self): .. Note:: This method does not wait for the recalculation to complete, that is up to the user. """ - await self.unpause_calculations() - self.queue_set(lambda *x: None, None, None) + self.unpause_calculations() + await self.queue_set(lambda *x: None, None, None) async def wait_for_calculations(self, timeout=10): """Wait until the physics calculations have taken account of all From d153fea53883b5e2a4fbfcd1722fb3933d0861cc Mon Sep 17 00:00:00 2001 From: Phil Smith Date: Wed, 7 Oct 2026 15:51:14 +0000 Subject: [PATCH 14/18] Fix function for async conversion --- src/atip/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/atip/utils.py b/src/atip/utils.py index 20bcd74..e2bfcfc 100644 --- a/src/atip/utils.py +++ b/src/atip/utils.py @@ -186,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. @@ -198,7 +198,7 @@ def trigger_calc(target): be extracted. """ atsim = get_atsim(target) - atsim.trigger_calculation() + await atsim.trigger_calculation() print("Recalculation manually triggered.") From 020428907a0409d9c07a04a03b4222e639ebefd0 Mon Sep 17 00:00:00 2001 From: Phil Smith Date: Wed, 7 Oct 2026 15:51:49 +0000 Subject: [PATCH 15/18] Fix tests for asyncio conversion --- pyproject.toml | 2 + tests/conftest.py | 29 ++++--- tests/test_at_simulator_object.py | 125 +++++++++++++----------------- tests/test_element_data_source.py | 66 +++++++++------- tests/test_lattice_data_source.py | 35 +++++---- tests/test_load.py | 36 +++++---- 6 files changed, 145 insertions(+), 148 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b32c4f9..9ad489a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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/tests/conftest.py b/tests/conftest.py index 81e5943..a13f521 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -59,20 +59,20 @@ 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"]) -def at_and_pytac_lattices(request): +async def at_and_pytac_lattices(request): lattices = [] - lattices.append(load_csv.load(request.param, cs.ControlSystem())) + lattices.append(await load_csv.load(request.param, cs.ControlSystem())) lattices.append(atip.utils.load_at_lattice(request.param)) return lattices @pytest.fixture(scope="function", params=["I04"]) -def pytac_lattice(request): - return load_csv.load(request.param, cs.ControlSystem()) +async def pytac_lattice(request): + return await load_csv.load(request.param, cs.ControlSystem()) @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(atsim): +def initial_phys_data(atsim, at_lattice): return { "tune": numpy.array([0.1823785, 0.2730096]), "chromaticity": numpy.array([2.05528097, 2.90000203]), 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 2629855..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,44 +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( +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() @@ -91,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, @@ -107,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 ) From e0ae35490214eaf53c31b2c25453bdf59a7a6e4d Mon Sep 17 00:00:00 2001 From: Phil Smith Date: Tue, 13 Oct 2026 12:11:00 +0000 Subject: [PATCH 16/18] Dont await pytac function which is no longer async --- src/atip/utils.py | 4 ++-- tests/conftest.py | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/atip/utils.py b/src/atip/utils.py index e2bfcfc..b9990ef 100644 --- a/src/atip/utils.py +++ b/src/atip/utils.py @@ -53,7 +53,7 @@ async def loader( pytac.lattice.Lattice: A Pytac lattice object with the simulator data source loaded. """ - pytac_lattice = await pytac.load_csv.load(mode, symmetry=24) + pytac_lattice = pytac.load_csv.load(mode, symmetry=24) at_lattice = load_at_lattice( mode, periodicity=1, @@ -204,7 +204,7 @@ async def trigger_calc(target): async def test_atip(): # Load the DIAD lattice from Pytac. - lat = await pytac.load_csv.load("DIAD") + 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) diff --git a/tests/conftest.py b/tests/conftest.py index a13f521..b2f553d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -63,16 +63,16 @@ def atlds(): @pytest.fixture(scope="function", params=["I04"]) -async def at_and_pytac_lattices(request): +def at_and_pytac_lattices(request): lattices = [] - lattices.append(await load_csv.load(request.param, cs.ControlSystem())) + lattices.append(load_csv.load(request.param, cs.ControlSystem())) lattices.append(atip.utils.load_at_lattice(request.param)) return lattices @pytest.fixture(scope="function", params=["I04"]) -async def pytac_lattice(request): - return await load_csv.load(request.param, cs.ControlSystem()) +def pytac_lattice(request): + return load_csv.load(request.param, cs.ControlSystem()) @pytest.fixture(scope="function", params=["I04"]) From db43bcb615139f75f56178ad3560cffc7fe0b6b5 Mon Sep 17 00:00:00 2001 From: Phil Smith Date: Tue, 13 Oct 2026 12:32:02 +0000 Subject: [PATCH 17/18] Remove virtac comment --- src/atip/simulator.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/atip/simulator.py b/src/atip/simulator.py index 643eb3f..7a69568 100644 --- a/src/atip/simulator.py +++ b/src/atip/simulator.py @@ -328,12 +328,6 @@ async def _recalculate_phys_data(self, callback): logging.debug( f"Executing callback function: {callback.__name__}" ) - # For Virtac this function calls update_pvs() which gets data - # from the pytac datasource to update the softioc pvs with. The - # data source is sim_data_sources.py and its get_value() - # function waits on the wait_for_calculation() function which - # waits for the up_to_date flag which currently will always be - # set, so this process is pointless. await callback() logging.debug("Callback completed.") From 280275cecb103e520e5d7c1283aee30da91ce3b9 Mon Sep 17 00:00:00 2001 From: Phil Smith Date: Tue, 13 Oct 2026 13:40:34 +0000 Subject: [PATCH 18/18] Use latest asyncio pytac version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 9ad489a..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",