opentrons 8.4.1a2__py2.py3-none-any.whl → 8.5.0__py2.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.
Files changed (67) hide show
  1. opentrons/config/defaults_ot3.py +1 -1
  2. opentrons/hardware_control/backends/flex_protocol.py +25 -0
  3. opentrons/hardware_control/backends/ot3controller.py +76 -1
  4. opentrons/hardware_control/backends/ot3simulator.py +27 -0
  5. opentrons/hardware_control/instruments/ot3/pipette_handler.py +1 -0
  6. opentrons/hardware_control/ot3api.py +32 -0
  7. opentrons/legacy_commands/commands.py +16 -4
  8. opentrons/legacy_commands/robot_commands.py +51 -0
  9. opentrons/legacy_commands/types.py +91 -2
  10. opentrons/protocol_api/_liquid.py +60 -15
  11. opentrons/protocol_api/_liquid_properties.py +149 -90
  12. opentrons/protocol_api/_transfer_liquid_validation.py +43 -14
  13. opentrons/protocol_api/core/engine/instrument.py +367 -221
  14. opentrons/protocol_api/core/engine/protocol.py +14 -15
  15. opentrons/protocol_api/core/engine/robot.py +2 -2
  16. opentrons/protocol_api/core/engine/transfer_components_executor.py +275 -163
  17. opentrons/protocol_api/core/engine/well.py +16 -0
  18. opentrons/protocol_api/core/instrument.py +11 -5
  19. opentrons/protocol_api/core/legacy/legacy_instrument_core.py +11 -5
  20. opentrons/protocol_api/core/legacy/legacy_protocol_core.py +2 -2
  21. opentrons/protocol_api/core/legacy/legacy_well_core.py +8 -0
  22. opentrons/protocol_api/core/legacy_simulator/legacy_instrument_core.py +11 -5
  23. opentrons/protocol_api/core/protocol.py +3 -3
  24. opentrons/protocol_api/core/well.py +8 -0
  25. opentrons/protocol_api/instrument_context.py +478 -111
  26. opentrons/protocol_api/labware.py +10 -0
  27. opentrons/protocol_api/module_contexts.py +5 -2
  28. opentrons/protocol_api/protocol_context.py +76 -11
  29. opentrons/protocol_api/robot_context.py +48 -6
  30. opentrons/protocol_api/validation.py +15 -8
  31. opentrons/protocol_engine/commands/command_unions.py +10 -10
  32. opentrons/protocol_engine/commands/generate_command_schema.py +1 -1
  33. opentrons/protocol_engine/commands/get_next_tip.py +2 -2
  34. opentrons/protocol_engine/commands/load_labware.py +0 -19
  35. opentrons/protocol_engine/commands/pick_up_tip.py +9 -3
  36. opentrons/protocol_engine/commands/robot/__init__.py +20 -20
  37. opentrons/protocol_engine/commands/robot/close_gripper_jaw.py +34 -24
  38. opentrons/protocol_engine/commands/robot/open_gripper_jaw.py +29 -20
  39. opentrons/protocol_engine/commands/seal_pipette_to_tip.py +1 -1
  40. opentrons/protocol_engine/commands/unsafe/__init__.py +17 -1
  41. opentrons/protocol_engine/commands/unsafe/unsafe_drop_tip_in_place.py +1 -2
  42. opentrons/protocol_engine/execution/labware_movement.py +9 -2
  43. opentrons/protocol_engine/execution/movement.py +12 -9
  44. opentrons/protocol_engine/execution/queue_worker.py +8 -1
  45. opentrons/protocol_engine/execution/thermocycler_movement_flagger.py +52 -19
  46. opentrons/protocol_engine/resources/labware_validation.py +7 -1
  47. opentrons/protocol_engine/state/_well_math.py +2 -2
  48. opentrons/protocol_engine/state/commands.py +14 -28
  49. opentrons/protocol_engine/state/frustum_helpers.py +11 -7
  50. opentrons/protocol_engine/state/labware.py +12 -0
  51. opentrons/protocol_engine/state/modules.py +1 -1
  52. opentrons/protocol_engine/state/pipettes.py +8 -0
  53. opentrons/protocol_engine/state/tips.py +46 -83
  54. opentrons/protocol_engine/state/update_types.py +8 -23
  55. opentrons/protocol_engine/types/liquid_level_detection.py +68 -8
  56. opentrons/protocol_runner/legacy_command_mapper.py +12 -6
  57. opentrons/protocol_runner/run_orchestrator.py +1 -1
  58. opentrons/protocols/advanced_control/transfers/common.py +54 -11
  59. opentrons/protocols/advanced_control/transfers/transfer_liquid_utils.py +55 -28
  60. opentrons/protocols/api_support/definitions.py +1 -1
  61. opentrons/types.py +6 -6
  62. {opentrons-8.4.1a2.dist-info → opentrons-8.5.0.dist-info}/METADATA +4 -4
  63. {opentrons-8.4.1a2.dist-info → opentrons-8.5.0.dist-info}/RECORD +67 -66
  64. {opentrons-8.4.1a2.dist-info → opentrons-8.5.0.dist-info}/LICENSE +0 -0
  65. {opentrons-8.4.1a2.dist-info → opentrons-8.5.0.dist-info}/WHEEL +0 -0
  66. {opentrons-8.4.1a2.dist-info → opentrons-8.5.0.dist-info}/entry_points.txt +0 -0
  67. {opentrons-8.4.1a2.dist-info → opentrons-8.5.0.dist-info}/top_level.txt +0 -0
@@ -1,6 +1,7 @@
1
1
  """Command models for opening a gripper jaw."""
2
+
2
3
  from __future__ import annotations
3
- from typing import Literal, Type, Optional
4
+ from typing import Literal, Type, Optional, TYPE_CHECKING
4
5
  from opentrons.hardware_control import HardwareControlAPI
5
6
  from opentrons.protocol_engine.resources import ensure_ot3_hardware
6
7
 
@@ -14,64 +15,72 @@ from ..command import (
14
15
  )
15
16
  from opentrons.protocol_engine.errors.error_occurrence import ErrorOccurrence
16
17
 
18
+ if TYPE_CHECKING:
19
+ from ...state.state import StateView
20
+
17
21
 
18
- openGripperJawCommandType = Literal["robot/openGripperJaw"]
22
+ OpenGripperJawCommandType = Literal["robot/openGripperJaw"]
19
23
 
20
24
 
21
- class openGripperJawParams(BaseModel):
25
+ class OpenGripperJawParams(BaseModel):
22
26
  """Payload required to release a gripper."""
23
27
 
24
28
  pass
25
29
 
26
30
 
27
- class openGripperJawResult(BaseModel):
31
+ class OpenGripperJawResult(BaseModel):
28
32
  """Result data from the execution of a openGripperJaw command."""
29
33
 
30
34
  pass
31
35
 
32
36
 
33
- class openGripperJawImplementation(
34
- AbstractCommandImpl[openGripperJawParams, SuccessData[openGripperJawResult]]
37
+ class OpenGripperJawImplementation(
38
+ AbstractCommandImpl[OpenGripperJawParams, SuccessData[OpenGripperJawResult]]
35
39
  ):
36
40
  """openGripperJaw command implementation."""
37
41
 
38
42
  def __init__(
39
43
  self,
40
44
  hardware_api: HardwareControlAPI,
45
+ state_view: StateView,
41
46
  **kwargs: object,
42
47
  ) -> None:
43
48
  self._hardware_api = hardware_api
49
+ self._state_view = state_view
44
50
 
45
51
  async def execute(
46
- self, params: openGripperJawParams
47
- ) -> SuccessData[openGripperJawResult]:
52
+ self, params: OpenGripperJawParams
53
+ ) -> SuccessData[OpenGripperJawResult]:
48
54
  """Release the gripper."""
55
+ if self._state_view.config.use_virtual_gripper:
56
+ return SuccessData(public=OpenGripperJawResult())
57
+
49
58
  ot3_hardware_api = ensure_ot3_hardware(self._hardware_api)
50
59
 
51
60
  await ot3_hardware_api.home_gripper_jaw()
52
61
  return SuccessData(
53
- public=openGripperJawResult(),
62
+ public=OpenGripperJawResult(),
54
63
  )
55
64
 
56
65
 
57
- class openGripperJaw(
58
- BaseCommand[openGripperJawParams, openGripperJawResult, ErrorOccurrence]
66
+ class OpenGripperJaw(
67
+ BaseCommand[OpenGripperJawParams, OpenGripperJawResult, ErrorOccurrence]
59
68
  ):
60
69
  """openGripperJaw command model."""
61
70
 
62
- commandType: openGripperJawCommandType = "robot/openGripperJaw"
63
- params: openGripperJawParams
64
- result: Optional[openGripperJawResult] = None
71
+ commandType: OpenGripperJawCommandType = "robot/openGripperJaw"
72
+ params: OpenGripperJawParams
73
+ result: Optional[OpenGripperJawResult] = None
65
74
 
66
75
  _ImplementationCls: Type[
67
- openGripperJawImplementation
68
- ] = openGripperJawImplementation
76
+ OpenGripperJawImplementation
77
+ ] = OpenGripperJawImplementation
69
78
 
70
79
 
71
- class openGripperJawCreate(BaseCommandCreate[openGripperJawParams]):
80
+ class OpenGripperJawCreate(BaseCommandCreate[OpenGripperJawParams]):
72
81
  """openGripperJaw command request model."""
73
82
 
74
- commandType: openGripperJawCommandType = "robot/openGripperJaw"
75
- params: openGripperJawParams
83
+ commandType: OpenGripperJawCommandType = "robot/openGripperJaw"
84
+ params: OpenGripperJawParams
76
85
 
77
- _CommandCls: Type[openGripperJaw] = openGripperJaw
86
+ _CommandCls: Type[OpenGripperJaw] = OpenGripperJaw
@@ -273,7 +273,7 @@ class SealPipetteToTipImplementation(
273
273
 
274
274
  # Begin relative pickup steps for the resin tips
275
275
 
276
- channels = self._state_view.tips.get_pipette_active_channels(pipette_id)
276
+ channels = self._state_view.pipettes.get_active_channels(pipette_id)
277
277
  mount = self._state_view.pipettes.get_mount(pipette_id)
278
278
  tip_pick_up_params = params.tipPickUpParams
279
279
 
@@ -1,4 +1,20 @@
1
- """Commands that will cause inaccuracy or incorrect behavior but are still necessary."""
1
+ """Commands that are "unsafe".
2
+
3
+ "Unsafe" means that they can cause inaccuracy or incorrect behavior. They should
4
+ therefore never be used in protocols, and should only be used otherwise as a last
5
+ resort.
6
+
7
+ These exist as a necessary evil for implementing things like error recovery.
8
+ Even in those narrow contexts, these commands must be used with care.
9
+ e.g. after an `UpdatePositionEstimators` command, there must be a `Home` command,
10
+ or positioning will be subtly wrong. Each unsafe command should document its intended
11
+ use case and its caveats.
12
+
13
+ Because we don't expect unsafe commands to be used in any protocols whose behavior we
14
+ must preserve, we may change the commands' semantics over time. We may also change
15
+ their shapes if we're confident that it won't break something in robot-server's
16
+ persistent storage.
17
+ """
2
18
 
3
19
  from .unsafe_blow_out_in_place import (
4
20
  UnsafeBlowOutInPlaceCommandType,
@@ -81,8 +81,7 @@ class UnsafeDropTipInPlaceImplementation(
81
81
  pipette_id=params.pipetteId,
82
82
  home_after=params.homeAfter,
83
83
  ignore_plunger=(
84
- self._state_view.tips.get_pipette_active_channels(params.pipetteId)
85
- == 96
84
+ self._state_view.pipettes.get_active_channels(params.pipetteId) == 96
86
85
  ),
87
86
  )
88
87
 
@@ -61,6 +61,7 @@ class LabwareMovementHandler:
61
61
  """Initialize a LabwareMovementHandler instance."""
62
62
  self._hardware_api = hardware_api
63
63
  self._state_store = state_store
64
+ self._equipment = equipment
64
65
  self._thermocycler_plate_lifter = (
65
66
  thermocycler_plate_lifter
66
67
  or ThermocyclerPlateLifter(
@@ -72,7 +73,13 @@ class LabwareMovementHandler:
72
73
  self._tc_movement_flagger = (
73
74
  thermocycler_movement_flagger
74
75
  or ThermocyclerMovementFlagger(
75
- state_store=self._state_store, hardware_api=self._hardware_api
76
+ state_store=self._state_store,
77
+ hardware_api=self._hardware_api,
78
+ equipment=self._equipment
79
+ or EquipmentHandler(
80
+ hardware_api=self._hardware_api,
81
+ state_store=self._state_store,
82
+ ),
76
83
  )
77
84
  )
78
85
  self._hs_movement_flagger = (
@@ -264,7 +271,7 @@ class LabwareMovementHandler:
264
271
  )
265
272
  for parent in (current_parent, new_location):
266
273
  try:
267
- await self._tc_movement_flagger.raise_if_labware_in_non_open_thermocycler(
274
+ await self._tc_movement_flagger.ensure_labware_in_open_thermocycler(
268
275
  labware_parent=parent
269
276
  )
270
277
  await self._hs_movement_flagger.raise_if_labware_latched_on_heater_shaker(
@@ -24,6 +24,7 @@ from .thermocycler_movement_flagger import ThermocyclerMovementFlagger
24
24
  from .heater_shaker_movement_flagger import HeaterShakerMovementFlagger
25
25
 
26
26
  from .gantry_mover import GantryMover
27
+ from .equipment import EquipmentHandler
27
28
 
28
29
 
29
30
  log = logging.getLogger(__name__)
@@ -43,6 +44,7 @@ class MovementHandler:
43
44
  model_utils: Optional[ModelUtils] = None,
44
45
  thermocycler_movement_flagger: Optional[ThermocyclerMovementFlagger] = None,
45
46
  heater_shaker_movement_flagger: Optional[HeaterShakerMovementFlagger] = None,
47
+ equipment: Optional[EquipmentHandler] = None,
46
48
  ) -> None:
47
49
  """Initialize a MovementHandler instance."""
48
50
  self._state_store = state_store
@@ -50,7 +52,13 @@ class MovementHandler:
50
52
  self._tc_movement_flagger = (
51
53
  thermocycler_movement_flagger
52
54
  or ThermocyclerMovementFlagger(
53
- state_store=self._state_store, hardware_api=hardware_api
55
+ state_store=self._state_store,
56
+ hardware_api=hardware_api,
57
+ equipment=equipment
58
+ or EquipmentHandler(
59
+ hardware_api=hardware_api,
60
+ state_store=state_store,
61
+ ),
54
62
  )
55
63
  )
56
64
  self._hs_movement_flagger = (
@@ -83,8 +91,7 @@ class MovementHandler:
83
91
  self._state_store.labware.raise_if_labware_has_labware_on_top(
84
92
  labware_id=labware_id
85
93
  )
86
-
87
- await self._tc_movement_flagger.raise_if_labware_in_non_open_thermocycler(
94
+ await self._tc_movement_flagger.ensure_labware_in_open_thermocycler(
88
95
  labware_parent=self._state_store.labware.get_location(labware_id=labware_id)
89
96
  )
90
97
 
@@ -105,9 +112,7 @@ class MovementHandler:
105
112
  self._hs_movement_flagger.raise_if_movement_restricted(
106
113
  hs_movement_restrictors=hs_movement_restrictors,
107
114
  destination_slot=dest_slot_int,
108
- is_multi_channel=(
109
- self._state_store.tips.get_pipette_channels(pipette_id) > 1
110
- ),
115
+ is_multi_channel=(self._state_store.pipettes.get_channels(pipette_id) > 1),
111
116
  destination_is_tip_rack=self._state_store.labware.is_tiprack(labware_id),
112
117
  )
113
118
 
@@ -204,9 +209,7 @@ class MovementHandler:
204
209
  self._hs_movement_flagger.raise_if_movement_restricted(
205
210
  hs_movement_restrictors=hs_movement_restrictors,
206
211
  destination_slot=dest_slot_int,
207
- is_multi_channel=(
208
- self._state_store.tips.get_pipette_channels(pipette_id) > 1
209
- ),
212
+ is_multi_channel=(self._state_store.pipettes.get_channels(pipette_id) > 1),
210
213
  destination_is_tip_rack=False,
211
214
  )
212
215
 
@@ -72,7 +72,14 @@ class QueueWorker:
72
72
  try:
73
73
  await self._command_executor.execute(command_id=command_id)
74
74
  except BaseException:
75
- log.exception("Unhandled failure in command executor")
75
+ log.exception(
76
+ # The state can tear if e.g. we've finished updating PipetteStore,
77
+ # but the exception came before we could update LabwareStore. Or
78
+ # the exception could have interrupted updating a single store.
79
+ "Unhandled failure in command executor."
80
+ " This is a bug in opentrons.protocol_engine"
81
+ " and has probably left the ProtocolEngine in a torn state."
82
+ )
76
83
  raise
77
84
  # Yield to the event loop in case we're executing a long sequence of commands
78
85
  # that never yields internally. For example, a long sequence of comment commands.
@@ -1,15 +1,18 @@
1
1
  """Helpers for flagging unsafe movements to a Thermocycler Module."""
2
-
3
2
  from typing import Optional
4
3
 
5
4
  from opentrons.drivers.types import ThermocyclerLidStatus
6
5
  from opentrons.hardware_control import HardwareControlAPI
7
6
  from opentrons.hardware_control.modules import Thermocycler as HardwareThermocycler
8
7
 
8
+
9
+ from opentrons.protocol_engine.state.module_substates import ThermocyclerModuleId
9
10
  from ..types import ModuleLocation, LabwareLocation
10
11
  from ..state.state import StateStore
11
12
  from ..errors import ThermocyclerNotOpenError, WrongModuleTypeError
12
13
 
14
+ from .equipment import EquipmentHandler
15
+
13
16
 
14
17
  class ThermocyclerMovementFlagger:
15
18
  """A helper for flagging unsafe movements to a Thermocycler Module.
@@ -19,7 +22,10 @@ class ThermocyclerMovementFlagger:
19
22
  """
20
23
 
21
24
  def __init__(
22
- self, state_store: StateStore, hardware_api: HardwareControlAPI
25
+ self,
26
+ state_store: StateStore,
27
+ hardware_api: HardwareControlAPI,
28
+ equipment: EquipmentHandler,
23
29
  ) -> None:
24
30
  """Initialize the ThermocyclerMovementFlagger.
25
31
 
@@ -28,18 +34,59 @@ class ThermocyclerMovementFlagger:
28
34
  which Thermocycler a labware is in, if any.
29
35
  hardware_api: The underlying hardware interface. Used to query
30
36
  Thermocyclers' current lid states.
37
+ equipment: The protocol engine interface to move a present thermocycler to an
38
+ operable state if need be.
31
39
  """
32
40
  self._state_store = state_store
33
41
  self._hardware_api = hardware_api
42
+ self._equipment = equipment
43
+
44
+ async def _verify_tc_lid_status(self, module_id: str) -> None:
45
+ """Ensure the thermocycler's lid state is correct or raise an error."""
46
+ try:
47
+ hw_tc_lid_status = await self._get_hardware_thermocycler_lid_status(
48
+ module_id=module_id
49
+ )
50
+ except self._HardwareThermocyclerMissingError as e:
51
+ raise ThermocyclerNotOpenError(
52
+ "Thermocycler must be open when moving to labware inside it,"
53
+ " but can't confirm Thermocycler's current status."
54
+ ) from e
55
+
56
+ if (
57
+ hw_tc_lid_status == ThermocyclerLidStatus.IN_BETWEEN
58
+ or hw_tc_lid_status == ThermocyclerLidStatus.UNKNOWN
59
+ ):
60
+ # NOTE(cm): due to a potential hardware bug, the thermocycler might lose its position
61
+ # status when idling in an open position and default to UNKNOWN or IN_BETWEEN.
62
+ # This tries to recover only from an unexpected known position.
63
+ await self._open_tc_lid(module_id=module_id)
64
+ hw_tc_lid_status = await self._get_hardware_thermocycler_lid_status(
65
+ module_id=module_id
66
+ )
67
+ if hw_tc_lid_status != ThermocyclerLidStatus.OPEN:
68
+ raise ThermocyclerNotOpenError(
69
+ f"Thermocycler must be open when moving to labware inside it,"
70
+ f' but Thermocycler is currently "{hw_tc_lid_status}".'
71
+ )
72
+
73
+ async def _open_tc_lid(self, module_id: str) -> None:
74
+ """Try to open the thermocycler lid."""
75
+ tc_hardware = self._equipment.get_module_hardware_api(
76
+ ThermocyclerModuleId(module_id)
77
+ )
78
+ if tc_hardware:
79
+ await tc_hardware.open()
34
80
 
35
- async def raise_if_labware_in_non_open_thermocycler(
81
+ async def ensure_labware_in_open_thermocycler(
36
82
  self, labware_parent: LabwareLocation
37
83
  ) -> None:
38
84
  """Flag unsafe movements to a Thermocycler.
39
85
 
40
86
  If the given labware is in a Thermocycler, and that Thermocycler's lid isn't
41
87
  currently open according the engine's thermocycler state as well as
42
- the hardware API (for non-virtual modules), raises ThermocyclerNotOpenError.
88
+ the hardware API (for non-virtual modules), tries to open the thermocycler lid.
89
+ If this is unsuccessful, raises ThermocyclerNotOpenError.
43
90
  If it is a virtual module, checks only for thermocycler lid state in engine.
44
91
 
45
92
  Otherwise, no-ops.
@@ -81,21 +128,7 @@ class ThermocyclerMovementFlagger:
81
128
  # There is a chance that the engine might not have the latest lid status;
82
129
  # do a hardware state check just to be sure that the lid is truly open.
83
130
  if not self._state_store.config.use_virtual_modules:
84
- try:
85
- hw_tc_lid_status = await self._get_hardware_thermocycler_lid_status(
86
- module_id=tc_substate.module_id
87
- )
88
- except self._HardwareThermocyclerMissingError as e:
89
- raise ThermocyclerNotOpenError(
90
- "Thermocycler must be open when moving to labware inside it,"
91
- " but can't confirm Thermocycler's current status."
92
- ) from e
93
-
94
- if hw_tc_lid_status != ThermocyclerLidStatus.OPEN:
95
- raise ThermocyclerNotOpenError(
96
- f"Thermocycler must be open when moving to labware inside it,"
97
- f' but Thermocycler is currently "{hw_tc_lid_status}".'
98
- )
131
+ await self._verify_tc_lid_status(module_id=module_id)
99
132
 
100
133
  async def _get_hardware_thermocycler_lid_status(
101
134
  self,
@@ -48,7 +48,13 @@ def validate_labware_can_be_stacked(
48
48
  top_labware_definition: LabwareDefinition, below_labware_load_name: str
49
49
  ) -> bool:
50
50
  """Validate that the labware being loaded onto is in the above labware's stackingOffsetWithLabware definition."""
51
- return below_labware_load_name in top_labware_definition.stackingOffsetWithLabware
51
+ return (
52
+ below_labware_load_name in top_labware_definition.stackingOffsetWithLabware
53
+ or (
54
+ "default" in top_labware_definition.stackingOffsetWithLabware
55
+ and top_labware_definition.compatibleParentLabware is None
56
+ )
57
+ )
52
58
 
53
59
 
54
60
  def validate_labware_can_be_ondeck(definition: LabwareDefinition) -> bool:
@@ -63,7 +63,7 @@ def wells_covered_dense( # noqa: C901
63
63
  row_downsample = len(target_wells_by_column[0]) // 8
64
64
  if column_downsample < 1 or row_downsample < 1:
65
65
  raise InvalidStoredData(
66
- "This labware cannot be used wells_covered_dense because it is less dense than an SBS 96 standard"
66
+ "This labware cannot be used with wells_covered_dense() because it is less dense than an SBS 96 standard"
67
67
  )
68
68
 
69
69
  for nozzle_column in range(len(nozzle_map.columns)):
@@ -126,7 +126,7 @@ def wells_covered_sparse( # noqa: C901
126
126
  row_upsample = 8 // len(target_wells_by_column[0])
127
127
  if column_upsample < 1 or row_upsample < 1:
128
128
  raise InvalidStoredData(
129
- "This labware cannot be used with wells_covered_sparse because it is more dense than an SBS 96 standard."
129
+ "This labware cannot be used with wells_covered_sparse() because it is more dense than an SBS 96 standard."
130
130
  )
131
131
  for nozzle_column in range(max(1, len(nozzle_map.columns) // column_upsample)):
132
132
  for nozzle_row in range(max(1, len(nozzle_map.rows) // row_upsample)):
@@ -611,49 +611,35 @@ class CommandView:
611
611
  """Get a subset of commands around a given cursor.
612
612
 
613
613
  If the cursor is omitted, a cursor will be selected automatically
614
- based on the currently running or most recently executed command.
614
+ based on the currently running or most recently executed command,
615
+ and the slice of commands returned is the previous `length` commands
616
+ inclusive of the currently running or most recently executed command.
615
617
  """
616
618
  command_ids = self._state.command_history.get_filtered_command_ids(
617
619
  include_fixit_commands=include_fixit_commands
618
620
  )
619
- running_command = self._state.command_history.get_running_command()
620
- queued_command_ids = self._state.command_history.get_queue_ids()
621
621
  total_length = len(command_ids)
622
622
 
623
- # TODO(mm, 2024-05-17): This looks like it's attempting to do the same thing
624
- # as self.get_current(), but in a different way. Can we unify them?
625
623
  if cursor is None:
626
- if running_command is not None:
627
- cursor = running_command.index
628
- elif len(queued_command_ids) > 0:
629
- # Get the most recently executed command,
630
- # which we can find just before the first queued command.
631
- cursor = (
632
- self._state.command_history.get(queued_command_ids.head()).index - 1
633
- )
634
- elif (
635
- self._state.run_result
636
- and self._state.run_result == RunResult.FAILED
637
- and self._state.failed_command
638
- ):
639
- # Currently, if the run fails, we mark all the commands we didn't
640
- # reach as failed. This makes command status alone insufficient to
641
- # find the most recent command that actually executed, so we need to
642
- # store that separately.
643
- cursor = self._state.failed_command.index
624
+ current_pointer = self.get_current()
625
+
626
+ if current_pointer is not None:
627
+ cursor = current_pointer.index
644
628
  else:
645
- cursor = total_length - length
629
+ cursor = total_length - 1
630
+
631
+ cursor = max(cursor - length + 1, 0)
646
632
 
647
633
  # start is inclusive, stop is exclusive
648
- actual_cursor = max(0, min(cursor, total_length - 1))
649
- stop = min(total_length, actual_cursor + length)
634
+ start = max(0, min(cursor, total_length - 1))
635
+ stop = min(total_length, start + length)
650
636
  commands = self._state.command_history.get_slice(
651
- start=actual_cursor, stop=stop, command_ids=command_ids
637
+ start=start, stop=stop, command_ids=command_ids
652
638
  )
653
639
 
654
640
  return CommandSlice(
655
641
  commands=commands,
656
- cursor=actual_cursor,
642
+ cursor=start,
657
643
  total_length=total_length,
658
644
  )
659
645
 
@@ -88,10 +88,12 @@ def _circular_frustum_polynomial_roots(
88
88
  def _volume_from_height_circular(
89
89
  target_height: float, segment: ConicalFrustum
90
90
  ) -> float:
91
- """Find the volume given a height within a circular frustum."""
92
- heights = segment.height_to_volume_table.keys()
93
- best_fit_height = min(heights, key=lambda x: abs(x - target_height))
94
- return segment.height_to_volume_table[best_fit_height]
91
+ return segment.volume_from_height_circular(
92
+ top_radius=segment.topDiameter / 2,
93
+ bottom_radius=segment.bottomDiameter / 2,
94
+ target_height=target_height,
95
+ total_height=segment.topHeight - segment.bottomHeight,
96
+ )
95
97
 
96
98
 
97
99
  def _volume_from_height_rectangular(
@@ -138,9 +140,7 @@ def _height_from_volume_circular(
138
140
  target_volume: float, segment: ConicalFrustum
139
141
  ) -> float:
140
142
  """Find the height given a volume within a squared cone segment."""
141
- volumes = segment.volume_to_height_table.keys()
142
- best_fit_volume = min(volumes, key=lambda x: abs(x - target_volume))
143
- return segment.volume_to_height_table[best_fit_volume]
143
+ return segment.height_from_volume_search(target_volume)
144
144
 
145
145
 
146
146
  def _height_from_volume_rectangular(
@@ -401,8 +401,12 @@ def _find_height_in_partial_frustum(
401
401
  ) -> float:
402
402
  """Look through a sorted list of frusta for a target volume, and find the height at that volume."""
403
403
  bottom_section_volume = 0.0
404
+ if target_volume == 0.0:
405
+ return 0.0
404
406
  for section, capacity in zip(sorted_well, volumetric_capacity):
405
407
  section_top_height, section_volume = capacity
408
+ if target_volume == section_volume + bottom_section_volume:
409
+ return section_top_height
406
410
  if (
407
411
  bottom_section_volume
408
412
  <= target_volume
@@ -1131,6 +1131,18 @@ class LabwareView:
1131
1131
  raise errors.LabwareCannotBeStackedError(
1132
1132
  f"Labware {top_labware_definition.parameters.loadName} cannot be loaded onto labware {below_labware.loadName}"
1133
1133
  )
1134
+ elif (
1135
+ labware_validation.validate_definition_is_lid(top_labware_definition)
1136
+ and top_labware_definition.compatibleParentLabware is not None
1137
+ and self.get_load_name(bottom_labware_id)
1138
+ not in top_labware_definition.compatibleParentLabware
1139
+ ):
1140
+ # This parent is assumed to be compatible, unless the lid enumerates
1141
+ # all its compatible parents and this parent is missing from the list.
1142
+ raise ValueError(
1143
+ f"Labware Lid {top_labware_definition.parameters.loadName} may not be loaded on parent labware"
1144
+ f" {self.get_display_name(bottom_labware_id)}."
1145
+ )
1134
1146
  elif isinstance(below_labware.location, ModuleLocation):
1135
1147
  below_definition = self.get_definition(labware_id=below_labware.id)
1136
1148
  if not labware_validation.validate_definition_is_adapter(
@@ -1361,7 +1361,7 @@ class ModuleView:
1361
1361
  col = (i % 12) + 1 # Convert index to column (1-12)
1362
1362
  well_key = f"{row}{col}"
1363
1363
  # Truncate the value to the third decimal place
1364
- well_map[well_key] = math.floor(value * 1000) / 1000
1364
+ well_map[well_key] = max(0.0, math.floor(value * 1000) / 1000)
1365
1365
  return well_map
1366
1366
  else:
1367
1367
  raise ValueError(
@@ -650,6 +650,10 @@ class PipetteView:
650
650
  """Return the max channels of the pipette."""
651
651
  return self.get_config(pipette_id).channels
652
652
 
653
+ def get_active_channels(self, pipette_id: str) -> int:
654
+ """Get the number of channels being used in the given pipette's configuration."""
655
+ return self.get_nozzle_configuration(pipette_id).tip_count
656
+
653
657
  def get_minimum_volume(self, pipette_id: str) -> float:
654
658
  """Return the given pipette's minimum volume."""
655
659
  return self.get_config(pipette_id).min_volume
@@ -727,6 +731,10 @@ class PipetteView:
727
731
  nozzle_map = self._state.nozzle_configuration_by_id[pipette_id]
728
732
  return nozzle_map.starting_nozzle
729
733
 
734
+ def get_nozzle_configurations(self) -> Dict[str, NozzleMap]:
735
+ """Get the nozzle maps of all pipettes, keyed by pipette ID."""
736
+ return self._state.nozzle_configuration_by_id.copy()
737
+
730
738
  def get_nozzle_configuration(self, pipette_id: str) -> NozzleMap:
731
739
  """Get the nozzle map of the pipette."""
732
740
  return self._state.nozzle_configuration_by_id[pipette_id]