opentrons 8.3.2__py2.py3-none-any.whl → 8.4.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.

Potentially problematic release.


This version of opentrons might be problematic. Click here for more details.

Files changed (196) hide show
  1. opentrons/calibration_storage/ot2/mark_bad_calibration.py +2 -0
  2. opentrons/calibration_storage/ot2/tip_length.py +6 -6
  3. opentrons/config/advanced_settings.py +9 -11
  4. opentrons/config/feature_flags.py +0 -4
  5. opentrons/config/reset.py +7 -2
  6. opentrons/drivers/asyncio/communication/__init__.py +2 -0
  7. opentrons/drivers/asyncio/communication/async_serial.py +4 -0
  8. opentrons/drivers/asyncio/communication/errors.py +41 -8
  9. opentrons/drivers/asyncio/communication/serial_connection.py +36 -10
  10. opentrons/drivers/flex_stacker/__init__.py +9 -3
  11. opentrons/drivers/flex_stacker/abstract.py +140 -15
  12. opentrons/drivers/flex_stacker/driver.py +593 -47
  13. opentrons/drivers/flex_stacker/errors.py +64 -0
  14. opentrons/drivers/flex_stacker/simulator.py +222 -24
  15. opentrons/drivers/flex_stacker/types.py +211 -15
  16. opentrons/drivers/flex_stacker/utils.py +19 -0
  17. opentrons/execute.py +4 -2
  18. opentrons/hardware_control/api.py +5 -0
  19. opentrons/hardware_control/backends/flex_protocol.py +4 -0
  20. opentrons/hardware_control/backends/ot3controller.py +12 -1
  21. opentrons/hardware_control/backends/ot3simulator.py +3 -0
  22. opentrons/hardware_control/backends/subsystem_manager.py +8 -4
  23. opentrons/hardware_control/instruments/ot2/instrument_calibration.py +10 -6
  24. opentrons/hardware_control/instruments/ot3/pipette_handler.py +59 -6
  25. opentrons/hardware_control/modules/__init__.py +12 -1
  26. opentrons/hardware_control/modules/absorbance_reader.py +11 -9
  27. opentrons/hardware_control/modules/flex_stacker.py +498 -0
  28. opentrons/hardware_control/modules/heater_shaker.py +12 -10
  29. opentrons/hardware_control/modules/magdeck.py +5 -1
  30. opentrons/hardware_control/modules/tempdeck.py +5 -1
  31. opentrons/hardware_control/modules/thermocycler.py +15 -14
  32. opentrons/hardware_control/modules/types.py +191 -1
  33. opentrons/hardware_control/modules/utils.py +3 -0
  34. opentrons/hardware_control/motion_utilities.py +20 -0
  35. opentrons/hardware_control/ot3api.py +145 -15
  36. opentrons/hardware_control/protocols/liquid_handler.py +47 -1
  37. opentrons/hardware_control/types.py +6 -0
  38. opentrons/legacy_commands/commands.py +102 -5
  39. opentrons/legacy_commands/helpers.py +74 -1
  40. opentrons/legacy_commands/types.py +33 -2
  41. opentrons/protocol_api/__init__.py +2 -0
  42. opentrons/protocol_api/_liquid.py +39 -8
  43. opentrons/protocol_api/_liquid_properties.py +20 -19
  44. opentrons/protocol_api/_transfer_liquid_validation.py +91 -0
  45. opentrons/protocol_api/core/common.py +3 -1
  46. opentrons/protocol_api/core/engine/deck_conflict.py +11 -1
  47. opentrons/protocol_api/core/engine/instrument.py +1356 -107
  48. opentrons/protocol_api/core/engine/labware.py +8 -4
  49. opentrons/protocol_api/core/engine/load_labware_params.py +68 -10
  50. opentrons/protocol_api/core/engine/module_core.py +118 -2
  51. opentrons/protocol_api/core/engine/pipette_movement_conflict.py +6 -14
  52. opentrons/protocol_api/core/engine/protocol.py +253 -11
  53. opentrons/protocol_api/core/engine/stringify.py +19 -8
  54. opentrons/protocol_api/core/engine/transfer_components_executor.py +858 -0
  55. opentrons/protocol_api/core/engine/well.py +73 -5
  56. opentrons/protocol_api/core/instrument.py +71 -21
  57. opentrons/protocol_api/core/labware.py +6 -2
  58. opentrons/protocol_api/core/legacy/labware_offset_provider.py +7 -3
  59. opentrons/protocol_api/core/legacy/legacy_instrument_core.py +76 -49
  60. opentrons/protocol_api/core/legacy/legacy_labware_core.py +8 -4
  61. opentrons/protocol_api/core/legacy/legacy_protocol_core.py +36 -0
  62. opentrons/protocol_api/core/legacy/legacy_well_core.py +27 -2
  63. opentrons/protocol_api/core/legacy/load_info.py +4 -12
  64. opentrons/protocol_api/core/legacy/module_geometry.py +6 -1
  65. opentrons/protocol_api/core/legacy/well_geometry.py +3 -3
  66. opentrons/protocol_api/core/legacy_simulator/legacy_instrument_core.py +73 -23
  67. opentrons/protocol_api/core/module.py +43 -0
  68. opentrons/protocol_api/core/protocol.py +33 -0
  69. opentrons/protocol_api/core/well.py +23 -2
  70. opentrons/protocol_api/instrument_context.py +454 -150
  71. opentrons/protocol_api/labware.py +98 -50
  72. opentrons/protocol_api/module_contexts.py +140 -0
  73. opentrons/protocol_api/protocol_context.py +163 -19
  74. opentrons/protocol_api/validation.py +51 -41
  75. opentrons/protocol_engine/__init__.py +21 -2
  76. opentrons/protocol_engine/actions/actions.py +5 -5
  77. opentrons/protocol_engine/clients/sync_client.py +6 -0
  78. opentrons/protocol_engine/commands/__init__.py +66 -36
  79. opentrons/protocol_engine/commands/absorbance_reader/__init__.py +0 -1
  80. opentrons/protocol_engine/commands/air_gap_in_place.py +3 -2
  81. opentrons/protocol_engine/commands/aspirate.py +6 -2
  82. opentrons/protocol_engine/commands/aspirate_in_place.py +3 -1
  83. opentrons/protocol_engine/commands/aspirate_while_tracking.py +210 -0
  84. opentrons/protocol_engine/commands/blow_out.py +2 -0
  85. opentrons/protocol_engine/commands/blow_out_in_place.py +4 -1
  86. opentrons/protocol_engine/commands/command_unions.py +102 -33
  87. opentrons/protocol_engine/commands/configure_for_volume.py +3 -0
  88. opentrons/protocol_engine/commands/dispense.py +3 -1
  89. opentrons/protocol_engine/commands/dispense_in_place.py +3 -0
  90. opentrons/protocol_engine/commands/dispense_while_tracking.py +204 -0
  91. opentrons/protocol_engine/commands/drop_tip.py +23 -1
  92. opentrons/protocol_engine/commands/flex_stacker/__init__.py +106 -0
  93. opentrons/protocol_engine/commands/flex_stacker/close_latch.py +72 -0
  94. opentrons/protocol_engine/commands/flex_stacker/common.py +15 -0
  95. opentrons/protocol_engine/commands/flex_stacker/empty.py +161 -0
  96. opentrons/protocol_engine/commands/flex_stacker/fill.py +164 -0
  97. opentrons/protocol_engine/commands/flex_stacker/open_latch.py +70 -0
  98. opentrons/protocol_engine/commands/flex_stacker/prepare_shuttle.py +112 -0
  99. opentrons/protocol_engine/commands/flex_stacker/retrieve.py +394 -0
  100. opentrons/protocol_engine/commands/flex_stacker/set_stored_labware.py +190 -0
  101. opentrons/protocol_engine/commands/flex_stacker/store.py +291 -0
  102. opentrons/protocol_engine/commands/generate_command_schema.py +31 -2
  103. opentrons/protocol_engine/commands/labware_handling_common.py +29 -0
  104. opentrons/protocol_engine/commands/liquid_probe.py +27 -13
  105. opentrons/protocol_engine/commands/load_labware.py +42 -39
  106. opentrons/protocol_engine/commands/load_lid.py +21 -13
  107. opentrons/protocol_engine/commands/load_lid_stack.py +130 -47
  108. opentrons/protocol_engine/commands/load_module.py +18 -17
  109. opentrons/protocol_engine/commands/load_pipette.py +3 -0
  110. opentrons/protocol_engine/commands/move_labware.py +139 -20
  111. opentrons/protocol_engine/commands/move_to_well.py +5 -11
  112. opentrons/protocol_engine/commands/pick_up_tip.py +5 -2
  113. opentrons/protocol_engine/commands/pipetting_common.py +159 -8
  114. opentrons/protocol_engine/commands/prepare_to_aspirate.py +15 -5
  115. opentrons/protocol_engine/commands/{evotip_dispense.py → pressure_dispense.py} +33 -34
  116. opentrons/protocol_engine/commands/reload_labware.py +6 -19
  117. opentrons/protocol_engine/commands/{evotip_seal_pipette.py → seal_pipette_to_tip.py} +97 -76
  118. opentrons/protocol_engine/commands/unsafe/unsafe_blow_out_in_place.py +3 -1
  119. opentrons/protocol_engine/commands/unsafe/unsafe_drop_tip_in_place.py +6 -1
  120. opentrons/protocol_engine/commands/{evotip_unseal_pipette.py → unseal_pipette_from_tip.py} +31 -40
  121. opentrons/protocol_engine/errors/__init__.py +10 -0
  122. opentrons/protocol_engine/errors/exceptions.py +62 -0
  123. opentrons/protocol_engine/execution/equipment.py +123 -106
  124. opentrons/protocol_engine/execution/labware_movement.py +8 -6
  125. opentrons/protocol_engine/execution/pipetting.py +235 -25
  126. opentrons/protocol_engine/execution/tip_handler.py +82 -32
  127. opentrons/protocol_engine/labware_offset_standardization.py +194 -0
  128. opentrons/protocol_engine/protocol_engine.py +22 -13
  129. opentrons/protocol_engine/resources/deck_configuration_provider.py +98 -2
  130. opentrons/protocol_engine/resources/deck_data_provider.py +1 -1
  131. opentrons/protocol_engine/resources/labware_data_provider.py +32 -12
  132. opentrons/protocol_engine/resources/labware_validation.py +7 -5
  133. opentrons/protocol_engine/slot_standardization.py +11 -23
  134. opentrons/protocol_engine/state/addressable_areas.py +84 -46
  135. opentrons/protocol_engine/state/frustum_helpers.py +36 -14
  136. opentrons/protocol_engine/state/geometry.py +892 -227
  137. opentrons/protocol_engine/state/labware.py +252 -55
  138. opentrons/protocol_engine/state/module_substates/__init__.py +4 -0
  139. opentrons/protocol_engine/state/module_substates/flex_stacker_substate.py +68 -0
  140. opentrons/protocol_engine/state/module_substates/heater_shaker_module_substate.py +22 -0
  141. opentrons/protocol_engine/state/module_substates/temperature_module_substate.py +13 -0
  142. opentrons/protocol_engine/state/module_substates/thermocycler_module_substate.py +20 -0
  143. opentrons/protocol_engine/state/modules.py +210 -67
  144. opentrons/protocol_engine/state/pipettes.py +54 -0
  145. opentrons/protocol_engine/state/state.py +1 -1
  146. opentrons/protocol_engine/state/tips.py +14 -0
  147. opentrons/protocol_engine/state/update_types.py +180 -25
  148. opentrons/protocol_engine/state/wells.py +55 -9
  149. opentrons/protocol_engine/types/__init__.py +300 -0
  150. opentrons/protocol_engine/types/automatic_tip_selection.py +39 -0
  151. opentrons/protocol_engine/types/command_annotations.py +53 -0
  152. opentrons/protocol_engine/types/deck_configuration.py +72 -0
  153. opentrons/protocol_engine/types/execution.py +96 -0
  154. opentrons/protocol_engine/types/hardware_passthrough.py +25 -0
  155. opentrons/protocol_engine/types/instrument.py +47 -0
  156. opentrons/protocol_engine/types/instrument_sensors.py +47 -0
  157. opentrons/protocol_engine/types/labware.py +111 -0
  158. opentrons/protocol_engine/types/labware_movement.py +22 -0
  159. opentrons/protocol_engine/types/labware_offset_location.py +111 -0
  160. opentrons/protocol_engine/types/labware_offset_vector.py +33 -0
  161. opentrons/protocol_engine/types/liquid.py +40 -0
  162. opentrons/protocol_engine/types/liquid_class.py +59 -0
  163. opentrons/protocol_engine/types/liquid_handling.py +13 -0
  164. opentrons/protocol_engine/types/liquid_level_detection.py +131 -0
  165. opentrons/protocol_engine/types/location.py +194 -0
  166. opentrons/protocol_engine/types/module.py +301 -0
  167. opentrons/protocol_engine/types/partial_tip_configuration.py +76 -0
  168. opentrons/protocol_engine/types/run_time_parameters.py +133 -0
  169. opentrons/protocol_engine/types/tip.py +18 -0
  170. opentrons/protocol_engine/types/util.py +21 -0
  171. opentrons/protocol_engine/types/well_position.py +124 -0
  172. opentrons/protocol_reader/extract_labware_definitions.py +7 -3
  173. opentrons/protocol_reader/file_format_validator.py +5 -3
  174. opentrons/protocol_runner/json_translator.py +4 -2
  175. opentrons/protocol_runner/legacy_command_mapper.py +6 -2
  176. opentrons/protocol_runner/run_orchestrator.py +4 -1
  177. opentrons/protocols/advanced_control/transfers/common.py +48 -1
  178. opentrons/protocols/advanced_control/transfers/transfer_liquid_utils.py +204 -0
  179. opentrons/protocols/api_support/definitions.py +1 -1
  180. opentrons/protocols/api_support/instrument.py +16 -3
  181. opentrons/protocols/labware.py +27 -23
  182. opentrons/protocols/models/__init__.py +0 -21
  183. opentrons/simulate.py +4 -2
  184. opentrons/types.py +20 -7
  185. opentrons/util/logging_config.py +94 -25
  186. opentrons/util/logging_queue_handler.py +61 -0
  187. {opentrons-8.3.2.dist-info → opentrons-8.4.0.dist-info}/METADATA +4 -4
  188. {opentrons-8.3.2.dist-info → opentrons-8.4.0.dist-info}/RECORD +192 -151
  189. opentrons/calibration_storage/ot2/models/defaults.py +0 -0
  190. opentrons/calibration_storage/ot3/models/defaults.py +0 -0
  191. opentrons/protocol_api/core/legacy/legacy_robot_core.py +0 -0
  192. opentrons/protocol_engine/types.py +0 -1311
  193. {opentrons-8.3.2.dist-info → opentrons-8.4.0.dist-info}/LICENSE +0 -0
  194. {opentrons-8.3.2.dist-info → opentrons-8.4.0.dist-info}/WHEEL +0 -0
  195. {opentrons-8.3.2.dist-info → opentrons-8.4.0.dist-info}/entry_points.txt +0 -0
  196. {opentrons-8.3.2.dist-info → opentrons-8.4.0.dist-info}/top_level.txt +0 -0
@@ -1,31 +1,49 @@
1
1
  """Geometry state getters."""
2
2
 
3
+ from logging import getLogger
3
4
  import enum
4
5
  from numpy import array, dot, double as npdouble
5
6
  from numpy.typing import NDArray
6
- from typing import Optional, List, Tuple, Union, cast, TypeVar, Dict
7
+ from typing import Optional, List, Tuple, Union, cast, TypeVar, Dict, Set
7
8
  from dataclasses import dataclass
8
9
  from functools import cached_property
9
10
 
10
- from opentrons.types import Point, DeckSlotName, StagingSlotName, MountType
11
+ from opentrons.types import (
12
+ Point,
13
+ DeckSlotName,
14
+ StagingSlotName,
15
+ MountType,
16
+ MeniscusTrackingTarget,
17
+ )
11
18
 
12
- from opentrons_shared_data.errors.exceptions import InvalidStoredData
19
+ from opentrons_shared_data.errors.exceptions import (
20
+ InvalidStoredData,
21
+ PipetteLiquidNotFoundError,
22
+ )
13
23
  from opentrons_shared_data.labware.constants import WELL_NAME_PATTERN
24
+ from opentrons_shared_data.labware.labware_definition import LabwareDefinition
14
25
  from opentrons_shared_data.deck.types import CutoutFixture
15
26
  from opentrons_shared_data.pipette import PIPETTE_X_SPAN
16
- from opentrons_shared_data.pipette.types import ChannelCount
17
- from opentrons.protocols.models import LabwareDefinition
27
+ from opentrons_shared_data.pipette.types import ChannelCount, LabwareUri
18
28
 
19
29
  from .. import errors
20
30
  from ..errors import (
31
+ LabwareNotLoadedError,
21
32
  LabwareNotLoadedOnLabwareError,
22
33
  LabwareNotLoadedOnModuleError,
23
34
  LabwareMovementNotAllowedError,
24
- OperationLocationNotInWellError,
35
+ InvalidLabwarePositionError,
36
+ LabwareNotOnDeckError,
37
+ )
38
+ from ..errors.exceptions import InvalidLiquidHeightFound
39
+ from ..resources import (
40
+ fixture_validation,
41
+ labware_validation,
42
+ deck_configuration_provider,
25
43
  )
26
- from ..resources import fixture_validation, labware_validation
27
44
  from ..types import (
28
45
  OFF_DECK_LOCATION,
46
+ SYSTEM_LOCATION,
29
47
  LoadedLabware,
30
48
  LoadedModule,
31
49
  WellLocation,
@@ -46,13 +64,30 @@ from ..types import (
46
64
  CurrentPipetteLocation,
47
65
  TipGeometry,
48
66
  LabwareMovementOffsetData,
67
+ InStackerHopperLocation,
49
68
  OnDeckLabwareLocation,
50
69
  AddressableAreaLocation,
51
70
  AddressableOffsetVector,
52
71
  StagingSlotLocation,
53
- LabwareOffsetLocation,
72
+ LabwareOffsetLocationSequence,
73
+ OnModuleOffsetLocationSequenceComponent,
74
+ OnAddressableAreaOffsetLocationSequenceComponent,
75
+ OnLabwareOffsetLocationSequenceComponent,
76
+ OnLabwareLocationSequenceComponent,
54
77
  ModuleModel,
78
+ PotentialCutoutFixture,
79
+ LabwareLocationSequence,
80
+ OnModuleLocationSequenceComponent,
81
+ OnAddressableAreaLocationSequenceComponent,
82
+ OnCutoutFixtureLocationSequenceComponent,
83
+ NotOnDeckLocationSequenceComponent,
84
+ AreaType,
85
+ labware_location_is_off_deck,
86
+ labware_location_is_system,
87
+ WellLocationType,
88
+ WellLocationFunction,
55
89
  )
90
+ from ..types.liquid_level_detection import SimulatedProbeResult, LiquidTrackingType
56
91
  from .config import Config
57
92
  from .labware import LabwareView
58
93
  from .wells import WellView
@@ -66,6 +101,7 @@ from .frustum_helpers import (
66
101
  from ._well_math import wells_covered_by_pipette_configuration, nozzles_per_well
67
102
 
68
103
 
104
+ _LOG = getLogger(__name__)
69
105
  SLOT_WIDTH = 128
70
106
  _PIPETTE_HOMED_POSITION_Z = (
71
107
  248.0 # Height of the bottom of the nozzle without the tip attached when homed
@@ -159,7 +195,8 @@ class GeometryView:
159
195
  (
160
196
  self._get_highest_z_from_labware_data(lw_data)
161
197
  for lw_data in self._labware.get_all()
162
- if lw_data.location != OFF_DECK_LOCATION
198
+ if lw_data.location not in [OFF_DECK_LOCATION, SYSTEM_LOCATION]
199
+ and not self._labware.get_labware_by_lid_id(lw_data.id)
163
200
  ),
164
201
  default=0.0,
165
202
  )
@@ -280,7 +317,6 @@ class GeometryView:
280
317
  child_definition=self._labware.get_definition(labware_id),
281
318
  parent=self._labware.get(labware_id).location,
282
319
  )
283
-
284
320
  return Point(
285
321
  parent_pos.x + offset_from_parent.x,
286
322
  parent_pos.y + offset_from_parent.y,
@@ -306,13 +342,66 @@ class GeometryView:
306
342
  return LabwareOffsetVector(x=0, y=0, z=0)
307
343
  elif isinstance(parent, ModuleLocation):
308
344
  module_id = parent.moduleId
345
+ module_model = self._modules.get_connected_model(module_id)
346
+ stacking_overlap = self._labware.get_module_overlap_offsets(
347
+ child_definition, module_model
348
+ )
309
349
  module_to_child = self._modules.get_nominal_offset_to_child(
310
350
  module_id=module_id, addressable_areas=self._addressable_areas
311
351
  )
352
+ return LabwareOffsetVector(
353
+ x=module_to_child.x - stacking_overlap.x,
354
+ y=module_to_child.y - stacking_overlap.y,
355
+ z=module_to_child.z - stacking_overlap.z,
356
+ )
357
+ elif isinstance(parent, OnLabwareLocation):
358
+ on_labware = self._labware.get(parent.labwareId)
359
+ on_labware_dimensions = self._labware.get_dimensions(
360
+ labware_id=on_labware.id
361
+ )
362
+ stacking_overlap = self._labware.get_labware_overlap_offsets(
363
+ definition=child_definition, below_labware_name=on_labware.loadName
364
+ )
365
+ labware_offset = LabwareOffsetVector(
366
+ x=stacking_overlap.x,
367
+ y=stacking_overlap.y,
368
+ z=on_labware_dimensions.z - stacking_overlap.z,
369
+ )
370
+ return labware_offset + self._get_offset_from_parent(
371
+ self._labware.get_definition(on_labware.id), on_labware.location
372
+ )
373
+ else:
374
+ raise errors.LabwareNotOnDeckError(
375
+ "Cannot access labware since it is not on the deck. "
376
+ "Either it has been loaded off-deck or its been moved off-deck."
377
+ )
378
+
379
+ def _get_offset_from_parent_addressable_area(
380
+ self, child_definition: LabwareDefinition, parent: LabwareLocation
381
+ ) -> LabwareOffsetVector:
382
+ """Gets the offset vector of a labware from its eventual parent addressable area.
383
+
384
+ This returns the sum of the offsets for any labware-on-labware pairs plus the
385
+ "base offset", which is (0, 0, 0) in all cases except for modules on the
386
+ OT-2. See
387
+ protocol_engine.state.modules.get_nominal_offset_to_child_from_addressable_area
388
+ for more.
389
+
390
+ This does not incorporate LPC offsets or module calibration offsets.
391
+ """
392
+ if isinstance(parent, (AddressableAreaLocation, DeckSlotLocation)):
393
+ return LabwareOffsetVector(x=0, y=0, z=0)
394
+ elif isinstance(parent, ModuleLocation):
395
+ module_id = parent.moduleId
312
396
  module_model = self._modules.get_connected_model(module_id)
313
397
  stacking_overlap = self._labware.get_module_overlap_offsets(
314
398
  child_definition, module_model
315
399
  )
400
+ module_to_child = (
401
+ self._modules.get_nominal_offset_to_child_from_addressable_area(
402
+ module_id=module_id
403
+ )
404
+ )
316
405
  return LabwareOffsetVector(
317
406
  x=module_to_child.x - stacking_overlap.x,
318
407
  y=module_to_child.y - stacking_overlap.y,
@@ -331,13 +420,13 @@ class GeometryView:
331
420
  y=stacking_overlap.y,
332
421
  z=on_labware_dimensions.z - stacking_overlap.z,
333
422
  )
334
- return labware_offset + self._get_offset_from_parent(
423
+ return labware_offset + self._get_offset_from_parent_addressable_area(
335
424
  self._labware.get_definition(on_labware.id), on_labware.location
336
425
  )
337
426
  else:
338
427
  raise errors.LabwareNotOnDeckError(
339
428
  "Cannot access labware since it is not on the deck. "
340
- "Either it has been loaded off-deck or its been moved off-deck."
429
+ "Either it has been loaded off-deck or it has been moved off-deck."
341
430
  )
342
431
 
343
432
  def _normalize_module_calibration_offset(
@@ -388,7 +477,11 @@ class GeometryView:
388
477
  elif isinstance(location, OnLabwareLocation):
389
478
  labware_data = self._labware.get(location.labwareId)
390
479
  return self._get_calibrated_module_offset(labware_data.location)
391
- elif location == OFF_DECK_LOCATION:
480
+ elif (
481
+ location == OFF_DECK_LOCATION
482
+ or location == SYSTEM_LOCATION
483
+ or isinstance(location, InStackerHopperLocation)
484
+ ):
392
485
  raise errors.LabwareNotOnDeckError(
393
486
  "Labware does not have a slot or module associated with it"
394
487
  " since it is no longer on the deck."
@@ -421,56 +514,59 @@ class GeometryView:
421
514
  """Get the calibrated origin of the labware."""
422
515
  origin_pos = self.get_labware_origin_position(labware_id)
423
516
  cal_offset = self._labware.get_labware_offset_vector(labware_id)
424
-
425
517
  return Point(
426
518
  x=origin_pos.x + cal_offset.x,
427
519
  y=origin_pos.y + cal_offset.y,
428
520
  z=origin_pos.z + cal_offset.z,
429
521
  )
430
522
 
431
- WellLocations = Union[
432
- WellLocation, LiquidHandlingWellLocation, PickUpTipWellLocation
433
- ]
523
+ def _validate_well_position(
524
+ self,
525
+ target_height: LiquidTrackingType, # height in mm inside a well relative to the bottom
526
+ well_max_height: float,
527
+ pipette_id: str,
528
+ ) -> LiquidTrackingType:
529
+ """If well offset would be outside the bounds of a well, silently bring it back to the boundary."""
530
+ if isinstance(target_height, SimulatedProbeResult):
531
+ return target_height
532
+ lld_min_height = self._pipettes.get_current_tip_lld_settings(
533
+ pipette_id=pipette_id
534
+ )
535
+ if target_height < lld_min_height:
536
+ target_height = lld_min_height
537
+ elif target_height > well_max_height:
538
+ target_height = well_max_height
539
+ return target_height
434
540
 
435
- def validate_well_position(
541
+ def validate_probed_height(
436
542
  self,
437
- well_location: WellLocations,
438
- z_offset: float,
439
- pipette_id: Optional[str] = None,
543
+ labware_id: str,
544
+ well_name: str,
545
+ pipette_id: str,
546
+ probed_height: LiquidTrackingType,
440
547
  ) -> None:
441
- """Raise exception if operation location is not within well.
442
-
443
- Primarily this checks if there is not enough liquid in a well to do meniscus-relative static aspiration.
444
- """
445
- if well_location.origin == WellOrigin.MENISCUS:
446
- assert pipette_id is not None
447
- lld_min_height = self._pipettes.get_current_tip_lld_settings(
448
- pipette_id=pipette_id
449
- )
450
- if z_offset < lld_min_height:
451
- if isinstance(well_location, PickUpTipWellLocation):
452
- raise OperationLocationNotInWellError(
453
- f"Specifying {well_location.origin} with an offset of {well_location.offset} results in an operation location that could be below the bottom of the well"
454
- )
455
- else:
456
- raise OperationLocationNotInWellError(
457
- f"Specifying {well_location.origin} with an offset of {well_location.offset} and a volume offset of {well_location.volumeOffset} results in an operation location that could be below the bottom of the well"
458
- )
459
- elif z_offset < 0:
460
- if isinstance(well_location, PickUpTipWellLocation):
461
- raise OperationLocationNotInWellError(
462
- f"Specifying {well_location.origin} with an offset of {well_location.offset} results in an operation location below the bottom of the well"
463
- )
464
- else:
465
- raise OperationLocationNotInWellError(
466
- f"Specifying {well_location.origin} with an offset of {well_location.offset} and a volume offset of {well_location.volumeOffset} results in an operation location below the bottom of the well"
467
- )
548
+ """Raise an error if a probed liquid height is not within well bounds."""
549
+ if isinstance(probed_height, SimulatedProbeResult):
550
+ return
551
+ lld_min_height = self._pipettes.get_current_tip_lld_settings(
552
+ pipette_id=pipette_id
553
+ )
554
+ well_def = self._labware.get_well_definition(labware_id, well_name)
555
+ well_depth = well_def.depth
556
+ if probed_height < lld_min_height:
557
+ raise PipetteLiquidNotFoundError(
558
+ f"Liquid Height of {probed_height} mm is lower minumum allowed lld height {lld_min_height} mm."
559
+ )
560
+ if probed_height > well_depth:
561
+ raise PipetteLiquidNotFoundError(
562
+ f"Liquid Height of {probed_height} mm is greater than maximum well height {well_depth} mm."
563
+ )
468
564
 
469
565
  def get_well_position(
470
566
  self,
471
567
  labware_id: str,
472
568
  well_name: str,
473
- well_location: Optional[WellLocations] = None,
569
+ well_location: Optional[WellLocationType] = None,
474
570
  operation_volume: Optional[float] = None,
475
571
  pipette_id: Optional[str] = None,
476
572
  ) -> Point:
@@ -481,19 +577,17 @@ class GeometryView:
481
577
 
482
578
  offset = WellOffset(x=0, y=0, z=well_depth)
483
579
  if well_location is not None:
484
- offset = well_location.offset
580
+ offset = well_location.offset # location of the bottom of the well
485
581
  offset_adjustment = self.get_well_offset_adjustment(
486
582
  labware_id=labware_id,
487
583
  well_name=well_name,
488
584
  well_location=well_location,
489
585
  well_depth=well_depth,
490
586
  operation_volume=operation_volume,
587
+ pipette_id=pipette_id,
491
588
  )
492
- offset = offset.model_copy(update={"z": offset.z + offset_adjustment})
493
- self.validate_well_position(
494
- well_location=well_location, z_offset=offset.z, pipette_id=pipette_id
495
- )
496
-
589
+ if not isinstance(offset_adjustment, SimulatedProbeResult):
590
+ offset = offset.model_copy(update={"z": offset.z + offset_adjustment})
497
591
  return Point(
498
592
  x=labware_pos.x + offset.x + well_def.x,
499
593
  y=labware_pos.y + offset.y + well_def.y,
@@ -515,52 +609,65 @@ class GeometryView:
515
609
  z=parent_pos.z + origin_offset.z + well_def.z + well_def.depth,
516
610
  )
517
611
 
518
- def get_relative_well_location(
612
+ def _get_relative_liquid_handling_well_location(
519
613
  self,
520
614
  labware_id: str,
521
615
  well_name: str,
522
616
  absolute_point: Point,
523
- ) -> WellLocation:
617
+ delta: Point,
618
+ meniscus_tracking: Optional[MeniscusTrackingTarget] = None,
619
+ ) -> Tuple[WellLocationType, bool]:
524
620
  """Given absolute position, get relative location of a well in a labware."""
525
- well_absolute_point = self.get_well_position(labware_id, well_name)
526
- delta = absolute_point - well_absolute_point
527
-
528
- return WellLocation(offset=WellOffset(x=delta.x, y=delta.y, z=delta.z))
529
-
530
- def get_relative_liquid_handling_well_location(
531
- self,
532
- labware_id: str,
533
- well_name: str,
534
- absolute_point: Point,
535
- is_meniscus: Optional[bool] = None,
536
- ) -> LiquidHandlingWellLocation:
537
- """Given absolute position, get relative location of a well in a labware.
538
-
539
- If is_meniscus is True, absolute_point will hold the z-offset in its z field.
540
- """
541
- if is_meniscus:
542
- return LiquidHandlingWellLocation(
621
+ dynamic_liquid_tracking = False
622
+ if meniscus_tracking:
623
+ location = LiquidHandlingWellLocation(
543
624
  origin=WellOrigin.MENISCUS,
544
625
  offset=WellOffset(x=0, y=0, z=absolute_point.z),
545
626
  )
627
+ # TODO(cm): handle operationVolume being a float other than 0
628
+ if meniscus_tracking == MeniscusTrackingTarget.END:
629
+ location.volumeOffset = "operationVolume"
630
+ elif meniscus_tracking == MeniscusTrackingTarget.DYNAMIC:
631
+ dynamic_liquid_tracking = True
546
632
  else:
547
- well_absolute_point = self.get_well_position(labware_id, well_name)
548
- delta = absolute_point - well_absolute_point
549
- return LiquidHandlingWellLocation(
633
+ location = LiquidHandlingWellLocation(
550
634
  offset=WellOffset(x=delta.x, y=delta.y, z=delta.z)
551
635
  )
636
+ return location, dynamic_liquid_tracking
552
637
 
553
- def get_relative_pick_up_tip_well_location(
638
+ def get_relative_well_location(
554
639
  self,
555
640
  labware_id: str,
556
641
  well_name: str,
557
642
  absolute_point: Point,
558
- ) -> PickUpTipWellLocation:
643
+ location_type: WellLocationFunction,
644
+ meniscus_tracking: Optional[MeniscusTrackingTarget] = None,
645
+ ) -> Tuple[WellLocationType, bool]:
559
646
  """Given absolute position, get relative location of a well in a labware."""
560
647
  well_absolute_point = self.get_well_position(labware_id, well_name)
561
648
  delta = absolute_point - well_absolute_point
562
-
563
- return PickUpTipWellLocation(offset=WellOffset(x=delta.x, y=delta.y, z=delta.z))
649
+ match location_type:
650
+ case WellLocationFunction.BASE | WellLocationFunction.DROP_TIP:
651
+ return (
652
+ WellLocation(offset=WellOffset(x=delta.x, y=delta.y, z=delta.z)),
653
+ False,
654
+ )
655
+ case WellLocationFunction.PICK_UP_TIP:
656
+ return (
657
+ PickUpTipWellLocation(
658
+ offset=WellOffset(x=delta.x, y=delta.y, z=delta.z)
659
+ ),
660
+ False,
661
+ )
662
+ case WellLocationFunction.LIQUID_HANDLING:
663
+ return self._get_relative_liquid_handling_well_location(
664
+ labware_id=labware_id,
665
+ well_name=well_name,
666
+ absolute_point=absolute_point,
667
+ delta=delta,
668
+ meniscus_tracking=meniscus_tracking,
669
+ )
670
+ return NotImplemented
564
671
 
565
672
  def get_well_height(
566
673
  self,
@@ -635,7 +742,7 @@ class GeometryView:
635
742
 
636
743
  return TipGeometry(
637
744
  length=effective_length,
638
- diameter=well_def.diameter, # type: ignore[arg-type]
745
+ diameter=well_def.diameter,
639
746
  # TODO(mc, 2020-11-12): WellDefinition type says totalLiquidVolume
640
747
  # is a float, but hardware controller expects an int
641
748
  volume=int(well_def.totalLiquidVolume),
@@ -647,6 +754,7 @@ class GeometryView:
647
754
  labware_id: str,
648
755
  well_location: DropTipWellLocation,
649
756
  partially_configured: bool = False,
757
+ override_default_offset: float | None = None,
650
758
  ) -> WellLocation:
651
759
  """Get tip drop location given labware and hardware pipette.
652
760
 
@@ -665,8 +773,9 @@ class GeometryView:
665
773
  origin=WellOrigin(well_location.origin.value),
666
774
  offset=well_location.offset,
667
775
  )
668
-
669
- if self._labware.get_definition(labware_id).parameters.isTiprack:
776
+ if override_default_offset is not None:
777
+ z_offset = override_default_offset
778
+ elif self._labware.get_definition(labware_id).parameters.isTiprack:
670
779
  z_offset = self._labware.get_tip_drop_z_offset(
671
780
  labware_id=labware_id,
672
781
  length_scale=self._pipettes.get_return_tip_scale(pipette_id),
@@ -721,7 +830,6 @@ class GeometryView:
721
830
  """Get the slot name of the labware or the module that the labware is on."""
722
831
  labware = self._labware.get(labware_id)
723
832
  slot_name: Union[DeckSlotName, StagingSlotName]
724
-
725
833
  if isinstance(labware.location, DeckSlotLocation):
726
834
  slot_name = labware.location.slotName
727
835
  elif isinstance(labware.location, ModuleLocation):
@@ -745,31 +853,188 @@ class GeometryView:
745
853
  f"Labware {labware_id} does not have a slot associated with it"
746
854
  f" since it is no longer on the deck."
747
855
  )
856
+ else:
857
+ _LOG.error(
858
+ f"Unhandled location type in get_ancestor_slot_name: {labware.location}"
859
+ )
860
+ raise errors.InvalidLabwarePositionError(
861
+ f"Cannot get ancestor slot of {self._labware.get_display_name(labware_id)} with location {labware.location}"
862
+ )
748
863
 
749
864
  return slot_name
750
865
 
866
+ def get_ancestor_addressable_area_name(self, labware_id: str) -> str:
867
+ """Get the name of the addressable area the labware is eventually on."""
868
+ labware = self._labware.get(labware_id)
869
+ original_display_name = self._labware.get_display_name(labware_id)
870
+ seen: Set[str] = set((labware_id,))
871
+ while isinstance(labware.location, OnLabwareLocation):
872
+ labware = self._labware.get(labware.location.labwareId)
873
+ if labware.id in seen:
874
+ raise InvalidLabwarePositionError(
875
+ f"Cycle detected in labware positioning for {original_display_name}"
876
+ )
877
+ seen.add(labware.id)
878
+ if isinstance(labware.location, DeckSlotLocation):
879
+ return labware.location.slotName.id
880
+ elif isinstance(labware.location, AddressableAreaLocation):
881
+ return labware.location.addressableAreaName
882
+ elif isinstance(labware.location, ModuleLocation):
883
+ return self._modules.get_provided_addressable_area(
884
+ labware.location.moduleId
885
+ )
886
+ else:
887
+ raise LabwareNotOnDeckError(
888
+ f"Labware {original_display_name} is not loaded on deck",
889
+ details={"eventual-location": repr(labware.location)},
890
+ )
891
+
751
892
  def ensure_location_not_occupied(
752
- self, location: _LabwareLocation
893
+ self,
894
+ location: _LabwareLocation,
895
+ desired_addressable_area: Optional[str] = None,
753
896
  ) -> _LabwareLocation:
754
897
  """Ensure that the location does not already have either Labware or a Module in it."""
755
- # TODO (spp, 2023-11-27): Slot locations can also be addressable areas
756
- # so we will need to cross-check against items loaded in both location types.
757
- # Something like 'check if an item is in lists of both- labware on addressable areas
758
- # as well as labware on slots'. Same for modules.
759
- if isinstance(
760
- location,
761
- (
762
- DeckSlotLocation,
763
- ModuleLocation,
764
- OnLabwareLocation,
765
- AddressableAreaLocation,
766
- ),
767
- ):
768
- self._labware.raise_if_labware_in_location(location)
769
- if isinstance(location, DeckSlotLocation):
770
- self._modules.raise_if_module_in_location(location)
898
+ # Collect set of existing fixtures, if any
899
+ existing_fixtures = self._get_potential_fixtures_for_location_occupation(
900
+ location
901
+ )
902
+ potential_fixtures = (
903
+ self._get_potential_fixtures_for_location_occupation(
904
+ AddressableAreaLocation(addressableAreaName=desired_addressable_area)
905
+ )
906
+ if desired_addressable_area is not None
907
+ else None
908
+ )
909
+
910
+ # Handle the checking conflict on an incoming fixture
911
+ if potential_fixtures is not None and isinstance(location, DeckSlotLocation):
912
+ if (
913
+ existing_fixtures is not None
914
+ and not any(
915
+ location.slotName.id in fixture.provided_addressable_areas
916
+ for fixture in potential_fixtures[1].intersection(
917
+ existing_fixtures[1]
918
+ )
919
+ )
920
+ ) or (
921
+ self._labware.get_by_slot(location.slotName) is not None
922
+ and not any(
923
+ location.slotName.id in fixture.provided_addressable_areas
924
+ for fixture in potential_fixtures[1]
925
+ )
926
+ ):
927
+ self._labware.raise_if_labware_in_location(location)
928
+
929
+ else:
930
+ self._modules.raise_if_module_in_location(location)
931
+
932
+ # Otherwise handle standard conflict checking
933
+ else:
934
+ if isinstance(
935
+ location,
936
+ (
937
+ DeckSlotLocation,
938
+ ModuleLocation,
939
+ OnLabwareLocation,
940
+ AddressableAreaLocation,
941
+ ),
942
+ ):
943
+ self._labware.raise_if_labware_in_location(location)
944
+
945
+ area = (
946
+ location.slotName.id
947
+ if isinstance(location, DeckSlotLocation)
948
+ else (
949
+ location.addressableAreaName
950
+ if isinstance(location, AddressableAreaLocation)
951
+ else None
952
+ )
953
+ )
954
+ if area is not None and (
955
+ existing_fixtures is None
956
+ or not any(
957
+ area in fixture.provided_addressable_areas
958
+ for fixture in existing_fixtures[1]
959
+ )
960
+ ):
961
+ if isinstance(location, DeckSlotLocation):
962
+ self._modules.raise_if_module_in_location(location)
963
+ elif isinstance(location, AddressableAreaLocation):
964
+ self._modules.raise_if_module_in_location(
965
+ DeckSlotLocation(
966
+ slotName=self._addressable_areas.get_addressable_area_base_slot(
967
+ location.addressableAreaName
968
+ )
969
+ )
970
+ )
971
+
771
972
  return location
772
973
 
974
+ def _get_potential_fixtures_for_location_occupation(
975
+ self, location: _LabwareLocation
976
+ ) -> Tuple[str, Set[PotentialCutoutFixture]] | None:
977
+ loc: DeckSlotLocation | AddressableAreaLocation | None = None
978
+ if isinstance(location, AddressableAreaLocation):
979
+ # Convert the addressable area into a staging slot if applicable
980
+ slots = StagingSlotName._value2member_map_
981
+ for slot in slots:
982
+ if location.addressableAreaName == slot:
983
+ loc = DeckSlotLocation(
984
+ slotName=DeckSlotName(location.addressableAreaName[0] + "3")
985
+ )
986
+ if loc is None:
987
+ loc = location
988
+ elif isinstance(location, DeckSlotLocation):
989
+ loc = location
990
+
991
+ if isinstance(loc, DeckSlotLocation):
992
+ module = self._modules.get_by_slot(loc.slotName)
993
+ if module is not None and self._config.robot_type != "OT-2 Standard":
994
+ fixtures = deck_configuration_provider.get_potential_cutout_fixtures(
995
+ addressable_area_name=self._modules.ensure_and_convert_module_fixture_location(
996
+ deck_slot=loc.slotName,
997
+ model=module.model,
998
+ ),
999
+ deck_definition=self._addressable_areas.deck_definition,
1000
+ )
1001
+ else:
1002
+ fixtures = None
1003
+ elif isinstance(loc, AddressableAreaLocation):
1004
+ fixtures = deck_configuration_provider.get_potential_cutout_fixtures(
1005
+ addressable_area_name=loc.addressableAreaName,
1006
+ deck_definition=self._addressable_areas.deck_definition,
1007
+ )
1008
+ else:
1009
+ fixtures = None
1010
+ return fixtures
1011
+
1012
+ def _get_potential_disposal_location_cutout_fixtures(
1013
+ self, slot_name: DeckSlotName
1014
+ ) -> CutoutFixture | None:
1015
+ for area in self._addressable_areas.get_all():
1016
+ if (
1017
+ self._addressable_areas.get_addressable_area(area).area_type
1018
+ == AreaType.WASTE_CHUTE
1019
+ or self._addressable_areas.get_addressable_area(area).area_type
1020
+ == AreaType.MOVABLE_TRASH
1021
+ ) and slot_name == self._addressable_areas.get_addressable_area_base_slot(
1022
+ area
1023
+ ):
1024
+ # Given we only have one Waste Chute fixture and one type of Trash bin fixture it's
1025
+ # fine to return the first result of our potential fixtures here. This will need to
1026
+ # change in the future if there multiple trash fixtures that share the same area type.
1027
+ potential_fixture = (
1028
+ deck_configuration_provider.get_potential_cutout_fixtures(
1029
+ area, self._addressable_areas.deck_definition
1030
+ )[1].pop()
1031
+ )
1032
+ return deck_configuration_provider.get_cutout_fixture(
1033
+ potential_fixture.cutout_fixture_id,
1034
+ self._addressable_areas.deck_definition,
1035
+ )
1036
+ return None
1037
+
773
1038
  def get_labware_grip_point(
774
1039
  self,
775
1040
  labware_definition: LabwareDefinition,
@@ -791,43 +1056,24 @@ class GeometryView:
791
1056
  self._labware.get_grip_height_from_labware_bottom(labware_definition)
792
1057
  )
793
1058
  location_name: str
794
-
1059
+ offset = self._get_offset_from_parent_addressable_area(
1060
+ child_definition=labware_definition, parent=location
1061
+ ) + self._get_calibrated_module_offset(location)
795
1062
  if isinstance(location, DeckSlotLocation):
796
1063
  location_name = location.slotName.id
797
- offset = LabwareOffsetVector(x=0, y=0, z=0)
798
1064
  elif isinstance(location, AddressableAreaLocation):
799
1065
  location_name = location.addressableAreaName
800
- if fixture_validation.is_gripper_waste_chute(location_name):
801
- drop_labware_location = (
802
- self._addressable_areas.get_addressable_area_move_to_location(
803
- location_name
804
- )
805
- )
806
- return drop_labware_location + Point(z=grip_height_from_labware_bottom)
807
- # Location should have been pre-validated so this will be a deck/staging area slot
808
- else:
809
- offset = LabwareOffsetVector(x=0, y=0, z=0)
810
- else:
811
- if isinstance(location, ModuleLocation):
812
- location_name = self._modules.get_location(
813
- location.moduleId
814
- ).slotName.id
815
- else: # OnLabwareLocation
816
- location_name = self.get_ancestor_slot_name(location.labwareId).id
817
- labware_offset = self._get_offset_from_parent(
818
- child_definition=labware_definition, parent=location
819
- )
820
- # Get the calibrated offset if the on labware location is on top of a module, otherwise return empty one
821
- cal_offset = self._get_calibrated_module_offset(location)
822
- offset = LabwareOffsetVector(
823
- x=labware_offset.x + cal_offset.x,
824
- y=labware_offset.y + cal_offset.y,
825
- z=labware_offset.z + cal_offset.z,
1066
+ elif isinstance(location, ModuleLocation):
1067
+ location_name = self._modules.get_provided_addressable_area(
1068
+ location.moduleId
826
1069
  )
1070
+ else: # OnLabwareLocation
1071
+ location_name = self.get_ancestor_addressable_area_name(location.labwareId)
827
1072
 
828
1073
  location_center = self._addressable_areas.get_addressable_area_center(
829
1074
  location_name
830
1075
  )
1076
+
831
1077
  return Point(
832
1078
  location_center.x + offset.x,
833
1079
  location_center.y + offset.y,
@@ -877,6 +1123,7 @@ class GeometryView:
877
1123
  maybe_fixture = self._addressable_areas.get_fixture_by_deck_slot_name(
878
1124
  slot_name
879
1125
  )
1126
+
880
1127
  # Ignore generic single slot fixtures
881
1128
  if maybe_fixture and maybe_fixture["id"] in {
882
1129
  "singleLeftSlot",
@@ -888,6 +1135,13 @@ class GeometryView:
888
1135
  maybe_module = self._modules.get_by_slot(
889
1136
  slot_name=slot_name,
890
1137
  ) or self._modules.get_overflowed_module_in_slot(slot_name=slot_name)
1138
+
1139
+ # For situations in which the deck config is none
1140
+ if maybe_fixture is None and maybe_labware is None and maybe_module is None:
1141
+ # todo(chb 2025-03-19): This can go away once we solve the problem of no deck config in analysis
1142
+ maybe_fixture = self._get_potential_disposal_location_cutout_fixtures(
1143
+ slot_name
1144
+ )
891
1145
  else:
892
1146
  # Modules and fixtures can't be loaded on staging slots
893
1147
  maybe_fixture = None
@@ -1257,10 +1511,6 @@ class GeometryView:
1257
1511
  # * The "additional offset" or "user offset", e.g. the `pickUpOffset` and `dropOffset`
1258
1512
  # params in the `moveLabware` command.
1259
1513
  #
1260
- # And this *does* take these extra offsets into account:
1261
- #
1262
- # * The labware's Labware Position Check offset
1263
- #
1264
1514
  # For robustness, we should combine this with `get_gripper_labware_movement_waypoints()`.
1265
1515
  #
1266
1516
  # We should also be more explicit about which offsets act to move the gripper paddles
@@ -1282,23 +1532,17 @@ class GeometryView:
1282
1532
  return
1283
1533
 
1284
1534
  tip = self._pipettes.get_attached_tip(pipette.id)
1285
- if tip:
1286
- # NOTE: This call to get_labware_highest_z() uses the labware's LPC offset,
1287
- # which is an inconsistency between this and the actual gripper movement.
1288
- # See the todo comment above this function.
1289
- labware_top_z_when_gripped = gripper_homed_position_z + (
1290
- self.get_labware_highest_z(labware_id=labware_id)
1291
- - self.get_labware_grip_point(
1292
- labware_definition=labware_definition, location=current_location
1293
- ).z
1535
+ if not tip:
1536
+ continue
1537
+ labware_top_z_when_gripped = gripper_homed_position_z + (
1538
+ self._labware.get_dimensions(labware_definition=labware_definition).z
1539
+ - self._labware.get_grip_height_from_labware_bottom(labware_definition)
1540
+ )
1541
+ # TODO(cb, 2024-01-18): Utilizing the nozzle map and labware X coordinates verify if collisions will occur on the X axis (analysis will use hard coded data to measure from the gripper critical point to the pipette mount)
1542
+ if (_PIPETTE_HOMED_POSITION_Z - tip.length) < labware_top_z_when_gripped:
1543
+ raise LabwareMovementNotAllowedError(
1544
+ f"Cannot move labware '{labware_definition.parameters.loadName}' when {int(tip.volume)} µL tips are attached."
1294
1545
  )
1295
- # TODO(cb, 2024-01-18): Utilizing the nozzle map and labware X coordinates verify if collisions will occur on the X axis (analysis will use hard coded data to measure from the gripper critical point to the pipette mount)
1296
- if (
1297
- _PIPETTE_HOMED_POSITION_Z - tip.length
1298
- ) < labware_top_z_when_gripped:
1299
- raise LabwareMovementNotAllowedError(
1300
- f"Cannot move labware '{labware_definition.parameters.loadName}' when {int(tip.volume)} µL tips are attached."
1301
- )
1302
1546
  return
1303
1547
 
1304
1548
  def _nominal_gripper_offsets_for_location(
@@ -1359,104 +1603,434 @@ class GeometryView:
1359
1603
  labware_id=labware_id, slot_name=None
1360
1604
  )
1361
1605
 
1362
- def get_offset_location(self, labware_id: str) -> Optional[LabwareOffsetLocation]:
1363
- """Provide the LabwareOffsetLocation specifying the current position of the labware.
1606
+ def get_location_sequence(self, labware_id: str) -> LabwareLocationSequence:
1607
+ """Provide the LocationSequence specifying the current position of the labware.
1364
1608
 
1365
- If the labware is in a location that cannot be specified by a LabwareOffsetLocation
1609
+ Elements in this sequence contain instance IDs of things. The chain is valid only until the
1610
+ labware is moved.
1611
+ """
1612
+ return self.get_predicted_location_sequence(
1613
+ self._labware.get_location(labware_id)
1614
+ )
1615
+
1616
+ def get_predicted_location_sequence(
1617
+ self,
1618
+ labware_location: LabwareLocation,
1619
+ labware_pending_load: dict[str, LoadedLabware] | None = None,
1620
+ ) -> LabwareLocationSequence:
1621
+ """Get the location sequence for this location. Useful for a labware that hasn't been loaded."""
1622
+ return self._recurse_labware_location(
1623
+ labware_location, [], labware_pending_load or {}
1624
+ )
1625
+
1626
+ def _cutout_fixture_location_sequence_from_addressable_area(
1627
+ self, addressable_area_name: str
1628
+ ) -> OnCutoutFixtureLocationSequenceComponent:
1629
+ (
1630
+ cutout_id,
1631
+ potential_fixtures,
1632
+ ) = self._addressable_areas.get_current_potential_cutout_fixtures_for_addressable_area(
1633
+ addressable_area_name
1634
+ )
1635
+ return OnCutoutFixtureLocationSequenceComponent(
1636
+ possibleCutoutFixtureIds=sorted(
1637
+ [fixture.cutout_fixture_id for fixture in potential_fixtures]
1638
+ ),
1639
+ cutoutId=cutout_id,
1640
+ )
1641
+
1642
+ def _recurse_labware_location_from_aa_component(
1643
+ self,
1644
+ labware_location: AddressableAreaLocation,
1645
+ building: LabwareLocationSequence,
1646
+ ) -> LabwareLocationSequence:
1647
+ cutout_location = self._cutout_fixture_location_sequence_from_addressable_area(
1648
+ labware_location.addressableAreaName
1649
+ )
1650
+ # If the labware is loaded on an AA that is a module, we want to respect the convention
1651
+ # of giving it an OnModuleLocation.
1652
+ possible_module = self._modules.get_by_addressable_area(
1653
+ labware_location.addressableAreaName
1654
+ )
1655
+ if possible_module is not None:
1656
+ return building + [
1657
+ OnAddressableAreaLocationSequenceComponent(
1658
+ addressableAreaName=labware_location.addressableAreaName
1659
+ ),
1660
+ OnModuleLocationSequenceComponent(moduleId=possible_module.id),
1661
+ cutout_location,
1662
+ ]
1663
+ else:
1664
+ return building + [
1665
+ OnAddressableAreaLocationSequenceComponent(
1666
+ addressableAreaName=labware_location.addressableAreaName,
1667
+ ),
1668
+ cutout_location,
1669
+ ]
1670
+
1671
+ def _recurse_labware_location_from_module_component(
1672
+ self, labware_location: ModuleLocation, building: LabwareLocationSequence
1673
+ ) -> LabwareLocationSequence:
1674
+ module_id = labware_location.moduleId
1675
+ module_aa = self._modules.get_provided_addressable_area(module_id)
1676
+ base_location: (
1677
+ OnCutoutFixtureLocationSequenceComponent
1678
+ | NotOnDeckLocationSequenceComponent
1679
+ ) = self._cutout_fixture_location_sequence_from_addressable_area(module_aa)
1680
+
1681
+ if self._modules.get_deck_supports_module_fixtures():
1682
+ # On a deck with modules as cutout fixtures, we want, in order,
1683
+ # - the addressable area of the module
1684
+ # - the module with its module id, which is what clients want
1685
+ # - the cutout
1686
+ loc = self._modules.get_location(module_id)
1687
+ model = self._modules.get_connected_model(module_id)
1688
+ module_aa = self._modules.ensure_and_convert_module_fixture_location(
1689
+ loc.slotName, model
1690
+ )
1691
+ return building + [
1692
+ OnAddressableAreaLocationSequenceComponent(
1693
+ addressableAreaName=module_aa
1694
+ ),
1695
+ OnModuleLocationSequenceComponent(moduleId=module_id),
1696
+ base_location,
1697
+ ]
1698
+ else:
1699
+ # If the module isn't a cutout fixture, then we want
1700
+ # - the module
1701
+ # - the addressable area the module is loaded on
1702
+ # - the cutout
1703
+ location = self._modules.get_location(module_id)
1704
+ return building + [
1705
+ OnModuleLocationSequenceComponent(moduleId=module_id),
1706
+ OnAddressableAreaLocationSequenceComponent(
1707
+ addressableAreaName=location.slotName.value
1708
+ ),
1709
+ base_location,
1710
+ ]
1711
+
1712
+ def _recurse_labware_location_from_stacker_hopper(
1713
+ self,
1714
+ labware_location: InStackerHopperLocation,
1715
+ building: LabwareLocationSequence,
1716
+ ) -> LabwareLocationSequence:
1717
+ loc = self._modules.get_location(labware_location.moduleId)
1718
+ model = self._modules.get_connected_model(labware_location.moduleId)
1719
+ module_aa = self._modules.ensure_and_convert_module_fixture_location(
1720
+ loc.slotName, model
1721
+ )
1722
+ cutout_base = self._cutout_fixture_location_sequence_from_addressable_area(
1723
+ module_aa
1724
+ )
1725
+ return building + [labware_location, cutout_base]
1726
+
1727
+ def _recurse_labware_location(
1728
+ self,
1729
+ labware_location: LabwareLocation,
1730
+ building: LabwareLocationSequence,
1731
+ labware_pending_load: dict[str, LoadedLabware],
1732
+ ) -> LabwareLocationSequence:
1733
+ if isinstance(labware_location, AddressableAreaLocation):
1734
+ return self._recurse_labware_location_from_aa_component(
1735
+ labware_location, building
1736
+ )
1737
+ elif labware_location_is_off_deck(
1738
+ labware_location
1739
+ ) or labware_location_is_system(labware_location):
1740
+ return building + [
1741
+ NotOnDeckLocationSequenceComponent(logicalLocationName=labware_location)
1742
+ ]
1743
+
1744
+ elif isinstance(labware_location, OnLabwareLocation):
1745
+ labware = self._get_or_default_labware(
1746
+ labware_location.labwareId, labware_pending_load
1747
+ )
1748
+ return self._recurse_labware_location(
1749
+ labware.location,
1750
+ building
1751
+ + [
1752
+ OnLabwareLocationSequenceComponent(
1753
+ labwareId=labware_location.labwareId, lidId=labware.lid_id
1754
+ )
1755
+ ],
1756
+ labware_pending_load,
1757
+ )
1758
+ elif isinstance(labware_location, ModuleLocation):
1759
+ return self._recurse_labware_location_from_module_component(
1760
+ labware_location, building
1761
+ )
1762
+ elif isinstance(labware_location, DeckSlotLocation):
1763
+ return building + [
1764
+ OnAddressableAreaLocationSequenceComponent(
1765
+ addressableAreaName=labware_location.slotName.value,
1766
+ ),
1767
+ self._cutout_fixture_location_sequence_from_addressable_area(
1768
+ labware_location.slotName.value
1769
+ ),
1770
+ ]
1771
+ elif isinstance(labware_location, InStackerHopperLocation):
1772
+ return self._recurse_labware_location_from_stacker_hopper(
1773
+ labware_location, building
1774
+ )
1775
+ else:
1776
+ _LOG.warn(f"Unhandled labware location kind: {labware_location}")
1777
+ return building
1778
+
1779
+ def get_offset_location(
1780
+ self, labware_id: str
1781
+ ) -> Optional[LabwareOffsetLocationSequence]:
1782
+ """Provide the LegacyLabwareOffsetLocation specifying the current position of the labware.
1783
+
1784
+ If the labware is in a location that cannot be specified by a LabwareOffsetLocationSequence
1366
1785
  (for instance, OFF_DECK) then return None.
1367
1786
  """
1368
1787
  parent_location = self._labware.get_location(labware_id)
1788
+ return self.get_projected_offset_location(parent_location)
1369
1789
 
1370
- if isinstance(parent_location, DeckSlotLocation):
1371
- return LabwareOffsetLocation(
1372
- slotName=parent_location.slotName, moduleModel=None, definitionUri=None
1373
- )
1374
- elif isinstance(parent_location, ModuleLocation):
1375
- module_model = self._modules.get_requested_model(parent_location.moduleId)
1376
- module_location = self._modules.get_location(parent_location.moduleId)
1377
- return LabwareOffsetLocation(
1378
- slotName=module_location.slotName,
1379
- moduleModel=module_model,
1380
- definitionUri=None,
1381
- )
1382
- elif isinstance(parent_location, OnLabwareLocation):
1383
- non_labware_parent_location = self._labware.get_parent_location(labware_id)
1384
-
1385
- parent_uri = self._labware.get_definition_uri(parent_location.labwareId)
1386
- if isinstance(non_labware_parent_location, DeckSlotLocation):
1387
- return LabwareOffsetLocation(
1388
- slotName=non_labware_parent_location.slotName,
1389
- moduleModel=None,
1390
- definitionUri=parent_uri,
1391
- )
1392
- elif isinstance(non_labware_parent_location, ModuleLocation):
1393
- module_model = self._modules.get_requested_model(
1394
- non_labware_parent_location.moduleId
1395
- )
1396
- module_location = self._modules.get_location(
1397
- non_labware_parent_location.moduleId
1790
+ def get_projected_offset_location(
1791
+ self,
1792
+ labware_location: LabwareLocation,
1793
+ labware_pending_load: dict[str, LoadedLabware] | None = None,
1794
+ ) -> Optional[LabwareOffsetLocationSequence]:
1795
+ """Get the offset location that a labware loaded into this location would match.
1796
+
1797
+ `None` indicates that the very concept of a labware offset would not make sense
1798
+ for the given location, such as if it's some kind of off-deck location. This
1799
+ is a difference from `get_predicted_location_sequence()`, where off-deck
1800
+ locations are still represented as lists, but with special final elements.
1801
+ """
1802
+ return self._recurse_labware_offset_location(
1803
+ labware_location, [], labware_pending_load or {}
1804
+ )
1805
+
1806
+ def _recurse_labware_offset_location(
1807
+ self,
1808
+ labware_location: LabwareLocation,
1809
+ building: LabwareOffsetLocationSequence,
1810
+ labware_pending_load: dict[str, LoadedLabware],
1811
+ ) -> LabwareOffsetLocationSequence | None:
1812
+ if isinstance(labware_location, DeckSlotLocation):
1813
+ return building + [
1814
+ OnAddressableAreaOffsetLocationSequenceComponent(
1815
+ addressableAreaName=labware_location.slotName.value
1398
1816
  )
1399
- return LabwareOffsetLocation(
1400
- slotName=module_location.slotName,
1401
- moduleModel=module_model,
1402
- definitionUri=parent_uri,
1817
+ ]
1818
+
1819
+ elif isinstance(labware_location, ModuleLocation):
1820
+ module_id = labware_location.moduleId
1821
+ # Allow ModuleNotLoadedError to propagate.
1822
+ # Note also that we match based on the module's requested model, not its
1823
+ # actual model, to implement robot-server's documented HTTP API semantics.
1824
+ module_model = self._modules.get_requested_model(module_id=module_id)
1825
+
1826
+ # If `module_model is None`, it probably means that this module was added by
1827
+ # `ProtocolEngine.use_attached_modules()`, instead of an explicit
1828
+ # `loadModule` command.
1829
+ #
1830
+ # This assert should never raise in practice because:
1831
+ # 1. `ProtocolEngine.use_attached_modules()` is only used by
1832
+ # robot-server's "stateless command" endpoints, under `/commands`.
1833
+ # 2. Those endpoints don't support loading labware, so this code will
1834
+ # never run.
1835
+ #
1836
+ # Nevertheless, if it does happen somehow, we do NOT want to pass the
1837
+ # `None` value along to `LabwareView.find_applicable_labware_offset()`.
1838
+ # `None` means something different there, which will cause us to return
1839
+ # wrong results.
1840
+ assert module_model is not None, (
1841
+ "Can't find offsets for labware"
1842
+ " that are loaded on modules"
1843
+ " that were loaded with ProtocolEngine.use_attached_modules()."
1844
+ )
1845
+
1846
+ module_location = self._modules.get_location(module_id=module_id)
1847
+ if self._modules.get_deck_supports_module_fixtures():
1848
+ module_aa = self._modules.ensure_and_convert_module_fixture_location(
1849
+ module_location.slotName, module_model
1403
1850
  )
1851
+ else:
1852
+ module_aa = module_location.slotName.value
1853
+ return building + [
1854
+ OnModuleOffsetLocationSequenceComponent(moduleModel=module_model),
1855
+ OnAddressableAreaOffsetLocationSequenceComponent(
1856
+ addressableAreaName=module_aa
1857
+ ),
1858
+ ]
1404
1859
 
1405
- return None
1860
+ elif isinstance(labware_location, OnLabwareLocation):
1861
+ parent_labware_id = labware_location.labwareId
1862
+ parent_labware = self._get_or_default_labware(
1863
+ parent_labware_id, labware_pending_load
1864
+ )
1865
+ parent_labware_uri = LabwareUri(parent_labware.definitionUri)
1866
+ base_location = parent_labware.location
1867
+ return self._recurse_labware_offset_location(
1868
+ base_location,
1869
+ building
1870
+ + [
1871
+ OnLabwareOffsetLocationSequenceComponent(
1872
+ labwareUri=parent_labware_uri
1873
+ )
1874
+ ],
1875
+ labware_pending_load,
1876
+ )
1877
+
1878
+ else: # Off deck
1879
+ return None
1880
+
1881
+ def get_liquid_handling_z_change(
1882
+ self,
1883
+ labware_id: str,
1884
+ well_name: str,
1885
+ pipette_id: str,
1886
+ operation_volume: float,
1887
+ ) -> float:
1888
+ """Get the change in height from a liquid handling operation."""
1889
+ initial_handling_height = self.get_meniscus_height(
1890
+ labware_id=labware_id, well_name=well_name
1891
+ )
1892
+ final_height = self.get_well_height_after_liquid_handling(
1893
+ labware_id=labware_id,
1894
+ well_name=well_name,
1895
+ pipette_id=pipette_id,
1896
+ initial_height=initial_handling_height,
1897
+ volume=operation_volume,
1898
+ )
1899
+ # this function is only called by
1900
+ # HardwarePipetteHandler::aspirate/dispense while_tracking, and shouldn't
1901
+ # be reached in the case of a simulated liquid_probe
1902
+ assert not isinstance(
1903
+ initial_handling_height, SimulatedProbeResult
1904
+ ), "Initial handling height got SimulatedProbeResult"
1905
+ assert not isinstance(
1906
+ final_height, SimulatedProbeResult
1907
+ ), "final height is SimulatedProbeResult"
1908
+ return final_height - initial_handling_height
1406
1909
 
1407
1910
  def get_well_offset_adjustment(
1408
1911
  self,
1409
1912
  labware_id: str,
1410
1913
  well_name: str,
1411
- well_location: WellLocations,
1914
+ well_location: WellLocationType,
1412
1915
  well_depth: float,
1916
+ pipette_id: Optional[str] = None,
1413
1917
  operation_volume: Optional[float] = None,
1414
- ) -> float:
1918
+ ) -> LiquidTrackingType:
1415
1919
  """Return a z-axis distance that accounts for well handling height and operation volume.
1416
1920
 
1417
1921
  Distance is with reference to the well bottom.
1418
1922
  """
1419
1923
  # TODO(pbm, 10-23-24): refactor to smartly reduce height/volume conversions
1924
+
1420
1925
  initial_handling_height = self.get_well_handling_height(
1421
1926
  labware_id=labware_id,
1422
1927
  well_name=well_name,
1423
1928
  well_location=well_location,
1424
1929
  well_depth=well_depth,
1425
1930
  )
1931
+ # if we're tracking a MENISCUS origin, and targeting either the beginning
1932
+ # position of the liquid or doing dynamic tracking, return the initial height
1933
+ if (
1934
+ well_location.origin == WellOrigin.MENISCUS
1935
+ and not well_location.volumeOffset
1936
+ ):
1937
+ return initial_handling_height
1938
+ volume: Optional[float] = None
1426
1939
  if isinstance(well_location, PickUpTipWellLocation):
1427
1940
  volume = 0.0
1428
- elif isinstance(well_location.volumeOffset, float):
1429
- volume = well_location.volumeOffset
1430
- elif well_location.volumeOffset == "operationVolume":
1431
- volume = operation_volume or 0.0
1941
+ elif isinstance(well_location, LiquidHandlingWellLocation):
1942
+ if well_location.volumeOffset == "operationVolume":
1943
+ volume = operation_volume or 0.0
1944
+ else:
1945
+ if not isinstance(well_location.volumeOffset, float):
1946
+ raise ValueError("Invalid volume offset.")
1947
+ volume = well_location.volumeOffset
1432
1948
 
1433
1949
  if volume:
1434
- return self.get_well_height_after_volume(
1950
+ if pipette_id is None:
1951
+ raise ValueError(
1952
+ "cannot get liquid handling offset without pipette id."
1953
+ )
1954
+ liquid_height_after = self.get_well_height_after_liquid_handling(
1435
1955
  labware_id=labware_id,
1436
1956
  well_name=well_name,
1957
+ pipette_id=pipette_id,
1437
1958
  initial_height=initial_handling_height,
1438
1959
  volume=volume,
1439
1960
  )
1961
+ return liquid_height_after
1440
1962
  else:
1441
1963
  return initial_handling_height
1442
1964
 
1965
+ def get_current_well_volume(
1966
+ self,
1967
+ labware_id: str,
1968
+ well_name: str,
1969
+ ) -> LiquidTrackingType:
1970
+ """Returns most recently updated volume in specified well."""
1971
+ last_updated = self._wells.get_last_liquid_update(labware_id, well_name)
1972
+ if last_updated is None:
1973
+ raise errors.LiquidHeightUnknownError(
1974
+ "Must LiquidProbe or LoadLiquid before specifying WellOrigin.MENISCUS."
1975
+ )
1976
+
1977
+ well_liquid = self._wells.get_well_liquid_info(
1978
+ labware_id=labware_id, well_name=well_name
1979
+ )
1980
+ if (
1981
+ well_liquid.probed_height is not None
1982
+ and well_liquid.probed_height.height is not None
1983
+ and well_liquid.probed_height.last_probed == last_updated
1984
+ ):
1985
+ volume = self.get_well_volume_at_height(
1986
+ labware_id=labware_id,
1987
+ well_name=well_name,
1988
+ height=well_liquid.probed_height.height,
1989
+ )
1990
+ return volume
1991
+ elif (
1992
+ well_liquid.loaded_volume is not None
1993
+ and well_liquid.loaded_volume.volume is not None
1994
+ and well_liquid.loaded_volume.last_loaded == last_updated
1995
+ ):
1996
+ return well_liquid.loaded_volume.volume
1997
+ elif (
1998
+ well_liquid.probed_volume is not None
1999
+ and well_liquid.probed_volume.volume is not None
2000
+ and well_liquid.probed_volume.last_probed == last_updated
2001
+ ):
2002
+ return well_liquid.probed_volume.volume
2003
+ else:
2004
+ # This should not happen if there was an update but who knows
2005
+ raise errors.LiquidVolumeUnknownError(
2006
+ f"Unable to find liquid volume despite an update at {last_updated}."
2007
+ )
2008
+
1443
2009
  def get_meniscus_height(
1444
2010
  self,
1445
2011
  labware_id: str,
1446
2012
  well_name: str,
1447
- ) -> float:
2013
+ ) -> LiquidTrackingType:
1448
2014
  """Returns stored meniscus height in specified well."""
2015
+ last_updated = self._wells.get_last_liquid_update(labware_id, well_name)
2016
+ if last_updated is None:
2017
+ raise errors.LiquidHeightUnknownError(
2018
+ "Must LiquidProbe or LoadLiquid before specifying WellOrigin.MENISCUS."
2019
+ )
2020
+
1449
2021
  well_liquid = self._wells.get_well_liquid_info(
1450
2022
  labware_id=labware_id, well_name=well_name
1451
2023
  )
1452
2024
  if (
1453
2025
  well_liquid.probed_height is not None
1454
2026
  and well_liquid.probed_height.height is not None
2027
+ and well_liquid.probed_height.last_probed == last_updated
1455
2028
  ):
1456
2029
  return well_liquid.probed_height.height
1457
2030
  elif (
1458
2031
  well_liquid.loaded_volume is not None
1459
2032
  and well_liquid.loaded_volume.volume is not None
2033
+ and well_liquid.loaded_volume.last_loaded == last_updated
1460
2034
  ):
1461
2035
  return self.get_well_height_at_volume(
1462
2036
  labware_id=labware_id,
@@ -1466,6 +2040,7 @@ class GeometryView:
1466
2040
  elif (
1467
2041
  well_liquid.probed_volume is not None
1468
2042
  and well_liquid.probed_volume.volume is not None
2043
+ and well_liquid.probed_volume.last_probed == last_updated
1469
2044
  ):
1470
2045
  return self.get_well_height_at_volume(
1471
2046
  labware_id=labware_id,
@@ -1473,84 +2048,137 @@ class GeometryView:
1473
2048
  volume=well_liquid.probed_volume.volume,
1474
2049
  )
1475
2050
  else:
2051
+ # This should not happen if there was an update but who knows
1476
2052
  raise errors.LiquidHeightUnknownError(
1477
- "Must LiquidProbe or LoadLiquid before specifying WellOrigin.MENISCUS."
2053
+ f"Unable to find liquid height despite an update at {last_updated}."
1478
2054
  )
1479
2055
 
1480
2056
  def get_well_handling_height(
1481
2057
  self,
1482
2058
  labware_id: str,
1483
2059
  well_name: str,
1484
- well_location: WellLocations,
2060
+ well_location: WellLocationType,
1485
2061
  well_depth: float,
1486
- ) -> float:
2062
+ ) -> LiquidTrackingType:
1487
2063
  """Return the handling height for a labware well (with reference to the well bottom)."""
1488
- handling_height = 0.0
2064
+ handling_height: LiquidTrackingType = 0.0
1489
2065
  if well_location.origin == WellOrigin.TOP:
1490
- handling_height = well_depth
2066
+ handling_height = float(well_depth)
1491
2067
  elif well_location.origin == WellOrigin.CENTER:
1492
- handling_height = well_depth / 2.0
2068
+ handling_height = float(well_depth / 2.0)
1493
2069
  elif well_location.origin == WellOrigin.MENISCUS:
1494
2070
  handling_height = self.get_meniscus_height(
1495
2071
  labware_id=labware_id, well_name=well_name
1496
2072
  )
1497
- return float(handling_height)
2073
+ return handling_height
1498
2074
 
1499
- def get_well_height_after_volume(
1500
- self, labware_id: str, well_name: str, initial_height: float, volume: float
1501
- ) -> float:
2075
+ def get_well_height_after_liquid_handling(
2076
+ self,
2077
+ labware_id: str,
2078
+ well_name: str,
2079
+ pipette_id: str,
2080
+ initial_height: LiquidTrackingType,
2081
+ volume: float,
2082
+ ) -> LiquidTrackingType:
1502
2083
  """Return the height of liquid in a labware well after a given volume has been handled.
1503
2084
 
1504
2085
  This is given an initial handling height, with reference to the well bottom.
1505
2086
  """
2087
+ well_def = self._labware.get_well_definition(labware_id, well_name)
2088
+ well_depth = well_def.depth
1506
2089
  well_geometry = self._labware.get_well_geometry(
1507
2090
  labware_id=labware_id, well_name=well_name
1508
2091
  )
1509
- initial_volume = find_volume_at_well_height(
1510
- target_height=initial_height, well_geometry=well_geometry
1511
- )
1512
- final_volume = initial_volume + volume
1513
- return find_height_at_well_volume(
1514
- target_volume=final_volume, well_geometry=well_geometry
1515
- )
2092
+ try:
2093
+ initial_volume = find_volume_at_well_height(
2094
+ target_height=initial_height, well_geometry=well_geometry
2095
+ )
2096
+ final_volume = initial_volume + (
2097
+ volume
2098
+ * self.get_nozzles_per_well(
2099
+ labware_id=labware_id,
2100
+ target_well_name=well_name,
2101
+ pipette_id=pipette_id,
2102
+ )
2103
+ )
2104
+ # NOTE(cm): if final_volume is outside the bounds of the well, it will get
2105
+ # adjusted inside find_height_at_well_volume to accomodate well the height
2106
+ # calculation.
2107
+ height_inside_well = find_height_at_well_volume(
2108
+ target_volume=final_volume, well_geometry=well_geometry
2109
+ )
2110
+ return self._validate_well_position(
2111
+ target_height=height_inside_well,
2112
+ well_max_height=well_depth,
2113
+ pipette_id=pipette_id,
2114
+ )
2115
+ except InvalidLiquidHeightFound as _exception:
2116
+ raise InvalidLiquidHeightFound(
2117
+ message=_exception.message
2118
+ + f"for well {well_name} of {self._labware.get_display_name(labware_id)} on slot {self.get_ancestor_slot_name(labware_id)}"
2119
+ )
1516
2120
 
1517
2121
  def get_well_height_at_volume(
1518
- self, labware_id: str, well_name: str, volume: float
1519
- ) -> float:
2122
+ self, labware_id: str, well_name: str, volume: LiquidTrackingType
2123
+ ) -> LiquidTrackingType:
1520
2124
  """Convert well volume to height."""
1521
2125
  well_geometry = self._labware.get_well_geometry(labware_id, well_name)
1522
- return find_height_at_well_volume(
1523
- target_volume=volume, well_geometry=well_geometry
1524
- )
2126
+ try:
2127
+ return find_height_at_well_volume(
2128
+ target_volume=volume, well_geometry=well_geometry
2129
+ )
2130
+ except InvalidLiquidHeightFound as _exception:
2131
+ raise InvalidLiquidHeightFound(
2132
+ message=_exception.message
2133
+ + f"for well {well_name} of {self._labware.get_display_name(labware_id)} on slot {self.get_ancestor_slot_name(labware_id)}"
2134
+ )
1525
2135
 
1526
2136
  def get_well_volume_at_height(
1527
- self, labware_id: str, well_name: str, height: float
1528
- ) -> float:
2137
+ self,
2138
+ labware_id: str,
2139
+ well_name: str,
2140
+ height: LiquidTrackingType,
2141
+ ) -> LiquidTrackingType:
1529
2142
  """Convert well height to volume."""
1530
2143
  well_geometry = self._labware.get_well_geometry(labware_id, well_name)
1531
- return find_volume_at_well_height(
1532
- target_height=height, well_geometry=well_geometry
1533
- )
2144
+ try:
2145
+ return find_volume_at_well_height(
2146
+ target_height=height, well_geometry=well_geometry
2147
+ )
2148
+ except InvalidLiquidHeightFound as _exception:
2149
+ raise InvalidLiquidHeightFound(
2150
+ message=_exception.message
2151
+ + f"for well {well_name} of {self._labware.get_display_name(labware_id)} on slot {self.get_ancestor_slot_name(labware_id)}"
2152
+ )
1534
2153
 
1535
2154
  def validate_dispense_volume_into_well(
1536
2155
  self,
1537
2156
  labware_id: str,
1538
2157
  well_name: str,
1539
- well_location: WellLocations,
2158
+ well_location: WellLocationType,
1540
2159
  volume: float,
1541
2160
  ) -> None:
1542
2161
  """Raise InvalidDispenseVolumeError if planned dispense volume will overflow well."""
1543
2162
  well_def = self._labware.get_well_definition(labware_id, well_name)
1544
- well_volumetric_capacity = well_def.totalLiquidVolume
2163
+ well_volumetric_capacity = float(well_def.totalLiquidVolume)
1545
2164
  if well_location.origin == WellOrigin.MENISCUS:
1546
2165
  # TODO(pbm, 10-23-24): refactor to smartly reduce height/volume conversions
1547
2166
  well_geometry = self._labware.get_well_geometry(labware_id, well_name)
1548
2167
  meniscus_height = self.get_meniscus_height(
1549
2168
  labware_id=labware_id, well_name=well_name
1550
2169
  )
1551
- meniscus_volume = find_volume_at_well_height(
1552
- target_height=meniscus_height, well_geometry=well_geometry
1553
- )
2170
+ try:
2171
+ meniscus_volume = find_volume_at_well_height(
2172
+ target_height=meniscus_height, well_geometry=well_geometry
2173
+ )
2174
+ except InvalidLiquidHeightFound as _exception:
2175
+ raise InvalidLiquidHeightFound(
2176
+ message=_exception.message
2177
+ + f"for well {well_name} of {self._labware.get_display_name(labware_id)} on slot {self.get_ancestor_slot_name(labware_id)}"
2178
+ )
2179
+ # if meniscus volume is a simulated value, comparisons aren't meaningful
2180
+ if isinstance(meniscus_volume, SimulatedProbeResult):
2181
+ return
1554
2182
  remaining_volume = well_volumetric_capacity - meniscus_volume
1555
2183
  if volume > remaining_volume:
1556
2184
  raise errors.InvalidDispenseVolumeError(
@@ -1605,3 +2233,40 @@ class GeometryView:
1605
2233
  target_well_name,
1606
2234
  self._labware.get_definition(labware_id).ordering,
1607
2235
  )
2236
+
2237
+ def get_height_of_labware_stack(
2238
+ self, definitions: list[LabwareDefinition]
2239
+ ) -> float:
2240
+ """Get the overall height of a stack of labware listed by definition in top-first order."""
2241
+ if len(definitions) == 0:
2242
+ return 0
2243
+ if len(definitions) == 1:
2244
+ return definitions[0].dimensions.zDimension
2245
+ total_height = 0.0
2246
+ upper_def: LabwareDefinition = definitions[0]
2247
+ for lower_def in definitions[1:]:
2248
+ overlap = self._labware.get_labware_overlap_offsets(
2249
+ upper_def, lower_def.parameters.loadName
2250
+ ).z
2251
+ total_height += upper_def.dimensions.zDimension - overlap
2252
+ upper_def = lower_def
2253
+ return total_height + upper_def.dimensions.zDimension
2254
+
2255
+ def get_height_of_stacker_labware_pool(self, module_id: str) -> float:
2256
+ """Get the overall height of a stack of labware in a Stacker module."""
2257
+ stacker = self._modules.get_flex_stacker_substate(module_id)
2258
+ pool_list = stacker.get_pool_definition_ordered_list()
2259
+ if not pool_list:
2260
+ return 0.0
2261
+ return self.get_height_of_labware_stack(pool_list)
2262
+
2263
+ def _get_or_default_labware(
2264
+ self, labware_id: str, pending_labware: dict[str, LoadedLabware]
2265
+ ) -> LoadedLabware:
2266
+ try:
2267
+ return self._labware.get(labware_id)
2268
+ except LabwareNotLoadedError as lnle:
2269
+ try:
2270
+ return pending_labware[labware_id]
2271
+ except KeyError as ke:
2272
+ raise lnle from ke