Skip to content

Commit

Permalink
feat(api): Create framework for running protocols against emulation (#…
Browse files Browse the repository at this point in the history
…8149)

* feat(api): Add G-Code only text output mode

* Put response property with other properties

* Move device parsing to G-Code Watcher

* Add smoothie protocols to validate against

* feat(api) Add reset methods to all emulated hardware

* feat(api): Refactor app.py into ServerManager class

* feat(api): Create Protocol Runner

* feat(api): Remove unused properties

* feat(api): Fix linting errors

* feat(api): Add support for settings

* feat(api): More linting

* feat(api): Cleanup

* feat(api): Remove file that was used for testing
  • Loading branch information
DerekMaggio authored Jul 22, 2021
1 parent d67f0f9 commit 1d046ea
Show file tree
Hide file tree
Showing 18 changed files with 338 additions and 119 deletions.
86 changes: 58 additions & 28 deletions api/src/opentrons/hardware_control/emulation/app.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import asyncio
import logging

from opentrons.hardware_control.emulation.connection_handler import \
ConnectionHandler
from opentrons.hardware_control.emulation.magdeck import MagDeckEmulator
Expand All @@ -9,7 +8,6 @@
from opentrons.hardware_control.emulation.tempdeck import TempDeckEmulator
from opentrons.hardware_control.emulation.thermocycler import ThermocyclerEmulator
from opentrons.hardware_control.emulation.smoothie import SmoothieEmulator

logger = logging.getLogger(__name__)


Expand All @@ -19,37 +17,69 @@
MAGDECK_PORT = 9999


async def run_server(host: str, port: int, handler: ConnectionHandler) -> None:
"""Run a server."""
server = await asyncio.start_server(handler, host, port)
class ServerManager:
"""
Class to start and stop emulated smoothie and modules.
"""
def __init__(self, settings=Settings()) -> None:
host = settings.host
self._mag_emulator = MagDeckEmulator(parser=Parser())
self._temp_emulator = TempDeckEmulator(parser=Parser())
self._therm_emulator = ThermocyclerEmulator(parser=Parser())
self._smoothie_emulator = SmoothieEmulator(
parser=Parser(), settings=settings.smoothie
)

self._mag_server = self._create_server(
host=host,
port=MAGDECK_PORT,
handler=ConnectionHandler(self._mag_emulator),
)
self._temp_server = self._create_server(
host=host,
port=TEMPDECK_PORT,
handler=ConnectionHandler(self._temp_emulator),
)
self._therm_server = self._create_server(
host=host,
port=THERMOCYCLER_PORT,
handler=ConnectionHandler(self._therm_emulator),
)
self._smoothie_server = self._create_server(
host=host,
port=SMOOTHIE_PORT,
handler=ConnectionHandler(self._smoothie_emulator),
)

async def run(self):
await asyncio.gather(
self._mag_server,
self._temp_server,
self._therm_server,
self._smoothie_server
)

async with server:
await server.serve_forever()
@staticmethod
async def _create_server(host: str, port: int, handler: ConnectionHandler) -> None:
"""Run a server."""
server = await asyncio.start_server(handler, host, port)

async with server:
await server.serve_forever()

async def run() -> None:
"""Run the module emulators."""
settings = Settings()
host = settings.host
def reset(self):
self._smoothie_emulator.reset()
self._mag_emulator.reset()
self._temp_emulator.reset()
self._therm_emulator.reset()

await asyncio.gather(
run_server(host=host,
port=MAGDECK_PORT,
handler=ConnectionHandler(MagDeckEmulator(parser=Parser()))),
run_server(host=host,
port=TEMPDECK_PORT,
handler=ConnectionHandler(TempDeckEmulator(parser=Parser()))),
run_server(host=host,
port=THERMOCYCLER_PORT,
handler=ConnectionHandler(ThermocyclerEmulator(parser=Parser()))),
run_server(host=host,
port=SMOOTHIE_PORT,
handler=ConnectionHandler(
SmoothieEmulator(parser=Parser(), settings=settings.smoothie))
),
)
def stop(self):
self._smoothie_server.close()
self._temp_server.close()
self._therm_server.close()
self._mag_server.close()


if __name__ == "__main__":
logging.basicConfig(format='%(asctime)s:%(message)s', level=logging.DEBUG)
asyncio.run(run())
asyncio.run(ServerManager().run())
7 changes: 5 additions & 2 deletions api/src/opentrons/hardware_control/emulation/magdeck.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,7 @@ class MagDeckEmulator(AbstractEmulator):
"""Magdeck emulator"""

def __init__(self, parser: Parser) -> None:
self.height: float = 0
self.position: float = 0
self.reset()
self._parser = parser

def handle(self, line: str) -> Optional[str]:
Expand All @@ -31,6 +30,10 @@ def handle(self, line: str) -> Optional[str]:
joined = ' '.join(r for r in results if r)
return None if not joined else joined

def reset(self):
self.height: float = 0
self.position: float = 0

def _handle(self, command: Command) -> Optional[str]:
"""Handle a command."""
logger.info(f"Got command {command}")
Expand Down
28 changes: 17 additions & 11 deletions api/src/opentrons/hardware_control/emulation/smoothie.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,17 @@ class SmoothieEmulator(AbstractEmulator):
INSTRUMENT_AND_MODEL_STRING_LENGTH = 64

def __init__(self, parser: Parser, settings: SmoothieSettings) -> None:
"""Constructor"""
self._parser = parser
self._settings = settings
self.reset()

def handle(self, line: str) -> Optional[str]:
"""Handle a line"""
results = (self._handle(c) for c in self._parser.parse(line))
joined = ' '.join(r for r in results if r)
return None if not joined else joined

def reset(self):
_, fw_version = _find_smoothie_file()
self._version_string = \
f"Build version: {fw_version}, Build date: CURRENT, " \
Expand All @@ -52,22 +62,21 @@ def __init__(self, parser: Parser, settings: SmoothieSettings) -> None:

self._pipette_model = {
"L": utils.string_to_hex(
settings.left.model, self.INSTRUMENT_AND_MODEL_STRING_LENGTH
self._settings.left.model, self.INSTRUMENT_AND_MODEL_STRING_LENGTH
),
"R": utils.string_to_hex(
settings.right.model, self.INSTRUMENT_AND_MODEL_STRING_LENGTH
self._settings.right.model, self.INSTRUMENT_AND_MODEL_STRING_LENGTH
),
}

self._pipette_id = {
"L": utils.string_to_hex(
settings.left.id, self.INSTRUMENT_AND_MODEL_STRING_LENGTH
self._settings.left.id, self.INSTRUMENT_AND_MODEL_STRING_LENGTH
),
"R": utils.string_to_hex(
settings.right.id, self.INSTRUMENT_AND_MODEL_STRING_LENGTH
self._settings.right.id, self.INSTRUMENT_AND_MODEL_STRING_LENGTH
),
}
self._parser = parser

self._gcode_to_function_mapping = {
GCODE.HOMING_STATUS.value: self._get_homing_status,
Expand All @@ -81,11 +90,8 @@ def __init__(self, parser: Parser, settings: SmoothieSettings) -> None:
GCODE.HOME.value: self._home_gantry,
}

def handle(self, line: str) -> Optional[str]:
"""Handle a line"""
results = (self._handle(c) for c in self._parser.parse(line))
joined = ' '.join(r for r in results if r)
return None if not joined else joined
def get_current_position(self):
return self._pos

def _get_homing_status(self, command: Command) -> str:
"""Get the current homing status of the emulated gantry"""
Expand Down
9 changes: 6 additions & 3 deletions api/src/opentrons/hardware_control/emulation/tempdeck.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,7 @@ class TempDeckEmulator(AbstractEmulator):
"""TempDeck emulator"""

def __init__(self, parser: Parser) -> None:
self._temperature = Temperature(
per_tick=.25, current=0.0
)
self.reset()
self._parser = parser

def handle(self, line: str) -> Optional[str]:
Expand All @@ -37,6 +35,11 @@ def handle(self, line: str) -> Optional[str]:
joined = ' '.join(r for r in results if r)
return None if not joined else joined

def reset(self):
self._temperature = Temperature(
per_tick=.25, current=0.0
)

def _handle(self, command: Command) -> Optional[str]:
"""Handle a command."""
logger.info(f"Got command {command}")
Expand Down
17 changes: 10 additions & 7 deletions api/src/opentrons/hardware_control/emulation/thermocycler.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,16 @@ class ThermocyclerEmulator(AbstractEmulator):
"""Thermocycler emulator"""

def __init__(self, parser: Parser) -> None:
self.reset()
self._parser = parser

def handle(self, line: str) -> Optional[str]:
"""Handle a line"""
results = (self._handle(c) for c in self._parser.parse(line))
joined = ' '.join(r for r in results if r)
return None if not joined else joined

def reset(self):
self._lid_temperate = Temperature(
per_tick=2, current=util.TEMPERATURE_ROOM
)
Expand All @@ -34,13 +44,6 @@ def __init__(self, parser: Parser) -> None:
self.lid_status = ThermocyclerLidStatus.OPEN
self.plate_volume = util.OptionalValue[float]()
self.plate_ramp_rate = util.OptionalValue[float]()
self._parser = parser

def handle(self, line: str) -> Optional[str]:
"""Handle a line"""
results = (self._handle(c) for c in self._parser.parse(line))
joined = ' '.join(r for r in results if r)
return None if not joined else joined

def _handle(self, command: Command) -> Optional[str]: # noqa: C901
"""
Expand Down
9 changes: 5 additions & 4 deletions api/src/opentrons/hardware_control/g_code_parsing/g_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,11 @@ def g_code_line(self) -> str:
"""
return f'{self.g_code} {self.g_code_body}'.strip()

@property
def response(self):
"""Unparsed G-Code Response"""
return self._response

def get_gcode_function(self) -> str:
"""
Returns the function that the G-Code performs.
Expand Down Expand Up @@ -256,7 +261,3 @@ def get_explanation(self) -> Explanation:
self.g_code_args,
self.response
)

@property
def response(self):
return self._response
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
from __future__ import annotations
import os
import json
from opentrons.hardware_control.emulation.app import \
TEMPDECK_PORT, THERMOCYCLER_PORT, SMOOTHIE_PORT, MAGDECK_PORT
from typing import List, Union
from opentrons.hardware_control.g_code_parsing.g_code_watcher import GCodeWatcher
from opentrons.hardware_control.g_code_parsing.g_code import GCode
Expand All @@ -14,14 +12,6 @@ class GCodeProgram:
Class for parsing various G-Code files and programs into a
list of GCode objects
"""

DEVICE_LOOKUP_BY_PORT = {
SMOOTHIE_PORT: 'smoothie',
TEMPDECK_PORT: 'tempdeck',
THERMOCYCLER_PORT: 'thermocycler',
MAGDECK_PORT: 'magdeck',
}

@classmethod
def from_g_code_watcher(cls, watcher: GCodeWatcher) -> GCodeProgram:
"""
Expand All @@ -32,11 +22,10 @@ def from_g_code_watcher(cls, watcher: GCodeWatcher) -> GCodeProgram:
"""
g_codes = []
for watcher_data in watcher.get_command_list():
device = cls._parse_device(watcher_data.serial_connection)
g_codes.extend(
GCode.from_raw_code(
watcher_data.raw_g_code,
device,
watcher_data.device,
watcher_data.response
)
)
Expand All @@ -45,16 +34,6 @@ def from_g_code_watcher(cls, watcher: GCodeWatcher) -> GCodeProgram:
def __init__(self, g_codes: List[GCode]):
self._g_codes = g_codes

@classmethod
def _parse_device(cls, serial_connection):
"""
Based on port specified in connection URL, parse out what the name
of the device is
"""
serial_port = serial_connection.port
device_port = serial_port[serial_port.rfind(':') + 1:]
return cls.DEVICE_LOOKUP_BY_PORT[int(device_port)]

def add_g_code(self, g_code: GCode) -> None:
"""Add singular G-Code to the end of the program"""
self._g_codes.append(g_code)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,23 @@ def concise_builder(code: GCode):
return MULTIPLE_SPACE_REGEX.sub(' ', message).strip()


def g_code_only_builder(code: GCode):
"""
Function to build string that contains only the raw G-Code input and output
<Raw G-Code> -> <Raw G-Code Output>
Example:
G28.2 X -> Homing the following axes: X
:param code: G-Code object to parse into a string
:return: Textual description
"""
message = f'{code.g_code_line} -> {code.response}'
return MULTIPLE_SPACE_REGEX.sub(' ', message).strip()


class SupportedTextModes(Enum):
"""
Class representing the different text modes that G-Codes can be parsed into
Expand All @@ -101,14 +118,20 @@ class SupportedTextModes(Enum):
"""
DEFAULT = 'Default'
CONCISE = 'Concise'
G_CODE = 'G-Code'

@classmethod
def get_valid_modes(cls):
return [cls.CONCISE.value, cls.DEFAULT.value, cls.G_CODE.value]

@classmethod
def get_text_mode(cls, key: str):
# Defining this inside of the function so that it does not show up
# when using the __members__ attribute
_internal_mapping = {
cls.DEFAULT.value: TextMode(cls.DEFAULT.value, default_builder),
cls.CONCISE.value: TextMode(cls.CONCISE.value, concise_builder)
cls.CONCISE.value: TextMode(cls.CONCISE.value, concise_builder),
cls.G_CODE.value: TextMode(cls.G_CODE.value, g_code_only_builder)
}
members = [member.value for member in list(cls.__members__.values())]
if key not in members:
Expand Down
Loading

0 comments on commit 1d046ea

Please sign in to comment.