opentrons 8.7.0a9__py3-none-any.whl → 8.8.0a7__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- opentrons/_version.py +2 -2
- opentrons/cli/analyze.py +4 -1
- opentrons/config/__init__.py +7 -0
- opentrons/drivers/asyncio/communication/serial_connection.py +126 -49
- opentrons/drivers/heater_shaker/abstract.py +5 -0
- opentrons/drivers/heater_shaker/driver.py +10 -0
- opentrons/drivers/heater_shaker/simulator.py +4 -0
- opentrons/drivers/thermocycler/abstract.py +6 -0
- opentrons/drivers/thermocycler/driver.py +61 -10
- opentrons/drivers/thermocycler/simulator.py +6 -0
- opentrons/drivers/vacuum_module/__init__.py +5 -0
- opentrons/drivers/vacuum_module/abstract.py +93 -0
- opentrons/drivers/vacuum_module/driver.py +208 -0
- opentrons/drivers/vacuum_module/errors.py +39 -0
- opentrons/drivers/vacuum_module/simulator.py +85 -0
- opentrons/drivers/vacuum_module/types.py +79 -0
- opentrons/execute.py +3 -0
- opentrons/hardware_control/api.py +24 -5
- opentrons/hardware_control/backends/controller.py +8 -2
- opentrons/hardware_control/backends/flex_protocol.py +1 -0
- opentrons/hardware_control/backends/ot3controller.py +35 -2
- opentrons/hardware_control/backends/ot3simulator.py +3 -1
- opentrons/hardware_control/backends/ot3utils.py +37 -0
- opentrons/hardware_control/backends/simulator.py +2 -1
- opentrons/hardware_control/backends/subsystem_manager.py +5 -2
- opentrons/hardware_control/emulation/abstract_emulator.py +6 -4
- opentrons/hardware_control/emulation/connection_handler.py +8 -5
- opentrons/hardware_control/emulation/heater_shaker.py +12 -3
- opentrons/hardware_control/emulation/settings.py +1 -1
- opentrons/hardware_control/emulation/thermocycler.py +67 -15
- opentrons/hardware_control/module_control.py +105 -10
- opentrons/hardware_control/modules/__init__.py +3 -0
- opentrons/hardware_control/modules/absorbance_reader.py +11 -4
- opentrons/hardware_control/modules/flex_stacker.py +38 -9
- opentrons/hardware_control/modules/heater_shaker.py +42 -5
- opentrons/hardware_control/modules/magdeck.py +8 -4
- opentrons/hardware_control/modules/mod_abc.py +14 -6
- opentrons/hardware_control/modules/tempdeck.py +25 -5
- opentrons/hardware_control/modules/thermocycler.py +68 -11
- opentrons/hardware_control/modules/types.py +20 -1
- opentrons/hardware_control/modules/utils.py +11 -4
- opentrons/hardware_control/motion_utilities.py +6 -6
- opentrons/hardware_control/nozzle_manager.py +3 -0
- opentrons/hardware_control/ot3api.py +85 -17
- opentrons/hardware_control/poller.py +22 -8
- opentrons/hardware_control/protocols/liquid_handler.py +6 -2
- opentrons/hardware_control/scripts/update_module_fw.py +5 -0
- opentrons/hardware_control/types.py +43 -2
- opentrons/legacy_commands/commands.py +58 -5
- opentrons/legacy_commands/module_commands.py +52 -0
- opentrons/legacy_commands/protocol_commands.py +53 -1
- opentrons/legacy_commands/types.py +155 -1
- opentrons/motion_planning/deck_conflict.py +17 -12
- opentrons/motion_planning/waypoints.py +15 -29
- opentrons/protocol_api/__init__.py +5 -1
- opentrons/protocol_api/_transfer_liquid_validation.py +17 -2
- opentrons/protocol_api/_types.py +8 -1
- opentrons/protocol_api/core/common.py +3 -1
- opentrons/protocol_api/core/engine/_default_labware_versions.py +33 -11
- opentrons/protocol_api/core/engine/deck_conflict.py +3 -1
- opentrons/protocol_api/core/engine/instrument.py +109 -26
- opentrons/protocol_api/core/engine/labware.py +8 -1
- opentrons/protocol_api/core/engine/module_core.py +95 -4
- opentrons/protocol_api/core/engine/protocol.py +51 -2
- opentrons/protocol_api/core/engine/stringify.py +2 -0
- opentrons/protocol_api/core/engine/tasks.py +48 -0
- opentrons/protocol_api/core/engine/well.py +8 -0
- opentrons/protocol_api/core/instrument.py +19 -2
- opentrons/protocol_api/core/legacy/legacy_instrument_core.py +19 -2
- opentrons/protocol_api/core/legacy/legacy_module_core.py +33 -2
- opentrons/protocol_api/core/legacy/legacy_protocol_core.py +23 -1
- opentrons/protocol_api/core/legacy/legacy_well_core.py +4 -0
- opentrons/protocol_api/core/legacy/tasks.py +19 -0
- opentrons/protocol_api/core/legacy_simulator/legacy_instrument_core.py +19 -2
- opentrons/protocol_api/core/legacy_simulator/legacy_protocol_core.py +14 -2
- opentrons/protocol_api/core/legacy_simulator/tasks.py +19 -0
- opentrons/protocol_api/core/module.py +58 -2
- opentrons/protocol_api/core/protocol.py +23 -2
- opentrons/protocol_api/core/tasks.py +31 -0
- opentrons/protocol_api/core/well.py +4 -0
- opentrons/protocol_api/instrument_context.py +388 -2
- opentrons/protocol_api/labware.py +10 -2
- opentrons/protocol_api/module_contexts.py +170 -6
- opentrons/protocol_api/protocol_context.py +87 -21
- opentrons/protocol_api/robot_context.py +41 -25
- opentrons/protocol_api/tasks.py +48 -0
- opentrons/protocol_api/validation.py +49 -3
- opentrons/protocol_engine/__init__.py +4 -0
- opentrons/protocol_engine/actions/__init__.py +6 -2
- opentrons/protocol_engine/actions/actions.py +31 -9
- opentrons/protocol_engine/clients/sync_client.py +42 -7
- opentrons/protocol_engine/commands/__init__.py +56 -0
- opentrons/protocol_engine/commands/absorbance_reader/close_lid.py +2 -15
- opentrons/protocol_engine/commands/absorbance_reader/open_lid.py +2 -15
- opentrons/protocol_engine/commands/absorbance_reader/read.py +22 -23
- opentrons/protocol_engine/commands/aspirate.py +1 -0
- opentrons/protocol_engine/commands/aspirate_while_tracking.py +52 -19
- opentrons/protocol_engine/commands/capture_image.py +302 -0
- opentrons/protocol_engine/commands/command.py +2 -0
- opentrons/protocol_engine/commands/command_unions.py +62 -0
- opentrons/protocol_engine/commands/create_timer.py +83 -0
- opentrons/protocol_engine/commands/dispense.py +1 -0
- opentrons/protocol_engine/commands/dispense_while_tracking.py +56 -19
- opentrons/protocol_engine/commands/drop_tip.py +32 -8
- opentrons/protocol_engine/commands/flex_stacker/common.py +35 -0
- opentrons/protocol_engine/commands/flex_stacker/set_stored_labware.py +7 -0
- opentrons/protocol_engine/commands/heater_shaker/__init__.py +14 -0
- opentrons/protocol_engine/commands/heater_shaker/common.py +20 -0
- opentrons/protocol_engine/commands/heater_shaker/set_and_wait_for_shake_speed.py +5 -4
- opentrons/protocol_engine/commands/heater_shaker/set_shake_speed.py +136 -0
- opentrons/protocol_engine/commands/heater_shaker/set_target_temperature.py +31 -5
- opentrons/protocol_engine/commands/move_labware.py +3 -4
- opentrons/protocol_engine/commands/move_to_addressable_area_for_drop_tip.py +1 -1
- opentrons/protocol_engine/commands/movement_common.py +31 -2
- opentrons/protocol_engine/commands/pick_up_tip.py +21 -11
- opentrons/protocol_engine/commands/pipetting_common.py +48 -3
- opentrons/protocol_engine/commands/set_tip_state.py +97 -0
- opentrons/protocol_engine/commands/temperature_module/set_target_temperature.py +38 -7
- opentrons/protocol_engine/commands/thermocycler/__init__.py +16 -0
- opentrons/protocol_engine/commands/thermocycler/run_extended_profile.py +6 -0
- opentrons/protocol_engine/commands/thermocycler/run_profile.py +8 -0
- opentrons/protocol_engine/commands/thermocycler/set_target_block_temperature.py +44 -7
- opentrons/protocol_engine/commands/thermocycler/set_target_lid_temperature.py +43 -14
- opentrons/protocol_engine/commands/thermocycler/start_run_extended_profile.py +191 -0
- opentrons/protocol_engine/commands/touch_tip.py +1 -1
- opentrons/protocol_engine/commands/unsafe/unsafe_place_labware.py +6 -22
- opentrons/protocol_engine/commands/wait_for_tasks.py +98 -0
- opentrons/protocol_engine/create_protocol_engine.py +12 -0
- opentrons/protocol_engine/engine_support.py +3 -0
- opentrons/protocol_engine/errors/__init__.py +12 -0
- opentrons/protocol_engine/errors/exceptions.py +119 -0
- opentrons/protocol_engine/execution/__init__.py +4 -0
- opentrons/protocol_engine/execution/command_executor.py +62 -1
- opentrons/protocol_engine/execution/create_queue_worker.py +9 -2
- opentrons/protocol_engine/execution/labware_movement.py +13 -15
- opentrons/protocol_engine/execution/movement.py +2 -0
- opentrons/protocol_engine/execution/pipetting.py +19 -25
- opentrons/protocol_engine/execution/queue_worker.py +4 -0
- opentrons/protocol_engine/execution/run_control.py +8 -0
- opentrons/protocol_engine/execution/task_handler.py +157 -0
- opentrons/protocol_engine/protocol_engine.py +137 -36
- opentrons/protocol_engine/resources/__init__.py +4 -0
- opentrons/protocol_engine/resources/camera_provider.py +110 -0
- opentrons/protocol_engine/resources/concurrency_provider.py +27 -0
- opentrons/protocol_engine/resources/deck_configuration_provider.py +7 -0
- opentrons/protocol_engine/resources/file_provider.py +133 -58
- opentrons/protocol_engine/resources/labware_validation.py +10 -6
- opentrons/protocol_engine/slot_standardization.py +2 -0
- opentrons/protocol_engine/state/_well_math.py +60 -18
- opentrons/protocol_engine/state/addressable_areas.py +2 -0
- opentrons/protocol_engine/state/camera.py +54 -0
- opentrons/protocol_engine/state/commands.py +37 -14
- opentrons/protocol_engine/state/geometry.py +276 -379
- opentrons/protocol_engine/state/labware.py +62 -108
- opentrons/protocol_engine/state/labware_origin_math/errors.py +94 -0
- opentrons/protocol_engine/state/labware_origin_math/stackup_origin_to_labware_origin.py +1336 -0
- opentrons/protocol_engine/state/module_substates/thermocycler_module_substate.py +37 -0
- opentrons/protocol_engine/state/modules.py +30 -8
- opentrons/protocol_engine/state/motion.py +44 -0
- opentrons/protocol_engine/state/preconditions.py +59 -0
- opentrons/protocol_engine/state/state.py +44 -0
- opentrons/protocol_engine/state/state_summary.py +4 -0
- opentrons/protocol_engine/state/tasks.py +139 -0
- opentrons/protocol_engine/state/tips.py +177 -258
- opentrons/protocol_engine/state/update_types.py +26 -9
- opentrons/protocol_engine/types/__init__.py +23 -4
- opentrons/protocol_engine/types/command_preconditions.py +18 -0
- opentrons/protocol_engine/types/deck_configuration.py +5 -1
- opentrons/protocol_engine/types/instrument.py +8 -1
- opentrons/protocol_engine/types/labware.py +1 -13
- opentrons/protocol_engine/types/location.py +26 -2
- opentrons/protocol_engine/types/module.py +11 -1
- opentrons/protocol_engine/types/tasks.py +38 -0
- opentrons/protocol_engine/types/tip.py +9 -0
- opentrons/protocol_runner/create_simulating_orchestrator.py +29 -2
- opentrons/protocol_runner/protocol_runner.py +14 -1
- opentrons/protocol_runner/run_orchestrator.py +49 -2
- opentrons/protocols/advanced_control/transfers/transfer_liquid_utils.py +2 -2
- opentrons/protocols/api_support/definitions.py +1 -1
- opentrons/protocols/api_support/types.py +2 -1
- opentrons/simulate.py +51 -15
- opentrons/system/camera.py +334 -4
- opentrons/system/ffmpeg.py +110 -0
- {opentrons-8.7.0a9.dist-info → opentrons-8.8.0a7.dist-info}/METADATA +4 -4
- {opentrons-8.7.0a9.dist-info → opentrons-8.8.0a7.dist-info}/RECORD +188 -160
- opentrons/protocol_engine/state/_labware_origin_math.py +0 -636
- {opentrons-8.7.0a9.dist-info → opentrons-8.8.0a7.dist-info}/WHEEL +0 -0
- {opentrons-8.7.0a9.dist-info → opentrons-8.8.0a7.dist-info}/entry_points.txt +0 -0
- {opentrons-8.7.0a9.dist-info → opentrons-8.8.0a7.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Task handling."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Protocol, AsyncIterator
|
|
6
|
+
from ..state.state import StateStore
|
|
7
|
+
from ..resources import ModelUtils, ConcurrencyProvider
|
|
8
|
+
from ..types import Task
|
|
9
|
+
import asyncio
|
|
10
|
+
import contextlib
|
|
11
|
+
from ..actions import ActionDispatcher, FinishTaskAction, StartTaskAction
|
|
12
|
+
from ..errors import ErrorOccurrence
|
|
13
|
+
from opentrons_shared_data.errors.exceptions import EnumeratedError, PythonException
|
|
14
|
+
|
|
15
|
+
log = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TaskFunction(Protocol):
|
|
19
|
+
"""The function run inside a task protocol."""
|
|
20
|
+
|
|
21
|
+
async def __call__(self, task_handler: TaskHandler) -> None:
|
|
22
|
+
"""The function called inside a task."""
|
|
23
|
+
...
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TaskHandler:
|
|
27
|
+
"""Implementation logic for fast concurrency."""
|
|
28
|
+
|
|
29
|
+
_state_store: StateStore
|
|
30
|
+
_model_utils: ModelUtils
|
|
31
|
+
_concurrency_provider: ConcurrencyProvider
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
state_store: StateStore,
|
|
36
|
+
action_dispatcher: ActionDispatcher,
|
|
37
|
+
model_utils: ModelUtils | None = None,
|
|
38
|
+
concurrency_provider: ConcurrencyProvider | None = None,
|
|
39
|
+
) -> None:
|
|
40
|
+
"""Initialize a TaskHandler instance."""
|
|
41
|
+
self._state_store = state_store
|
|
42
|
+
self._model_utils = model_utils or ModelUtils()
|
|
43
|
+
self._concurrency_provider = concurrency_provider or ConcurrencyProvider()
|
|
44
|
+
self._action_dispatcher = action_dispatcher
|
|
45
|
+
|
|
46
|
+
async def create_task(
|
|
47
|
+
self, task_function: TaskFunction, id: str | None = None
|
|
48
|
+
) -> Task:
|
|
49
|
+
"""Create a task and immediately schedules it."""
|
|
50
|
+
task_id = self._model_utils.ensure_id(id)
|
|
51
|
+
asyncio_task = asyncio.create_task(
|
|
52
|
+
task_function(task_handler=self), name=f"engine-task-{task_id}"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
def _done_callback(task: asyncio.Task[None]) -> None:
|
|
56
|
+
try:
|
|
57
|
+
maybe_exception = task.exception()
|
|
58
|
+
except asyncio.CancelledError as e:
|
|
59
|
+
maybe_exception = e
|
|
60
|
+
if isinstance(maybe_exception, EnumeratedError):
|
|
61
|
+
occurence: ErrorOccurrence | None = ErrorOccurrence.from_failed(
|
|
62
|
+
id=self._model_utils.generate_id(),
|
|
63
|
+
createdAt=self._model_utils.get_timestamp(),
|
|
64
|
+
error=maybe_exception,
|
|
65
|
+
)
|
|
66
|
+
elif isinstance(maybe_exception, BaseException):
|
|
67
|
+
occurence = ErrorOccurrence.from_failed(
|
|
68
|
+
id=self._model_utils.generate_id(),
|
|
69
|
+
createdAt=self._model_utils.get_timestamp(),
|
|
70
|
+
error=PythonException(maybe_exception),
|
|
71
|
+
)
|
|
72
|
+
else:
|
|
73
|
+
occurence = None
|
|
74
|
+
try:
|
|
75
|
+
self._action_dispatcher.dispatch(
|
|
76
|
+
FinishTaskAction(
|
|
77
|
+
task_id=task_id,
|
|
78
|
+
finished_at=self._model_utils.get_timestamp(),
|
|
79
|
+
error=occurence,
|
|
80
|
+
),
|
|
81
|
+
)
|
|
82
|
+
except BaseException:
|
|
83
|
+
log.exception("Exception in task finish dispatch.")
|
|
84
|
+
|
|
85
|
+
asyncio_task.add_done_callback(_done_callback)
|
|
86
|
+
task = Task(
|
|
87
|
+
id=task_id,
|
|
88
|
+
createdAt=self._model_utils.get_timestamp(),
|
|
89
|
+
asyncioTask=asyncio_task,
|
|
90
|
+
)
|
|
91
|
+
self._action_dispatcher.dispatch(StartTaskAction(task))
|
|
92
|
+
return task
|
|
93
|
+
|
|
94
|
+
@staticmethod
|
|
95
|
+
def _empty_queue(
|
|
96
|
+
queue: "asyncio.Queue[asyncio.Task[None]]", this_task: asyncio.Task[None]
|
|
97
|
+
) -> None:
|
|
98
|
+
"""Empties the queue."""
|
|
99
|
+
try:
|
|
100
|
+
while True:
|
|
101
|
+
task = queue.get_nowait()
|
|
102
|
+
if task is this_task:
|
|
103
|
+
break
|
|
104
|
+
except asyncio.QueueEmpty:
|
|
105
|
+
pass
|
|
106
|
+
|
|
107
|
+
@contextlib.asynccontextmanager
|
|
108
|
+
async def synchronize_cancel_latest(self, group_id: str) -> AsyncIterator[None]:
|
|
109
|
+
"""Cancel current task."""
|
|
110
|
+
lock = self._concurrency_provider.lock_for_group(group_id)
|
|
111
|
+
if lock.locked():
|
|
112
|
+
raise asyncio.CancelledError()
|
|
113
|
+
async with lock:
|
|
114
|
+
yield
|
|
115
|
+
|
|
116
|
+
@contextlib.asynccontextmanager
|
|
117
|
+
async def synchronize_cancel_previous(self, group_id: str) -> AsyncIterator[None]:
|
|
118
|
+
"""Cancel previous run."""
|
|
119
|
+
queue = self._concurrency_provider.queue_for_group(group_id)
|
|
120
|
+
while not queue.empty():
|
|
121
|
+
task = queue.get_nowait()
|
|
122
|
+
task.cancel()
|
|
123
|
+
this_task = asyncio.current_task()
|
|
124
|
+
assert this_task is not None
|
|
125
|
+
queue.put_nowait(this_task)
|
|
126
|
+
try:
|
|
127
|
+
yield
|
|
128
|
+
except asyncio.CancelledError:
|
|
129
|
+
raise
|
|
130
|
+
except BaseException:
|
|
131
|
+
self._empty_queue(queue, this_task)
|
|
132
|
+
raise
|
|
133
|
+
else:
|
|
134
|
+
self._empty_queue(queue, this_task)
|
|
135
|
+
|
|
136
|
+
@contextlib.asynccontextmanager
|
|
137
|
+
async def synchronize_sequential(self, group_id: str) -> AsyncIterator[None]:
|
|
138
|
+
"""Run tasks one after the other."""
|
|
139
|
+
lock = self._concurrency_provider.lock_for_group(group_id)
|
|
140
|
+
async with lock:
|
|
141
|
+
yield
|
|
142
|
+
|
|
143
|
+
@contextlib.asynccontextmanager
|
|
144
|
+
async def synchronize_concurrent(self, group_id: str) -> AsyncIterator[None]:
|
|
145
|
+
"""Run a list of tasks at the same time."""
|
|
146
|
+
yield
|
|
147
|
+
|
|
148
|
+
def cancel_all(self, message: str | None = None) -> None:
|
|
149
|
+
"""Cancel all asyncio tasks immediately.
|
|
150
|
+
|
|
151
|
+
Do not call this more than once synchronously because
|
|
152
|
+
that could lead to tasks cancelling more than once.
|
|
153
|
+
It can be called if there are no current tasks. In that case
|
|
154
|
+
nothing will happen.
|
|
155
|
+
"""
|
|
156
|
+
for task in self._state_store.tasks.get_all_current():
|
|
157
|
+
task.asyncioTask.cancel(msg=message)
|
|
@@ -13,6 +13,7 @@ from opentrons_shared_data.labware.labware_definition import LabwareDefinition
|
|
|
13
13
|
from opentrons.hardware_control import HardwareControlAPI
|
|
14
14
|
from opentrons.hardware_control.modules import AbstractModule as HardwareModuleAPI
|
|
15
15
|
from opentrons.hardware_control.types import PauseType as HardwarePauseType
|
|
16
|
+
from opentrons.system import camera
|
|
16
17
|
|
|
17
18
|
from .actions.actions import (
|
|
18
19
|
ResumeFromRecoveryAction,
|
|
@@ -22,7 +23,8 @@ from .errors import ProtocolCommandFailedError, ErrorOccurrence, CommandNotAllow
|
|
|
22
23
|
from .errors.exceptions import EStopActivatedError
|
|
23
24
|
from .error_recovery_policy import ErrorRecoveryPolicy
|
|
24
25
|
from . import commands, slot_standardization, labware_offset_standardization
|
|
25
|
-
from .resources import ModelUtils, ModuleDataProvider, FileProvider
|
|
26
|
+
from .resources import ModelUtils, ModuleDataProvider, FileProvider, CameraProvider
|
|
27
|
+
from .resources.camera_provider import CameraSettings
|
|
26
28
|
from .types import (
|
|
27
29
|
LabwareOffset,
|
|
28
30
|
LabwareOffsetCreate,
|
|
@@ -55,11 +57,11 @@ from .actions import (
|
|
|
55
57
|
AddLabwareOffsetAction,
|
|
56
58
|
AddLabwareDefinitionAction,
|
|
57
59
|
AddLiquidAction,
|
|
60
|
+
AddCameraSettingsAction,
|
|
58
61
|
SetDeckConfigurationAction,
|
|
59
62
|
AddAddressableAreaAction,
|
|
60
63
|
AddModuleAction,
|
|
61
64
|
HardwareStoppedAction,
|
|
62
|
-
ResetTipsAction,
|
|
63
65
|
SetPipetteMovementSpeedAction,
|
|
64
66
|
)
|
|
65
67
|
|
|
@@ -97,6 +99,7 @@ class ProtocolEngine:
|
|
|
97
99
|
door_watcher: DoorWatcher,
|
|
98
100
|
module_data_provider: ModuleDataProvider,
|
|
99
101
|
file_provider: FileProvider,
|
|
102
|
+
camera_provider: CameraProvider,
|
|
100
103
|
queue_worker: Optional[QueueWorker] = None,
|
|
101
104
|
) -> None:
|
|
102
105
|
"""Initialize a ProtocolEngine instance.
|
|
@@ -108,6 +111,7 @@ class ProtocolEngine:
|
|
|
108
111
|
"""
|
|
109
112
|
self._hardware_api = hardware_api
|
|
110
113
|
self._file_provider = file_provider
|
|
114
|
+
self._camera_provider = camera_provider
|
|
111
115
|
self._state_store = state_store
|
|
112
116
|
self._model_utils = model_utils
|
|
113
117
|
self._action_dispatcher = action_dispatcher
|
|
@@ -322,24 +326,10 @@ class ProtocolEngine:
|
|
|
322
326
|
)
|
|
323
327
|
return completed_command
|
|
324
328
|
|
|
325
|
-
def
|
|
326
|
-
"""Signal to the engine that an E-stop event occurred.
|
|
327
|
-
|
|
328
|
-
If an estop happens while the robot is moving, lower layers physically stop
|
|
329
|
-
motion and raise the event as an exception, which fails the Protocol Engine
|
|
330
|
-
command. No action from the `ProtocolEngine` caller is needed to handle that.
|
|
331
|
-
|
|
332
|
-
However, if an estop happens in between commands, or in the middle of
|
|
333
|
-
a command like `comment` or `waitForDuration` that doesn't access the hardware,
|
|
334
|
-
`ProtocolEngine` needs to be told about it so it can interrupt the command
|
|
335
|
-
and stop executing any more. This method is how to do that.
|
|
336
|
-
|
|
337
|
-
This acts roughly like `request_stop()`. After calling this, you should call
|
|
338
|
-
`finish()` with an EStopActivatedError.
|
|
339
|
-
"""
|
|
329
|
+
def _stop_from_asynchronous_error(self) -> None:
|
|
340
330
|
try:
|
|
341
331
|
action = self._state_store.commands.validate_action_allowed(
|
|
342
|
-
StopAction(
|
|
332
|
+
StopAction(from_asynchronous_error=True)
|
|
343
333
|
)
|
|
344
334
|
except Exception: # todo(mm, 2024-04-16): Catch a more specific type.
|
|
345
335
|
# This is likely called from some hardware API callback that doesn't care
|
|
@@ -358,9 +348,112 @@ class ProtocolEngine:
|
|
|
358
348
|
# do this because we want to make sure non-hardware commands, like
|
|
359
349
|
# `waitForDuration`, are also interrupted.
|
|
360
350
|
self._get_queue_worker.cancel()
|
|
351
|
+
|
|
352
|
+
def estop(self) -> None:
|
|
353
|
+
"""Signal to the engine that an E-stop event occurred.
|
|
354
|
+
|
|
355
|
+
If an estop happens while the robot is moving, lower layers physically stop
|
|
356
|
+
motion and raise the event as an exception, which fails the Protocol Engine
|
|
357
|
+
command. No action from the `ProtocolEngine` caller is needed to handle that.
|
|
358
|
+
|
|
359
|
+
However, if an estop happens in between commands, or in the middle of
|
|
360
|
+
a command like `comment` or `waitForDuration` that doesn't access the hardware,
|
|
361
|
+
`ProtocolEngine` needs to be told about it so it can interrupt the command
|
|
362
|
+
and stop executing any more. This method is how to do that.
|
|
363
|
+
|
|
364
|
+
This acts roughly like `request_stop()`. After calling this, you should call
|
|
365
|
+
`finish()` with an EStopActivatedError.
|
|
366
|
+
"""
|
|
361
367
|
# Unlike self.request_stop(), we don't need to do
|
|
362
368
|
# self._hardware_api.cancel_execution_and_running_tasks(). Since this was an
|
|
363
369
|
# E-stop event, the hardware API already knows.
|
|
370
|
+
self._stop_from_asynchronous_error()
|
|
371
|
+
|
|
372
|
+
async def async_module_error(
|
|
373
|
+
self, module_model: ModuleModel, serial: str | None
|
|
374
|
+
) -> bool:
|
|
375
|
+
"""Signal to the engine that an asynchronous module error occured.
|
|
376
|
+
|
|
377
|
+
The return value of this function signals whether the error is relevant to the protocol
|
|
378
|
+
or not. If the function returns True, the error is relevant. The engine will stop, and
|
|
379
|
+
the caller should call `finish()` with the error object that signaled the error. If
|
|
380
|
+
the function returns False, the error is not relevant. The engine will not stop, and the
|
|
381
|
+
caller should not call `finish()`.
|
|
382
|
+
|
|
383
|
+
Asynchronous module errors are signaled when a module enters a hardware error state
|
|
384
|
+
- for instance, a thermocycler's thermistors fail because of condensation, or a
|
|
385
|
+
heater-shaker's wires fray and snap, or a module is accidentally disconnected. These
|
|
386
|
+
errors are not related to a particular command, even a currently-happening module
|
|
387
|
+
control command for the module in the error state.
|
|
388
|
+
|
|
389
|
+
Similar to an estop error, the error can occur at any time relative to the lifecycle
|
|
390
|
+
of the engine run or of any particular command.
|
|
391
|
+
|
|
392
|
+
Unlike an estop, the motion control hardware will not be raising an error and will not
|
|
393
|
+
stop on its own; the stop action derived from this call will do that.
|
|
394
|
+
"""
|
|
395
|
+
if not self._state_store.modules.get_has_module_probably_matching_hardware_details(
|
|
396
|
+
module_model, serial
|
|
397
|
+
):
|
|
398
|
+
return False
|
|
399
|
+
|
|
400
|
+
if self._state_store.commands.get_is_terminal():
|
|
401
|
+
# Do not stop multiple times; it will be common for this action to fire
|
|
402
|
+
# many times when a module enters an error state, and we don't want to do
|
|
403
|
+
# the stop behavior over and over
|
|
404
|
+
return False
|
|
405
|
+
|
|
406
|
+
self._stop_from_asynchronous_error()
|
|
407
|
+
# like self.request_stop, and unlike self.estop(), we must explicitly request that the
|
|
408
|
+
# hardware stops execution, since not all asynchronous errors will cause the hardware
|
|
409
|
+
# to know that it should stop.
|
|
410
|
+
await self._do_hardware_stop()
|
|
411
|
+
return True
|
|
412
|
+
|
|
413
|
+
async def module_disconnected(
|
|
414
|
+
self, module_model: ModuleModel, serial: str | None
|
|
415
|
+
) -> bool:
|
|
416
|
+
"""Signal to the engine that a module has disconnected.
|
|
417
|
+
|
|
418
|
+
The return value of this function signals whether the module was relevant to this
|
|
419
|
+
protocol or not. If the function returns True, the module was relevant. The engine
|
|
420
|
+
will stop, and the caller should call `finish()` with an appropriate error to indicate
|
|
421
|
+
the missing module. If the function returns False, the error is not relevant. The engine
|
|
422
|
+
will not stop, and the caller should not call `finish()`.
|
|
423
|
+
|
|
424
|
+
Module disconnects are signaled when a hardware module's status poller indicates that the
|
|
425
|
+
module is no logner connected - for instance, someone unplugs the module, or it crashes.
|
|
426
|
+
These errors are not related to a particular command, even a currently-happening module
|
|
427
|
+
control command for the module in the error state.
|
|
428
|
+
|
|
429
|
+
Similar to an estop error, the error can occur at any time relative to the lifecycle
|
|
430
|
+
of the engine run or of any particular command.
|
|
431
|
+
|
|
432
|
+
Unlike an estop, the motion control hardware will not be raising an error and will not
|
|
433
|
+
stop on its own; the stop action derived from this call will do that.
|
|
434
|
+
"""
|
|
435
|
+
if not self._state_store.modules.get_has_module_probably_matching_hardware_details(
|
|
436
|
+
module_model, serial
|
|
437
|
+
):
|
|
438
|
+
return False
|
|
439
|
+
self._stop_from_asynchronous_error()
|
|
440
|
+
# like self.request_stop, and unlike self.estop(), we must explicitly request that the
|
|
441
|
+
# hardware stops execution, since not all asynchronous errors will cause the hardware
|
|
442
|
+
# to know that it should stop.
|
|
443
|
+
await self._do_hardware_stop()
|
|
444
|
+
return True
|
|
445
|
+
|
|
446
|
+
async def _do_hardware_stop(self) -> None:
|
|
447
|
+
"""Make the hardware stop now."""
|
|
448
|
+
if self._hardware_api.is_movement_execution_taskified():
|
|
449
|
+
# We 'taskify' hardware controller movement functions when running protocols
|
|
450
|
+
# that are not backed by the engine. Such runs cannot be stopped by cancelling
|
|
451
|
+
# the queue worker and hence need to be stopped via the execution manager.
|
|
452
|
+
# `cancel_execution_and_running_tasks()` sets the execution manager in a CANCELLED state
|
|
453
|
+
# and cancels the running tasks, which raises an error and gets us out of the
|
|
454
|
+
# run function execution, just like `_queue_worker.cancel()` does for
|
|
455
|
+
# engine-backed runs.
|
|
456
|
+
await self._hardware_api.cancel_execution_and_running_tasks()
|
|
364
457
|
|
|
365
458
|
async def request_stop(self) -> None:
|
|
366
459
|
"""Make command execution stop soon.
|
|
@@ -378,15 +471,7 @@ class ProtocolEngine:
|
|
|
378
471
|
action = self._state_store.commands.validate_action_allowed(StopAction())
|
|
379
472
|
self._action_dispatcher.dispatch(action)
|
|
380
473
|
self._get_queue_worker.cancel()
|
|
381
|
-
|
|
382
|
-
# We 'taskify' hardware controller movement functions when running protocols
|
|
383
|
-
# that are not backed by the engine. Such runs cannot be stopped by cancelling
|
|
384
|
-
# the queue worker and hence need to be stopped via the execution manager.
|
|
385
|
-
# `cancel_execution_and_running_tasks()` sets the execution manager in a CANCELLED state
|
|
386
|
-
# and cancels the running tasks, which raises an error and gets us out of the
|
|
387
|
-
# run function execution, just like `_queue_worker.cancel()` does for
|
|
388
|
-
# engine-backed runs.
|
|
389
|
-
await self._hardware_api.cancel_execution_and_running_tasks()
|
|
474
|
+
await self._do_hardware_stop()
|
|
390
475
|
|
|
391
476
|
async def wait_until_complete(self) -> None:
|
|
392
477
|
"""Wait until there are no more commands to execute.
|
|
@@ -398,7 +483,7 @@ class ProtocolEngine:
|
|
|
398
483
|
)
|
|
399
484
|
self._state_store.commands.raise_fatal_command_error()
|
|
400
485
|
|
|
401
|
-
async def finish(
|
|
486
|
+
async def finish( # noqa: C901
|
|
402
487
|
self,
|
|
403
488
|
error: Optional[Exception] = None,
|
|
404
489
|
drop_tips_after_run: bool = True,
|
|
@@ -429,13 +514,13 @@ class ProtocolEngine:
|
|
|
429
514
|
post_run_hardware_state: The state in which to leave the gantry and motors in
|
|
430
515
|
after the run is over.
|
|
431
516
|
"""
|
|
432
|
-
if self._state_store.commands.
|
|
517
|
+
if self._state_store.commands.get_is_stopped_by_async_error():
|
|
433
518
|
# This handles the case where the E-stop was pressed while we were *not* in the middle
|
|
434
519
|
# of some hardware interaction that would raise it as an exception. For example, imagine
|
|
435
520
|
# we were paused between two commands, or imagine we were executing a waitForDuration.
|
|
436
521
|
drop_tips_after_run = False
|
|
437
522
|
post_run_hardware_state = PostRunHardwareState.DISENGAGE_IN_PLACE
|
|
438
|
-
if error is None:
|
|
523
|
+
if error is None and self._state_store.commands.get_error() is None:
|
|
439
524
|
error = EStopActivatedError()
|
|
440
525
|
|
|
441
526
|
if error:
|
|
@@ -512,6 +597,16 @@ class ProtocolEngine:
|
|
|
512
597
|
else:
|
|
513
598
|
finish_error_details = None
|
|
514
599
|
|
|
600
|
+
try:
|
|
601
|
+
await camera.update_live_stream_status(
|
|
602
|
+
self.state_view.config.robot_type,
|
|
603
|
+
False,
|
|
604
|
+
self._camera_provider,
|
|
605
|
+
self.state_view.camera.get_enablement_settings(),
|
|
606
|
+
)
|
|
607
|
+
except Exception as e:
|
|
608
|
+
_log.exception(f"Exception during live stream post-run cleanup: {e}")
|
|
609
|
+
|
|
515
610
|
self._action_dispatcher.dispatch(
|
|
516
611
|
HardwareStoppedAction(
|
|
517
612
|
completed_at=self._model_utils.get_timestamp(),
|
|
@@ -549,6 +644,17 @@ class ProtocolEngine:
|
|
|
549
644
|
labware_offset_id=labware_offset_id
|
|
550
645
|
)
|
|
551
646
|
|
|
647
|
+
def add_camera_enablement_settings(
|
|
648
|
+
self, enablement_settings: CameraSettings
|
|
649
|
+
) -> CameraSettings:
|
|
650
|
+
"""Add new camera enablement settings."""
|
|
651
|
+
self._action_dispatcher.dispatch(
|
|
652
|
+
AddCameraSettingsAction(enablement_settings=enablement_settings)
|
|
653
|
+
)
|
|
654
|
+
camera_settings = self.state_view.camera.get_enablement_settings()
|
|
655
|
+
assert camera_settings is not None
|
|
656
|
+
return camera_settings
|
|
657
|
+
|
|
552
658
|
def add_labware_definition(self, definition: LabwareDefinition) -> LabwareUri:
|
|
553
659
|
"""Add a labware definition to the state for subsequent labware loads."""
|
|
554
660
|
self._action_dispatcher.dispatch(
|
|
@@ -586,12 +692,6 @@ class ProtocolEngine:
|
|
|
586
692
|
AddAddressableAreaAction(addressable_area_name)
|
|
587
693
|
)
|
|
588
694
|
|
|
589
|
-
def reset_tips(self, labware_id: str) -> None:
|
|
590
|
-
"""Reset the tip state of a given labware."""
|
|
591
|
-
# TODO(mm, 2023-03-10): Safely raise an error if the given labware isn't a
|
|
592
|
-
# tip rack?
|
|
593
|
-
self._action_dispatcher.dispatch(ResetTipsAction(labware_id=labware_id))
|
|
594
|
-
|
|
595
695
|
# TODO(mm, 2022-11-10): This is a method on ProtocolEngine instead of a command
|
|
596
696
|
# as a quick hack to support Python protocols. We should consider making this a
|
|
597
697
|
# command, or adding speed parameters to existing commands.
|
|
@@ -635,6 +735,7 @@ class ProtocolEngine:
|
|
|
635
735
|
self._queue_worker = create_queue_worker(
|
|
636
736
|
hardware_api=self._hardware_api,
|
|
637
737
|
file_provider=self._file_provider,
|
|
738
|
+
camera_provider=self._camera_provider,
|
|
638
739
|
state_store=self._state_store,
|
|
639
740
|
action_dispatcher=self._action_dispatcher,
|
|
640
741
|
command_generator=command_generator,
|
|
@@ -10,7 +10,9 @@ from .deck_data_provider import DeckDataProvider, DeckFixedLabware
|
|
|
10
10
|
from .labware_data_provider import LabwareDataProvider
|
|
11
11
|
from .module_data_provider import ModuleDataProvider
|
|
12
12
|
from .file_provider import FileProvider
|
|
13
|
+
from .camera_provider import CameraProvider
|
|
13
14
|
from .ot3_validation import ensure_ot3_hardware
|
|
15
|
+
from .concurrency_provider import ConcurrencyProvider
|
|
14
16
|
|
|
15
17
|
|
|
16
18
|
__all__ = [
|
|
@@ -18,8 +20,10 @@ __all__ = [
|
|
|
18
20
|
"LabwareDataProvider",
|
|
19
21
|
"DeckDataProvider",
|
|
20
22
|
"DeckFixedLabware",
|
|
23
|
+
"ConcurrencyProvider",
|
|
21
24
|
"ModuleDataProvider",
|
|
22
25
|
"FileProvider",
|
|
26
|
+
"CameraProvider",
|
|
23
27
|
"ensure_ot3_hardware",
|
|
24
28
|
"pipette_data_provider",
|
|
25
29
|
"labware_validation",
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Camera interaction resource provider."""
|
|
2
|
+
from typing import Optional, Callable, Tuple, Awaitable
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
from ..errors import CameraCaptureError, CameraSettingsInvalidError
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
from opentrons_shared_data.robot.types import RobotType
|
|
8
|
+
|
|
9
|
+
log = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CameraError(BaseModel):
|
|
13
|
+
"""Generic base class for Camera errors that occur on entities handled through the Camera Provider."""
|
|
14
|
+
|
|
15
|
+
message: str = Field(..., description="Description of error content.")
|
|
16
|
+
code: str | None = Field(
|
|
17
|
+
..., description="Return code, if any, that was paired with the error."
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class CameraSettings(BaseModel):
|
|
22
|
+
"""Camera API settings for general enablement and use."""
|
|
23
|
+
|
|
24
|
+
cameraEnabled: bool = Field(
|
|
25
|
+
..., description="Enablement status for general camera use."
|
|
26
|
+
)
|
|
27
|
+
liveStreamEnabled: bool = Field(
|
|
28
|
+
..., description="Enablement status for the Opentrons Live Stream service."
|
|
29
|
+
)
|
|
30
|
+
errorRecoveryEnabled: bool = Field(
|
|
31
|
+
..., description="Enablement status for camera usage with Error Recovery."
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ImageParameters(BaseModel):
|
|
36
|
+
"""Parameters for an Image Capture to determine filters. These are the inputs as expected by FFMPEG."""
|
|
37
|
+
|
|
38
|
+
resolution: Optional[Tuple[int, int]] = Field(
|
|
39
|
+
None,
|
|
40
|
+
description="Width by height resolution in pixels for the image to be captured with.",
|
|
41
|
+
)
|
|
42
|
+
zoom: Optional[float] = Field(
|
|
43
|
+
None,
|
|
44
|
+
description="Multiplier to use when cropping and scaling a captured image.",
|
|
45
|
+
)
|
|
46
|
+
pan: Optional[Tuple[int, int]] = Field(
|
|
47
|
+
None,
|
|
48
|
+
description="Position to pan to for a given zoom. Format is X and Y coordinates (in pixels) to the bottom left of a frame.",
|
|
49
|
+
)
|
|
50
|
+
contrast: Optional[float] = Field(
|
|
51
|
+
None, description="The contrast to use when processing an image."
|
|
52
|
+
)
|
|
53
|
+
brightness: Optional[int] = Field(
|
|
54
|
+
None, description="The brightness to use when processing an image."
|
|
55
|
+
)
|
|
56
|
+
saturation: Optional[float] = Field(
|
|
57
|
+
None, description="The saturation to use when processing an image."
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class CameraProvider:
|
|
62
|
+
"""Provider class to wrap camera interactions between the server and the engine."""
|
|
63
|
+
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
camera_settings_callback: Optional[Callable[[], CameraSettings]] = None,
|
|
67
|
+
image_capture_callback: Optional[
|
|
68
|
+
Callable[[RobotType, ImageParameters], Awaitable[bytes | CameraError]]
|
|
69
|
+
] = None,
|
|
70
|
+
) -> None:
|
|
71
|
+
"""Initialize the interface callbacks of the Camera Provider within the Protocol Engine.
|
|
72
|
+
|
|
73
|
+
Params:
|
|
74
|
+
camera_settings_callback: Callback to query the Camera Enablement settings from the Boolean Settings table.
|
|
75
|
+
image_capture_callback: Callback to process an image capture request and return a bytestream of image data in response.
|
|
76
|
+
"""
|
|
77
|
+
self._camera_settings_callback = camera_settings_callback
|
|
78
|
+
self._image_capture_callback = image_capture_callback
|
|
79
|
+
|
|
80
|
+
async def get_camera_settings(self) -> CameraSettings:
|
|
81
|
+
"""Query the Robot Server for the current Camera Enablement settings."""
|
|
82
|
+
if self._camera_settings_callback is not None:
|
|
83
|
+
return self._camera_settings_callback()
|
|
84
|
+
# If we are in analysis or simulation, return as if the camera is enabled
|
|
85
|
+
return CameraSettings(
|
|
86
|
+
cameraEnabled=True, liveStreamEnabled=True, errorRecoveryEnabled=True
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
async def capture_image(
|
|
90
|
+
self, robot_type: RobotType, parameters: ImageParameters
|
|
91
|
+
) -> bytes | None:
|
|
92
|
+
"""Process through the Camera Executor on robot server an image capture request with a given set of filters.
|
|
93
|
+
|
|
94
|
+
Returns a bytesteam of image data upon success. Raises an error if an error occurred during capture.
|
|
95
|
+
Conditionally returns None if an image capture callback does not exist (simulation).
|
|
96
|
+
"""
|
|
97
|
+
if self._image_capture_callback is not None:
|
|
98
|
+
capture_result = await self._image_capture_callback(robot_type, parameters)
|
|
99
|
+
if not isinstance(capture_result, CameraError):
|
|
100
|
+
return capture_result
|
|
101
|
+
else:
|
|
102
|
+
if capture_result.code == "IMAGE_SETTINGS":
|
|
103
|
+
raise CameraSettingsInvalidError(message=capture_result.message)
|
|
104
|
+
elif capture_result.code is not None:
|
|
105
|
+
error_str = f"Camera capture has failed to return an image with return code {capture_result.code}: {capture_result.message}"
|
|
106
|
+
else:
|
|
107
|
+
error_str = f"Camera capture has failed with exception: {capture_result.message}"
|
|
108
|
+
raise CameraCaptureError(message=error_str)
|
|
109
|
+
# Return None if the image capture callback is unavailable (simulation)
|
|
110
|
+
return None
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Concurrency primitives providers."""
|
|
2
|
+
import asyncio
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ConcurrencyProvider:
|
|
6
|
+
"""Concurrency primitives for engine tasks."""
|
|
7
|
+
|
|
8
|
+
def __init__(self) -> None:
|
|
9
|
+
"""Build a concurrency provider."""
|
|
10
|
+
self._locks: dict[str, asyncio.Lock] = {}
|
|
11
|
+
self._queues: dict[str, "asyncio.Queue[asyncio.Task[None]]"] = {}
|
|
12
|
+
|
|
13
|
+
def lock_for_group(self, group_id: str) -> asyncio.Lock:
|
|
14
|
+
"""Returns the lock for specified group id."""
|
|
15
|
+
try:
|
|
16
|
+
return self._locks[group_id]
|
|
17
|
+
except KeyError:
|
|
18
|
+
self._locks[group_id] = asyncio.Lock()
|
|
19
|
+
return self._locks[group_id]
|
|
20
|
+
|
|
21
|
+
def queue_for_group(self, group_id: str) -> "asyncio.Queue[asyncio.Task[None]]":
|
|
22
|
+
"""Returns the queue for specified group id."""
|
|
23
|
+
try:
|
|
24
|
+
return self._queues[group_id]
|
|
25
|
+
except KeyError:
|
|
26
|
+
self._queues[group_id] = asyncio.Queue()
|
|
27
|
+
return self._queues[group_id]
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from typing import List, Set, Tuple
|
|
4
4
|
|
|
5
|
+
from opentrons_shared_data.module.types import ModuleOrientation
|
|
5
6
|
from opentrons_shared_data.deck.types import (
|
|
6
7
|
DeckDefinitionV5,
|
|
7
8
|
CutoutFixture,
|
|
@@ -124,6 +125,11 @@ def get_addressable_area_from_name(
|
|
|
124
125
|
z=addressable_area["boundingBox"]["zDimension"],
|
|
125
126
|
)
|
|
126
127
|
features = addressable_area["features"]
|
|
128
|
+
orientation = (
|
|
129
|
+
addressable_area["orientation"]
|
|
130
|
+
if addressable_area["orientation"]
|
|
131
|
+
else ModuleOrientation.NOT_APPLICABLE
|
|
132
|
+
)
|
|
127
133
|
mating_surface_unit_vector = addressable_area.get("matingSurfaceUnitVector")
|
|
128
134
|
|
|
129
135
|
return AddressableArea(
|
|
@@ -138,6 +144,7 @@ def get_addressable_area_from_name(
|
|
|
138
144
|
"compatibleModuleTypes", []
|
|
139
145
|
),
|
|
140
146
|
features=features,
|
|
147
|
+
orientation=orientation,
|
|
141
148
|
)
|
|
142
149
|
raise AddressableAreaDoesNotExistError(
|
|
143
150
|
f"Could not find addressable area with name {addressable_area_name}"
|