diff --git a/.vscode/settings.json b/.vscode/settings.json index 09d83a6b..dbb37615 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -15,4 +15,5 @@ "editor.codeActionsOnSave": { "source.organizeImports": true }, + "restructuredtext.confPath": "", } \ No newline at end of file diff --git a/Pipfile.lock b/Pipfile.lock index 748c8210..a0f40fb5 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -373,6 +373,13 @@ } }, "develop": { + "aioca": { + "hashes": [ + "sha256:2f01c5fd327916d6c13c7a6106189fd8411ee52f6e34b276368cb831252734f0", + "sha256:f5c6f1d1774f5cd96bd7827eda7f9f6a71f735699b424745c3fd8293146211ae" + ], + "version": "==1.2" + }, "aiohttp": { "hashes": [ "sha256:02f46fc0e3c5ac58b80d4d56eb0a7c7d97fcef69ace9326289fb9f1955e65cfe", diff --git a/docs/conf.py b/docs/conf.py index bef0708a..133cbb9c 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -61,6 +61,8 @@ ("py:class", "SimTime"), ("py:class", "immutables._map.Map"), ("py:class", "_asyncio.Task"), + ("py:class", "apischema.conversions.conversions.Conversion"), + ("py:class", "apischema.conversions.conversions.LazyConversion"), ] # Both the class’ and the __init__ method’s docstring are concatenated and diff --git a/docs/reference/api.rst b/docs/reference/api.rst index 7a2814fb..3943ac20 100644 --- a/docs/reference/api.rst +++ b/docs/reference/api.rst @@ -26,24 +26,24 @@ API .. automodule:: examples.devices.trampoline :members: - :exclude-members: RandomTrampoline + :exclude-members: RandomTrampolineDevice .. - RandomTrampoline excluded such that Inputs & Outputs TypedDicts may be + RandomTrampolineDevice excluded such that Inputs & Outputs TypedDicts may be :noindex:ed to prevent namespace collision with Trampoline Inputs & Outputs as TypedDict lacks proper __qualname__ ``examples.devices.trampoline`` ------------------------------- - .. autoclass:: examples.devices.trampoline.RandomTrampoline + .. autoclass:: examples.devices.trampoline.RandomTrampolineDevice :members: :exclude-members: Inputs, Outputs - .. autoclass:: examples.devices.trampoline.RandomTrampoline.Inputs + .. autoclass:: examples.devices.trampoline.RandomTrampolineDevice.Inputs :noindex: - .. autoclass:: examples.devices.trampoline.RandomTrampoline.Outputs + .. autoclass:: examples.devices.trampoline.RandomTrampolineDevice.Outputs :noindex: .. automodule:: tickit @@ -216,10 +216,10 @@ API ``tickit.core.adapter`` ----------------------- - .. automodule:: tickit.core.lifetime_runnable + .. automodule:: tickit.core.runner :members: - ``tickit.core.lifetime_runnable`` + ``tickit.core.runner`` --------------------------------- .. automodule:: tickit.core.typedefs diff --git a/docs/tutorials/creating-a-device.rst b/docs/tutorials/creating-a-device.rst index 59d0b043..c5b37800 100644 --- a/docs/tutorials/creating-a-device.rst +++ b/docs/tutorials/creating-a-device.rst @@ -15,16 +15,16 @@ will determine the operation of our device. Device Class ------------ -We shall begin by defining the Shutter class which inerits `ConfigurableDevice` - by +We shall begin by defining the Shutter class which inherits `Device` - by doing so a confiuration dataclass will automatically be created for the device, allowing for easy YAML configuration. .. code-block:: python - from tickit.core.device import ConfigurableDevice + from tickit.core.device import Device - class Shutter(ConfigurableDevice): + class ShutterDevice(Device): Device Constructor and Configuration ------------------------------------ @@ -41,10 +41,10 @@ the initial ``position`` will be random. from random import random from typing import Optional - from tickit.core.device import ConfigurableDevice + from tickit.core.device import Device - class Shutter(ConfigurableDevice): + class ShutterDevice(Device): def __init__( self, default_position: float, initial_position: Optional[float] = None ) -> None: @@ -53,7 +53,7 @@ the initial ``position`` will be random. .. note:: Arguments to the ``__init__`` method may be specified in the simulation config file - if the device inherits `ConfigurableDevice`. + if the device inherits `Device`. Device Logic ------------ @@ -70,11 +70,11 @@ which consists of ``outputs`` - a mapping of output ports and their value - and from typing import Optional from typing_extensions import TypedDict - from tickit.core.device import ConfigurableDevice, DeviceUpdate + from tickit.core.device import Device, DeviceUpdate from tickit.core.typedefs import SimTime - class Shutter(ConfigurableDevice): + class ShutterDevice(Device): Inputs = TypedDict("Inputs", {"flux": float}) Outputs = TypedDict("Outputs", {"flux": float}) @@ -107,6 +107,33 @@ which consists of ``outputs`` - a mapping of output ports and their value - and output_flux = inputs["flux"] * self.position return DeviceUpdate(Shutter.Outputs(flux=output_flux), call_at) +Creating a ComponentConfig +-------------------------- + +In order to create the Device we must create a `ComponentConfig` that knows how +to instantiate a Device. + +.. code-block:: python + + from tickit.core.components.component import Component, ComponentConfig + from tickit.core.components.device_simulation import DeviceSimulation + + + @dataclass + class Shutter(ComponentConfig): + default_position: float + initial_position: Optional[float] = None + + def __call__(self) -> Component: + return DeviceSimulation( + name=self.name, + device=ShutterDevice( + default_position=self.default_position, + initial_position=self.initial_position, + ), + ) + + Using the Device ---------------- @@ -118,28 +145,20 @@ per our implementation, and a `Sink` named sink which will recieve the resulting .. code-block:: yaml - - tickit.core.components.device_simulation.DeviceSimulation: - adapters: [] - device: - tickit.devices.source.Source: - value: 42.0 - inputs: {} + - tickit.devices.source.Source: name: source - - tickit.core.components.device_simulation.DeviceSimulation: - adapters: [] - device: - my_shutter.Shutter: - default_position: 0.2 - inputs: - flux: source:value + inputs: {} + value: 42.0 + - examples.devices.shutter.Shutter: name: shutter - - tickit.core.components.device_simulation.DeviceSimulation: - adapters: [] - device: - tickit.devices.sink.Sink: {} inputs: - flux: shutter:flux + flux: source:value + default_position: 0.2 + initial_position: 0.24 + - tickit.devices.sink.Sink: name: sink + inputs: + flux: shutter:flux .. seealso:: See the `Creating a Simulation` tutorial for a walk-through of creating simulation diff --git a/docs/tutorials/creating-an-adapter.rst b/docs/tutorials/creating-an-adapter.rst index 40ff3c44..325b11c7 100644 --- a/docs/tutorials/creating-an-adapter.rst +++ b/docs/tutorials/creating-an-adapter.rst @@ -21,7 +21,7 @@ Adapter Class ------------- We shall begin by defining the ShutterAdapter class which inherits -`ConfigurableAdapter` - by doing so a configuration dataclass will automatically be +`Adapter` - by doing so a configuration dataclass will automatically be created for the adapter, allowing easy YAML configuration. .. code-block:: python @@ -69,7 +69,7 @@ appending a line break and a ``CommandInterpreter``. .. note:: Arguments to the ``__init__`` method may be specified in the simulation config file - if the device inherits `ConfigurableAdapter` (excluding ``device`` and + if the device inherits `Adapter` (excluding ``device`` and ``raise_interrupt`` which are injected at run-time). Adapter Commands diff --git a/examples/configs/attns.yaml b/examples/configs/attns.yaml index 3d987229..6cbf3f9a 100644 --- a/examples/configs/attns.yaml +++ b/examples/configs/attns.yaml @@ -1,18 +1,11 @@ -- tickit.core.components.device_simulation.DeviceSimulation: - adapters: - - tickit.devices.pneumatic.pneumatic.PneumaticAdapter: - ioc_name: PNEUMATIC - db_file: tickit/devices/pneumatic/db_files/filter1.db - device: - tickit.devices.pneumatic.pneumatic.Pneumatic: - initial_speed: 2.5 - initial_state: False - inputs: {} +- tickit.devices.pneumatic.Pneumatic: name: filter1 -- tickit.core.components.device_simulation.DeviceSimulation: - adapters: [] - device: - tickit.devices.sink.Sink: {} + inputs: {} + initial_speed: 0.5 + initial_state: False + ioc_name: PNEUMATIC + db_file: tickit/devices/pneumatic/db_files/filter1.db +- tickit.devices.sink.Sink: + name: contr_sink inputs: input: filter1:output - name: contr_sink \ No newline at end of file diff --git a/examples/configs/cryo-tcp.yaml b/examples/configs/cryo-tcp.yaml index 1c5bbf2f..16d3de6d 100644 --- a/examples/configs/cryo-tcp.yaml +++ b/examples/configs/cryo-tcp.yaml @@ -1,15 +1,8 @@ -- tickit.core.components.device_simulation.DeviceSimulation: - adapters: - - tickit.devices.cryostream.cryostream.CryostreamAdapter: {} - device: - tickit.devices.cryostream.cryostream.Cryostream: {} - inputs: {} +- tickit.devices.cryostream.Cryostream: name: cryo -- tickit.core.components.device_simulation.DeviceSimulation: - adapters: [] - device: - tickit.devices.sink.Sink: {} - inputs: - input: cryo:temperature - name: contr_sink + inputs: {} +- tickit.devices.sink.Sink: + name: contr_sink + inputs: + input: cryo:temperature diff --git a/examples/configs/current-monitor.yaml b/examples/configs/current-monitor.yaml index f8e1fbc6..1683e241 100644 --- a/examples/configs/current-monitor.yaml +++ b/examples/configs/current-monitor.yaml @@ -1,19 +1,12 @@ -- tickit.core.components.device_simulation.DeviceSimulation: - adapters: - - tickit.devices.femto.femto.FemtoAdapter: - ioc_name: FEMTO - db_file: tickit/devices/femto/record.db - device: - tickit.devices.femto.femto.Femto: - initial_gain: 2.5 - initial_current: 0.0 +- tickit.devices.femto.Current: + name: current_device + inputs: {} + callback_period: 1000000000 +- tickit.devices.femto.Femto: + name: femto inputs: input: current_device:output - name: femto -- tickit.core.components.device_simulation.DeviceSimulation: - adapters: [] - device: - tickit.devices.femto.femto.CurrentDevice: - callback_period: 1000000000 - inputs: {} - name: current_device \ No newline at end of file + initial_gain: 2.5 + initial_current: 0.0 + db_file: tickit/devices/femto/record.db + ioc_name: FEMTO diff --git a/examples/configs/http-device.yaml b/examples/configs/http-device.yaml index 38e7e6e5..75c592a9 100644 --- a/examples/configs/http-device.yaml +++ b/examples/configs/http-device.yaml @@ -1,17 +1,10 @@ -- tickit.core.components.device_simulation.DeviceSimulation: - adapters: [] - device: - tickit.devices.source.Source: - value: False - inputs: {} +- tickit.devices.source.Source: name: source -- tickit.core.components.device_simulation.DeviceSimulation: - adapters: - - examples.devices.http_device.ExampleHTTPAdapter: {} - device: - examples.devices.http_device.ExampleHTTPDevice: - foo: False - bar: 10 + inputs: {} + value: False +- examples.devices.http_device.ExampleHTTP: + name: http-device inputs: foo: source:value - name: http-device \ No newline at end of file + foo: False + bar: 10 diff --git a/examples/configs/nested.yaml b/examples/configs/nested.yaml index 59c74dfb..7c99648c 100644 --- a/examples/configs/nested.yaml +++ b/examples/configs/nested.yaml @@ -1,37 +1,22 @@ -- tickit.core.components.device_simulation.DeviceSimulation: - adapters: [] - device: - examples.devices.trampoline.RandomTrampoline: - callback_period: 10000000000 - inputs: {} +- examples.devices.trampoline.RandomTrampoline: name: random_trampoline + inputs: {} + callback_period: 10000000000 - tickit.core.components.system_simulation.SystemSimulation: - components: - - tickit.core.components.device_simulation.DeviceSimulation: - adapters: [] - device: - tickit.devices.sink.Sink: {} - inputs: - sink_1: external:input_1 - name: internal_sink - - tickit.core.components.device_simulation.DeviceSimulation: - adapters: - - examples.devices.remote_controlled.RemoteControlledAdapter: - server: - tickit.adapters.servers.tcp.TcpServer: {} - device: - examples.devices.remote_controlled.RemoteControlled: {} - inputs: {} - name: internal_tcp_controlled + name: internal_tickit inputs: input_1: random_trampoline:output - name: internal_tickit + components: + - tickit.devices.sink.Sink: + name: internal_sink + inputs: + sink_1: external:input_1 + - examples.devices.remote_controlled.RemoteControlled: + name: internal_tcp_controlled + inputs: {} expose: output_1: internal_tcp_controlled:observed -- tickit.core.components.device_simulation.DeviceSimulation: - adapters: [] - device: - tickit.devices.sink.Sink: {} +- tickit.devices.sink.Sink: + name: external_sink inputs: sink_1: internal_tickit:output_1 - name: external_sink \ No newline at end of file diff --git a/examples/configs/shutter.yaml b/examples/configs/shutter.yaml index dc98b36d..9efe214e 100644 --- a/examples/configs/shutter.yaml +++ b/examples/configs/shutter.yaml @@ -1,24 +1,14 @@ -- tickit.core.components.device_simulation.DeviceSimulation: - adapters: [] - device: - tickit.devices.source.Source: - value: 42.0 - inputs: {} +- tickit.devices.source.Source: name: source -- tickit.core.components.device_simulation.DeviceSimulation: - adapters: - - examples.devices.shutter.ShutterAdapter: {} - device: - examples.devices.shutter.Shutter: - default_position: 0.2 - initial_position: 0.24 + inputs: {} + value: 42.0 +- examples.devices.shutter.Shutter: + name: shutter inputs: flux: source:value - name: shutter -- tickit.core.components.device_simulation.DeviceSimulation: - adapters: [] - device: - tickit.devices.sink.Sink: {} + default_position: 0.2 + initial_position: 0.24 +- tickit.devices.sink.Sink: + name: sink inputs: - flux: shutter:flux - name: sink \ No newline at end of file + flux: shutter:flux \ No newline at end of file diff --git a/examples/configs/sunk-tcp.yaml b/examples/configs/sunk-tcp.yaml index e378f2a3..641394a3 100644 --- a/examples/configs/sunk-tcp.yaml +++ b/examples/configs/sunk-tcp.yaml @@ -1,17 +1,8 @@ -- tickit.core.components.device_simulation.DeviceSimulation: - adapters: - - examples.devices.remote_controlled.RemoteControlledAdapter: - server: - tickit.adapters.servers.tcp.TcpServer: - format: "%b\r\n" - device: - examples.devices.remote_controlled.RemoteControlled: {} - inputs: {} +- examples.devices.remote_controlled.RemoteControlled: name: tcp_contr -- tickit.core.components.device_simulation.DeviceSimulation: - adapters: [] - device: - tickit.devices.sink.Sink: {} + inputs: {} + format: "%b\r\n" +- tickit.devices.sink.Sink: + name: contr_sink inputs: input: tcp_contr:observed - name: contr_sink \ No newline at end of file diff --git a/examples/configs/sunk-trampoline.yaml b/examples/configs/sunk-trampoline.yaml index 58c939a1..9e20fcac 100644 --- a/examples/configs/sunk-trampoline.yaml +++ b/examples/configs/sunk-trampoline.yaml @@ -1,14 +1,8 @@ -- tickit.core.components.device_simulation.DeviceSimulation: - adapters: [] - device: - examples.devices.trampoline.RandomTrampoline: - callback_period: 1000000000 - inputs: {} +- examples.devices.trampoline.RandomTrampoline: name: rand_tramp -- tickit.core.components.device_simulation.DeviceSimulation: - adapters: [] - device: - tickit.devices.sink.Sink: {} + inputs: {} + callback_period: 1000000000 +- tickit.devices.sink.Sink: + name: tramp_sink inputs: input: rand_tramp:output - name: tramp_sink diff --git a/examples/devices/http_device.py b/examples/devices/http_device.py index d18ee321..3a4ee384 100644 --- a/examples/devices/http_device.py +++ b/examples/devices/http_device.py @@ -1,18 +1,18 @@ -from typing import Awaitable, Callable, Optional +from dataclasses import dataclass +from typing import Optional from aiohttp import web from tickit.adapters.httpadapter import HTTPAdapter from tickit.adapters.interpreters.endpoints.http_endpoint import HTTPEndpoint -from tickit.adapters.servers.http_server import HTTPServer -from tickit.core.adapter import ConfigurableAdapter -from tickit.core.device import ConfigurableDevice, DeviceUpdate +from tickit.core.components.component import Component, ComponentConfig +from tickit.core.components.device_simulation import DeviceSimulation +from tickit.core.device import Device, DeviceUpdate from tickit.core.typedefs import SimTime -from tickit.utils.byte_format import ByteFormat from tickit.utils.compat.typing_compat import TypedDict -class ExampleHTTPDevice(ConfigurableDevice): +class ExampleHTTPDevice(Device): """A device class for an example HTTP device. ... @@ -51,33 +51,10 @@ def update(self, time: SimTime, inputs: Inputs) -> DeviceUpdate[Outputs]: pass -class ExampleHTTPAdapter(HTTPAdapter, ConfigurableAdapter): +class ExampleHTTPAdapter(HTTPAdapter): """An Eiger adapter which parses the commands sent to the HTTP server.""" - _device: ExampleHTTPDevice - - def __init__( - self, - device: ExampleHTTPDevice, - raise_interrupt: Callable[[], Awaitable[None]], - host: str = "localhost", - port: int = 8080, - ) -> None: - """An adapter which instantiates a HTTPServer with configured host and port. - - Args: - device (ExampleHTTPDevice): The example HTTP device - raise_interrupt (Callable): A callback to request that the device is - updated immediately. - host (Optional[str]): The host address of the HTTPServer. Defaults to - "localhost". - port (Optional[str]): The bound port of the HTTPServer. Defaults to 8080. - """ - super().__init__( - device, - raise_interrupt, - HTTPServer(host, port, ByteFormat(b"%b\r\n")), - ) + device: ExampleHTTPDevice @HTTPEndpoint.put("/command/foo/") async def foo(self, request: web.Request) -> web.Response: @@ -102,3 +79,18 @@ async def bar(self, request: web.Request) -> web.Response: web.Response: [description] """ return web.Response(text="Your data: {}".format(request.match_info["data"])) + + +@dataclass +class ExampleHTTP(ComponentConfig): + """Example HTTP device.""" + + foo: bool = False + bar: Optional[int] = 10 + + def __call__(self) -> Component: # noqa: D102 + return DeviceSimulation( + name=self.name, + device=ExampleHTTPDevice(foo=self.foo, bar=self.bar), + adapters=[ExampleHTTPAdapter()], + ) diff --git a/examples/devices/remote_controlled.py b/examples/devices/remote_controlled.py index 6be157a4..51ea83eb 100644 --- a/examples/devices/remote_controlled.py +++ b/examples/devices/remote_controlled.py @@ -1,19 +1,24 @@ import asyncio import logging import struct -from typing import AsyncIterable, Awaitable, Callable +from dataclasses import dataclass +from typing import AsyncIterable from tickit.adapters.composed import ComposedAdapter from tickit.adapters.interpreters.command import CommandInterpreter, RegexCommand -from tickit.core.adapter import ConfigurableAdapter, ServerConfig -from tickit.core.device import ConfigurableDevice, DeviceUpdate +from tickit.adapters.servers.tcp import TcpServer +from tickit.core.adapter import Server +from tickit.core.components.component import Component, ComponentConfig +from tickit.core.components.device_simulation import DeviceSimulation +from tickit.core.device import Device, DeviceUpdate from tickit.core.typedefs import SimTime +from tickit.utils.byte_format import ByteFormat from tickit.utils.compat.typing_compat import TypedDict LOGGER = logging.getLogger(__name__) -class RemoteControlled(ConfigurableDevice): +class RemoteControlledDevice(Device): """A trivial toy device which is controlled by an adapter.""" #: An empty typed mapping of device inputs @@ -53,19 +58,17 @@ def update(self, time: SimTime, inputs: Inputs) -> DeviceUpdate[Outputs]: The produced update event which contains the observed value, the device never requests a callback. """ - return DeviceUpdate(RemoteControlled.Outputs(observed=self.observed), None) + return DeviceUpdate(self.Outputs(observed=self.observed), None) -class RemoteControlledAdapter(ComposedAdapter, ConfigurableAdapter): +class RemoteControlledAdapter(ComposedAdapter): """A trivial composed adapter which gets and sets device properties.""" - _device: RemoteControlled + device: RemoteControlledDevice def __init__( self, - device: RemoteControlled, - raise_interrupt: Callable[[], Awaitable[None]], - server: ServerConfig, + server: Server, ) -> None: """A constructor of the Shutter adapter, which builds the configured server. @@ -73,13 +76,11 @@ def __init__( device (Device): The device which this adapter is attached to. raise_interrupt (Callable): A callback to request that the device is updated immediately. - server (ServerConfig): The immutable data container used to configure a + server (Server): The immutable data container used to configure a server. """ super().__init__( - device, - raise_interrupt, - server.configures()(**server.kwargs), + server, CommandInterpreter(), ) @@ -92,7 +93,7 @@ async def on_connect(self) -> AsyncIterable[bytes]: """ while True: await asyncio.sleep(5.0) - yield "U is {}".format(self._device.unobserved).encode("utf-8") + yield "U is {}".format(self.device.unobserved).encode("utf-8") @RegexCommand(b"\x01") async def get_observed_bytes(self) -> bytes: @@ -101,7 +102,7 @@ async def get_observed_bytes(self) -> bytes: Returns: bytes: The big endian float encoded value of observed. """ - return struct.pack(">f", self._device.observed) + return struct.pack(">f", self.device.observed) @RegexCommand(r"O", format="utf-8") async def get_observed_str(self) -> bytes: @@ -110,7 +111,7 @@ async def get_observed_str(self) -> bytes: Returns: bytes: The utf-8 encoded value of observed. """ - return str(self._device.observed).encode("utf-8") + return str(self.device.observed).encode("utf-8") @RegexCommand(b"\x01(.{4})", interrupt=True) async def set_observed_bytes(self, value: bytes) -> bytes: @@ -122,8 +123,8 @@ async def set_observed_bytes(self, value: bytes) -> bytes: Returns: bytes: The big endian float encoded value of observed. """ - self._device.observed = struct.unpack(">f", value)[0] - return struct.pack(">f", self._device.observed) + self.device.observed = struct.unpack(">f", value)[0] + return struct.pack(">f", self.device.observed) @RegexCommand(r"O=(\d+\.?\d*)", interrupt=True, format="utf-8") async def set_observed_str(self, value: float) -> bytes: @@ -135,8 +136,8 @@ async def set_observed_str(self, value: float) -> bytes: Returns: bytes: The utf-8 encoded value of observed. """ - self._device.observed = value - return "Observed set to {}".format(self._device.observed).encode("utf-8") + self.device.observed = value + return "Observed set to {}".format(self.device.observed).encode("utf-8") @RegexCommand(b"\x02") async def get_unobserved_bytes(self) -> bytes: @@ -145,7 +146,7 @@ async def get_unobserved_bytes(self) -> bytes: Returns: bytes: The big endian float encoded value of unobserved. """ - return struct.pack(">f", self._device.unobserved) + return struct.pack(">f", self.device.unobserved) @RegexCommand(r"U", format="utf-8") async def get_unobserved_str(self) -> bytes: @@ -154,7 +155,7 @@ async def get_unobserved_str(self) -> bytes: Returns: bytes: The utf-8 encoded value of unobserved. """ - return str(self._device.unobserved).encode("utf-8") + return str(self.device.unobserved).encode("utf-8") @RegexCommand(b"\x02(.{4})") async def set_unobserved_bytes(self, value: bytes) -> bytes: @@ -166,8 +167,8 @@ async def set_unobserved_bytes(self, value: bytes) -> bytes: Returns: bytes: The big endian float encoded value of unobserved. """ - self._device.unobserved = struct.unpack(">f", value)[0] - return struct.pack(">f", self._device.unobserved) + self.device.unobserved = struct.unpack(">f", value)[0] + return struct.pack(">f", self.device.unobserved) @RegexCommand(r"U=(\d+\.?\d*)", format="utf-8") async def set_unobserved_str(self, value: float) -> bytes: @@ -179,8 +180,8 @@ async def set_unobserved_str(self, value: float) -> bytes: Returns: bytes: The utf-8 encoded value of unobserved. """ - self._device.unobserved = value - return "Unobserved set to {}".format(self._device.unobserved).encode("utf-8") + self.device.unobserved = value + return "Unobserved set to {}".format(self.device.unobserved).encode("utf-8") @RegexCommand(chr(0x1F95A), format="utf-8") async def misc(self) -> bytes: @@ -198,7 +199,7 @@ async def set_hidden(self, value: float) -> None: Args: value (float): The new value of hidden. """ - LOGGER.info("Hidden set to {}".format(self._device.hidden)) + LOGGER.info("Hidden set to {}".format(self.device.hidden)) @RegexCommand(r"H", format="utf-8") async def get_hidden(self) -> None: @@ -219,4 +220,18 @@ async def yield_observed(self, n: int = 10) -> AsyncIterable[bytes]: """ for i in range(1, int(n)): await asyncio.sleep(1.0) - yield "Observed is {}".format(self._device.observed).encode("utf-8") + yield "Observed is {}".format(self.device.observed).encode("utf-8") + + +@dataclass +class RemoteControlled(ComponentConfig): + """Thing you can poke over TCP.""" + + format: ByteFormat = ByteFormat(b"%b\r\n") + + def __call__(self) -> Component: # noqa: D102 + return DeviceSimulation( + name=self.name, + device=RemoteControlledDevice(), + adapters=[RemoteControlledAdapter(TcpServer())], + ) diff --git a/examples/devices/shutter.py b/examples/devices/shutter.py index c923f402..2b238cc8 100644 --- a/examples/devices/shutter.py +++ b/examples/devices/shutter.py @@ -1,18 +1,20 @@ +from dataclasses import dataclass from random import random -from typing import Awaitable, Callable, Optional +from typing import Optional from tickit.adapters.composed import ComposedAdapter from tickit.adapters.interpreters.command.command_interpreter import CommandInterpreter from tickit.adapters.interpreters.command.regex_command import RegexCommand from tickit.adapters.servers.tcp import TcpServer -from tickit.core.adapter import ConfigurableAdapter -from tickit.core.device import ConfigurableDevice, DeviceUpdate +from tickit.core.components.component import Component, ComponentConfig +from tickit.core.components.device_simulation import DeviceSimulation +from tickit.core.device import Device, DeviceUpdate from tickit.core.typedefs import SimTime from tickit.utils.byte_format import ByteFormat from tickit.utils.compat.typing_compat import TypedDict -class Shutter(ConfigurableDevice): +class ShutterDevice(Device): """A toy device which downscales flux according to a set position. A toy device which produces an output flux which is downscaled from the input flux @@ -84,7 +86,7 @@ def update(self, time: SimTime, inputs: Inputs) -> DeviceUpdate[Outputs]: move. """ if self.last_time: - self.position = Shutter.move( + self.position = self.move( self.position, self.target_position, self.rate, @@ -95,18 +97,16 @@ def update(self, time: SimTime, inputs: Inputs) -> DeviceUpdate[Outputs]: None if self.position == self.target_position else SimTime(time + int(1e8)) ) output_flux = inputs["flux"] * self.position - return DeviceUpdate(Shutter.Outputs(flux=output_flux), call_at) + return DeviceUpdate(self.Outputs(flux=output_flux), call_at) -class ShutterAdapter(ComposedAdapter, ConfigurableAdapter): +class ShutterAdapter(ComposedAdapter): """A toy composed adapter which gets shutter position and target and sets target.""" - _device: Shutter + device: ShutterDevice def __init__( self, - device: Shutter, - raise_interrupt: Callable[[], Awaitable[None]], host: str = "localhost", port: int = 25565, ) -> None: @@ -121,8 +121,6 @@ def __init__( port (Optional[int]): The bound port of the TcpServer. Defaults to 25565. """ super().__init__( - device, - raise_interrupt, TcpServer(host, port, ByteFormat(b"%b\r\n")), CommandInterpreter(), ) @@ -134,7 +132,7 @@ async def get_position(self) -> bytes: Returns: bytes: The utf-8 encoded value of position. """ - return str(self._device.position).encode("utf-8") + return str(self.device.position).encode("utf-8") @RegexCommand(r"T\?", False, "utf-8") async def get_target(self) -> bytes: @@ -143,7 +141,7 @@ async def get_target(self) -> bytes: Returns: bytes: The utf-8 encoded value of target. """ - return str(self._device.target_position).encode("utf-8") + return str(self.device.target_position).encode("utf-8") @RegexCommand(r"T=(\d+\.?\d*)", True, "utf-8") async def set_target(self, target: str) -> None: @@ -152,5 +150,25 @@ async def set_target(self, target: str) -> None: Args: target (str): The target position of the shutter. """ - self._device.target_position = float(target) - self._device.last_time = None + self.device.target_position = float(target) + self.device.last_time = None + + +@dataclass +class Shutter(ComponentConfig): + """Shutter you can open or close over TCP.""" + + default_position: float + initial_position: Optional[float] = None + host: str = "localhost" + port: int = 25565 + + def __call__(self) -> Component: # noqa: D102 + return DeviceSimulation( + name=self.name, + device=ShutterDevice( + default_position=self.default_position, + initial_position=self.initial_position, + ), + adapters=[ShutterAdapter(host=self.host, port=self.port)], + ) diff --git a/examples/devices/trampoline.py b/examples/devices/trampoline.py index 46fa3668..b9810724 100644 --- a/examples/devices/trampoline.py +++ b/examples/devices/trampoline.py @@ -1,14 +1,17 @@ import logging +from dataclasses import dataclass from random import randint -from tickit.core.device import ConfigurableDevice, DeviceUpdate +from tickit.core.components.component import Component, ComponentConfig +from tickit.core.components.device_simulation import DeviceSimulation +from tickit.core.device import Device, DeviceUpdate from tickit.core.typedefs import SimTime from tickit.utils.compat.typing_compat import TypedDict LOGGER = logging.getLogger(__name__) -class Trampoline(ConfigurableDevice): +class TrampolineDevice(Device): """A trivial toy device which requests a callback every update.""" #: An empty typed mapping of device inputs @@ -42,10 +45,12 @@ def update(self, time: SimTime, inputs: Inputs) -> DeviceUpdate[Outputs]: requests a callback after the configured callback period. """ LOGGER.debug("Boing! ({}, {})".format(time, inputs)) - return DeviceUpdate(Trampoline.Outputs(), SimTime(time + self.callback_period)) + return DeviceUpdate( + TrampolineDevice.Outputs(), SimTime(time + self.callback_period) + ) -class RandomTrampoline(ConfigurableDevice): +class RandomTrampolineDevice(Device): """A trivial toy device which produced a random output and requests a callback.""" #: An empty typed mapping of device inputs @@ -83,6 +88,19 @@ def update(self, time: SimTime, inputs: Inputs) -> DeviceUpdate[Outputs]: "Boing! (delta: {}, inputs: {}, output: {})".format(time, inputs, output) ) return DeviceUpdate( - RandomTrampoline.Outputs(output=output), + RandomTrampolineDevice.Outputs(output=output), SimTime(time + self.callback_period), ) + + +@dataclass +class RandomTrampoline(ComponentConfig): + """Random thing that goes boing.""" + + callback_period: int = int(1e9) + + def __call__(self) -> Component: # noqa: D102 + return DeviceSimulation( + name=self.name, + device=RandomTrampolineDevice(callback_period=self.callback_period), + ) diff --git a/setup.cfg b/setup.cfg index ad6484a5..ecf744e5 100644 --- a/setup.cfg +++ b/setup.cfg @@ -26,7 +26,7 @@ install_requires = [options.extras_require] # For development tests/docs -dev = +dev = black==21.7b0 isort>5.0 pytest-cov @@ -40,6 +40,7 @@ dev = mock types-mock types-PyYAML + aioca # If you want to include data files in packages, # either define [options.package_data] or diff --git a/tests/adapters/test_composed.py b/tests/adapters/test_composed.py deleted file mode 100644 index a5b21b0c..00000000 --- a/tests/adapters/test_composed.py +++ /dev/null @@ -1,120 +0,0 @@ -import pytest -from mock import MagicMock, Mock, PropertyMock, create_autospec - -from tickit.adapters.composed import ComposedAdapter -from tickit.adapters.servers.tcp import TcpServer -from tickit.core.adapter import Interpreter -from tickit.core.device import Device - - -@pytest.fixture -def MockDevice() -> Mock: - return create_autospec(Device, instance=False) - - -@pytest.fixture -def mock_device() -> Mock: - return create_autospec(Device, instance=True) - - -@pytest.fixture -def MockServer() -> Mock: - return create_autospec(TcpServer, instance=False) - - -@pytest.fixture -def mock_server() -> Mock: - return create_autospec(TcpServer, instance=True) - - -@pytest.fixture -def MockServerConfig() -> Mock: - return create_autospec(TcpServer.TcpServerConfig, instance=False) - - -@pytest.fixture -def mock_server_config() -> Mock: - return create_autospec(TcpServer.TcpServerConfig, instance=True) - - -@pytest.fixture -def populated_mock_server_config(mock_server_config: Mock, MockServer: Mock) -> Mock: - mock_server_config.configures.return_value = MockServer - type(mock_server_config).kwargs = PropertyMock( - return_value={"host": "localhost", "port": 25566, "format": b"%b\r\n"} - ) - return mock_server_config - - -@pytest.fixture -def MockInterpreter() -> Mock: - return MagicMock(Interpreter, instance=False) - - -@pytest.fixture -def mock_interpreter() -> Mock: - return MagicMock(Interpreter, instance=True) - - -@pytest.fixture -def raise_interrupt(): - async def raise_interrupt(): - return False - - return Mock(raise_interrupt) - - -@pytest.fixture -def composed_adapter( - mock_device: Mock, raise_interrupt: Mock, mock_server: Mock, mock_interpreter: Mock -): - return type("TestComposedAdapter", (ComposedAdapter,), {})( - mock_device, raise_interrupt, mock_server, mock_interpreter - ) - - -@pytest.mark.asyncio -async def test_composed_adapter_on_connect_does_not_iterate(composed_adapter: Mock): - with pytest.raises(StopAsyncIteration): - await composed_adapter.on_connect().__anext__() - - -@pytest.mark.asyncio -async def test_composed_adapter_run_forever_runs_server(composed_adapter: Mock): - await composed_adapter.run_forever() - composed_adapter._server.run_forever.assert_called_once_with( - composed_adapter.on_connect, composed_adapter.handle_message - ) - - -@pytest.mark.asyncio -async def test_composed_adapter_handle_calls_interpreter_handle(composed_adapter: Mock): - composed_adapter._interpreter.handle.return_value = ("ReplyMessage", False) - await composed_adapter.handle_message("TestMessage") - composed_adapter._interpreter.handle.assert_called_once_with( - composed_adapter, "TestMessage" - ) - - -@pytest.mark.asyncio -async def test_composed_adapter_handle_does_not_interrupt_for_non_interrupting( - composed_adapter: Mock, -): - composed_adapter._interpreter.handle.return_value = ("ReplyMessage", False) - await composed_adapter.handle_message("TestMessage") - composed_adapter._raise_interrupt.assert_not_called() - - -@pytest.mark.asyncio -async def test_composed_adapter_raise_interrupts_for_interrupting( - composed_adapter: Mock, -): - composed_adapter._interpreter.handle.return_value = ("ReplyMessage", True) - await composed_adapter.handle_message("TestMessage") - composed_adapter._raise_interrupt.assert_called_once_with() - - -@pytest.mark.asyncio -async def test_composed_adapter_handle_returns_reply(composed_adapter: Mock): - composed_adapter._interpreter.handle.return_value = ("ReplyMessage", False) - assert "ReplyMessage" == await composed_adapter.handle_message("TestMessage") diff --git a/tests/adapters/test_epics_adapter.py b/tests/adapters/test_epics_adapter.py index dce57ffd..e75dcc1a 100644 --- a/tests/adapters/test_epics_adapter.py +++ b/tests/adapters/test_epics_adapter.py @@ -1,11 +1,10 @@ -from dataclasses import is_dataclass from typing import Dict import pytest from mock import MagicMock, Mock, create_autospec, mock_open, patch from tickit.adapters.epicsadapter import EpicsAdapter, InputRecord -from tickit.core.adapter import ConfigurableAdapter, Interpreter +from tickit.core.adapter import Adapter, Interpreter from tickit.core.device import Device @@ -35,16 +34,8 @@ def getter(): return InputRecord("input", Mock(setter), Mock(getter)) -def test_epics_adapter_is_configurable_adapter(): - assert issubclass(EpicsAdapter, ConfigurableAdapter) - - -def test_epics_adapter_configures_dataclass(): - assert is_dataclass(EpicsAdapter.EpicsAdapterConfig) - - -def test_epics_adapter_config_configures_epics_adapter(): - assert EpicsAdapter.EpicsAdapterConfig.configures() is EpicsAdapter +def test_epics_adapter_is_adapter(): + assert issubclass(EpicsAdapter, Adapter) def test_epics_adapter_constuctor(epics_adapter: EpicsAdapter): diff --git a/tests/adapters/test_http.py b/tests/adapters/test_http.py deleted file mode 100644 index 97c497fa..00000000 --- a/tests/adapters/test_http.py +++ /dev/null @@ -1,75 +0,0 @@ -import pytest -from aiohttp import web -from mock import Mock, PropertyMock, create_autospec - -from tickit.adapters.httpadapter import HTTPAdapter -from tickit.adapters.servers.http_server import HTTPServer -from tickit.core.device import Device - - -@pytest.fixture -def MockDevice() -> Mock: - return create_autospec(Device, instance=False) - - -@pytest.fixture -def mock_device() -> Mock: - return create_autospec(Device, instance=True) - - -@pytest.fixture -def MockServer() -> Mock: - return create_autospec(HTTPServer, instance=False) - - -@pytest.fixture -def mock_server() -> Mock: - return create_autospec(HTTPServer, instance=True) - - -@pytest.fixture -def MockServerConfig() -> Mock: - return create_autospec(HTTPServer.HTTPServerConfig, instance=False) - - -@pytest.fixture -def mock_server_config() -> Mock: - return create_autospec(HTTPServer.HTTPServerConfig, instance=True) - - -@pytest.fixture -def populated_mock_server_config(mock_server_config: Mock, MockServer: Mock) -> Mock: - mock_server_config.configures.return_value = MockServer - type(mock_server_config).kwargs = PropertyMock( - return_value={"host": "localhost", "port": 8080, "format": b"%b\r\n"} - ) - return mock_server_config - - -@pytest.fixture -def raise_interrupt(): - async def raise_interrupt(): - return False - - return Mock(raise_interrupt) - - -@pytest.fixture -def http_adapter( - mock_device: Mock, - raise_interrupt: Mock, - mock_server: Mock, -): - return type("TestHTTPAdapter", (HTTPAdapter,), {})( - mock_device, - raise_interrupt, - mock_server, - ) - - -@pytest.mark.asyncio -async def test_http_adapter_run_forever_runs_server(http_adapter: Mock): - http_adapter._server.app = web.Application() - http_adapter._server.routes = web.RouteTableDef() - await http_adapter.run_forever() - http_adapter._server.run_forever.assert_called_once_with() diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..e4bf43dd --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,57 @@ +import asyncio +import signal +import sys +from subprocess import PIPE, STDOUT, Popen + +import pytest + +from tickit.core.management.event_router import InverseWiring +from tickit.core.management.schedulers.master import MasterScheduler +from tickit.core.runner import run_all_forever +from tickit.core.state_interfaces.state_interface import get_interface +from tickit.utils.configuration.loading import read_configs + + +# https://docs.pytest.org/en/latest/example/parametrize.html#indirect-parametrization +@pytest.fixture +def tickit_process(request): + """Subprocess that runs ``tickit all ``.""" + config_path: str = request.param + proc = Popen( + [sys.executable, "-m", "tickit", "all", config_path], + stdout=PIPE, + stderr=STDOUT, + text=True, + ) + # Wait for IOC to be up + while True: + if "complete" in proc.stdout.readline(): + break + yield proc + proc.send_signal(signal.SIGINT) + print(proc.communicate()[0]) + + +@pytest.fixture +async def tickit_task(request): + """Task that runs ``tickit all ``.""" + config_path: str = request.param + configs = read_configs(config_path) + inverse_wiring = InverseWiring.from_component_configs(configs) + scheduler = MasterScheduler(inverse_wiring, *get_interface("internal")) + t = asyncio.Task( + run_all_forever( + [c().run_forever(*get_interface("internal")) for c in configs] + + [scheduler.run_forever()] + ) + ) + # TODO: would like to await all_servers_running() here + await asyncio.sleep(0.5) + yield t + tasks = asyncio.tasks.all_tasks() + for task in tasks: + task.cancel() + try: + await t + except asyncio.CancelledError: + pass diff --git a/tests/core/components/test_component.py b/tests/core/components/test_component.py index c8496805..d2069df9 100644 --- a/tests/core/components/test_component.py +++ b/tests/core/components/test_component.py @@ -3,22 +3,15 @@ import pytest from immutables import Map -from mock import AsyncMock, MagicMock, create_autospec - -from tickit.core.components.component import ( - BaseComponent, - Component, - ComponentConfig, - ConfigurableComponent, - create_components, -) +from mock import AsyncMock, create_autospec + +from tickit.core.components.component import BaseComponent, ComponentConfig from tickit.core.state_interfaces.internal import ( InternalStateConsumer, InternalStateProducer, ) from tickit.core.state_interfaces.state_interface import StateConsumer, StateProducer from tickit.core.typedefs import Changes, ComponentID, Input, Interrupt, Output, SimTime -from tickit.utils.configuration.configurable import Config from tickit.utils.topic_naming import input_topic, output_topic @@ -26,33 +19,8 @@ def test_component_config_is_dataclass(): assert is_dataclass(ComponentConfig) -def test_component_config_is_config(): - assert isinstance(ComponentConfig, Config) - - -def test_component_config_configure_raises_not_implemented(): - with pytest.raises(NotImplementedError): - ComponentConfig.configures() - - -def test_component_config_kwargs_raises_not_implemented(): - component_config = ComponentConfig(ComponentID("Test"), dict()) - with pytest.raises(NotImplementedError): - component_config.kwargs - - -def test_inherit_configurable_component_makes_configurable(): - assert isinstance( - type("Component", (ConfigurableComponent,), dict()).ComponentConfig, Config - ) - - def test_base_component_initialises(): - assert BaseComponent( - ComponentID("TestBase"), - MagicMock(InternalStateConsumer), - MagicMock(InternalStateProducer), - ) + assert BaseComponent(ComponentID("TestBase")) @pytest.fixture @@ -71,14 +39,8 @@ def TestComponent(): @pytest.fixture -def test_component( - TestComponent: Type[BaseComponent], - MockConsumer: Type[StateConsumer], - MockProducer: Type[StateProducer], -): - return TestComponent( - ComponentID("TestBase"), MockConsumer, MockProducer - ) # type: ignore +def test_component(TestComponent: Type[BaseComponent]): + return TestComponent(ComponentID("TestBase")) # type: ignore @pytest.mark.asyncio @@ -93,8 +55,12 @@ async def test_base_component_handle_input_awaits_on_tick( @pytest.mark.asyncio -async def test_base_component_output_sends_output(test_component: BaseComponent): - await test_component.set_up_state_interfaces() +async def test_base_component_output_sends_output( + test_component: BaseComponent, + MockConsumer: Type[StateConsumer], + MockProducer: Type[StateProducer], +): + await test_component.run_forever(MockConsumer, MockProducer) test_component.state_producer.produce = AsyncMock() # type: ignore await test_component.output(SimTime(42), Changes(Map()), None) test_component.state_producer.produce.assert_awaited_once_with( @@ -106,8 +72,10 @@ async def test_base_component_output_sends_output(test_component: BaseComponent) @pytest.mark.asyncio async def test_base_component_raise_interrupt_sends_output( test_component: BaseComponent, + MockConsumer: Type[StateConsumer], + MockProducer: Type[StateProducer], ): - await test_component.set_up_state_interfaces() + await test_component.run_forever(MockConsumer, MockProducer) test_component.state_producer.produce = AsyncMock() # type: ignore await test_component.raise_interrupt() test_component.state_producer.produce.assert_awaited_once_with( @@ -122,18 +90,18 @@ async def test_base_component_set_up_state_interfaces_creates_consumer( MockConsumer: Type[StateConsumer], MockProducer: Type[StateProducer], ): - test_coponent = TestComponent( - ComponentID("TestBase"), MockConsumer, MockProducer - ) # type: ignore - await test_coponent.set_up_state_interfaces() - assert test_coponent.state_consumer == MockConsumer(AsyncMock()) + test_component = TestComponent(ComponentID("TestBase")) # type: ignore + await test_component.run_forever(MockConsumer, MockProducer) + assert test_component.state_consumer == MockConsumer(AsyncMock()) @pytest.mark.asyncio async def test_base_component_set_up_state_interfaces_subscribes_consumer( test_component: BaseComponent, + MockConsumer: Type[StateConsumer], + MockProducer: Type[StateProducer], ): - await test_component.set_up_state_interfaces() + await test_component.run_forever(MockConsumer, MockProducer) test_component.state_consumer.subscribe.assert_called_once_with( # type: ignore [input_topic(ComponentID("TestBase"))] ) @@ -146,9 +114,9 @@ async def test_base_component_set_up_state_interfaces_creates_producer( MockProducer: Type[StateProducer], ): test_component = TestComponent( - ComponentID("TestBase"), MockConsumer, MockProducer + ComponentID("TestBase"), ) # type: ignore - await test_component.set_up_state_interfaces() + await test_component.run_forever(MockConsumer, MockProducer) assert test_component.state_producer == MockProducer() @@ -158,55 +126,3 @@ async def test_base_component_on_tick_raises_not_implemented( ): with pytest.raises(NotImplementedError): await test_component.on_tick(SimTime(42), Changes(Map())) - - -def test_create_simulations_creates_configured( - MockConsumer: Type[StateConsumer], - MockProducer: Type[StateProducer], -): - MockComponent = MagicMock(Component, instance=False) - MockComponentConfig = MagicMock(ComponentConfig, instance=False) - MockComponentConfig.configures.return_value = MockComponent - MockComponentConfig.kwargs.return_value = dict() - config = MockComponentConfig(name=ComponentID("TestComponent"), inputs=dict()) - - create_components([config], MockConsumer, MockProducer) - config.configures().assert_called_once_with( - name=config.name, - state_consumer=MockConsumer, - state_producer=MockProducer, - ) - - -def test_create_simulations_creates_configured_with_kwargs( - MockConsumer: Type[StateConsumer], - MockProducer: Type[StateProducer], -): - MockComponent = MagicMock(Component, instance=False) - MockComponentConfig = MagicMock(ComponentConfig, instance=False) - MockComponentConfig.configures.return_value = MockComponent - MockComponentConfig.kwargs = {"kwarg1": "One", "kwarg2": "Two"} - config = MockComponentConfig(name=ComponentID("TestComponent"), inputs=dict()) - - create_components([config], MockConsumer, MockProducer) - config.configures().assert_called_once_with( - name=config.name, - state_consumer=MockConsumer, - state_producer=MockProducer, - **config.kwargs - ) - - -def test_create_simulations_returns_created_simulations( - MockConsumer: Type[StateConsumer], - MockProducer: Type[StateProducer], -): - MockComponent = MagicMock(Component, instance=False) - MockComponentConfig = MagicMock(ComponentConfig, instance=False) - MockComponentConfig.configures.return_value = MockComponent - MockComponentConfig.kwargs.return_value = dict() - config = MockComponentConfig(name=ComponentID("TestComponent"), inputs=dict()) - - assert [config.configures()()] == create_components( - [config], MockConsumer, MockProducer - ) diff --git a/tests/core/state_interfaces/test_state_interface.py b/tests/core/state_interfaces/test_state_interface.py index adf5ba55..48c51407 100644 --- a/tests/core/state_interfaces/test_state_interface.py +++ b/tests/core/state_interfaces/test_state_interface.py @@ -12,12 +12,18 @@ @pytest.fixture(autouse=True) def reset_consumers(): + old_consumers = state_interface.consumers state_interface.consumers = dict() + yield + state_interface.consumers = old_consumers @pytest.fixture(autouse=True) def reset_producers(): + old_producers = state_interface.producers state_interface.producers = dict() + yield + state_interface.producers = old_producers @pytest.fixture diff --git a/tests/core/test_adapter.py b/tests/core/test_adapter.py deleted file mode 100644 index 1aa6d508..00000000 --- a/tests/core/test_adapter.py +++ /dev/null @@ -1,61 +0,0 @@ -from dataclasses import is_dataclass - -import pytest - -from tickit.core.adapter import ( - AdapterConfig, - ConfigurableAdapter, - ConfigurableServer, - ServerConfig, -) -from tickit.utils.configuration.configurable import Config - - -def test_adapter_config_is_dataclass(): - assert is_dataclass(AdapterConfig) - - -def test_adapter_config_is_config(): - isinstance(AdapterConfig, Config) - - -def test_adapter_config_configures_raises_not_implemented(): - with pytest.raises(NotImplementedError): - AdapterConfig.configures() - - -def test_adapter_config_kwargs_raises_not_implemented(): - adapter_config = AdapterConfig() - with pytest.raises(NotImplementedError): - adapter_config.kwargs - - -def test_inherit_configurable_adapter_makes_configurable(): - assert isinstance( - type("Adapter", (ConfigurableAdapter,), dict()).AdapterConfig, Config - ) - - -def test_server_config_is_dataclass(): - assert is_dataclass(ServerConfig) - - -def test_server_config_is_config(): - assert isinstance(ServerConfig, Config) - - -def test_server_config_configure_raises_not_implemented(): - with pytest.raises(NotImplementedError): - ServerConfig.configures() - - -def test_server_config_kwargs_raises_not_implemented(): - server_config = ServerConfig() - with pytest.raises(NotImplementedError): - server_config.kwargs - - -def test_inherit_configurable_server_makes_configurable(): - assert isinstance( - type("Server", (ConfigurableServer,), dict()).ServerConfig, Config - ) diff --git a/tests/core/test_device.py b/tests/core/test_device.py index 3c8d548d..86d12572 100644 --- a/tests/core/test_device.py +++ b/tests/core/test_device.py @@ -1,9 +1,7 @@ from dataclasses import is_dataclass from typing import Optional -import pytest - -from tickit.core.device import DeviceConfig, DeviceUpdate, OutMap +from tickit.core.device import DeviceUpdate, OutMap from tickit.core.typedefs import SimTime @@ -17,18 +15,3 @@ def test_device_update_has_state(): def test_device_update_has_call_in(): assert Optional[SimTime] == DeviceUpdate.__annotations__["call_at"] - - -def test_device_config_is_dataclass(): - assert is_dataclass(DeviceConfig) - - -def test_device_config_configures_raises_not_implemented(): - with pytest.raises(NotImplementedError): - DeviceConfig.configures() - - -def test_device_config_kwargs_raises_not_implemented(): - device_config = DeviceConfig() - with pytest.raises(NotImplementedError): - device_config.kwargs diff --git a/tests/core/test_lifetime_runnable.py b/tests/core/test_lifetime_runnable.py deleted file mode 100644 index df144841..00000000 --- a/tests/core/test_lifetime_runnable.py +++ /dev/null @@ -1,24 +0,0 @@ -from typing import Type -from unittest.mock import MagicMock - -import pytest - -from tickit.core.lifetime_runnable import LifetimeRunnable, run_all_forever - - -@pytest.fixture -def TestLifetimeRunnable(): - class TestLifetimeRunnable: - async def run_forever(self) -> None: - while True: - pass - - return TestLifetimeRunnable - - -@pytest.mark.asyncio -async def test_run_all_forever_runs(TestLifetimeRunnable: Type[LifetimeRunnable]): - test_lifetime_runnable = TestLifetimeRunnable() - test_lifetime_runnable.run_forever = MagicMock() # type: ignore - await run_all_forever([test_lifetime_runnable]) - test_lifetime_runnable.run_forever.assert_called_once_with() diff --git a/tests/core/test_runner.py b/tests/core/test_runner.py new file mode 100644 index 00000000..312a461c --- /dev/null +++ b/tests/core/test_runner.py @@ -0,0 +1,11 @@ +import pytest +from mock import AsyncMock + +from tickit.core.runner import run_all_forever + + +@pytest.mark.asyncio +async def test_run_forever_all_awaits(): + awaitable = AsyncMock() + await run_all_forever([awaitable()]) + awaitable.assert_awaited_once_with() diff --git a/tests/devices/cryostream/test_cryostream.py b/tests/devices/cryostream/test_cryostream.py index afd40e2b..1011b322 100644 --- a/tests/devices/cryostream/test_cryostream.py +++ b/tests/devices/cryostream/test_cryostream.py @@ -1,28 +1,27 @@ +import asyncio import logging import struct from typing import Optional import numpy as np import pytest -from mock import Mock -from mock.mock import create_autospec from tickit.core.device import DeviceUpdate from tickit.core.typedefs import SimTime -from tickit.devices.cryostream.cryostream import Cryostream, CryostreamAdapter +from tickit.devices.cryostream.cryostream import CryostreamDevice from tickit.devices.cryostream.states import PhaseIds -from tickit.devices.cryostream.status import Status +from tickit.devices.cryostream.status import ExtendedStatus # # # # # Cryostream Tests # # # # # @pytest.fixture -def cryostream() -> Cryostream: - return Cryostream() +def cryostream() -> CryostreamDevice: + return CryostreamDevice() def test_cryostream_constructor(): - Cryostream() + CryostreamDevice() def test_cryostream_update_hold(cryostream): @@ -32,7 +31,7 @@ def test_cryostream_update_hold(cryostream): @pytest.mark.asyncio -async def test_cryostream_update_cool(cryostream: Cryostream): +async def test_cryostream_update_cool(cryostream: CryostreamDevice): starting_temperature = cryostream.gas_temp target_temperature = starting_temperature - 50 await cryostream.cool(target_temperature) @@ -58,7 +57,7 @@ async def test_cryostream_update_cool(cryostream: Cryostream): @pytest.mark.asyncio -async def test_cryostream_update_end(cryostream: Cryostream): +async def test_cryostream_update_end(cryostream: CryostreamDevice): starting_temperature = cryostream.default_temp_shutdown - 100 cryostream.gas_temp = starting_temperature await cryostream.end(cryostream.default_ramp_rate) @@ -85,7 +84,7 @@ async def test_cryostream_update_end(cryostream: Cryostream): @pytest.mark.asyncio -async def test_cryostream_update_plat(cryostream: Cryostream): +async def test_cryostream_update_plat(cryostream: CryostreamDevice): starting_temperature = cryostream.gas_temp await cryostream.plat(5) assert cryostream.phase_id == PhaseIds.PLAT.value @@ -106,139 +105,39 @@ async def test_cryostream_update_plat(cryostream: Cryostream): assert device_update.outputs["temperature"] == starting_temperature -# # # # # CryostreamAdapter Tests # # # # # - - -@pytest.fixture -def mock_status() -> Mock: - return create_autospec(Status, instance=True) - - -@pytest.fixture -def mock_cryostream(mock_status) -> Mock: - mock_cryostream = create_autospec(Cryostream, instance=True) - mock_cryostream.get_status.return_value = mock_status - return mock_cryostream - - -@pytest.fixture -def raise_interrupt(): - async def raise_interrupt(): - return False - - return Mock(raise_interrupt) - - -@pytest.fixture -def cryostream_adapter(mock_cryostream): - return CryostreamAdapter( - mock_cryostream, raise_interrupt, host="localhost", port=25565 - ) - - -def test_cryostream_adapter_constructor(): - CryostreamAdapter(mock_cryostream, raise_interrupt, host="localhost", port=25565) - - -@pytest.mark.asyncio -async def test_cryostream_adapter_on_connect_gets_device_status( - cryostream_adapter: CryostreamAdapter, -): - await cryostream_adapter.on_connect().__anext__() - device = cryostream_adapter._device - device.get_status.assert_awaited_once_with(1) - - -@pytest.mark.asyncio -async def test_cryostream_adapter_restart(cryostream_adapter): - await cryostream_adapter.restart() - device = cryostream_adapter._device - device.restart.assert_awaited_once_with() - - -@pytest.mark.asyncio -async def test_cryostream_adapter_hold(cryostream_adapter: CryostreamAdapter): - await cryostream_adapter.hold() - device = cryostream_adapter._device - device.hold.assert_awaited_once_with() - - -@pytest.mark.asyncio -async def test_cryostream_adapter_purge(cryostream_adapter: CryostreamAdapter): - await cryostream_adapter.purge() - device = cryostream_adapter._device - device.purge.assert_awaited_once_with() - - -@pytest.mark.asyncio -async def test_cryostream_adapter_pause(cryostream_adapter: CryostreamAdapter): - await cryostream_adapter.pause() - device = cryostream_adapter._device - device.pause.assert_awaited_once_with() - - -@pytest.mark.asyncio -async def test_cryostream_adapter_resume(cryostream_adapter: CryostreamAdapter): - await cryostream_adapter.resume() - device = cryostream_adapter._device - device.resume.assert_awaited_once_with() - - -@pytest.mark.asyncio -async def test_cryostream_adapter_stop(cryostream_adapter: CryostreamAdapter): - await cryostream_adapter.stop() - device = cryostream_adapter._device - device.stop.assert_awaited_once_with() - - -@pytest.mark.asyncio -async def test_cryostream_adapter_turbo(cryostream_adapter: CryostreamAdapter): - await cryostream_adapter.turbo((1).to_bytes(1, byteorder="big")) - device = cryostream_adapter._device - device.turbo.assert_awaited_once_with(1) - - -@pytest.mark.asyncio -async def test_cryostream_adapter_set_status_format( - cryostream_adapter: CryostreamAdapter, -): - await cryostream_adapter.set_status_format((1).to_bytes(1, byteorder="big")) - device = cryostream_adapter._device - device.set_status_format.assert_awaited_once_with(1) - - -@pytest.mark.asyncio -async def test_cryostream_adapter_plat( - cryostream_adapter: CryostreamAdapter, -): - await cryostream_adapter.plat((1).to_bytes(2, byteorder="big")) - device = cryostream_adapter._device - device.plat.assert_awaited_once_with(1) - - -@pytest.mark.asyncio -async def test_cryostream_adapter_end( - cryostream_adapter: CryostreamAdapter, -): - await cryostream_adapter.end((360).to_bytes(2, byteorder="big")) - device = cryostream_adapter._device - device.end.assert_awaited_once_with(360) - - -@pytest.mark.asyncio -async def test_cryostream_adapter_cool( - cryostream_adapter: CryostreamAdapter, -): - await cryostream_adapter.cool((300).to_bytes(2, byteorder="big")) - device = cryostream_adapter._device - device.cool.assert_awaited_once_with(300) - - @pytest.mark.asyncio -async def test_cryostream_adapter_ramp( - cryostream_adapter: CryostreamAdapter, -): - values = struct.pack(">HH", 360, 1000) - await cryostream_adapter.ramp(values) - device = cryostream_adapter._device - device.ramp.assert_awaited_once_with(360, 1000) +@pytest.mark.parametrize( + "tickit_task", ["examples/configs/cryo-tcp.yaml"], indirect=True +) +async def test_cryostream_system(tickit_task): + reader, writer = await asyncio.open_connection("localhost", 25565) + + async def write(data: bytes): + writer.write(data) + await writer.drain() + + async def get_status() -> ExtendedStatus: + return ExtendedStatus.from_packed(await reader.read(42)) + + # Check we start at 300K + status = await get_status() + assert status.gas_temp == 30000 + assert status.turbo_mode == 0 + # Start a ramp + await write(b"\x06\x0b" + struct.pack(">HH", 360, 30030)) + # Check we go to to 300.3K, eventually + status = await get_status() + assert status.gas_temp == pytest.approx(30015, rel=5) + status = await get_status() + assert status.gas_temp == pytest.approx(30025, rel=5) + status = await get_status() + assert status.gas_temp == pytest.approx(30030, rel=5) + # Turbo on + await write(b"\x03\x14\x01") + status = await get_status() + assert status.gas_temp == pytest.approx(30030, rel=5) + assert status.turbo_mode == 1 + # Cool to 300K + await write(b"\x04\x0e" + struct.pack(">H", 30000)) + status = await get_status() + assert status.gas_temp == pytest.approx(30000, rel=5) diff --git a/tests/devices/femto/test_femto.py b/tests/devices/femto/test_femto.py index 92840c81..ed928df2 100644 --- a/tests/devices/femto/test_femto.py +++ b/tests/devices/femto/test_femto.py @@ -1,31 +1,34 @@ +import asyncio + import pytest -from mock import Mock, create_autospec +from aioca import caget, caput from tickit.core.device import DeviceUpdate from tickit.core.typedefs import SimTime -from tickit.devices.femto.femto import CurrentDevice, Femto, FemtoAdapter +from tickit.devices.femto.current import CurrentDevice +from tickit.devices.femto.femto import FemtoDevice @pytest.fixture -def femto() -> Femto: - return Femto() +def femto() -> FemtoDevice: + return FemtoDevice(initial_gain=2.5, initial_current=0.0) -def test_femto_constructor(femto: Femto): +def test_femto_constructor(femto: FemtoDevice): pass -def test_set_and_get_gain(femto: Femto): +def test_set_and_get_gain(femto: FemtoDevice): femto.set_gain(3.0) assert femto.get_gain() == 3.0 -def test_set_and_get_current(femto: Femto): +def test_set_and_get_current(femto: FemtoDevice): femto.set_current(3.0) assert femto.get_current() == 3.0 * femto.get_gain() -def test_femto_update(femto: Femto): +def test_femto_update(femto: FemtoDevice): device_input = {"input": 3.0} time = SimTime(0) update: DeviceUpdate = femto.update(time, device_input) @@ -37,7 +40,7 @@ def test_femto_update(femto: Femto): @pytest.fixture def current_device() -> CurrentDevice: - return CurrentDevice() + return CurrentDevice(callback_period=1000000000) def test_current_device_constructor(current_device: CurrentDevice): @@ -48,55 +51,19 @@ def test_current_device_update(current_device: CurrentDevice): time = SimTime(0) device_input = {"bleep": "bloop"} update: DeviceUpdate = current_device.update(time, device_input) - assert 0.1 <= update.outputs["output"] <= 200.1 - - -# # # # # # # # # # FemtoAdapter # # # # # # # # # # - - -@pytest.fixture -def mock_femto() -> Mock: - return create_autospec(Femto, instance=True) - - -@pytest.fixture -def raise_interrupt() -> Mock: - async def raise_interrupt(): - return False - - return Mock(raise_interrupt) - - -@pytest.fixture -def femto_adapter(mock_femto: Mock, raise_interrupt: Mock) -> FemtoAdapter: - return FemtoAdapter(mock_femto, raise_interrupt) - - -def test_femto_adapter_constructor(femto_adapter: FemtoAdapter): - pass - - -@pytest.mark.asyncio -async def test_run_forever(femto_adapter: FemtoAdapter): - femto_adapter.build_ioc = Mock(femto_adapter.build_ioc) - await femto_adapter.run_forever() - femto_adapter.build_ioc.assert_called_once() + assert 100 <= update.outputs["output"] < 200 @pytest.mark.asyncio -async def test_femto_adapter_callback(femto_adapter: FemtoAdapter): - await femto_adapter.callback(2.0) - femto_adapter._device.set_gain.assert_called_with(2.0) - femto_adapter.raise_interrupt.assert_awaited_once_with() - - -def test_femto_adapter_on_db_load_method(femto_adapter: FemtoAdapter): - - femto_adapter.on_db_load() - - input_record = femto_adapter.input_record - current_record = femto_adapter.current_record - interrupt_records = femto_adapter.interrupt_records - - assert interrupt_records[input_record] == femto_adapter._device.get_gain - assert interrupt_records[current_record] == femto_adapter._device.get_current +@pytest.mark.parametrize( + "tickit_process", ["examples/configs/current-monitor.yaml"], indirect=True +) +async def test_femto_system(tickit_process): + assert (await caget("FEMTO:GAIN_RBV")) == 2.5 + current = await caget("FEMTO:CURRENT") + assert 100 * 2.5 <= current < 200 * 2.5 + await caput("FEMTO:GAIN", 0.01) + await asyncio.sleep(0.5) + assert (await caget("FEMTO:GAIN_RBV")) == 0.01 + current = await caget("FEMTO:CURRENT") + assert 100 * 0.01 <= current < 200 * 0.01 diff --git a/tests/devices/pneumatic/test_pneumatic.py b/tests/devices/pneumatic/test_pneumatic.py index eb3bf8f9..8fc19c57 100644 --- a/tests/devices/pneumatic/test_pneumatic.py +++ b/tests/devices/pneumatic/test_pneumatic.py @@ -1,32 +1,34 @@ +import asyncio + import pytest -from mock import Mock, create_autospec +from aioca import caget, caput from tickit.core.device import DeviceUpdate from tickit.core.typedefs import SimTime -from tickit.devices.pneumatic.pneumatic import Pneumatic, PneumaticAdapter +from tickit.devices.pneumatic.pneumatic import PneumaticDevice @pytest.fixture -def pneumatic() -> Pneumatic: - return Pneumatic() +def pneumatic() -> PneumaticDevice: + return PneumaticDevice(initial_speed=0.5, initial_state=False) -def test_pneumatic_constructor(pneumatic: Pneumatic): +def test_pneumatic_constructor(pneumatic: PneumaticDevice): pass -def test_pneumatic_set_and_get_speed(pneumatic: Pneumatic): +def test_pneumatic_set_and_get_speed(pneumatic: PneumaticDevice): pneumatic.set_speed(3.0) assert pneumatic.get_speed() == 3.0 -def test_pneumatic_set_and_get_state(pneumatic: Pneumatic): +def test_pneumatic_set_and_get_state(pneumatic: PneumaticDevice): initial_state = pneumatic.get_state() pneumatic.set_state() assert pneumatic.target_state is not initial_state -def test_pneumatic_update(pneumatic: Pneumatic): +def test_pneumatic_update(pneumatic: PneumaticDevice): time = SimTime(0) initial_state = pneumatic.get_state() pneumatic.set_state() @@ -35,7 +37,7 @@ def test_pneumatic_update(pneumatic: Pneumatic): assert device_update.outputs["output"] is not initial_state -def test_pneumatic_update_no_state_change(pneumatic: Pneumatic): +def test_pneumatic_update_no_state_change(pneumatic: PneumaticDevice): time = SimTime(0) initial_state = pneumatic.get_state() device_update: DeviceUpdate = pneumatic.update(time, {}) @@ -43,44 +45,16 @@ def test_pneumatic_update_no_state_change(pneumatic: Pneumatic): assert device_update.outputs["output"] is initial_state -# # # # # # # # # # PneumaticAdapter # # # # # # # # # # - - -@pytest.fixture -def mock_pneumatic() -> Mock: - return create_autospec(Pneumatic) - - -@pytest.fixture -def raise_interrupt() -> Mock: - async def raise_interrupt(): - return False - - return Mock(raise_interrupt) - - -@pytest.fixture -def pneumatic_adapter(mock_pneumatic: Mock, raise_interrupt: Mock) -> PneumaticAdapter: - return PneumaticAdapter(mock_pneumatic, raise_interrupt, "data.db") - - -def test_pneumatic_adapter_constructor(pneumatic_adapter: PneumaticAdapter): - pass - - @pytest.mark.asyncio -async def test_pneumatic_adapter_run_forever(pneumatic_adapter: PneumaticAdapter): - pneumatic_adapter.build_ioc = Mock(pneumatic_adapter.build_ioc) - await pneumatic_adapter.run_forever() - pneumatic_adapter.build_ioc.assert_called_once() - - -@pytest.mark.asyncio -async def test_pneumatic_adapter_callback(pneumatic_adapter: PneumaticAdapter): - await pneumatic_adapter.callback(None) - pneumatic_adapter._device.set_state.assert_called_once() - pneumatic_adapter.raise_interrupt.assert_awaited_once_with() - - -def test_pneumatic_adapter_on_db_load(pneumatic_adapter: PneumaticAdapter): - pneumatic_adapter.on_db_load() +@pytest.mark.parametrize( + "tickit_process", ["examples/configs/attns.yaml"], indirect=True +) +async def test_pneumatic_system(tickit_process): + async def toggle(expected: bool): + assert (await caget("PNEUMATIC:FILTER_RBV")) != expected + await caput("PNEUMATIC:FILTER", expected) + await asyncio.sleep(0.8) + assert (await caget("PNEUMATIC:FILTER_RBV")) == expected + + await toggle(True) + await toggle(False) diff --git a/tests/utils/configuration/test_configurable.py b/tests/utils/configuration/test_configurable.py deleted file mode 100644 index 254af044..00000000 --- a/tests/utils/configuration/test_configurable.py +++ /dev/null @@ -1,84 +0,0 @@ -from dataclasses import dataclass -from typing import List - -import apischema -from pytest import fixture - -from tickit.utils.configuration.configurable import ( - Config, - configurable, - configurable_base, -) - - -@fixture -def TestBaseConfig(): - @configurable_base - @dataclass - class TestBase: - base_prop: str - - return TestBase - - -@fixture -def TestBase(TestBaseConfig): - class TestBase: - def __init_subclass__(cls) -> None: - cls = configurable(TestBaseConfig)(cls) - - return TestBase - - -@fixture -def TestDerived(TestBase): - class TestDerived(TestBase): - def __init__(self, derived_field: int) -> None: - pass - - return TestDerived - - -@fixture -def OtherTestDerived(TestBase): - class OtherTestDerived(TestBase): - def __init__(self, other_derived_field: float) -> None: - pass - - return OtherTestDerived - - -def test_serialization_consistency(TestBaseConfig, TestDerived, OtherTestDerived): - to_serialize: List[TestBaseConfig] = [ - TestDerived.TestDerivedConfig("Hello", 42), - OtherTestDerived.OtherTestDerivedConfig("World", 3.14), - ] - serialized = apischema.serialize(List[TestBaseConfig], to_serialize) - deserialized = apischema.deserialize(List[TestBaseConfig], serialized) - assert to_serialize == deserialized - - -def test_configurable_adds_config(TestDerived): - assert isinstance(TestDerived.TestDerivedConfig, Config) - - -def test_config_fields(TestDerived): - assert { - "base_prop": str, - "derived_field": int, - } == TestDerived.TestDerivedConfig.__annotations__ - - -def test_config_inherits(TestDerived, TestBaseConfig): - assert TestBaseConfig in TestDerived.TestDerivedConfig.__bases__ - - -def test_config_configures(TestDerived): - assert TestDerived == TestDerived.TestDerivedConfig.configures() - - -def test_configurable_kwargs(TestDerived): - assert {"derived_field": 42} == TestDerived.TestDerivedConfig( - base_prop="Hello", - derived_field=42, - ).kwargs diff --git a/tests/utils/test_configurable.py b/tests/utils/test_configurable.py new file mode 100644 index 00000000..4a592ee7 --- /dev/null +++ b/tests/utils/test_configurable.py @@ -0,0 +1,53 @@ +from dataclasses import dataclass + +from apischema import deserialize +from apischema.json_schema import deserialization_schema + +from tickit.utils.configuration.configurable import as_tagged_union + + +@as_tagged_union +class MyBase: + pass + + +@dataclass +class MyClass(MyBase): + a: int + b: str + + +@dataclass +class MyOtherClass(MyBase): + a: int + c: float + + +def test_tagged_union_deserializes(): + expected = MyClass(a=1, b="foo") + actual = deserialize(MyBase, {"test_configurable.MyClass": {"a": 1, "b": "foo"}}) + assert actual == expected + + +def test_deserialization_schema(): + assert deserialization_schema(MyBase) == { + "$schema": "http://json-schema.org/draft/2019-09/schema#", + "additionalProperties": False, + "maxProperties": 1, + "minProperties": 1, + "properties": { + "test_configurable.MyClass": { + "additionalProperties": False, + "properties": {"a": {"type": "integer"}, "b": {"type": "string"}}, + "required": ["a", "b"], + "type": "object", + }, + "test_configurable.MyOtherClass": { + "additionalProperties": False, + "properties": {"a": {"type": "integer"}, "c": {"type": "number"}}, + "required": ["a", "c"], + "type": "object", + }, + }, + "type": "object", + } diff --git a/tickit/adapters/composed.py b/tickit/adapters/composed.py index a7d13c23..3fdf0401 100644 --- a/tickit/adapters/composed.py +++ b/tickit/adapters/composed.py @@ -1,7 +1,7 @@ from dataclasses import dataclass -from typing import AsyncIterable, Awaitable, Callable, Optional, TypeVar +from typing import AsyncIterable, Optional, TypeVar -from tickit.core.adapter import Interpreter, Server +from tickit.core.adapter import Adapter, Interpreter, RaiseInterrupt, Server from tickit.core.device import Device #: Message type @@ -9,17 +9,15 @@ @dataclass -class ComposedAdapter: +class ComposedAdapter(Adapter): """An adapter implementation which delegates to a server and interpreter. An adapter implementation which delegates the hosting of an external messaging protocol to a server and message handling to an interpreter. """ - _device: Device - _raise_interrupt: Callable[[], Awaitable[None]] - _server: Server - _interpreter: Interpreter + server: Server + interpreter: Interpreter async def on_connect(self) -> AsyncIterable[Optional[T]]: """An overridable asynchronous iterable which yields messages on client connection. @@ -39,11 +37,14 @@ async def handle_message(self, message: T) -> AsyncIterable[Optional[T]]: Returns: AsyncIterable[Optional[T]]: An asynchronous iterable of reply messages. """ - reply, interrupt = await self._interpreter.handle(self, message) + reply, interrupt = await self.interpreter.handle(self, message) if interrupt: - await self._raise_interrupt() + await self.raise_interrupt() return reply - async def run_forever(self) -> None: + async def run_forever( + self, device: Device, raise_interrupt: RaiseInterrupt + ) -> None: """Runs the server continously.""" - await self._server.run_forever(self.on_connect, self.handle_message) + await super().run_forever(device, raise_interrupt) + await self.server.run_forever(self.on_connect, self.handle_message) diff --git a/tickit/adapters/epicsadapter.py b/tickit/adapters/epicsadapter.py index 64cafaa1..dc337382 100644 --- a/tickit/adapters/epicsadapter.py +++ b/tickit/adapters/epicsadapter.py @@ -8,7 +8,8 @@ from softioc import asyncio_dispatcher, builder, softioc -from tickit.core.adapter import ConfigurableAdapter +from tickit.core.adapter import Adapter, RaiseInterrupt +from tickit.core.device import Device @dataclass(frozen=True) @@ -27,11 +28,9 @@ class OutputRecord: name: str -class EpicsAdapter(ConfigurableAdapter): +class EpicsAdapter(Adapter): """An adapter implementation which acts as an EPICS IOC.""" - interrupt_records: Dict[InputRecord, Callable[[], Any]] - def __init__(self, db_file: str, ioc_name: str) -> None: """An EpicsAdapter constructor which stores the db_file path and the IOC name. @@ -41,6 +40,7 @@ def __init__(self, db_file: str, ioc_name: str) -> None: """ self.db_file = db_file self.ioc_name = ioc_name + self.interrupt_records: Dict[InputRecord, Callable[[], Any]] = {} def link_input_on_interrupt( self, record: InputRecord, getter: Callable[[], Any] @@ -65,6 +65,17 @@ def on_db_load(self) -> None: """Customises records that have been loaded in to suit the simulation.""" raise NotImplementedError + def load_records_without_DTYP_fields(self): + """Loads the records without DTYP fields.""" + with open(self.db_file, "rb") as inp: + with NamedTemporaryFile(suffix=".db", delete=False) as out: + for line in inp.readlines(): + if not re.match(rb"\s*field\s*\(\s*DTYP", line): + out.write(line) + + softioc.dbLoadDatabase(out.name, substitutions=f"device={self.ioc_name}") + os.unlink(out.name) + def build_ioc(self) -> None: """Builds an EPICS python soft IOC for the adapter.""" builder.SetDeviceName(self.ioc_name) @@ -79,13 +90,9 @@ def build_ioc(self) -> None: dispatcher = asyncio_dispatcher.AsyncioDispatcher(event_loop) softioc.iocInit(dispatcher) - def load_records_without_DTYP_fields(self): - """Loads the records without DTYP fields.""" - with open(self.db_file, "rb") as inp: - with NamedTemporaryFile(suffix=".db", delete=False) as out: - for line in inp.readlines(): - if not re.match(rb"\s*field\s*\(\s*DTYP", line): - out.write(line) - - softioc.dbLoadDatabase(out.name, substitutions=f"device={self.ioc_name}") - os.unlink(out.name) + async def run_forever( + self, device: Device, raise_interrupt: RaiseInterrupt + ) -> None: + """Runs the server continously.""" + await super().run_forever(device, raise_interrupt) + self.build_ioc() diff --git a/tickit/adapters/httpadapter.py b/tickit/adapters/httpadapter.py index a3e3ae18..b8862d54 100644 --- a/tickit/adapters/httpadapter.py +++ b/tickit/adapters/httpadapter.py @@ -1,31 +1,48 @@ +import asyncio from dataclasses import dataclass from inspect import getmembers -from typing import Awaitable, Callable, Iterable +from typing import Iterable + +from aiohttp import web +from aiohttp.web_routedef import RouteDef from tickit.adapters.interpreters.endpoints.http_endpoint import HTTPEndpoint -from tickit.adapters.servers.http_server import HTTPServer +from tickit.core.adapter import Adapter, RaiseInterrupt from tickit.core.device import Device @dataclass -class HTTPAdapter: +class HTTPAdapter(Adapter): """An adapter implementation which delegates to a server and sets up endpoints. An adapter implementation which delegates the hosting of an http requests to a server and sets up the endpoints for said server. """ - _device: Device - _raise_interrupt: Callable[[], Awaitable[None]] - _server: HTTPServer + host: str = "localhost" + port: int = 8080 - async def run_forever(self) -> None: + async def run_forever( + self, device: Device, raise_interrupt: RaiseInterrupt + ) -> None: """Runs the server continously.""" - self._server.app.add_routes(list(self.endpoints())) - - await self._server.run_forever() - - def endpoints(self) -> Iterable[HTTPEndpoint]: + await super().run_forever(device, raise_interrupt) + app = web.Application() + app.add_routes(list(self.endpoints())) + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, host=self.host, port=self.port) + await site.start() + try: + await asyncio.Event().wait() + except KeyboardInterrupt: + pass + finally: + # TODO: This doesn't work yet due to asyncio's own exception handler + await app.shutdown() + await app.cleanup() + + def endpoints(self) -> Iterable[RouteDef]: """Returns list of endpoints. Fetches the defined HTTP endpoints in the device adapter, parses them and @@ -38,6 +55,6 @@ def endpoints(self) -> Iterable[HTTPEndpoint]: Iterator[Iterable[HTTPEndpoint]]: The iterator of the defined endpoints """ for _, func in getmembers(self): - endpoint = getattr(func, "__endpoint__", None) + endpoint: HTTPEndpoint = getattr(func, "__endpoint__", None) if endpoint is not None: yield endpoint.define(func) diff --git a/tickit/adapters/servers/http_server.py b/tickit/adapters/servers/http_server.py deleted file mode 100644 index e104ff29..00000000 --- a/tickit/adapters/servers/http_server.py +++ /dev/null @@ -1,55 +0,0 @@ -import asyncio -import logging - -from aiohttp import web - -from tickit.core.adapter import ConfigurableServer -from tickit.utils.byte_format import ByteFormat - -LOGGER = logging.getLogger(__name__) - - -class HTTPServer(ConfigurableServer): - """A configurable http server with message handling for use in adapters.""" - - def __init__( - self, - host: str = "localhost", - port: int = 8080, - format: ByteFormat = ByteFormat(b"%b"), - ) -> None: - """The HttpServer constructor which takes a host, port and format byte string. - - Args: - host (str): The host name which the server should be run under. - port (int): The port number which the server should listen to. - format (ByteFormat): A formatting string for messages sent by the server, - allowing for the prepending and appending of data. Defaults to b"%b". - """ - self.host = host - self.port = port - self.format = format.format - self.app = web.Application() - self.routes = web.RouteTableDef() - - async def run_forever( - self, - ) -> None: - """Runs the HTTP server indefinitely on the configured host and port. - - An asynchronous method used to run the server indefinitely on the configured - host and port. - """ - runner = web.AppRunner(self.app) - await runner.setup() - site = web.TCPSite(runner, host=self.host, port=self.port) - await site.start() - - try: - await asyncio.Event().wait() - except KeyboardInterrupt: - pass - finally: - # TODO: This doesn't work yet due to asyncio's own exception handler - await self.app.shutdown() - await self.app.cleanup() diff --git a/tickit/adapters/servers/tcp.py b/tickit/adapters/servers/tcp.py index 10383de9..d54fa32c 100644 --- a/tickit/adapters/servers/tcp.py +++ b/tickit/adapters/servers/tcp.py @@ -1,15 +1,15 @@ import asyncio import logging from asyncio.streams import StreamReader, StreamWriter -from typing import AsyncIterable, Awaitable, Callable, List +from typing import AsyncIterable, Awaitable, Callable, List, Optional -from tickit.core.adapter import ConfigurableServer +from tickit.core.adapter import Server from tickit.utils.byte_format import ByteFormat LOGGER = logging.getLogger(__name__) -class TcpServer(ConfigurableServer): +class TcpServer(Server[bytes]): """A configurable tcp server with delegated message handling for use in adapters.""" def __init__( @@ -32,8 +32,8 @@ def __init__( async def run_forever( self, - on_connect: Callable[[], AsyncIterable[bytes]], - handler: Callable[[bytes], Awaitable[AsyncIterable[bytes]]], + on_connect: Callable[[], AsyncIterable[Optional[bytes]]], + handler: Callable[[bytes], Awaitable[AsyncIterable[Optional[bytes]]]], ) -> None: """Runs the TCP server indefinitely on the configured host and port. @@ -53,7 +53,7 @@ async def run_forever( tasks: List[asyncio.Task] = list() async def handle(reader: StreamReader, writer: StreamWriter) -> None: - async def reply(replies: AsyncIterable[bytes]) -> None: + async def reply(replies: AsyncIterable[Optional[bytes]]) -> None: async for reply in replies: if reply is None: continue diff --git a/tickit/cli.py b/tickit/cli.py index 0005e2ec..6baf56c5 100644 --- a/tickit/cli.py +++ b/tickit/cli.py @@ -1,14 +1,12 @@ import asyncio import logging -from typing import cast import click from click.core import Context -from tickit.core.components.component import create_components -from tickit.core.lifetime_runnable import LifetimeRunnable, run_all_forever from tickit.core.management.event_router import InverseWiring from tickit.core.management.schedulers.master import MasterScheduler +from tickit.core.runner import run_all_forever from tickit.core.state_interfaces.state_interface import get_interface, interfaces from tickit.utils.configuration.loading import read_configs @@ -48,9 +46,9 @@ def component(config_path: str, component: str, backend: str) -> None: backend (str): The message broker to be used. """ configs = read_configs(config_path) - config = next(config for config in configs if config.name == component) - components = create_components([config], *get_interface(backend)) - asyncio.run(run_all_forever(components)) + config = [config for config in configs if config.name == component] + assert len(config) == 1, f"Expected only one component {component}" + asyncio.run(run_all_forever([config[0]().run_forever(*get_interface(backend))])) @main.command(help="run the simulation scheduler") @@ -66,7 +64,7 @@ def scheduler(config_path: str, backend: str) -> None: configs = read_configs(config_path) inverse_wiring = InverseWiring.from_component_configs(configs) scheduler = MasterScheduler(inverse_wiring, *get_interface(backend)) - asyncio.run(run_all_forever([cast(LifetimeRunnable, scheduler)])) + asyncio.run(run_all_forever([scheduler.run_forever()])) @main.command(help="run a collection of devices with a scheduler") @@ -84,5 +82,9 @@ def all(config_path: str, backend: str) -> None: configs = read_configs(config_path) inverse_wiring = InverseWiring.from_component_configs(configs) scheduler = MasterScheduler(inverse_wiring, *get_interface(backend)) - components = create_components(configs, *get_interface(backend)) - asyncio.run(run_all_forever([cast(LifetimeRunnable, scheduler), *components])) + asyncio.run( + run_all_forever( + [config().run_forever(*get_interface(backend)) for config in configs] + + [scheduler.run_forever()] + ) + ) diff --git a/tickit/core/adapter.py b/tickit/core/adapter.py index 066ea40c..ea530fdd 100644 --- a/tickit/core/adapter.py +++ b/tickit/core/adapter.py @@ -1,113 +1,65 @@ -import sys -from dataclasses import dataclass from typing import ( - TYPE_CHECKING, + Any, AsyncIterable, Awaitable, Callable, - Dict, + Generic, Optional, Tuple, - Type, TypeVar, ) -from tickit.utils.configuration.configurable import configurable, configurable_base +from typing_extensions import Protocol -# TODO: Investigate why import from tickit.utils.compat.typing_compat causes mypy error: -# >>> 54: error: Argument 1 to "handle" of "Interpreter" has incompatible type -# "ComposedAdapter"; expected "Adapter" -# See mypy issue for details: https://github.com/python/mypy/issues/10851 -if sys.version_info >= (3, 8): - from typing import Protocol, runtime_checkable -elif sys.version_info >= (3, 5): - from typing_extensions import Protocol, runtime_checkable - -if TYPE_CHECKING: - from tickit.core.device import Device +from tickit.core.device import Device +from tickit.utils.configuration.configurable import as_tagged_union #: Message type T = TypeVar("T") -@runtime_checkable -class Adapter(Protocol): +# https://github.com/python/mypy/issues/708#issuecomment-647124281 +class RaiseInterrupt(Protocol): + """A raise_interrupt function that should be passed to `Adapter`.""" + + async def __call__(self) -> None: + """The actual call signature.""" + pass + + +@as_tagged_union +class Adapter: """An interface for types which implement device adapters.""" - def __init__( - self, device: "Device", raise_interrupt: Callable[[], Awaitable[None]], **kwargs - ) -> None: - """The Adapter constructor which takes device, raise_interrupt and key word arguments. + device: Device + raise_interrupt: RaiseInterrupt - Args: - device (Device): The device which this adapter is attached to. - raise_interrupt (Callable): A callback to request that the device is - updated immediately. - """ - pass + def __getattr__(self, name: str) -> Any: + """Improve error message for getting attributes before `run_forever`.""" + if name in ("device", "raise_interrup"): + raise RuntimeError( + "Can't get self.device or self.raise_interrupt before run_forever()" + ) + return super().__getattribute__(name) - async def run_forever(self) -> None: + async def run_forever( + self, device: Device, raise_interrupt: RaiseInterrupt + ) -> None: """An asynchronous method allowing indefinite running of core adapter logic. An asynchronous method allowing for indefinite running of core adapter logic (typically the hosting of a protocol server and the interpretation of commands which are supplied via it). """ - pass - - -@runtime_checkable -class ListeningAdapter(Adapter, Protocol): - """An interface for adapters which require to be notified after a device updates.""" + self.device = device + self.raise_interrupt = raise_interrupt def after_update(self): """A method which is called immediately after the device updates.""" - pass - - -@configurable_base -@dataclass -class AdapterConfig: - """A data container for adapter configuration. - - A data container for adapter configuration which acts as a named union of subclasses - to facilitate automatic deserialization. - """ - - @staticmethod - def configures() -> Type[Adapter]: - """A static method which returns the Adapter class configured by this config. - - Returns: - Type[Adapter]: The Adapter class configured by this config. - """ - raise NotImplementedError - - @property - def kwargs(self) -> Dict[str, object]: - """A property which returns the key word arguments of the configured adapter. - - Returns: - Dict[str, object]: The key word argument of the configured Adapter. - """ - raise NotImplementedError -class ConfigurableAdapter: - """A mixin used to create an adapter with a configuration data container.""" - - def __init_subclass__(cls) -> None: - """A subclass init method which makes the subclass configurable. - - A subclass init method which makes the subclass configurable with a - AdapterConfig template, ignoring the "device" and "raise_interrupt" - arguments. - """ - cls = configurable(AdapterConfig, ["device", "raise_interrupt"])(cls) - - -@runtime_checkable -class Interpreter(Protocol[T]): +@as_tagged_union +class Interpreter(Generic[T]): """An interface for types which handle messages recieved by an adapter.""" async def handle( @@ -127,17 +79,12 @@ async def handle( Tuple[AsyncIterable[T], bool]: A tuple containing both an asynchronous iterable of reply messages and an interrupt flag. """ - pass -@runtime_checkable -class Server(Protocol[T]): +@as_tagged_union +class Server(Generic[T]): """An interface for types which implement an external messaging protocol.""" - def __init__(self, **kwargs) -> None: - """A Server constructor which may recieve key word arguments.""" - pass - async def run_forever( self, on_connect: Callable[[], AsyncIterable[Optional[T]]], @@ -152,44 +99,3 @@ async def run_forever( asynchronous method used to handle recieved messages, returning an asynchronous iterable of replies. """ - pass - - -@configurable_base -@dataclass -class ServerConfig: - """A data container for server configuration. - - A data container for server configuration which acts as a named union of subclasses - to facilitate automatic deserialization. - """ - - @staticmethod - def configures() -> Type[Server]: - """A static method which returns the Adapter class configured by this config. - - Returns: - Type[Server]: The Server class configured by this config. - """ - raise NotImplementedError - - @property - def kwargs(self) -> Dict[str, object]: - """A property which returns the key word arguments of the configured server. - - Returns: - Dict[str, object]: The key word argument of the configured Server. - """ - raise NotImplementedError - - -class ConfigurableServer: - """A mixin used to create a server with a configuration data container.""" - - def __init_subclass__(cls) -> None: - """A subclass init method which makes the subclass configurable. - - A subclass init method which makes the subclass configurable with a - ServerConfig template. - """ - cls = configurable(ServerConfig)(cls) diff --git a/tickit/core/components/component.py b/tickit/core/components/component.py index e552bc1f..76564764 100644 --- a/tickit/core/components/component.py +++ b/tickit/core/components/component.py @@ -1,7 +1,7 @@ import logging from abc import abstractmethod from dataclasses import dataclass -from typing import Dict, Iterable, List, Optional, Type, Union +from typing import Dict, Optional, Type, Union from tickit.core.state_interfaces.state_interface import StateConsumer, StateProducer from tickit.core.typedefs import ( @@ -14,15 +14,15 @@ PortID, SimTime, ) -from tickit.utils.compat.typing_compat import Protocol, runtime_checkable -from tickit.utils.configuration.configurable import configurable, configurable_base +from tickit.utils.configuration.configurable import as_tagged_union from tickit.utils.topic_naming import input_topic, output_topic LOGGER = logging.getLogger(__name__) -@runtime_checkable -class Component(Protocol): +@dataclass +@as_tagged_union +class Component: """An interface for types which implement stand-alone simulation components. An interface for types which implement stand-alone simulation components. @@ -31,10 +31,15 @@ class Component(Protocol): SystemSimulation which hosts a SlaveScheduler and internal Components). """ - async def run_forever(self) -> None: + name: ComponentID + + @abstractmethod + async def run_forever( + self, state_consumer: Type[StateConsumer], state_producer: Type[StateProducer] + ) -> None: """An asynchronous method allowing indefinite running of core logic.""" - pass + @abstractmethod async def on_tick(self, time: SimTime, changes: Changes): """An asynchronous method called whenever the component is to be updated. @@ -43,11 +48,10 @@ async def on_tick(self, time: SimTime, changes: Changes): changes (Changes): A mapping of changed component inputs and their new values. """ - pass -@configurable_base @dataclass +@as_tagged_union class ComponentConfig: """A data container for component configuration. @@ -58,62 +62,17 @@ class ComponentConfig: name: ComponentID inputs: Dict[PortID, ComponentPort] - @staticmethod - def configures() -> Type[Component]: - """The Component class configured by this config type. - - Returns: - Type[Component]: The Component class configured by this config type. - """ - raise NotImplementedError - - @property - def kwargs(self) -> Dict[str, object]: - """The key word arguments of the configured component. - - Returns: - Dict[str, object]: The key word argument of the configured Component. - """ - raise NotImplementedError - - -class ConfigurableComponent: - """A mixin used to create a component with a configuration data container.""" - - def __init_subclass__(cls) -> None: - """A subclass init method which makes the subclass configurable. - - A subclass init method which makes the subclass configurable with a - ComponentConfig template, ignoring the "state_consumer" and "state_producer" - arguments. - """ - cls = configurable( - ComponentConfig, - ignore=["state_consumer", "state_producer"], - )(cls) + @abstractmethod + def __call__(self) -> Component: + """Create the component from the given config.""" + raise NotImplementedError(self) -class BaseComponent(ConfigurableComponent): +class BaseComponent(Component): """A base class for compnents, implementing state interface related methods.""" - def __init__( - self, - name: ComponentID, - state_consumer: Type[StateConsumer], - state_producer: Type[StateProducer], - ) -> None: - """A BaseComponent constructor which stores state interface types. - - Args: - name (ComponentID): The unique identifier of the component. - state_consumer (Type[StateConsumer]): The state consumer class to be used - by the component. - state_producer (Type[StateProducer]): The state producer class to be used - by the component. - """ - self.name = name - self._state_consumer_cls = state_consumer - self._state_producer_cls = state_producer + state_consumer: StateConsumer[Input] + state_producer: StateProducer[Union[Interrupt, Output]] async def handle_input(self, input: Input): """Calls on_tick when an input is recieved. @@ -154,20 +113,18 @@ async def raise_interrupt(self) -> None: """ await self.state_producer.produce(output_topic(self.name), Interrupt(self.name)) - async def set_up_state_interfaces(self): + async def run_forever( + self, state_consumer: Type[StateConsumer], state_producer: Type[StateProducer] + ) -> None: """Creates and configures a state consumer and state producer. An asynchronous method which creates a state consumer which is subscribed to the input topic of the component and calls back to handle_input, and a state producer to produce Interrupt or Output messages. """ - self.state_consumer: StateConsumer[Input] = self._state_consumer_cls( - self.handle_input - ) + self.state_consumer = state_consumer(self.handle_input) await self.state_consumer.subscribe([input_topic(self.name)]) - self.state_producer: StateProducer[ - Union[Interrupt, Output] - ] = self._state_producer_cls() + self.state_producer = state_producer() @abstractmethod async def on_tick(self, time: SimTime, changes: Changes): @@ -179,34 +136,3 @@ async def on_tick(self, time: SimTime, changes: Changes): values. """ raise NotImplementedError - - -def create_components( - configs: Iterable[ComponentConfig], - state_consumer: Type[StateConsumer], - state_producer: Type[StateProducer], -) -> List[Component]: - """Creates a list of components from component config objects. - - Args: - configs (Iterable[ComponentConfig]): An iterable of component configuration - data containers. - state_consumer (Type[StateConsumer]): The state consumer class to be used by - the components. - state_producer (Type[StateProducer]): The state producer class to be used by - the components. - - Returns: - List[Component]: A list of instantiated components. - """ - components: List[Component] = list() - for config in configs: - components.append( - config.configures()( - name=config.name, - state_consumer=state_consumer, - state_producer=state_producer, - **config.kwargs - ) - ) - return components diff --git a/tickit/core/components/device_simulation.py b/tickit/core/components/device_simulation.py index ac33b48a..5508e79f 100644 --- a/tickit/core/components/device_simulation.py +++ b/tickit/core/components/device_simulation.py @@ -1,18 +1,20 @@ import asyncio +from dataclasses import dataclass, field from typing import Awaitable, Callable, Dict, Hashable, List, Mapping, Type, cast from immutables import Map -from tickit.core.adapter import AdapterConfig, ListeningAdapter +from tickit.core.adapter import Adapter from tickit.core.components.component import BaseComponent -from tickit.core.device import DeviceConfig, DeviceUpdate -from tickit.core.lifetime_runnable import run_all +from tickit.core.device import Device, DeviceUpdate +from tickit.core.runner import run_all from tickit.core.state_interfaces import StateConsumer, StateProducer -from tickit.core.typedefs import Changes, ComponentID, SimTime, State +from tickit.core.typedefs import Changes, SimTime, State InterruptHandler = Callable[[], Awaitable[None]] +@dataclass class DeviceSimulation(BaseComponent): """A component containing a device and the corresponding adapters. @@ -21,41 +23,20 @@ class DeviceSimulation(BaseComponent): allowing adapters to raise interrupts. """ - last_outputs: State = State(dict()) - device_inputs: Dict[str, Hashable] = dict() + device: Device + adapters: List[Adapter] = field(default_factory=list) + last_outputs: State = field(init=False, default_factory=lambda: State({})) + device_inputs: Dict[str, Hashable] = field(init=False, default_factory=dict) - def __init__( - self, - name: ComponentID, - state_consumer: Type[StateConsumer], - state_producer: Type[StateProducer], - device: DeviceConfig, - adapters: List[AdapterConfig], - ): - """A DeviceSimulation constructor which builds a device and adapters from config. - - Args: - name (ComponentID): The unique identifier of the device simulation. - state_consumer (Type[StateConsumer]): The state consumer class to be used - by the component. - state_producer (Type[StateProducer]): The state producer class to be used - by the component. - device (DeviceConfig): An immuatable device configuration data container, - used to construct the device. - adapters (List[AdapterConfig]): A list of immutable adapter configuration - data containers, used to construct adapters. - """ - super().__init__(name, state_consumer, state_producer) - self.device = device.configures()(**device.kwargs) - self.adapters = [ - adapter.configures()(self.device, self.raise_interrupt, **adapter.kwargs) - for adapter in adapters - ] - - async def run_forever(self): + async def run_forever( + self, state_consumer: Type[StateConsumer], state_producer: Type[StateProducer] + ) -> None: """Sets up state interfaces, runs adapters and blocks until any complete.""" - tasks = run_all(self.adapters) - await self.set_up_state_interfaces() + tasks = run_all( + adapter.run_forever(self.device, self.raise_interrupt) + for adapter in self.adapters + ) + await super().run_forever(state_consumer, state_producer) if tasks: await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) @@ -64,7 +45,7 @@ async def on_tick(self, time: SimTime, changes: Changes) -> None: An asynchronous method which updates device inputs according to external changes, delegates core behaviour to the device update method, informs - ListeningAdapters of the update, computes changes to the state of the component + Adapters of the update, computes changes to the state of the component and sends the resulting Output. Args: @@ -80,8 +61,7 @@ async def on_tick(self, time: SimTime, changes: Changes) -> None: SimTime(time), self.device_inputs ) for adapter in self.adapters: - if isinstance(adapter, ListeningAdapter): - adapter.after_update() + adapter.after_update() out_changes = Changes( Map( { diff --git a/tickit/core/components/system_simulation.py b/tickit/core/components/system_simulation.py index 53802b46..2fdbd772 100644 --- a/tickit/core/components/system_simulation.py +++ b/tickit/core/components/system_simulation.py @@ -1,19 +1,17 @@ import asyncio +from dataclasses import dataclass from typing import Dict, List, Type -from tickit.core.components.component import ( - BaseComponent, - ComponentConfig, - create_components, -) -from tickit.core.lifetime_runnable import run_all +from tickit.core.components.component import BaseComponent, Component, ComponentConfig from tickit.core.management.event_router import InverseWiring from tickit.core.management.schedulers.slave import SlaveScheduler +from tickit.core.runner import run_all from tickit.core.state_interfaces.state_interface import StateConsumer, StateProducer -from tickit.core.typedefs import Changes, ComponentID, ComponentPort, PortID, SimTime +from tickit.core.typedefs import Changes, ComponentPort, PortID, SimTime -class SystemSimulation(BaseComponent): +@dataclass +class SystemSimulationComponent(BaseComponent): """A component containing a slave scheduler and several components. A component which acts as a nested tickit simulation by wrapping a slave scheduler @@ -21,50 +19,35 @@ class SystemSimulation(BaseComponent): components within it, whilst outputting their requests for wakeups and interrupts. """ - def __init__( - self, - name: ComponentID, - components: List[ComponentConfig], - state_consumer: Type[StateConsumer], - state_producer: Type[StateProducer], - expose: Dict[PortID, ComponentPort], + #: A list of immutable component configuration data containers, used to + #: construct internal components. + components: List[ComponentConfig] + #: A mapping of outputs which the system simulation exposes and the + #: corresponding output of an internal component. + expose: Dict[PortID, ComponentPort] + + async def run_forever( + self, state_consumer: Type[StateConsumer], state_producer: Type[StateProducer] ) -> None: - """A constructor which creates component simulations and adds exposing wiring. + """Sets up state interfaces, the scheduler and components and blocks until any complete. - Args: - name (ComponentID): The unique identifier of the system simulation. - components (List[ComponentConfig]): A list of immutable component - configuration data containers, used to construct internal components. - state_consumer (Type[StateConsumer]): The state consumer class to be used - by the component. - state_producer (Type[StateProducer]): The state producer class to be used - by the component. - expose (Dict[PortID, ComponentPort]): A mapping of outputs which - the system simulation exposes and the corresponding output of an - internal component. + An asynchronous method starts the run_forever method of each component, runs + the scheduler, and sets up externally facing state interfaces. The method + blocks until and of the components or the scheduler complete. """ - super().__init__(name, state_consumer, state_producer) - inverse_wiring = InverseWiring.from_component_configs(components) + inverse_wiring = InverseWiring.from_component_configs(self.components) self.scheduler = SlaveScheduler( inverse_wiring, state_consumer, state_producer, - expose, + self.expose, self.raise_interrupt, ) - self.component_simulations = create_components( - components, state_consumer, state_producer - ) - - async def run_forever(self): - """Sets up state interfaces, the scheduler and components and blocks until any complete. - - An asynchronous method starts the run_forever method of each component, runs - the scheduler, and sets up externally facing state interfaces. The method - blocks until and of the components or the scheduler complete. - """ - tasks = run_all((*self.component_simulations, self.scheduler)) - await self.set_up_state_interfaces() + tasks = run_all( + component().run_forever(state_consumer, state_producer) + for component in self.components + ) + run_all([self.scheduler.run_forever()]) + await super().run_forever(state_consumer, state_producer) await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) async def on_tick(self, time: SimTime, changes: Changes) -> None: @@ -81,3 +64,18 @@ async def on_tick(self, time: SimTime, changes: Changes) -> None: """ output_changes, call_in = await self.scheduler.on_tick(time, changes) await self.output(time, output_changes, call_in) + + +@dataclass +class SystemSimulation(ComponentConfig): + """Simulation of a nested set of components.""" + + components: List[ComponentConfig] + expose: Dict[PortID, ComponentPort] + + def __call__(self) -> Component: # noqa: D102 + return SystemSimulationComponent( + name=self.name, + components=self.components, + expose=self.expose, + ) diff --git a/tickit/core/device.py b/tickit/core/device.py index f05c2084..fc877885 100644 --- a/tickit/core/device.py +++ b/tickit/core/device.py @@ -1,9 +1,8 @@ from dataclasses import dataclass -from typing import Dict, Generic, Hashable, Mapping, Optional, Type, TypeVar +from typing import Generic, Hashable, Mapping, Optional, TypeVar from tickit.core.typedefs import SimTime -from tickit.utils.compat.typing_compat import Protocol, runtime_checkable -from tickit.utils.configuration.configurable import configurable, configurable_base +from tickit.utils.configuration.configurable import as_tagged_union #: A bound typevar for mappings of device inputs InMap = TypeVar("InMap", bound=Mapping[str, Hashable]) @@ -24,8 +23,8 @@ class DeviceUpdate(Generic[OutMap]): call_at: Optional[SimTime] -@runtime_checkable -class Device(Protocol): +@as_tagged_union +class Device: """An interface for types which implement simulated devices.""" def update(self, time: SimTime, inputs: InMap) -> DeviceUpdate[OutMap]: @@ -41,44 +40,3 @@ def update(self, time: SimTime, inputs: InMap) -> DeviceUpdate[OutMap]: time: The current simulation time (in nanoseconds). inputs: A mapping of device inputs and their values. """ - pass - - -@configurable_base -@dataclass -class DeviceConfig: - """A data container for device configuration. - - A data container for device configuration which acts as a named union of subclasses - to facilitate automatic deserialization. - """ - - @staticmethod - def configures() -> Type[Device]: - """A static method which returns the Device class configured by this config. - - Returns: - Type[Device]: The Device class configured by this config. - """ - raise NotImplementedError - - @property - def kwargs(self) -> Dict[str, object]: - """A property which returns the key word arguments of the configured device. - - Returns: - Dict[str, object]: The key word argument of the configured Device. - """ - raise NotImplementedError - - -class ConfigurableDevice: - """A mixin used to create a device with a configuration data container.""" - - def __init_subclass__(cls) -> None: - """A subclass init method which makes the subclass configurable. - - A subclass init method which makes the subclass configurable with a - DeviceConfig template. - """ - cls = configurable(DeviceConfig)(cls) diff --git a/tickit/core/lifetime_runnable.py b/tickit/core/runner.py similarity index 50% rename from tickit/core/lifetime_runnable.py rename to tickit/core/runner.py index d0b4912b..f7e8f411 100644 --- a/tickit/core/lifetime_runnable.py +++ b/tickit/core/runner.py @@ -1,35 +1,24 @@ import asyncio import traceback -from typing import Iterable, List +from typing import Awaitable, Iterable, List -from tickit.utils.compat.typing_compat import Protocol, runtime_checkable - -@runtime_checkable -class LifetimeRunnable(Protocol): - """An interface for types which implement an awaitable run_forever method.""" - - async def run_forever(self) -> None: - """An asynchronous method which may run indefinitely.""" - pass - - -def run_all(runnables: Iterable[LifetimeRunnable]) -> List[asyncio.Task]: +def run_all(awaitables: Iterable[Awaitable[None]]) -> List[asyncio.Task]: """Asynchronously runs the run_forever method of each lifetime runnable. Creates and runs an asyncio task for the run_forever method of each lifetime runnable. Calls to the run_forever method are wrapped with an error handler. Args: - runnables: An iterable of objects which implement run_forever. + awaitables: An iterable of awaitable objects. Returns: - List[asyncio.Task]: A list of asyncio tasks for the runnables. + List[asyncio.Task]: A list of asyncio tasks for the awaitables. """ - async def run_with_error_handling(runnable: LifetimeRunnable) -> None: + async def run_with_error_handling(awaitable: Awaitable[None]) -> None: try: - await runnable.run_forever() + await awaitable except Exception as e: # These are prints rather than logging because we just want the # result going directly to stdout. @@ -37,11 +26,12 @@ async def run_with_error_handling(runnable: LifetimeRunnable) -> None: print(traceback.format_exc()) return [ - asyncio.create_task(run_with_error_handling(runnable)) for runnable in runnables + asyncio.create_task(run_with_error_handling(awaitable)) + for awaitable in awaitables ] -async def run_all_forever(runnables: Iterable[LifetimeRunnable]) -> None: +async def run_all_forever(awaitables: Iterable[Awaitable[None]]) -> None: """Asynchronously runs the run_forever method of each lifetime runnable. Creates and runs an asyncio task for the run_forever method of each lifetime @@ -49,6 +39,6 @@ async def run_all_forever(runnables: Iterable[LifetimeRunnable]) -> None: This function blocks until all run_forever methods have completed. Args: - runnables: An iterable of objects which implement run_forever. + awaitables: An iterable of awaitable objects. """ - await asyncio.wait(run_all(runnables)) + await asyncio.wait(run_all(awaitables)) diff --git a/tickit/devices/cryostream/__init__.py b/tickit/devices/cryostream/__init__.py index e69de29b..9446a784 100644 --- a/tickit/devices/cryostream/__init__.py +++ b/tickit/devices/cryostream/__init__.py @@ -0,0 +1,21 @@ +from dataclasses import dataclass + +from tickit.core.components.component import Component, ComponentConfig +from tickit.core.components.device_simulation import DeviceSimulation + +from .cryostream import CryostreamAdapter, CryostreamDevice + + +@dataclass +class Cryostream(ComponentConfig): + """Cryostream simulation with TCP server.""" + + host: str = "localhost" + port: int = 25565 + + def __call__(self) -> Component: # noqa: D102 + return DeviceSimulation( + name=self.name, + device=CryostreamDevice(), + adapters=[CryostreamAdapter(host=self.host, port=self.port)], + ) diff --git a/tickit/devices/cryostream/cryostream.py b/tickit/devices/cryostream/cryostream.py index bc476fa3..cfa54124 100644 --- a/tickit/devices/cryostream/cryostream.py +++ b/tickit/devices/cryostream/cryostream.py @@ -1,12 +1,11 @@ import asyncio import struct -from typing import AsyncIterable, Awaitable, Callable +from typing import AsyncIterable from tickit.adapters.composed import ComposedAdapter from tickit.adapters.interpreters.command import CommandInterpreter, RegexCommand from tickit.adapters.servers.tcp import TcpServer -from tickit.core.adapter import ConfigurableAdapter -from tickit.core.device import ConfigurableDevice, Device, DeviceUpdate +from tickit.core.device import DeviceUpdate from tickit.core.typedefs import SimTime from tickit.devices.cryostream.base import CryostreamBase from tickit.devices.cryostream.states import PhaseIds @@ -16,7 +15,7 @@ _EXTENDED_STATUS = ">BBHHHBBHHHHHBBBBBBHHBBBBBBBBHH" -class Cryostream(CryostreamBase, ConfigurableDevice): +class CryostreamDevice(CryostreamBase): """A Cryostream device, used for cooling of samples using cold gas.""" #: An empty typed mapping of device inputs @@ -42,27 +41,25 @@ def update(self, time: SimTime, inputs: Inputs) -> DeviceUpdate[Outputs]: if self.phase_id in (PhaseIds.RAMP.value, PhaseIds.COOL.value): self.gas_temp = self.update_temperature(time) return DeviceUpdate( - Cryostream.Outputs(temperature=self.gas_temp), + self.Outputs(temperature=self.gas_temp), SimTime(time + self.callback_period), ) if self.phase_id == PhaseIds.PLAT.value: self.phase_id = PhaseIds.HOLD.value return DeviceUpdate( - Cryostream.Outputs(temperature=self.gas_temp), + self.Outputs(temperature=self.gas_temp), SimTime(time + int(self.plat_duration * 1e10)), ) - return DeviceUpdate(Cryostream.Outputs(temperature=self.gas_temp), None) + return DeviceUpdate(self.Outputs(temperature=self.gas_temp), None) -class CryostreamAdapter(ComposedAdapter, ConfigurableAdapter): +class CryostreamAdapter(ComposedAdapter): """A Cryostream TCP adapter which sends regular status packets and can set modes.""" - _device: Cryostream + device: CryostreamDevice def __init__( self, - device: Device, - raise_interrupt: Callable[[], Awaitable[None]], host: str = "localhost", port: int = 25565, ) -> None: @@ -77,8 +74,6 @@ def __init__( port (Optional[int]): The bound port of the TcpServer. Defaults to 25565. """ super().__init__( - device, - raise_interrupt, TcpServer(format=ByteFormat(b"%b"), host=host, port=port), CommandInterpreter(), ) @@ -92,39 +87,39 @@ async def on_connect(self) -> AsyncIterable[bytes]: """ while True: await asyncio.sleep(2.0) - await self._device.set_status_format(1) - status = await self._device.get_status(1) + await self.device.set_status_format(1) + status = await self.device.get_status(1) yield status.pack() @RegexCommand(b"\\x02\\x0a", interrupt=True) async def restart(self) -> None: """A regex bytes command which restarts the Cryostream.""" - await self._device.restart() + await self.device.restart() @RegexCommand(b"\\x02\\x0d", interrupt=True) async def hold(self) -> None: """A regex bytes command which holds the current temperature.""" - await self._device.hold() + await self.device.hold() @RegexCommand(b"\\x02\\x10", interrupt=True) async def purge(self) -> None: """A regex bytes command which purges (immediately raise to 300K).""" - await self._device.purge() + await self.device.purge() @RegexCommand(b"\\x02\\x11", interrupt=True) async def pause(self) -> None: """A regex bytes command which pauses.""" - await self._device.pause() + await self.device.pause() @RegexCommand(b"\\x02\\x12", interrupt=True) async def resume(self) -> None: """A regex bytes command which resumes the last command.""" - await self._device.resume() + await self.device.resume() @RegexCommand(b"\\x02\\x13", interrupt=True) async def stop(self) -> None: """A regex bytes command which stops gas flow.""" - await self._device.stop() + await self.device.stop() @RegexCommand(b"\\x03\\x14([\\x00\\x01])", interrupt=True) async def turbo(self, turbo_on: bytes) -> None: @@ -135,7 +130,7 @@ async def turbo(self, turbo_on: bytes) -> None: on. """ turbo_on = struct.unpack(">B", turbo_on)[0] - await self._device.turbo(turbo_on) + await self.device.turbo(turbo_on) # Todo set status format not interrupt @RegexCommand(b"\\x03\\x28([\\x00\\x01])", interrupt=False) @@ -147,7 +142,7 @@ async def set_status_format(self, status_format: bytes) -> None: status packet and 1 denotes an extended status packet. """ status_format = struct.unpack(">B", status_format)[0] - await self._device.set_status_format(status_format) + await self.device.set_status_format(status_format) @RegexCommand(b"\\x04\\x0c(.{2})", interrupt=True) async def plat(self, duration: bytes) -> None: @@ -157,7 +152,7 @@ async def plat(self, duration: bytes) -> None: duration (bytes): The duration for which the temperature should be held. """ duration = struct.unpack(">H", duration)[0] - await self._device.plat(duration) + await self.device.plat(duration) @RegexCommand(b"\\x04\\x0f(.{2})", interrupt=True) async def end(self, ramp_rate: bytes) -> None: @@ -167,7 +162,7 @@ async def end(self, ramp_rate: bytes) -> None: ramp_rate (bytes): The rate at which the temperature should change. """ ramp_rate = struct.unpack(">H", ramp_rate)[0] - await self._device.end(ramp_rate) + await self.device.end(ramp_rate) @RegexCommand(b"\\x04\\x0e(.{2})", interrupt=True) async def cool(self, target_temp: bytes) -> None: @@ -177,7 +172,7 @@ async def cool(self, target_temp: bytes) -> None: target_temp (bytes): The target temperature. """ target_temp = struct.unpack(">H", target_temp)[0] - await self._device.cool(target_temp) + await self.device.cool(target_temp) @RegexCommand(b"\\x06\\x0b(.{2,4})", interrupt=True) async def ramp(self, values: bytes) -> None: @@ -188,4 +183,4 @@ async def ramp(self, values: bytes) -> None: target temperature. """ ramp_rate, target_temp = struct.unpack(">HH", values) - await self._device.ramp(ramp_rate, target_temp) + await self.device.ramp(ramp_rate, target_temp) diff --git a/tickit/devices/cryostream/status.py b/tickit/devices/cryostream/status.py index d5a15e18..155e8e57 100644 --- a/tickit/devices/cryostream/status.py +++ b/tickit/devices/cryostream/status.py @@ -30,6 +30,11 @@ class Status: evap_adjust: int status_bytes_string: str = ">BBHHhBBHHHHHBBBBBBHHBB" + @classmethod + def from_packed(cls, b: bytes) -> "Status": + """Create a status packet from its packed byte format.""" + return cls(*struct.unpack(cls.status_bytes_string, b)) + def pack(self) -> bytes: """Perform serialization of the status packet. @@ -101,6 +106,11 @@ class ExtendedStatus: total_hours: int extended_packet_string: str = ">BBHHhBBHHHHHBBBBBBHHBBBBBBBBHH" + @classmethod + def from_packed(cls, b: bytes) -> "ExtendedStatus": + """Create a status packet from its packed byte format.""" + return cls(*struct.unpack(cls.extended_packet_string, b)) + def pack(self) -> bytes: """Perform serialization of the extended status packet. diff --git a/tickit/devices/femto/__init__.py b/tickit/devices/femto/__init__.py index e69de29b..dc4edf7f 100644 --- a/tickit/devices/femto/__init__.py +++ b/tickit/devices/femto/__init__.py @@ -0,0 +1,39 @@ +from dataclasses import dataclass + +from tickit.core.components.component import Component, ComponentConfig +from tickit.core.components.device_simulation import DeviceSimulation + +from .current import CurrentDevice +from .femto import FemtoAdapter, FemtoDevice + + +@dataclass +class Femto(ComponentConfig): + """Femto simulation with EPICS IOC.""" + + initial_gain: float = 2.5 + initial_current: float = 0.0 + db_file: str = "tickit/devices/femto/record.db" + ioc_name: str = "FEMTO" + + def __call__(self) -> Component: # noqa: D102 + return DeviceSimulation( + name=self.name, + device=FemtoDevice( + initial_gain=self.initial_gain, initial_current=self.initial_current + ), + adapters=[FemtoAdapter(db_file=self.db_file, ioc_name=self.ioc_name)], + ) + + +@dataclass +class Current(ComponentConfig): + """Simulated current source.""" + + callback_period: int = int(1e9) + + def __call__(self) -> Component: # noqa: D102 + return DeviceSimulation( + name=self.name, + device=CurrentDevice(callback_period=self.callback_period), + ) diff --git a/tickit/devices/femto/current.py b/tickit/devices/femto/current.py new file mode 100644 index 00000000..02a64535 --- /dev/null +++ b/tickit/devices/femto/current.py @@ -0,0 +1,39 @@ +from random import uniform + +from tickit.core.device import Device, DeviceUpdate +from tickit.core.typedefs import SimTime +from tickit.utils.compat.typing_compat import TypedDict + + +class CurrentDevice(Device): + """The current configured device.""" + + #: A typed mapping containing the current output value + Outputs: TypedDict = TypedDict("Outputs", {"output": float}) + + def __init__(self, callback_period: int) -> None: + """Initialise the current device. + + Args: + callback_period (Optional[int]): The duration in which the device should \ + next be updated. Defaults to int(1e9). + """ + self.callback_period = SimTime(callback_period) + + def update(self, time: SimTime, inputs) -> DeviceUpdate[Outputs]: + """Updates the state of the current device. + + Args: + time (SimTime): The time of the simulation in nanoseconds. + inputs (State): The state of the input values of the device. + + Returns: + DeviceUpdate: A container for the Device's outputs and a callback time. + """ + output = uniform(100, 200) + print( + "Output! (delta: {}, inputs: {}, output: {})".format(time, inputs, output) + ) + return DeviceUpdate( + self.Outputs(output=output), SimTime(time + self.callback_period) + ) diff --git a/tickit/devices/femto/femto.py b/tickit/devices/femto/femto.py index 78f24f3d..f7511d9a 100644 --- a/tickit/devices/femto/femto.py +++ b/tickit/devices/femto/femto.py @@ -1,23 +1,23 @@ -from random import uniform -from typing import Awaitable, Callable, Dict - from softioc import builder -from tickit.adapters.epicsadapter import EpicsAdapter, InputRecord, OutputRecord -from tickit.core.device import ConfigurableDevice, DeviceUpdate -from tickit.core.typedefs import SimTime, State +from tickit.adapters.epicsadapter import EpicsAdapter +from tickit.core.device import Device, DeviceUpdate +from tickit.core.typedefs import SimTime from tickit.utils.compat.typing_compat import TypedDict -class Femto(ConfigurableDevice): +class FemtoDevice(Device): """Electronic signal amplifier.""" - Output = TypedDict("Output", {"current": float}) + #: An empty typed mapping of device inputs + Inputs: TypedDict = TypedDict("Inputs", {"input": float}) + #: A typed mapping containing the current output value + Outputs: TypedDict = TypedDict("Outputs", {"current": float}) def __init__( self, - initial_gain: float = 2.5, - initial_current: float = 0.0, + initial_gain: float, + initial_current: float, ) -> None: """Initialise the Femto device class. @@ -65,7 +65,7 @@ def get_current(self) -> float: """ return self._output_current - def update(self, time: SimTime, inputs: dict) -> DeviceUpdate: + def update(self, time: SimTime, inputs: Inputs) -> DeviceUpdate[Outputs]: """Updates the state of the Femto device. Args: @@ -75,79 +75,17 @@ def update(self, time: SimTime, inputs: dict) -> DeviceUpdate: Returns: DeviceUpdate: A container for the Device's outputs and a callback time. """ - current_value = inputs.get("input", None) + current_value = inputs["input"] if current_value is not None: self.set_current(current_value) - return DeviceUpdate(Femto.Output(current=self.get_current()), None) - - -class CurrentDevice(ConfigurableDevice): - """The current configured device.""" - - Output = TypedDict("Output", {"output": float}) - - def __init__(self, callback_period: int = int(1e9)) -> None: - """Initialise the current device. - - Args: - callback_period (Optional[int]): The duration in which the device should \ - next be updated. Defaults to int(1e9). - """ - self.callback_period = SimTime(callback_period) - - def update(self, time: SimTime, inputs: State) -> DeviceUpdate: - """Updates the state of the current device. - - Args: - time (SimTime): The time of the simulation in nanoseconds. - inputs (State): The state of the input values of the device. - - Returns: - DeviceUpdate: A container for the Device's outputs and a callback time. - """ - output = uniform(0.1, 200.1) - print( - "Output! (delta: {}, inputs: {}, output: {})".format(time, inputs, output) - ) - return DeviceUpdate( - CurrentDevice.Output(output=output), SimTime(time + self.callback_period) - ) + return DeviceUpdate(self.Outputs(current=self.get_current()), None) class FemtoAdapter(EpicsAdapter): """The adapter for the Femto device.""" - current_record: InputRecord - input_record: InputRecord - output_record: OutputRecord - - def __init__( - self, - device: Femto, - raise_interrupt: Callable[[], Awaitable[None]], - db_file: str = "record.db", - ioc_name: str = "FEMTO", - ): - """Initialise the Femto device adapter. - - Args: - device (Device): The Femto device class. - raise_interrupt (Callable[[], Awaitable[None]]): A method used to request \ - an immediate update of the device. - db_file (Optional[str]): The name of the database file. \ - Defaults to "record.db". - ioc_name (Optional[str]): The name of the IOC. Defaults to "FEMTO". - """ - super().__init__(db_file, ioc_name) - self._device = device - self.raise_interrupt = raise_interrupt - - self.interrupt_records: Dict[InputRecord, Callable] = {} - - async def run_forever(self) -> None: - """Builds the IOC.""" - self.build_ioc() + device: FemtoDevice async def callback(self, value) -> None: """Device callback function. @@ -155,18 +93,13 @@ async def callback(self, value) -> None: Args: value (float): The value to set the gain to. """ - print("Callback", value) - self._device.set_gain(value) + self.device.set_gain(value) await self.raise_interrupt() def on_db_load(self) -> None: """Customises records that have been loaded in to suit the simulation.""" - self.input_record = builder.aIn("GAIN_RBV") - self.output_record = builder.aOut( - "GAIN", initial_value=self._device.get_gain(), on_update=self.callback + builder.aOut( + "GAIN", initial_value=self.device.get_gain(), on_update=self.callback ) - - self.current_record = builder.aIn("CURRENT") - - self.link_input_on_interrupt(self.input_record, self._device.get_gain) - self.link_input_on_interrupt(self.current_record, self._device.get_current) + self.link_input_on_interrupt(builder.aIn("GAIN_RBV"), self.device.get_gain) + self.link_input_on_interrupt(builder.aIn("CURRENT"), self.device.get_current) diff --git a/tickit/devices/pneumatic/__init__.py b/tickit/devices/pneumatic/__init__.py index e69de29b..62d6d746 100644 --- a/tickit/devices/pneumatic/__init__.py +++ b/tickit/devices/pneumatic/__init__.py @@ -0,0 +1,25 @@ +from dataclasses import dataclass + +from tickit.core.components.component import Component, ComponentConfig +from tickit.core.components.device_simulation import DeviceSimulation + +from .pneumatic import PneumaticAdapter, PneumaticDevice + + +@dataclass +class Pneumatic(ComponentConfig): + """Pneumatic simulation with EPICS IOC adapter.""" + + initial_speed: float = 2.5 + initial_state: bool = False + db_file: str = "tickit/devices/pneumatic/db_files/filter1.db" + ioc_name: str = "PNEUMATIC" + + def __call__(self) -> Component: # noqa: D102 + return DeviceSimulation( + name=self.name, + device=PneumaticDevice( + initial_speed=self.initial_speed, initial_state=self.initial_state + ), + adapters=[PneumaticAdapter(db_file=self.db_file, ioc_name=self.ioc_name)], + ) diff --git a/tickit/devices/pneumatic/pneumatic.py b/tickit/devices/pneumatic/pneumatic.py index f30e5c3e..91a28cec 100644 --- a/tickit/devices/pneumatic/pneumatic.py +++ b/tickit/devices/pneumatic/pneumatic.py @@ -1,22 +1,21 @@ -from typing import Awaitable, Callable, Dict - from softioc import builder -from tickit.adapters.epicsadapter import EpicsAdapter, InputRecord, OutputRecord -from tickit.core.device import ConfigurableDevice, DeviceUpdate +from tickit.adapters.epicsadapter import EpicsAdapter +from tickit.core.device import Device, DeviceUpdate from tickit.core.typedefs import SimTime from tickit.utils.compat.typing_compat import TypedDict -class Pneumatic(ConfigurableDevice): +class PneumaticDevice(Device): """Pneumatic Device with movement controls.""" - Output = TypedDict("Output", {"output": float}) + #: A typed mapping containing the current output value + Outputs: TypedDict = TypedDict("Outputs", {"output": float}) def __init__( self, - initial_speed: float = 2.5, - initial_state: bool = False, + initial_speed: float, + initial_state: bool, ) -> None: """Initialise a Pneumatic object.""" self.speed: float = initial_speed @@ -45,7 +44,7 @@ def get_state(self) -> bool: """Gets the current state of the device.""" return self.state - def update(self, time: SimTime, inputs: dict) -> DeviceUpdate: + def update(self, time: SimTime, inputs) -> DeviceUpdate[Outputs]: """Run the update logic for the device. If the device is moving then the state of the device is updated. @@ -64,11 +63,11 @@ def update(self, time: SimTime, inputs: dict) -> DeviceUpdate: self.state = self.target_state self.moving = False return DeviceUpdate( - Pneumatic.Output(output=self.state), + self.Outputs(output=self.state), callback_period, ) else: - return DeviceUpdate(Pneumatic.Output(output=self.state), None) + return DeviceUpdate(self.Outputs(output=self.state), None) class PneumaticAdapter(EpicsAdapter): @@ -77,29 +76,7 @@ class PneumaticAdapter(EpicsAdapter): Connects the device to an external messaging protocol. """ - current_record: InputRecord - input_record: InputRecord - output_record: OutputRecord - - interrupt_records: Dict[InputRecord, Callable] - - def __init__( - self, - device: Pneumatic, - raise_interrupt: Callable[[], Awaitable[None]], - db_file: str, - ioc_name: str = "PNEUMATIC", - ) -> None: - """Initialise a PneumaticAdapter object.""" - super().__init__(db_file, ioc_name) - self._device = device - self.raise_interrupt = raise_interrupt - - self.interrupt_records = {} - - async def run_forever(self) -> None: - """Run the device indefinitely.""" - self.build_ioc() + device: PneumaticDevice async def callback(self, value) -> None: """Set the state of the device and await a response. @@ -107,13 +84,12 @@ async def callback(self, value) -> None: Args: value (bool): The value to set the state to. """ - self._device.set_state() + self.device.set_state() await self.raise_interrupt() def on_db_load(self): """Adds a record of the current state to the mapping of interrupting records.""" - self.state_rbv = builder.boolIn("FILTER_RBV") - self.state_record = builder.boolOut( - "FILTER", initial_value=False, on_update=self.callback + builder.boolOut("FILTER", initial_value=False, on_update=self.callback) + self.link_input_on_interrupt( + builder.boolIn("FILTER_RBV"), self.device.get_state ) - self.link_input_on_interrupt(self.state_rbv, self._device.get_state) diff --git a/tickit/devices/sink.py b/tickit/devices/sink.py index ec8c8f5b..51390823 100644 --- a/tickit/devices/sink.py +++ b/tickit/devices/sink.py @@ -1,14 +1,16 @@ import logging from typing import Any -from tickit.core.device import ConfigurableDevice, DeviceUpdate +from tickit.core.components.component import Component, ComponentConfig +from tickit.core.components.device_simulation import DeviceSimulation +from tickit.core.device import Device, DeviceUpdate from tickit.core.typedefs import SimTime from tickit.utils.compat.typing_compat import TypedDict LOGGER = logging.getLogger(__name__) -class Sink(ConfigurableDevice): +class SinkDevice(Device): """A simple device which can take any input and produces no output.""" #: A typed mapping containing the 'input' input value @@ -16,10 +18,6 @@ class Sink(ConfigurableDevice): #: An empty typed mapping of device outputs Outputs: TypedDict = TypedDict("Outputs", {}) - def __init__(self) -> None: - """A constructor of the sink, with no arguments.""" - pass - def update(self, time: SimTime, inputs: Inputs) -> DeviceUpdate[Outputs]: """The update method which logs the inputs at debug level and produces no outputs. @@ -33,4 +31,14 @@ def update(self, time: SimTime, inputs: Inputs) -> DeviceUpdate[Outputs]: requests a callback. """ LOGGER.debug("Sunk {}".format({k: v for k, v in inputs.items()})) - return DeviceUpdate(Sink.Outputs(), None) + return DeviceUpdate(SinkDevice.Outputs(), None) + + +class Sink(ComponentConfig): + """Arbitrary value sink that logs the value.""" + + def __call__(self) -> Component: # noqa: D102 + return DeviceSimulation( + name=self.name, + device=SinkDevice(), + ) diff --git a/tickit/devices/source.py b/tickit/devices/source.py index 136491b6..1bf9dc16 100644 --- a/tickit/devices/source.py +++ b/tickit/devices/source.py @@ -1,14 +1,17 @@ import logging +from dataclasses import dataclass from typing import Any -from tickit.core.device import ConfigurableDevice, DeviceUpdate +from tickit.core.components.component import Component, ComponentConfig +from tickit.core.components.device_simulation import DeviceSimulation +from tickit.core.device import Device, DeviceUpdate from tickit.core.typedefs import SimTime from tickit.utils.compat.typing_compat import TypedDict LOGGER = logging.getLogger(__name__) -class Source(ConfigurableDevice): +class SourceDevice(Device): """A simple device which produces a pre-configured value.""" #: An empty typed mapping of device inputs @@ -37,4 +40,17 @@ def update(self, time: SimTime, inputs: Inputs) -> DeviceUpdate[Outputs]: never requests a callback. """ LOGGER.debug("Sourced {}".format(self.value)) - return DeviceUpdate(Source.Outputs(value=self.value), None) + return DeviceUpdate(SourceDevice.Outputs(value=self.value), None) + + +@dataclass +class Source(ComponentConfig): + """Source of a fixed value.""" + + value: Any + + def __call__(self) -> Component: # noqa: D102 + return DeviceSimulation( + name=self.name, + device=SourceDevice(self.value), + ) diff --git a/tickit/utils/configuration/configurable.py b/tickit/utils/configuration/configurable.py index 15894e74..45b79ed3 100644 --- a/tickit/utils/configuration/configurable.py +++ b/tickit/utils/configuration/configurable.py @@ -1,22 +1,14 @@ -from dataclasses import is_dataclass, make_dataclass -from inspect import Parameter, signature -from typing import Any, Callable, Dict, Iterator, Sequence, Set, Type, TypeVar +from typing import Any, DefaultDict, Dict, Iterator, TypeVar -from apischema import deserializer, serializer -from apischema.conversions.conversions import Conversion +from apischema import deserializer +from apischema.conversions import Conversion from apischema.tagged_unions import Tagged, TaggedUnion, get_tagged -from tickit.utils.compat.typing_compat import Protocol, runtime_checkable - # Implementation adapted from apischema example: Class as tagged union of its subclasses # see: https://wyfo.github.io/apischema/examples/subclass_tagged_union/ -#: A function or method -Func = TypeVar("Func", bound=Callable) #: A class Cls = TypeVar("Cls", bound=type) -#: A configurable type -T = TypeVar("T", covariant=True) def rec_subclasses(cls: type) -> Iterator[type]: @@ -33,19 +25,11 @@ def rec_subclasses(cls: type) -> Iterator[type]: yield from rec_subclasses(sub_cls) -def configurable_alias(sub: Type) -> str: - """Gets the alias of a configurable sub-class. - - Args: - sub (Type): The sub-class to be aliased. - - Returns: - str: The alias assigned to the sub-class. - """ - return sub.configures().__module__ + "." + sub.configures().__name__ +#: Whether the current class is registered as a tagged union +is_tagged_union = DefaultDict(lambda: False) -def configurable_base(cls: Cls) -> Cls: +def as_tagged_union(cls: Cls) -> Cls: """A decorator to make a config base class which can deserialize aliased sub-classes. A decorator which makes a config class the root of a tagged union of sub-classes @@ -60,43 +44,15 @@ def configurable_base(cls: Cls) -> Cls: Returns: Cls: The modified config base class. """ - - def serialization() -> Conversion: - """Create an apischema Conversion with a converter for recursive sub-classes. - - Returns: - Conversion: An apischema Conversion with a converter for recursive - sub-classes. - """ - serialization_union = type( - cls.__name__, - (TaggedUnion,), - { - "__annotations__": { - configurable_alias(sub): Tagged[sub] # type: ignore - for sub in rec_subclasses(cls) - } - }, - ) - return Conversion( - lambda obj: serialization_union(**{configurable_alias(obj.__class__): obj}), - source=cls, - target=serialization_union, - inherited=False, - ) - + # This will only be used if we want to generate a json schema (which we will) def deserialization() -> Conversion: - """Create an apischema Conversion which gets a sub-class by tag. - - Returns: - Conversion: An apischema Conversion which gets a sub-class by tag. - """ annotations: Dict[str, Any] = {} deserialization_namespace: Dict[str, Any] = {"__annotations__": annotations} for sub in rec_subclasses(cls): - annotations[configurable_alias(sub)] = Tagged[sub] # type: ignore + fullname = sub.__module__ + "." + sub.__name__ + annotations[fullname] = Tagged[sub] # type: ignore deserialization_union = type( - cls.__name__, + cls.__name__ + "TaggedUnion", (TaggedUnion,), deserialization_namespace, ) @@ -105,98 +61,5 @@ def deserialization() -> Conversion: ) deserializer(lazy=deserialization, target=cls) - serializer(lazy=serialization, source=cls) + is_tagged_union[cls] = True return cls - - -@runtime_checkable -class Config(Protocol[T]): - """An interface for types which implement configurations.""" - - @staticmethod - def configures() -> Type[T]: - """A static method which returns the class configured by this config. - - Returns: - Type[T]: The class configured by this config. - """ - pass - - @property - def kwargs(self) -> Dict[str, object]: - """A property which returns the key word arguments of the configured class. - - Returns: - Dict[str, object]: The key word argument of the configured class. - """ - pass - - -def configurable(template: Type, ignore: Sequence[str] = []) -> Callable[[Type], Type]: - """A decorator to add a config data container sub-class to a class. - - A decorator to make a class configurable by adding a config data container - sub-class which is typically registered against a configurable base class allowing - for serialization & deserialization by union tagging. - - Args: - template (Type): A template dataclass from which fields are borrwoed - ignore (Sequence[str]): Fields which should not be serialized / deserialized. - Defaults to []. - - Returns: - Callable[[Type], Type]: A decorator which adds the config data container. - """ - assert is_dataclass(template) - - def add_config(cls: Type) -> Type: - """A decorator to add a config data container sub-class to a class. - - Args: - cls (Type): The class to which the config data container should be added. - - Returns: - Type: The modified class. - """ - - def configures() -> Type: - return cls - - def kwargs(self) -> Dict[str, object]: - remove: Set[str] = ( - set(template.__annotations__) - if hasattr(template, "__annotations__") - else set() - ) - return {k: self.__dict__[k] for k in set(self.__dict__) - remove} - - signature_items = set( - (name, param) - for typ in (template, cls) - for name, param in signature(typ).parameters.items() - if name not in ignore - ) - - config_data_class: Type = make_dataclass( - f"{cls.__name__}Config", - sorted( - ( - (name, param.annotation) - if param.default is Parameter.empty - else (name, param.annotation, param.default) - for name, param in signature_items - ), - key=len, - ), - bases=(template,), - namespace={ - "configures": staticmethod(configures), - "kwargs": property(kwargs), - "__doc__": cls.__init__.__doc__, - }, - ) - - setattr(cls, config_data_class.__qualname__, config_data_class) - return cls - - return add_config diff --git a/tickit/utils/configuration/loading.py b/tickit/utils/configuration/loading.py index 62e0e042..4785e5cf 100644 --- a/tickit/utils/configuration/loading.py +++ b/tickit/utils/configuration/loading.py @@ -1,12 +1,37 @@ -import re -from contextlib import suppress from importlib import import_module -from typing import Deque, List +from typing import Any, Dict, List, Optional, Type -import apischema import yaml +from apischema import deserialize +from apischema.conversions import AnyConversion, Conversion +from apischema.conversions.conversions import Conversion +from apischema.conversions.converters import default_deserialization from tickit.core.components.component import ComponentConfig +from tickit.utils.configuration.configurable import is_tagged_union + + +def importing_conversion(typ: Type) -> Optional[AnyConversion]: + """Create a conversion that imports the module of a ComponentConfig. + + When a ComponentConfig is requested from a dict, take its fully qualified + name from the tagged union dict and import it before deserializing it + """ + if is_tagged_union[typ]: + + def conversion(d: Dict[str, Any]): + # We can't use the deserialization union above as the classes + # haven't been imported so won't appear in __subclasses__, so use a + # single element dict instead + assert len(d) == 1, d + fullname, args = list(d.items())[0] + pkg, clsname = fullname.rsplit(".", maxsplit=1) + cls = getattr(import_module(pkg), clsname) + return deserialize(cls, args, default_conversion=importing_conversion) + + return Conversion(conversion, source=dict, target=typ) + + return default_deserialization(typ) def read_configs(config_path) -> List[ComponentConfig]: @@ -20,35 +45,12 @@ def read_configs(config_path) -> List[ComponentConfig]: config_path ([type]): The path to the config file. Returns: - List[ComponentConfig]: A list of component configuration objects. + List[Component]: A list of component configuration objects. """ yaml_struct = yaml.load(open(config_path, "r"), Loader=yaml.Loader) - load_modules(yaml_struct) - configs = apischema.deserialize(List[ComponentConfig], yaml_struct) + configs = deserialize( + List[ComponentConfig], + yaml_struct, + default_conversion=importing_conversion, + ) return configs - - -def load_modules(yaml_struct) -> None: - """A utility function which loads modules referenced within a configuration yaml struct. - - Args: - yaml_struct ([type]): The possibly nested yaml structure generated when a - configuration file is loaded. - """ - - def possibly_import_class(path: str) -> None: - if re.fullmatch(r"[\w+\.]+\.\w+", path): - with suppress(ModuleNotFoundError): - import_module(path) - - to_crawl = Deque([yaml_struct]) - while to_crawl: - cfg = to_crawl.popleft() - if isinstance(cfg, list): - to_crawl.extend(cfg) - elif isinstance(cfg, dict): - to_crawl.extend(cfg.values()) - for key in cfg.keys(): - possibly_import_class(str(key)) - else: - possibly_import_class(str(cfg))